| name | sylius-dev |
| description | Build Sylius 2.x features idiomatically. Use for any task that adds or modifies persisted data, frontend UI on Sylius pages, admin CRUD, emails, async work, inventory listeners, back-in-stock notifications, product badges, admin grids, fixtures, migrations, Doctrine listeners, or twig hooks in a Sylius project. Triggers on phrases like "Sylius feature", "Sylius resource", "add to product page", "back-in-stock", "admin grid", "Sylius email", "Sylius listener", "Sylius fixture", "Sylius twig hook", "Sylius migration". |
Sylius Vibe Feature
You are building a feature in a Sylius 2.x project. Follow this brief. Never skip the Mate-first protocol or hard rules.
Mental Model
- Resource-first. New persisted thing → register
sylius_resource. Never plain Doctrine entity for app domain data.
- Hook-first. Frontend changes → TwigHook entry. Never
{% extends '@SyliusShop/...' %} override.
- Event-first. React to domain change → Sylius event or domain message. Doctrine
preUpdate last resort. For inventory mutations use Doctrine onFlush UnitOfWork.
- Factory + Repository, never EntityManager. Controllers/handlers inject
FactoryInterface + RepositoryInterface. Use $repository->add($x). No EntityManagerInterface in controllers.
- Interfaces, never concretes. Type-hint
ChannelInterface, ProductInterface, CustomerInterface. Never App\Entity\Channel\Channel.
Mate-First Protocol (non-negotiable)
The sylius_* tools below are Mate CLI tools — invoke each as vendor/bin/mate tools:call <tool> --<param>=<value> (nested values via --json). Before writing ANY file, you MUST complete this discovery checklist (Mate tool calls where one exists):
sylius_project_profile - first call, always. Returns app_namespace (PSR-4 root, never hardcode App\), locales (enabled list for translation file emission), and feature flags. R-NAMESPACE-FROM-COMPOSER + R-MULTI-LOCALE depend on this.
sylius_installed_plugins - inventory installed Sylius plugins (MSI, wishlist, refund, multi-currency, b2b) before designing any listener / inventory checker / price service / channel resolver. R-PLUGIN-AWARENESS.
sylius_domain_list_resources - does target resource exist? Learn shape.
sylius_hooks_find_for_template - for UI placement.
- Domain event check. Grep
vendor/sylius/*/src/**/SyliusEvents.php for an existing event before reaching for a Doctrine listener. No Mate tool for this - these are compile-time constants, not kernel state, so a tool would just wrap the same grep.
sylius_twig_list_functions - verify any sylius_* Twig function before use.
- Mailer code check. Read
sylius_mailer.emails.* keys from config/packages/_sylius_mailer.yaml to confirm existing mailer code shape. No dedicated Mate list tool; sylius_mailer_verify_template confirms the final code+template pair in the verify pass.
sylius_domain_list_grids - mirror existing grid for admin CRUD.
Before declaring DONE, you MUST execute this verify script. Every command output empty/passing. Any failure → STOP, fix, re-run. Do not report task complete with non-empty error output.
for f in <touched_php_files>; do php -l "$f" || exit 1; done
bin/console lint:yaml config/ --parse-tags
bin/console lint:twig templates/
bin/console debug:container app.repository.<alias>
bin/console debug:container app.factory.<alias>
for fqcn in <every_new_or_modified_FQCN>; do
bin/console debug:container --show-arguments "$fqcn" || exit 1
done
bin/console doctrine:schema:validate --skip-sync
bin/console debug:router | grep <route_name>
bin/console messenger:debug | grep <MessageClass>
If the Mate CLI with the Sylius Mate Extension is not available (vendor/bin/mate tools:list fails or lists no sylius_* tools):
- Tell user once: "Install
sylius/sylius-mate-extension for full guidance. Continuing best-effort."
- Fall back to filesystem reads:
sylius_project_profile → read composer.json (autoload.psr-4 first entry pointing at src/ → app namespace) + config/packages/_sylius.yaml (sylius_locale.locales) + .env (APP_DEFAULT_URI).
sylius_installed_plugins → grep composer.json require for sylius/*-plugin entries.
sylius_domain_list_resources → grep config/packages/_sylius.yaml for sylius_resource.resources.* keys.
sylius_hooks_find_for_template → grep vendor/sylius/sylius/src/Sylius/Bundle/{Shop,Admin}Bundle/Resources/views/ for {% hook %} tags.
sylius_twig_list_functions → grep vendor/sylius/*/src/**/Twig/*Extension.php for new TwigFunction(...).
sylius_domain_list_grids → grep config/packages/_sylius_grid.yaml + vendor/sylius/*/Resources/config/grids/.
- Mark every fact derived this way with
⚠ unverified so the user knows to double-check.
Workflow
Full checklist in workflow.md. Summary:
-
Discover (list_resources, find_for_template, grep domain events).
-
Resource Bundle (all-or-nothing). Single phase, mandatory artifacts ship together:
- entity + interface, repo + interface
- No custom factory by default. Sylius default
Sylius\Resource\Factory\Factory wires automatically when classes.factory: is omitted. When a controller / handler needs the factory instance, inject by service id via Autowire attribute, not via a custom interface: #[Autowire(service: 'app.factory.<alias>')] private FactoryInterface $factory. Add a custom factory CLASS only when feature needs pre-construction behavior (set defaults, attach related entity). If you do add one, constructor MUST be __construct(string $className) - Sylius compiler pass injects the entity FQCN string into that slot.
- form extending
AbstractResourceType
sylius_resource yaml registration (omit classes.factory: for plain resources)
sylius_grid admin grid yaml + admin route
Conditional artifacts:
- Fixture - only if feature needs new seed data beyond
sylius:fixtures:load defaults. Otherwise skip.
- Behat scenario - optional; Playwright acceptance (step 11) is the required acceptance gate. Add Behat only when user requests or feature has pure-domain logic better expressed as Gherkin.
-
bin/console doctrine:migrations:diff - never hand-write migration.
-
Form rendered via form_start(form, {action: path(...)}) + form_row + form_end(form). Never hand-roll <form> tag. Never manually render form._token. Inject form via controller or Twig function.
-
Frontend = TwigHook entry + template.
-
Controller invokable. Inject Factory, Core-package repo interface, ChannelContext, LocaleContext. No EM.
-
Listener via Sylius event; for inventory use Doctrine onFlush (collect) + postFlush (dispatch). Never dispatch from onFlush. Never register_shutdown_function from listener.
-
Async handler #[AsMessageHandler] + Sylius for email. Send context MUST include + + resource.
Never run bin/console cache:clear automatically - ask user (CLAUDE.md rule). Never run fos:elastica:populate automatically.
Hard Rules (refuse if violated)
Details + ✅ replacements in anti-patterns.md. Refuse-list:
-
❌ Plain #[ORM\Entity] without sylius_resource registration.
-
❌ EntityManagerInterface injected in controller when Resource exists.
-
❌ extends AbstractType for a Sylius resource form (must extends AbstractResourceType).
-
❌ Hand-rolled <input name="..."> mirroring Form Type fields.
-
❌ Concrete App\Entity\... type-hint on entity getter/setter or service signature.
-
❌ {% extends '@SyliusShop/...' %} template override (use TwigHooks).
-
❌ Hand-written CREATE TABLE migration (doctrine:migrations:diff only).
-
❌ User-facing resource without admin grid.
-
❌ For Sylius core entities (product, product_variant, channel, customer, order, taxon, shipment, payment, address): injecting bare Sylius\Resource\Doctrine\Persistence\RepositoryInterface. Always inject the Core-package interface: Sylius\Component\Core\Repository\ProductRepositoryInterface, …\ChannelRepositoryInterface, etc. Bare interface = ambiguous binding, runtime resolve fail or wrong service.
-
❌ Hand-rolled <form method="post" action="..."> open tag. Use {{ form_start(form, {action: path('...')}) }}. Never manually render form._token.
-
❌ Doctrine #[ORM\Column] on camelCase property without explicit name: snake_case. Example: private \DateTimeImmutable $createdAt → #[ORM\Column(name: 'created_at', type: 'datetime_immutable')].
-
❌ Doctrine preUpdate when Sylius event covers the case.
-
❌ Sync mail send loop inside controller/listener (use Messenger async).
-
❌ Catching exceptions never thrown on the path (e.g. ChannelNotFoundException after ChannelContext::getChannel() always returns).
-
❌ \DateTime / type: 'datetime'. Use \DateTimeImmutable + type: 'datetime_immutable'. Property type , not .
Event Source Decision
For features watching a field change (a threshold crossing, a status flip):
- Doctrine
onFlush + postFlush (Pattern A, attribute-only) - catches all paths: admin grid mutations, API, order-workflow side effects, bulk imports. Preferred for cross-path coverage.
- Sylius Resource event (
<host>.post_update) - admin/API only; order-workflow side effects bypass the Resource controller. Idiomatic for admin-only-mutation features.
Many fields worth watching (stock, order state) live on an existing resource rather than being a resource of their own, and have no dedicated Sylius domain event. Doctrine listener is then the simplest reliable signal - reference/worked-example.md walks the stock case end to end.
Core Repo Aliases (R-CORE-REPO-ALIASES)
When injecting a Sylius core repository FQCN interface, ensure config/services.yaml declares aliases to the canonical repository services. Add the aliases relevant to the feature, idempotent.
Full yaml block + notes (incl. Sylius\Component\Channel\Repository\ChannelRepositoryInterface lives in Channel component, NOT Core) in reference/services.md.
Cache Clear
Not the skill's responsibility. CLAUDE.md forbids bin/console cache:clear; the harness auto-mode classifier denies even tools that shell out to it. Cache:clear is owned by:
- the Mate tool
sylius_cache_clear impl - must bypass Bash entirely (FS ops + cache warmup via Symfony Kernel API), OR
- Project CLAUDE.md preset that grants the exception.
Skill assumes the cache is fresh enough or that the Mate tool / project setup handles it. Never invoke cache:clear via Bash.
Code Style
Sylius-idiomatic style only - not personal preferences. Match the host project's coding standard (vendor/bin/ecs / php-cs-fixer if configured) for everything else.
- snake_case for Twig variables (Symfony / Sylius convention).
- Pass
template: explicitly to Twig hooks and Twig components. Do not rely on Symfony UX auto-template paths - Sylius hooks need explicit paths (R-COMP-TPL).
- Form types extending
AbstractResourceType must be registered via explicit yaml service def (R-FORM-SVC) - Sylius-Standard sets _instanceof: AbstractResourceType: { autowire: false }.
Playwright Acceptance Protocol (mandatory step 11)
End-of-workflow live run. Author a repeatable spec file at tests/Playwright/<feature>.spec.ts (or project's equivalent path) - not exploratory one-shot tool calls. AI runs the file via Playwright MCP and refuses "done" if it fails. Refuse "done" without green pass on every step. reference/worked-example.md walks this exact protocol against a concrete feature.
Coverage rule: spec must drive ALL observable user paths in the feature, not just the happy entry point - setup, the user action, the downstream trigger, any email assertion, AND the post-change UI state (e.g. a widget disappearing once its precondition no longer holds). Single-step specs are rejected.
Pre-req: if the Mate tool sylius_cache_clear is available, call it once before the spec. Otherwise rely on project setup. NEVER bin/console cache:clear via Bash.
- Pre-verify cache clear. If the Mate tool
sylius_cache_clear is available, call once. Else rely on project setup. NEVER bin/console cache:clear via Bash.
- Ensure dev server up. Project context provides URL (default
http://localhost:8000). If down, ask user to start it - do not silently skip.
- Setup state. Force the feature's precondition (e.g. a resource into its "before" state) via a project CLI command or fixture preset - never a hand-rolled
UPDATE.
browser_navigate → the page the feature's UI lives on. browser_snapshot → assert feature widget visible (form, button, badge).
browser_type / browser_fill_form → fill required inputs (e.g. email).
browser_click → submit. browser_snapshot → assert success flash / state change.
- Trigger the downstream condition through ORM (CLI command, admin UI flow, or API call). NEVER raw SQL (
doctrine:query:sql "UPDATE ...") - R-PLAYWRIGHT-NO-RAW-SQL: bypasses UoW → Doctrine listener never fires → handler never runs → assertion fails.
- Mate Symfony profiler tools →
symfony-profiler-list filtered by URL / method / recency → pick latest matching token. Sync Messenger transport in dev ⇒ handler ran in same request ⇒ same token covers email dispatch.
- Email proof (R-EMAIL-PROOF). Assert email via inspectable target - NOT a DB column:
- Capture transport (mailpit/mailhog) - scrape
http://localhost:8025/api/v1/messages for matching subject + recipient + locale-correct body.
- OR profiler mailer collector - works ONLY if the triggering mutation happened via HTTP request (admin form / API). A CLI-triggered mutation bypasses profiler.
- If neither available: print
// TODO: assert email via mailpit/profiler and report acceptance INCOMPLETE - do not pass on a handler-written DB flag as proof.
- Post-state assertion.
browser_navigate back to the feature's page. browser_snapshot → assert the widget NO LONGER visible (precondition no longer holds). Catches stale-cache bugs and listener idempotency failures.
- Any step fails → fix root cause, re-run from step 0. Do not declare done until full sequence green.
If feature has no email leg: stop at step 5. If feature has no async leg: skip steps 6–8. Surface assertions (steps 2–5, 9) always mandatory.
Linked Files
workflow.md - 13-step build checklist with Mate tool calls + verify commands.
anti-patterns.md - ❌/✅ pairs per hard rule with "Why" line.
reference/resource.md, reference/twig-hooks.md, reference/mailer.md, reference/events.md, reference/services.md - deep dives, fetch on demand.
reference/worked-example.md - one feature (back-in-stock notifications) built end to end, concretely, tagged against the rule IDs above. Every other file in this skill is written generically; this is the file that proves it out on a real case.