Upgrade plugin to Sylius 2.x#15
Merged
Merged
Conversation
- Bump composer floors: PHP >=8.2, Symfony 6.4|7.4, Sylius ~2.2.5
- Move src/Resources/{config,translations,views,public}/ to repo root
(templates/, translations/, config/, public/) and add the matching
getPath() override on the bundle class
- Convert services to PHP DSL (config/services.php) with FQCN service ids
- Delete AddUrlColumnToCouponGridSubscriber; the URL grid column is now
declared via sylius_grid config from the bundle extension's prepend()
- Replace the sylius_ui events prepend with a sylius_twig_hooks prepend
on sylius_admin.promotion_coupon.index#javascripts; rewrite the JS
partial to load assets via asset() (the v1 @SyliusUi/_javascripts
template is gone in v2)
- Migrate ApplyCouponSubscriber to Setono\Doctrine\ORMTrait and update
the eligibility-checker service id to sylius.checker.promotion_coupon_eligibility
- Rebuild tests/Application/ against Setono/SyliusPluginSkeleton@2.2.x;
register SyliusTwigHooksBundle + the UX bundle stack
- Swap tooling: psalm -> PHPStan (level: max), code-quality-pack ->
setono/sylius-plugin ^2.1; add rector.php,
composer-dependency-analyser.php, infection.json5,
tests/PHPStan/console_application.php; collapse the GHA workflow onto
setono/sylius-plugin/*@v2 composite actions
- Update shop templates to extend @SyliusShop/shared/layout/base.html.twig
and Bootstrap 5 utility classes
- Add UPGRADE.md (2.x -> 3.0 migration notes) and rewrite CLAUDE.md
for v2 conventions
The Sylius 2 admin coupon grid no longer carries a URL column with an inline
"Use other base URL" input. Instead, each row gets a Show URL item action that
opens a Bootstrap 5 modal containing the coupon URL, an editable base URL,
and a copy-to-clipboard button. The chosen base URL is persisted in
localStorage so it sticks across rows.
- Grid: replace the `fields.url` declaration with an `actions.item.show_url`
entry pointing at a custom template that renders a modal-trigger button
- Templates: add `grid/action/show_url.html.twig` and `_modal.html.twig`;
simplify `_javascripts.html.twig` to a single asset include
- Hooks: register both the modal markup and the JS bundle on
`sylius_admin.promotion_coupon.index#javascripts`
- JS: replace `redirect.js` (inline column header input) with
`coupon-url-modal.js` (modal lifecycle, localStorage persistence,
navigator.clipboard + execCommand fallback)
- Translations: drop `ui.url` and `ui.use_other_base_url`; add `ui.show_url`
and `ui.modal.{title,base_url,url,copy,copied}` in en/da/no
- UPGRADE.md and CLAUDE.md updated to describe the new admin UX
- Render the grid action via @SyliusAdmin/shared/helper/button.html.twig with icon_only: true, matching the update/delete buttons' btn-icon sizing (44x44). Wrap in a data-bs-toggle=tooltip div the way the delete_modal does so the modal trigger keeps its data-bs-toggle=modal. - Switch the modal's Copy button from btn-outline-secondary to btn-primary so it reads as the modal's primary action instead of a disabled control. Also: record the project rule that the assistant must not commit or push without an explicit request.
The skeleton's CLAUDE.md specifies 16 locales (en source + 15 translations) split into Nordic, Large EU, and other common Sylius locales. Bring the plugin's translation coverage in line: - Add messages.<locale>.yaml and flashes.<locale>.yaml for sv, fi, de, fr, es, it, nl, pl, pt, cs, hu, ro, uk - en/da/no were already in place - Update CLAUDE.md: new "Translations" section listing the locale matrix verbatim from the skeleton; the working-in-repo bullet now points to it instead of repeating the locale list inline
Sylius 2 ships a native inline coupon-input on /cart via the cart Live
Component, so the plugin's /_partial/coupon route + partial template
(used previously for {{ render() }} embedding from other shop templates)
is redundant. Remove it.
- Delete templates/shop/partial/coupon.html.twig
- Remove the setono_sylius_coupon_url_application_shop_partial_apply_coupon
route from config/routes/shop.yaml
- Drop the RequestStack injection + isMainRequest branching from
ApplyCouponAction; it always renders shop/coupon.html.twig now
- Drop service('request_stack') from services.php
While we're here, give the /coupon page the standard Sylius 2 shop-page
wrapper (container > row.justify-content-center > col-12 col-md-6) and a
proper <title> block so it looks like a normal Sylius page instead of an
unwrapped form.
UPGRADE.md and CLAUDE.md updated to describe the removal and the new
single-template flow.
loevgaard
commented
May 11, 2026
The URL-based coupon attachment is intentionally lenient — a customer
should be able to click a coupon link in an email before they have a
cart, and the discount should activate as soon as the cart qualifies.
But the existing success flash ("The coupon code is now activated in
your cart") was misleading when the underlying promotion's rules
(cart-total threshold, taxon, etc.) weren't yet satisfied: the coupon
got attached and persisted, but no discount was applied.
Two changes:
- Add a second eligibility check after attaching the coupon, using
Sylius's PromotionEligibilityCheckerInterface (the same check the
cart-page widget's PromotionSubjectCouponValidator runs). If the
promotion-level rules pass, fire the existing success flash. If they
don't, fire the soft "coupon_applied_not_fulfilled" info flash
instead. The coupon stays attached either way; the message just
reflects whether the discount is active now or queued for later.
- Rewrite the "coupon_applied_not_fulfilled" wording in all 16 locales
so it leads with the positive outcome ("the coupon is saved") and
then explains the qualifier. The previous wording read as a
rejection.
Also drop the "coupon_already_applied" flash entirely:
- Removed from ApplyCouponAction (the action no longer fires any flash
on its own — it just renders the form).
- Translation key purged from all 16 flashes.<locale>.yaml files.
- Action's unused Request parameter + Session import dropped as a side
effect.
README documents the lenient-attachment design choice so consumers
understand why the plugin's behaviour deliberately differs from the
Sylius cart-page widget's strict-up-front validation.
- tests/Unit/Form/Type/ApplyCouponTypeTest.php — extends Symfony TypeTestCase per https://symfony.com/doc/6.4/form/unit_testing.html; asserts the form synchronizes valid + empty submissions, exposes a single TextType-backed `coupon` field, applies the plugin's translation key as its label, and pre-populates from initial data - tests/Unit/EventSubscriber/ApplyCouponSubscriberTest.php — covers all branches of the request listener using Prophecy doubles: subscribed events shape, sub-request bypass, missing/empty/unmatched coupon parameter early-returns, error flash on coupon-level ineligibility, success vs queued flash split based on promotion-level eligibility, and the coupon-with-no-promotion edge case - tests/Unit/Controller/Action/ApplyCouponActionTest.php — verifies the action passes the cart's current coupon code (or null) into the form factory, calls Twig with the rendered form view, and returns a 200 Response with the rendered body Mark SetonoSyliusCouponUrlApplicationPlugin with @codeCoverageIgnore since its only responsibility is registering itself as a Sylius plugin.
PHPStan complained on the lowest-dep matrix
(static-code-analysis (8.4, lowest, ~6.4.0)):
tests/Unit/Form/Type/ApplyCouponTypeTest.php:65
Cannot access offset 'value' on mixed.
🪪 offsetAccess.nonOffsetAccessible
Symfony 6.4.0 ships looser type info on FormView::$vars than newer 6.4.x
patches, so $form->createView()->children['coupon']->vars['value']
analyses as mixed['value']. The assertion duplicates the
$form->get('coupon')->getData() check on the line above, which already
proves the initial data threads through to the field. Drop the view
assertion.
- UPGRADE.md: document the flash-message behaviour change for ?coupon=... (successful URL applications can now land as an info flash instead of success when the cart doesn't yet satisfy the promotion rules) plus the removed coupon_already_applied translation key and the rewritten coupon_applied_not_fulfilled wording - CLAUDE.md: fix Architecture #2 — the /coupon action no longer fires an "already applied" flash, all flash messaging lives in the subscriber. Drop the deleted partial template from the UI-surface Playwright checklist - README.md: point the Stryker badge + dashboard links at 3.x instead of 2.x
Drop entries for files removed during the v2 upgrade: - /composer-require-checker.json (replaced by composer-dependency-analyser.php) - /psalm.xml (replaced by phpstan.neon) - /infection.json.dist (replaced by infection.json5) Add entries for new dev-only files so they stay out of the composer source archive: - /CLAUDE.md, /UPGRADE.md - /composer-dependency-analyser.php - /infection.json5 - /phpstan.neon - /rector.php Alphabetise and re-pad to match the SyliusPluginSkeleton house style.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Migrates the plugin from Sylius 1.12 / Symfony 5.4–7.0 / PHP 8.1 to Sylius ~2.2 / Symfony 6.4|7.4 / PHP ≥8.2 following the Setono v1→v2 plugin upgrade playbook, then keeps going: redesigns the admin coupon-URL UX, rebuilds the
/couponshop page to match Sylius 2 styling, expands the translation set, restructures coupon-application semantics, and adds a unit test suite.Sylius v1 → v2 baseline
^6.4 || ^7.4, Sylius~2.2.5.setono/code-quality-pack→setono/sylius-plugin: ^2.1.setono/doctrine-object-manager-trait→setono/doctrine-orm-trait.src/Resources/{config,translations,views,public}/→ repo-rootconfig/,translations/,templates/,public/. Bundle class addsgetPath()override.config/services.php). Snake-case service ids → FQCN ids.XmlFileLoader→PhpFileLoader.ApplyCouponSubscriberusesSetono\Doctrine\ORMTrait. Eligibility-checker service id updated tosylius.checker.promotion_coupon_eligibility.@SyliusShop/shared/layout/base.html.twig; form theme@SyliusShop/form/theme.html.twig; Bootstrap 5 utility classes;/couponwrapped in the standardcontainer → row.justify-content-center → col-12 col-md-6shop-page scaffold with a proper<title>block.Setono/SyliusPluginSkeleton@2.2.x;SyliusTwigHooksBundle+ the UX bundle stack registered;HEADER_X_FORWARDED_ALLreplaced with explicit bitmask.level: max). Newrector.php,composer-dependency-analyser.php,infection.json5,tests/PHPStan/console_application.php. CI collapsed ontosetono/sylius-plugin/*@v2composite actions across PHP 8.2/8.3/8.4 × Symfony 6.4/7.4 × lowest/highest.Behaviour & UX changes beyond the mechanical upgrade
Admin coupon list — Show URL action replaces the URL column
The 2.x inline URL column + "Use other base URL" input was driven by a runtime
GridDefinitionConverterEventsubscriber and asylius_uievent override. Both are gone in Sylius 2 (sylius_uievents removed; the inline-input pattern doesn't fit the Sylius 2 admin look). Replaced with:sylius_gridconfig from the bundle extension'sprepend(). Custom action template (templates/admin/promotion_coupon/grid/action/show_url.html.twig) uses the Sylius adminbutton.default({…, icon_only: true})helper macro so it sizes/styles identically to the built-in update/delete icon buttons.templates/admin/promotion_coupon/_modal.html.twig) andpublic/js/coupon-url-modal.jshooked intosylius_admin.promotion_coupon.index#javascriptsviasylius_twig_hooks. The modal lets the admin edit the base URL inline, copies the resulting URL to the clipboard (navigator.clipboardwithexecCommand('copy')fallback), and remembers the last-used base URL across rows inlocalStorage.Lenient coupon attachment + two-stage eligibility flash
The plugin's headline use case is sending coupon URLs from email campaigns — the customer clicks
https://example.com/?coupon=CHRISTMASbefore they've added anything to their cart, and the discount activates as soon as the cart matches. To make that flow legible without misleading the customer,ApplyCouponSubscribernow runs two checks:PromotionCouponEligibilityCheckerInterface) gates attachment — duration, total usage, per-customer usage, channel. Failure →errorflash, coupon not attached.PromotionEligibilityCheckerInterface) gates the flash type after attachment — adds the promotion's rules check (cart-total threshold, taxon allow-lists, etc.) on top of the coupon-level checks.successflashcoupon_applied("The coupon code is now activated in your cart").infoflashcoupon_applied_not_fulfilled(coupon stays attached; discount activates as soon as the cart qualifies).The
coupon_applied_not_fulfilledwording was rewritten in all 16 locales to lead with the positive outcome ("The coupon code is saved in your cart…") instead of the previous rejection-sounding text.This deliberately differs from Sylius 2's cart-page coupon widget, which rejects coupons up-front when the cart doesn't satisfy promotion rules. See README for the consumer-facing explanation.
Shop surface cleanup
/_partial/couponroute +templates/shop/partial/coupon.html.twigremoved. Sylius 2's cart Live Component on/cartalready exposes a native inline coupon input (hooksylius_shop.cart.index.content.form.sections.general#left), so the plugin's sub-request partial is redundant.ApplyCouponActionsimplifies accordingly — no moreRequestStackinjection, no moreisMainRequestbranching, always renderstemplates/shop/coupon.html.twig.coupon_already_appliedtranslation key removed entirely. The action no longer fires any flash on its own; all flash messaging now lives inApplyCouponSubscriber, so the same outcomes surface whether the customer applies via?coupon=or via the form on/coupon.Translations
Expanded from 3 locales (en/da/no) to the full 16-locale skeleton set (en/da/no/sv/fi/de/fr/es/it/nl/pl/pt/cs/hu/ro/uk), both
messages.*.yamlandflashes.*.yamldomains.Tests
New unit test suite under
tests/Unit/:Form/Type/ApplyCouponTypeTest.php— extends Symfony 6.4TypeTestCaseper the guide; asserts synchronisation on valid + empty submissions, the form's singleTextTypecouponchild, the translation key onlabel, and initial-data pre-population.EventSubscriber/ApplyCouponSubscriberTest.php— full branch coverage with Prophecy doubles: subscribed-events shape, sub-request bypass, missing/empty/unmatched coupon query early-returns, error flash on coupon-level ineligibility, the success-vs-queued flash split based on promotion-level eligibility, and the orphan-promotion edge case.Controller/Action/ApplyCouponActionTest.php— verifies the action passes the cart's current coupon code (or null) into the form factory, calls Twig with the rendered form view, and returns a 200 Response with the rendered body.tests/Unit/DependencyInjection/and asserts the three FQCN-keyed services exist.Totals: 17 tests / 57 assertions, all green locally. ECS + PHPStan (
level: max) clean.Docs
UPGRADE.mddocuments every consumer-facing breaking change (PHP/Symfony/Sylius floors, layout move, route-import path change, removed services + classes, removed/_partial/couponroute, flash-message behaviour change, removed/rewritten translation keys, ORMTrait swap).README.mdrewritten: routing-import examples point at the new paths; new "How coupon application works" section explains the lenient-attachment design choice and the two flash outcomes; Stryker badge/dashboard links updated to3.x.CLAUDE.mdrewritten for v2 conventions (PHP DSL, FQCN ids,prepend()-over-YAML, ORMTrait, Prophecy mocks, TypeTestCase, ConstraintValidatorTestCase) and kept in sync as the implementation evolved through this PR.Out of scope
These playbook sections don't apply to this plugin and were intentionally not touched:
CollectionType→LiveCollectionType(no admin collection forms)ConstraintValidatorTestCase/ console-command tests (no validators or commands in repo)/ajaxadmin path conventions (no admin routes — admin surface is the grid action + modal, declared viasylius_grid+sylius_twig_hooksprepends)Test plan
composer fix-style/analyse/phpunitall green locallyvendor/bin/rector process --dry-runclean./tests/Application/bin/console lint:container/lint:yaml/lint:twigcleandoctrine:schema:create+doctrine:schema:validatesucceed against a local MySQL/admin/promotions/1/coupons/— Show URL icon button renders alongside update/delete with matchingbtn-iconsizing; clicking opens the modal seeded withsylius_shop_homepage; typing a new base URL live-updates the rendered URL and persists inlocalStorage; reopening the modal on another row remembers the chosen base URL; Copy button writes to the clipboard with confirmation feedback./en_US/coupon— form renders inside the standard Sylius shop page wrapper with a proper<title>block./en_US/?coupon=CHRISTMAS_SALEon a qualifying cart →successflash "The coupon code is now activated in your cart". On an unqualifying cart →infoflash "The coupon code is saved in your cart, but the discount is not active yet…". Coupon stays attached in both cases./couponpageKnown follow-ups
composer-dependency-analyserflagssylius/syliusas a dev-only dep used in src/ because the v2 metapackagereplacesthe sub-package classes. Same situation as the redirect plugin; the CI composite action handles it.infection.json5MSI floor is0.0for now (the suite has 17 tests but mutation coverage hasn't been audited). Tighten in a follow-up once the test suite stabilises.