| name | add-model |
| description | Add a Doctrine entity as a Sylius Resource |
| argument-hint | [ModelName] |
| allowed-tools | AskUserQuestion, Bash, Read, Edit, Write, Glob, Grep |
Add a Model
Ask the user for the ModelName if not provided, the list of fields with their types, and any relations to other resources.
Where to place the files
Group by domain like Sylius-Standard (src/Entity/Product/, src/Entity/Customer/):
- Name starts with an existing resource (
{Parent}Image, {Parent}Translation, …) → src/Entity/{Parent}/
- Otherwise → own folder
src/Entity/{ModelName}/
1. Create the interface
src/Entity/{ModelName}/{ModelName}Interface.php:
<?php
declare(strict_types=1);
namespace $SYLIUS_NAMESPACE\Entity\{ModelName};
use Sylius\Resource\Model\ResourceInterface;
interface {ModelName}Interface extends ResourceInterface
{
public function getId(): ?int;
}
2. Create the entity
src/Entity/{ModelName}/{ModelName}.php:
<?php
declare(strict_types=1);
namespace $SYLIUS_NAMESPACE\Entity\{ModelName};
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: '${SYLIUS_PREFIX}_{model_snake}')]
class {ModelName} implements {ModelName}Interface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
protected ?int $id = null;
public function getId(): ?int
{
return $this->id;
}
}
For fields that require validation, add Symfony constraints directly on the property:
use Symfony\Component\Validator\Constraints as Assert;
#[Assert\NotBlank]
#[Assert\Length(max: 255)]
#[ORM\Column(length: 255)]
private ?string $title = null;
For relations to another resource in the same project, always use the target's interface as targetEntity — Sylius's ResolveTargetEntityListener resolves it at runtime from the interface: key in the resource config. In forms, EntityType does not go through this listener — pass the concrete class or use /sylius-plugin:add-autocomplete.
ManyToOne — {ModelName} belongs to one {RelatedModel}:
#[ORM\ManyToOne(targetEntity: {RelatedModel}Interface::class)]
private ?{RelatedModel}Interface ${related_model} = null;
OneToMany (inverse side) — {ModelName} has many {RelatedModel}. Initialize the collection in the constructor. Requires the owning side ({RelatedModel} entity) to declare a matching ManyToOne field named {model_snake}:
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
#[ORM\OneToMany(mappedBy: '{model_snake}', targetEntity: {RelatedModel}Interface::class)]
private Collection ${related_model_plural};
public function __construct()
{
$this->{related_model_plural} = new ArrayCollection();
}
Add the canonical Sylius adder/remover pair so LiveCollectionType and other form types sync the inverse side correctly:
public function add{RelatedModel}({RelatedModel}Interface ${related_model}): void
{
if (!$this->{related_model_plural}->contains(${related_model})) {
$this->{related_model_plural}->add(${related_model});
${related_model}->set{ModelName}($this);
}
}
public function remove{RelatedModel}({RelatedModel}Interface ${related_model}): void
{
if ($this->{related_model_plural}->removeElement(${related_model})) {
${related_model}->set{ModelName}(null);
}
}
Same adder/remover pattern for ManyToMany — omit the inverse sync call (no owning-side field to update on the other entity).
ManyToMany:
#[ORM\ManyToMany(targetEntity: {RelatedModel}Interface::class)]
private Collection ${related_model_plural};
public function __construct()
{
$this->{related_model_plural} = new ArrayCollection();
}
To reach a Sylius core entity from a plugin (e.g. attach plugin data to Product), do not add a relation on the plugin entity — use /sylius-plugin:extends-model + a plugin-provided trait instead (the FK lives on the extended core entity).
3. Register as Sylius Resource
Create config/resources/{model_snake}.yaml at the plugin root (one file per resource — matches ProductBundlePlugin / MolliePlugin convention). Add the glob import once to the plugin's config/config.yaml: - { resource: "resources/*.yaml" }. The test app loads @{PluginBundle}/config/config.yaml via its own tests/TestApplication/config/config.yaml, which cascades the import.
sylius_resource:
resources:
${SYLIUS_PREFIX}.{model_snake}:
driver: doctrine/orm
classes:
model: $SYLIUS_NAMESPACE\Entity\{ModelName}\{ModelName}
interface: $SYLIUS_NAMESPACE\Entity\{ModelName}\{ModelName}Interface
4. Generate and apply the migration
The plugin's DI extension declares the DoctrineMigrations namespace via PrependDoctrineMigrationsTrait, so generate into it:
vendor/bin/console doctrine:migrations:diff --namespace=DoctrineMigrations
Always review the generated migration before applying. doctrine:migrations:diff captures every difference between mapping and DB — including pre-existing schema drift unrelated to your model (e.g. ALTER TABLE messenger_messages ... from a Sylius update never migrated locally). The migration should only contain CREATE TABLE ${SYLIUS_PREFIX}_{model_snake} + its indexes/FKs.
If unrelated SQL is present, manually trim the migration up()/down() to keep only your table — the drift belongs to the test app baseline, not your plugin.
Then apply:
vendor/bin/console doctrine:migrations:migrate --no-interaction
5. Clear cache
vendor/bin/console cache:clear
6. Verify
Next steps
/sylius-plugin:add-form to add an admin form
/sylius-plugin:add-translatable-model if the model needs translations
/sylius-plugin:add-grid, then /sylius-plugin:add-routes, then /sylius-plugin:add-menu