Skip to content

Upgrade plugin to Sylius 2.x#15

Merged
loevgaard merged 12 commits into
3.xfrom
upgrade/sylius-v2
May 18, 2026
Merged

Upgrade plugin to Sylius 2.x#15
loevgaard merged 12 commits into
3.xfrom
upgrade/sylius-v2

Conversation

@loevgaard

@loevgaard loevgaard commented May 11, 2026

Copy link
Copy Markdown
Member

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 /coupon shop page to match Sylius 2 styling, expands the translation set, restructures coupon-application semantics, and adds a unit test suite.

Sylius v1 → v2 baseline

  • Composer floors: PHP ≥8.2, Symfony ^6.4 || ^7.4, Sylius ~2.2.5. setono/code-quality-packsetono/sylius-plugin: ^2.1. setono/doctrine-object-manager-traitsetono/doctrine-orm-trait.
  • File layout: src/Resources/{config,translations,views,public}/ → repo-root config/, translations/, templates/, public/. Bundle class adds getPath() override.
  • DI: XML → PHP DSL (config/services.php). Snake-case service ids → FQCN ids. XmlFileLoaderPhpFileLoader.
  • Doctrine: ApplyCouponSubscriber uses Setono\Doctrine\ORMTrait. Eligibility-checker service id updated to sylius.checker.promotion_coupon_eligibility.
  • Shop templates: extend @SyliusShop/shared/layout/base.html.twig; form theme @SyliusShop/form/theme.html.twig; Bootstrap 5 utility classes; /coupon wrapped in the standard container → row.justify-content-center → col-12 col-md-6 shop-page scaffold with a proper <title> block.
  • Test app: rebased against Setono/SyliusPluginSkeleton@2.2.x; SyliusTwigHooksBundle + the UX bundle stack registered; HEADER_X_FORWARDED_ALL replaced with explicit bitmask.
  • Tooling: Psalm → PHPStan (level: max). New rector.php, composer-dependency-analyser.php, infection.json5, tests/PHPStan/console_application.php. CI collapsed onto setono/sylius-plugin/*@v2 composite 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 GridDefinitionConverterEvent subscriber and a sylius_ui event override. Both are gone in Sylius 2 (sylius_ui events removed; the inline-input pattern doesn't fit the Sylius 2 admin look). Replaced with:

  • A per-row Show URL item action declared via sylius_grid config from the bundle extension's prepend(). Custom action template (templates/admin/promotion_coupon/grid/action/show_url.html.twig) uses the Sylius admin button.default({…, icon_only: true}) helper macro so it sizes/styles identically to the built-in update/delete icon buttons.
  • A single shared Bootstrap 5 modal (templates/admin/promotion_coupon/_modal.html.twig) and public/js/coupon-url-modal.js hooked into sylius_admin.promotion_coupon.index#javascripts via sylius_twig_hooks. The modal lets the admin edit the base URL inline, copies the resulting URL to the clipboard (navigator.clipboard with execCommand('copy') fallback), and remembers the last-used base URL across rows in localStorage.

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=CHRISTMAS before 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, ApplyCouponSubscriber now runs two checks:

  1. Coupon-level (PromotionCouponEligibilityCheckerInterface) gates attachment — duration, total usage, per-customer usage, channel. Failure → error flash, coupon not attached.
  2. Promotion-level (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.
    • Pass → success flash coupon_applied ("The coupon code is now activated in your cart").
    • Fail → info flash coupon_applied_not_fulfilled (coupon stays attached; discount activates as soon as the cart qualifies).

The coupon_applied_not_fulfilled wording 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/coupon route + templates/shop/partial/coupon.html.twig removed. Sylius 2's cart Live Component on /cart already exposes a native inline coupon input (hook sylius_shop.cart.index.content.form.sections.general#left), so the plugin's sub-request partial is redundant. ApplyCouponAction simplifies accordingly — no more RequestStack injection, no more isMainRequest branching, always renders templates/shop/coupon.html.twig.
  • coupon_already_applied translation key removed entirely. The action no longer fires any flash on its own; all flash messaging now lives in ApplyCouponSubscriber, 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.*.yaml and flashes.*.yaml domains.

Tests

New unit test suite under tests/Unit/:

  • Form/Type/ApplyCouponTypeTest.php — extends Symfony 6.4 TypeTestCase per the guide; asserts synchronisation on valid + empty submissions, the form's single TextType coupon child, the translation key on label, 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.
  • The existing extension test moved to 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

  • New UPGRADE.md documents every consumer-facing breaking change (PHP/Symfony/Sylius floors, layout move, route-import path change, removed services + classes, removed /_partial/coupon route, flash-message behaviour change, removed/rewritten translation keys, ORMTrait swap).
  • README.md rewritten: 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 to 3.x.
  • CLAUDE.md rewritten 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:

  • CollectionTypeLiveCollectionType (no admin collection forms)
  • Doctrine PHPDoc → attribute mappings (plugin ships no entities)
  • ConstraintValidatorTestCase / console-command tests (no validators or commands in repo)
  • /ajax admin path conventions (no admin routes — admin surface is the grid action + modal, declared via sylius_grid + sylius_twig_hooks prepends)

Test plan

  • composer fix-style / analyse / phpunit all green locally
  • vendor/bin/rector process --dry-run clean
  • ./tests/Application/bin/console lint:container / lint:yaml / lint:twig clean
  • doctrine:schema:create + doctrine:schema:validate succeed against a local MySQL
  • Playwright walkthrough on the test app:
    • /admin/promotions/1/coupons/ — Show URL icon button renders alongside update/delete with matching btn-icon sizing; clicking opens the modal seeded with sylius_shop_homepage; typing a new base URL live-updates the rendered URL and persists in localStorage; 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_SALE on a qualifying cart → success flash "The coupon code is now activated in your cart". On an unqualifying cart → info flash "The coupon code is saved in your cart, but the discount is not active yet…". Coupon stays attached in both cases.
  • CI matrix green across PHP 8.2/8.3/8.4 × Symfony 6.4/7.4 × lowest/highest
  • Reviewer visual sign-off on the new admin modal + the /coupon page

Known follow-ups

  • composer-dependency-analyser flags sylius/sylius as a dev-only dep used in src/ because the v2 metapackage replaces the sub-package classes. Same situation as the redirect plugin; the CI composite action handles it.
  • infection.json5 MSI floor is 0.0 for now (the suite has 17 tests but mutation coverage hasn't been audited). Tighten in a follow-up once the test suite stabilises.

loevgaard added 5 commits May 11, 2026 13:22
- 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.
Comment thread tests/Application/config/packages/_sylius.yaml
loevgaard added 7 commits May 11, 2026 15:30
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.
@loevgaard loevgaard merged commit d091079 into 3.x May 18, 2026
37 of 38 checks passed
@loevgaard loevgaard deleted the upgrade/sylius-v2 branch May 18, 2026 06:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant