This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This is a Sylius plugin for generating product feeds (e.g., Google Shopping feeds). It processes feed items in batches using Symfony Messenger for async processing. Feeds have a workflow state machine (unprocessed -> processing -> ready/error).
Follow clean code principles and SOLID design patterns when working with this codebase:
- Write clean, readable, and maintainable code
- Apply SOLID principles (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion)
- Use meaningful variable and method names
- Keep methods and classes focused on a single responsibility
- Favor composition over inheritance
- Write code that is easy to test and extend
- Every feature must be tested. A feature is not "done" until it ships with tests that exercise it. Write unit tests for all new classes/behaviour, and add functional tests whenever the code crosses a Sylius/Doctrine/container/HTTP boundary — DI/service wiring, Doctrine mappings, the workflow, value resolvers/data sources against real entities, writers' streamed output, public routes, and admin actions. Use a functional test when a unit test cannot meaningfully prove the behaviour.
- The test suite is split into two PHPUnit suites (see
phpunit.xml.dist):tests/Unit/(suiteunit) — fast, isolated tests with no kernel/container/database; mirror thesrc/namespace structure.tests/Functional/(suitefunctional) — boot the test-application kernel by extendingSetono\SyliusFeedPlugin\Tests\Functional\FunctionalTestCase. May use the database:dama/doctrine-test-bundlewraps each test in a transaction that is rolled back (requiresdoctrine.dbal.use_savepoints: true), so persist freely and the DB stays clean. The schema must exist first (doctrine:schema:create).- Run one suite via
composer phpunit-unit/composer phpunit-functional;composer phpunitruns both. The unit suite needs no database; the functional suite does — in CI it runs in theintegration-testsjob (and the coverage job), notunit-tests.
- Follow the BDD-style naming convention for test methods (e.g.,
it_should_do_something_when_condition_is_met) - MUST use Prophecy for mocking - Use the
ProphecyTraitand$this->prophesize()for all mocks, NOT PHPUnit's$this->createMock() - Form testing - Use Symfony's best practices for form testing as documented at https://symfony.com/doc/current/form/unit_testing.html
- Extend
Symfony\Component\Form\Test\TypeTestCasefor form type tests - Use
$this->factory->create()to create form instances - Test form submission, validation, and data transformation
- Extend
- Ensure tests are isolated and don't depend on external state
- Test both happy path and edge cases
- Every UI feature must be verified in a real browser with Playwright (MCP). Automated
PHPUnit tests are not enough for admin/shop UI: after building or changing anything users
interact with (grids, forms, menu items, admin actions, public pages), drive it end-to-end with
the Playwright MCP tools against the running test app and confirm it actually works — pages load
(no 500s), forms submit and persist, grid row actions run, and generated output is reachable.
-
Run the test app over HTTPS via
symfony server:start -dintests/Application(note the port it reports — it is not necessarily:8000if another project already holds that port). Log in at/admin/loginwithsylius/sylius(load fixtures first:bin/console sylius:fixtures:load -n --env=dev). Build assets withyarn build. The test app inlines@sylius-ui/frontend's build dependencies directly inpackage.json(instead of the meta-package) so versions are controllable, with two deliberate pins:sassis pinned to1.77.8(dart-sass; the latest requires Node ≥20.19, which would force Node 20+) so the build runs on Node 18.- a
resolutionsentry dedupesjqueryto3.7.1. Without it,jquery.dirtyforms(dep"jquery": ">=1.4.2") andsemantic-ui-csseach pull their ownjquery@4, so the plugin attaches.dirtyFormsto a different jQuery instance than Sylius's admin entry uses → the admin JS throwsdirtyForms is not a functionand silently breaks every admin form interaction (collection add/remove, etc.). One jQuery fixes it.
tests/Application/.nvmrcpins Node 18. With Node 18 active (nvm use), a cleanyarn install && yarn buildworks on Apple Silicon (node-sassis not used). If you see anode-sass/arm64 error, it's a stalenode_modules— delete it and reinstall. -
The plugin's messages must be routed to an async transport in the app (see the test app's
config/packages/dev/setono_sylius_feed.yaml) so "Generate now" queues a run; process it withbin/console messenger:consume main. (The functional test suite keeps them synchronous.) -
Check the browser console for errors as part of every verification, and prefer
browser_snapshotover screenshots for assertions.
-
- Use the FQCN as the service id. Register a service under its fully-qualified class name
(e.g.
<service id="Setono\SyliusFeedPlugin\FeedType\FeedTypeRegistry">), not a custom dotted id likesetono_sylius_feed.registry.feed_type. - Every single-implementation service gets an interface + an FQCN alias. Define an interface,
implement it, and register
<service id="…\FooInterface" alias="…\Foo"/>; consumers depend on the interface, never the concrete class. The only services without an alias are the tagged extension-point services (collected through a registry) and framework entry points (console commands, message handlers, event subscribers, controllers). - Do NOT use
autowireorautoconfigureon the plugin's own services. This applies to all services in the plugin. Wire every constructor argument explicitly with<argument>, define each tagged service explicitly (no<prototype>), and apply every tag explicitly with<tag>—messenger.message_handler,kernel.event_subscriber,console.command,controller.service_arguments, and thesetono_sylius_feed.*registry tags. Do not rely on attributes for registration: message handlers use amessenger.message_handlertag (not#[AsMessageHandler]), and#[AsCommand]may remain only as name/description metadata next to an explicitconsole.commandtag. - Event subscribers live in
src/EventSubscriberand are registered in their ownservices/event_subscriber.xml. - The extension still calls
registerForAutoconfiguration()for each extension-point interface — but only as a developer-experience aid for applications that add their own implementations. The plugin must never depend on it for its own wiring.
- Never inject
EntityManagerInterface(or a repository) directly into a service. InjectDoctrine\Persistence\ManagerRegistryand use theSetono\Doctrine\ORMTrait(fromsetono/doctrine-orm-trait): adduse ORMTrait;, assign$this->managerRegistry = $managerRegistry;in the constructor, then call$this->getManager($class)/$this->getRepository($class). The trait resolves the correct manager per entity class and transparently re-opens a closed manager — important for long-running/batch feed generation where one failure would otherwise close the EM for the rest of the run. Test classes may still fetch the manager from the container directly.
# Run all tests (unit + functional)
composer phpunit
# Run only one suite
composer phpunit-unit
composer phpunit-functional
# Run a single test file
vendor/bin/phpunit tests/Unit/Context/FeedContextTest.php
# Static analysis (PHPStan at max level)
composer analyse
# Check coding standards (ECS with Sylius coding standard)
composer check-style
# Fix coding standards
composer fix-style
# Run Rector (dry-run)
vendor/bin/rector process --dry-run
# Mutation testing (currently non-blocking in CI — see CI Gates below)
vendor/bin/infection
# Lint Symfony container (requires test application)
(cd tests/Application && bin/console lint:container)
# Lint YAML/Twig files
(cd tests/Application && bin/console lint:yaml ../../src/Resources)
(cd tests/Application && bin/console lint:twig ../../src/Resources)PHPStan is configured in phpstan.neon with:
- Analysis Level: max (strictest)
- Extensions: Auto-loaded via
phpstan/extension-installerphpstan/phpstan-symfony- Symfony framework integrationphpstan/phpstan-doctrine- Doctrine ORM integrationphpstan/phpstan-phpunit- PHPUnit test integrationjangregor/phpstan-prophecy- Prophecy mocking integration
- Symfony Integration: Uses console application loader (
tests/PHPStan/console_application.php) - Doctrine Integration: Uses object manager loader (
tests/PHPStan/object_manager.php) - Exclusions: Test application directory and Configuration.php
- No baseline: the rewrite keeps zero PHPStan errors, so there is intentionally no
phpstan-baseline.neon. Fix issues at the source rather than reintroducing a baseline.
These are enforced by .github/workflows/build.yaml and will fail the build if violated:
- Mutation testing is currently non-blocking.
infection.json.diststill setsminMsi/minCoveredMsito100.00, but themutation-testsCI job runs withcontinue-on-error: trueduring the rewrite, so it does not fail the build. Mutation coverage is therefore not enforced right now — rely on the "every feature must be tested" rule above instead. - PHP 8.1 is the floor: the package supports PHP
>=8.1, and CI runs against 8.1/8.2/8.3 with Symfony~6.4. Coding-standards run on 8.1 and Rector targetsLevelSetList::UP_TO_PHP_81, so do not use syntax/features newer than 8.1. lowestandhighestdependencies are both tested — avoid relying on behavior only present in newer versions of a^-constrained dependency.composer normalize --dry-runmust pass — keepcomposer.jsonnormalized (runcomposer normalize).- Dependency analysis (
shipmonk/composer-dependency-analyser, config incomposer-dependency-analyser.php) checks that every used package is a direct dependency.
The plugin includes a test Symfony application in tests/Application/ for development and testing:
- Navigate to
tests/Application/directory - Run
yarn install && yarn buildto build assets - Use standard Symfony commands for the test app
- Sylius Backend Credentials: Username:
sylius, Password:sylius
Database setup:
(cd tests/Application && bin/console doctrine:database:create)
(cd tests/Application && bin/console doctrine:schema:create)Use the right tool for the right job when executing bash commands:
- Finding FILES? → Use
fd(fast file finder) - Finding TEXT/strings? → Use
rg(ripgrep for text search) - Finding CODE STRUCTURE? → Use
ast-grep(syntax-aware code search) - SELECTING from multiple results? → Pipe to
fzf(interactive fuzzy finder) - Interacting with JSON? → Use
jq(JSON processor) - Interacting with YAML or XML? → Use
yq(YAML/XML processor)
Examples:
fd "*.php" | fzf- Find PHP files and interactively select onerg "function.*validate" | fzf- Search for validation functions and selectast-grep --lang php -p 'class $name extends $parent'- Find class inheritance patterns
The pipeline is a fan-out of async messages, and the lifecycle is driven by Symfony Workflow transition events — not by the handlers calling each other directly.
ProcessFeedsCommand(setono:sylius-feed:process) callsFeedProcessor::process(), which dispatches oneProcessFeedper enabled feed.ProcessFeedHandlervalidates the feed type's template, applies theprocesstransition, and dispatches oneGenerateFeedper channel/locale combination.GenerateFeedHandlerasks the feed type'sDataProviderfor batches (getBatches()) and dispatches oneGenerateBatchper batch.GenerateBatchHandlerresolves the batch's items, runs each through the item context, validates every context, renders the Twigitemblock, writes a partial file per channel/locale, then dispatchesBatchGeneratedEvent.FinishGenerationHandlerconcatenates the partials into the final feed (wrapping them with the feed start/end rendered from@SetonoSyliusFeedPlugin/Feed/feed.txt.twig, split on the<!-- ITEM_BOUNDARY -->marker), deletes the partials, and applies theprocessedtransition.
Completion detection is counter-based, not "last handler wins". The total batch count is set on the feed when the process transition fires (StartProcessingSubscriber → Feed::setBatches()). On each BatchGeneratedEvent, IncrementFinishedBatchesSubscriber (priority 100) increments the counter, then SendFinishGenerationCommandSubscriber dispatches FinishGeneration only once FeedRepository::batchesGenerated() is true. This is what makes the flow safe under out-of-order async batch processing.
Workflow-transition subscribers (workflow.setono_sylius_feed.feed.transition.*) handle side effects so handlers stay focused:
process→StartProcessingSubscriberresets and sets the batch count.processed→MoveGeneratedFeedSubscribermoves the feed from the temporary filesystem to its final (public) location.errored→DeleteGeneratedFilesSubscribercleans up generated files.
Validation/violation behavior: each context is validated with the feed type's validation groups. A violation with severity error causes that item to be skipped (not written to the feed); other severities are recorded as Violations on the feed but the item is still written. Any thrown error transitions the feed to error.
- FeedType (
FeedTypeInterface): Defines a feed format. Contains data provider, templates, feed context, and item context. Register with tagsetono_sylius_feed.feed_type. - DataProvider (
DataProviderInterface): Provides items to be included in the feed (e.g., products) - FeedContext/ItemContext: Transform raw data into context for Twig templates
- Workflow (
FeedGraph): States: unprocessed, processing, ready, error. Transitions: process, processed, errored
All commands implement CommandInterface and can be routed to async transport:
ProcessFeed- Start processing a feedGenerateFeed- Generate feed for a specific channel/localeGenerateBatch- Process a batch of itemsFinishGeneration- Finalize feed after all batches complete
Templates in src/Resources/views/Feed/ must define an item block. Example structure:
{% block item %}
{# Render single feed item #}
{% endblock %}- Implement
FeedTypeInterfacefor custom feed formats - Use event listeners on
QueryBuilderEventto filter data - Filter listeners in
EventListener/Filter/(channel, enabled, in-stock filters) - Subscribe to
GenerateBatchItemEventandGenerateBatchViolationEventfor item processing hooks
Product models can implement optional interfaces for feed data:
BrandAwareInterface,GtinAwareInterface,MpnAwareInterfaceColorAwareInterface,SizeAwareInterface,ConditionAwareInterface- Localized variants:
LocalizedBrandAwareInterface, etc.
The plugin provides multilingual support through translation files in src/Resources/translations/:
- Translation Files: Available in 10 languages (en, da, de, es, fr, it, nl, no, pl, sv)
- Translation Domains:
messages.*- UI labels and general translationsflashes.*- Flash message translations (success/error messages)validators.*- Validation error messages
Key translation keys:
setono_sylius_feed.ui.*- UI labels (feeds, violations, states)setono_sylius_feed.form.*- Form field labelssetono_sylius_feed.feed_type.*- Feed type names