| name | spryker-upgrade |
| description | Upgrade this Spryker project's modules/features to a newer release. First checks whether the project's customisations are covered by tests at all and offers to close that gap, then resolves the constraint blockers that stop a release-group bump from resolving, then detects the silent damage (PHP 8.3 typed-member fatals, dead method overrides, shadowed Twig/component changes, replaced plugin stacks, broken config constants, transfer strict mismatches) and requires a migration guide for every major module bump. Trigger on "upgrade the project", "update to latest release", "update spryker modules/features", "run the upgrade", or any request to bump spryker-feature/* or spryker/* packages. |
Spryker Project Upgrade
Non-negotiables — read these before Phase 0, re-read them on every compaction
Twelve phases and six lanes is more than fits in one working memory, and these four are the rules a
long run has been observed to drop. They are not repeated in § Hard rules at the end of the file.
- An upgrade changes versions, not architecture. Never integrate a feature, DI mechanism,
storage backend or extension point the developer did not explicitly ask for, however strongly the
new version invites it. A missing capability is not damage. If restoring existing behaviour looks
impossible without adopting the new thing, that is a vendor BC break to report, not a
re-architecture to perform. Full rule + worked example: § Scope.
Failure signature: the plan contains a file the project never had (
config/bundles.php, a new
service config, a new provider registration) that no phase's detector asked for.
- Lane 0 is not skippable. No major bump ships without its migration guide processed, or an
explicit
none published record naming the module and version pair.
Failure signature: list-major-bumps.php lists a module that appears in no Lane 0 record.
- Re-run the detectors after EVERY resolution lane. Fixes create new conflicts, and a fix in
one lane routinely resolves or reveals entries in another.
Failure signature: a lane closed on a report file whose mtime predates that lane's last edit.
- A red detector, PHPStan regression, or failing test blocks the next phase. Never suppress.
Not with a baseline entry, not with a phpcs/phpstan ignore, not by narrowing the analysed paths.
Failure signature: the diff touches
phpstan-baseline.neon, a @phpstan-ignore, or a
detector's exclude list — none of which is ever part of an upgrade's deliverable.
Plan tracking is mandatory, and it is what makes the above survive a multi-hour run. At the end
of Phase 0, arrange the phases and lanes as a task list with TaskCreate — one task each for
Phase 0.5, 1, 1.5, 2, 3, Lane 0, Lanes 1–4, Lane 5 (if reached), Phase 4.9, 5, 5.5, 6, 6.5, 7 — and
drive it with TaskUpdate: in_progress on entry, completed only once that phase's or lane's
detectors are green. On a re-run of the detectors that reopens a lane, reopen that lane's task rather
than opening a new one. TaskList is the cheapest thing to read to re-orient, and it survives
compaction, which the phase you are currently in otherwise does not. This is the same pattern
spryker-bugfix drives its twelve stages with. An upgrade that has lost track of which lane it is in
has also lost the "re-run the detectors after every lane" rule — the two failures always arrive
together.
Locate the scripts: .claude/skills/spryker-upgrade/scripts/ (setup install, relative to the
project cwd) or ${CLAUDE_PLUGIN_ROOT}/skills/spryker-upgrade/scripts/ (plugin install). $UP
below is shorthand for whichever resolves — substitute it inline as a literal path; never set a
shell variable and never cd, both prompt on every call. Always invoke them from the project
root: they discover the project from the working directory (override with SPRYKER_PROJECT_ROOT)
and write every snapshot and report into <project>/.spryker-upgrade/state/, which is created
self-gitignoring so no baseline or merge artifact can ever be committed.
Orchestrated upgrade of a Spryker project to a newer release. Deterministic detection is done by
the fourteen scripts in $UP/ (see README.md next to this skill); your job is to run
the phases in order, do the semantic resolution work, and stop at the decision gates that belong to
the developer.
Start by establishing whether the upgrade can be verified at all (Phase 0.5). Everything a Spryker
project overrides fails quietly, so an upgrade with no tests over the override surface is not an
upgrade you can report on — offer to close that gap before moving a single constraint.
Scope: an upgrade updates what the project already has. Nothing else.
The deliverable is the project doing exactly what it did before, on newer versions. Not the
project modernised, not the project on the vendor's latest recommended architecture.
- Moving a module to a newer version is not consent to adopt what that version enables. A module
can arrive carrying a new architecture, a new DI mechanism, a new storage backend, a new extension
point. None of that gets integrated because the version moved.
- No new feature is integrated unless the developer explicitly asked for it. This is the same rule
as Phase 6's new-features gate, and it applies with equal force to new capabilities that appear
inside a module the project already had — that case is easier to miss precisely because no new
package shows up in the lock diff.
- A missing feature is not damage. Damage is the project's existing behaviour changing or
disappearing. A capability the project never used and still does not use is not a finding.
Worked example, from the validation run. spryker/container 1.4.6 → 1.11.0 shipped
ContainerDelegator, whose presence flips Kernel::boot() onto a Symfony-DI code path. The project
had no config/bundles.php, so application plugins stopped booting and the Back Office login broke.
- Wrong resolution: "adopt the Symfony container — add
config/bundles.php and service config."
That integrates a new architecture the developer never asked for, in an upgrade ticket, and it is
what this skill first recommended. It is out of scope.
- Right resolution: restore the behaviour the project had. Find the minimal way to keep
application plugins booting as before, and if the new version genuinely offers no such path, that is
a vendor BC break — report it as a blocker with evidence and let the developer decide, rather
than quietly re-architecting the project to route around it.
Restoring behaviour usually means a narrowly-guarded project-side shim. When a vendor version
changes a lifecycle the project depended on, the in-scope fix is to re-establish the old behaviour from
project code — override the project's own bootstrap/factory and call what the vendor stopped calling.
Two rules make this safe:
- Guard on the exact broken condition, not on the version. The shim must disable itself once the
condition disappears — because the vendor fixed it, or because the project later adopted the new
path. A shim that always fires becomes a double-execution bug the moment upstream is fixed.
- Check for idempotence before assuming a re-run is harmless. On the validation run
Application::$booted was never set to true, so calling registerPluginsAndBoot() unguarded would
have re-registered a security event subscriber on every request.
Mark it as upgrade debt in the docblock, with the condition for removal and a pointer to the vendor
issue. It is a bridge until upstream is fixed, not the destination.
When the only apparent fix is "integrate the new thing", stop and say so. Presenting an
architecture migration as an upgrade step, without ever asking, is the failure mode this section
exists to prevent. Offer the new capability as a separate, explicitly-scoped piece of work with
its own decision — never as a side effect of a version bump.
Sibling skills to use rather than reinvent:
spryker-docs-research — locating and reading migration guides / release notes for Lane 0.
codecept-functional — writing the characterization tests Phase 0.5 asks for.
cypress-migration — E2E coverage for overridden templates (Lane 2's verification side).
static-validation — phpcs/phpstan over the changed files after each lane.
spryker-runtime / boot-and-verify — booting and exercising the app once the code resolves.
This was validated end to end on a real 202410.0 → 202606.0 upgrade across five release groups;
EXAMPLE-UPGRADE-REPORT.md is that run written up, and the matrix below is what it
actually hit rather than what the docs predicted.
Environment:
Never conclude the environment is unavailable from a missing or empty docker/ directory.
In most Spryker projects docker/ is a git submodule and an un-initialised submodule looks
exactly like an absent one — an empty directory. Establish it with three commands before making
any claim, and re-run them before writing "not verifiable" in a report:
git submodule status # a leading '-' means present but NOT initialised
git submodule update --init --depth 1 docker
docker info --format '{{.ServerVersion}} CPUs={{.NCPU}} Mem={{.MemTotal}}'
This is not hypothetical: on the validation run the entire Phase 5 verification set was reported
as "no docker environment on this host" when the submodule simply had not been initialised and the
Docker daemon had been running the whole time. Everything downstream — migration diff, index map,
the asset build, every browser check — was reachable. Declaring work unverifiable is a
load-bearing claim; earn it.
- Prefer running composer and CI-grade checks inside docker:
script -q /dev/null docker/sdk cli <cmd> (a pseudo-TTY is required in non-interactive shells). The detector scripts use reflection
or static parsing only and run fine on host PHP.
- Without
docker/sdk, console commands need the environment passed explicitly and a raised memory
limit — see Phase 5.
- Mutagen two-way sync can resurrect deleted files — delete in the container too, then
mutagen sync flush <sync-session>.
- Never modify files under
vendor/ — all resolution happens in src/Pyz and config/. The one
exception is a package whose bundles are merged into the root via composer-merge-plugin: that is
another repository and must be fixed there (matrix #39/#40).
Coverage matrix — every upgrade failure mode and what catches it
| # | Use case | Detection | Resolution |
|---|
| 1 | Overridden vendor method removed/renamed (logic silently unhooked) | check-dead-overrides.php (OVERRIDE_ORPHANED) | Lane 1 |
| 2 | Vendor parent class/interface removed → Pyz class fatals | check-dead-overrides.php (CLASS_BROKEN) | Lane 1 |
| 3 | Vendor interface gained a method → Pyz implementor fatals | check-dead-overrides.php (CLASS_BROKEN) + PHPStan | Lane 1 |
| 4 | Constructor/signature change in classes Pyz factories instantiate | PHPStan (level 6, docker) | Lane 1 |
| 5 | Vendor constant/class removed that config/* references | check-config-constants.php | Lane 4 |
| 6 | Vendor constant removed that Pyz code references | PHPStan | Lane 1 |
| 7 | Shadowed Yves Twig/scss/ts changed in vendor (change never reaches shop) | twig-shadow-map.php (VENDOR_FILE_CHANGED) | Lane 2 |
| 8 | Shadowed Zed Presentation twig (Backoffice, OMS mails) changed | twig-shadow-map.php (Zed scopes) | Lane 2 |
| 9 | Shadowed vendor template removed/renamed | twig-shadow-map.php (VENDOR_FILE_REMOVED) | Lane 2 |
| 10 | New vendor file in an overridden component scope (template split, new sub-component) | twig-shadow-map.php (NEW_VENDOR_FILE, info) | Lane 2 |
| 11 | Frontend build contract changes (ShopUi, webpack, tsconfig, node deps) | npm run yves build in docker | Lane 2 |
| 12 | Wired vendor plugin removed → stack replaced | check-plugin-usage.php (MISSING) | Lane 3 |
| 13 | Wired vendor plugin deprecated (replacement named in note) | check-plugin-usage.php (DEPRECATED) | Lane 3 |
| 14 | Project plugin implements deprecated/removed vendor interface |
If during the run you meet a failure mode not in this table, add it to the table and to the
detectors (or Phase 5 checks) before finishing the run — the matrix must stay exhaustive.
Phase 0 — Preflight (abort on failure)
git status must be clean; create branch upgrade/<target-release>.
- Baseline snapshots (all BEFORE touching composer):
php $UP/check-dead-overrides.php snapshot
php $UP/twig-shadow-map.php snapshot
php $UP/check-plugin-usage.php || true # baseline: MISSING here = pre-existing damage
php $UP/check-config-constants.php || true # baseline: problems here = pre-existing damage
php $UP/check-typed-members.php || true # baseline: should be clean before upgrading
php $UP/check-constraint-style.php || true # baseline: patch-locked + merged constraints
php $UP/check-platform-alignment.php || true # is this host even valid to resolve on? (do this FIRST)
php $UP/check-test-coverage.php || true # baseline: is the override surface verifiable at all?
php $UP/check-vendor-class-replacement.php || true # baseline: classes declared in vendor namespaces
mkdir -p .spryker-upgrade/state && cp composer.lock .spryker-upgrade/state/composer.lock.before
The snapshots must be taken against a COMPLETE vendor tree — run composer install first and
confirm vendor/autoload.php resolves a core class, or the maps silently under-record.
- If baselines already report MISSING plugins, config problems, or unloadable classes,
surface them: these are pre-existing breaks, not upgrade fallout — typically leftovers from a
previously removed feature. Offer to fix first; at minimum record the counts so post-upgrade
reports can be de-noised against them.
- Baseline quality gates (record, don't fix):
script -q /dev/null docker/sdk cli vendor/bin/evaluator evaluate
script -q /dev/null docker/sdk cli php -d memory_limit=2048M vendor/bin/phpstan analyze -c phpstan.neon src/ -l 6
- Baseline test run — the post-upgrade suite result is meaningless without it, because projects
routinely start with red or skipped tests:
script -q /dev/null docker/sdk cli vendor/bin/codecept build
script -q /dev/null docker/sdk cli vendor/bin/codecept run --no-exit 2>&1 | tee .spryker-upgrade/state/codecept-baseline.txt
Record pass/fail/skip counts per suite. If a suite cannot run here at all (no DB, no search
backend, no webdriver), say which and why — an unrunnable suite is not a passing suite.
- Arrange the plan as a task list — last action of Phase 0, before any constraint moves.
TaskCreate one task per phase and lane (P0.5 Verifiability gate, P1 Target selection,
P1.5 Constraint style, P2 Composer update, P3 Detection, L0 Migration guides,
L1 Dead overrides, , , ,
, , , ,
, ), then drive it with per § Non-negotiables. Add
only if Lane 5 is actually reached. Skipping this step is the single
change that most reliably loses the phase order on a long run.
Phase 0.5 — Verifiability gate (developer gate #1)
Do this before touching a single constraint. An upgrade can only be trusted as far as it can be
verified, and the parts of a Spryker project that break silently are exactly the parts that
customise core: a dead override still loads, a replaced plugin stack still boots, a stale template
still renders. Nothing turns red — the behaviour just leaves.
php $UP/check-test-coverage.php # gaps, highest risk first
php $UP/check-test-coverage.php --all # full per-module surface
It intersects the upgrade risk surface (overridden vendor methods, wired vendor plugins, shadowed
templates, per <Layer>/<Module>) with what the test suite actually touches, and ranks the gaps.
Exit 1 means at least one HIGH-risk module has no test at all.
Read it honestly and report two numbers to the developer: how many business-logic overrides
exist, and how many of those sit in modules with no test. Then present the options with
AskUserQuestion — this is a decision about risk appetite, not something to decide silently:
- Write characterization tests for the HIGH-risk gaps first (recommended) — pin current
behaviour before the upgrade so the tests fail if the upgrade changes it. A characterization
test written after the upgrade only pins whatever the upgrade produced, which proves nothing.
- Cover a chosen subset — typically the modules whose logic is business-critical (checkout,
pricing, cart, order flow) even if the score ranks something else higher; the developer knows
which those are.
- Proceed without new tests — legitimate for a small step or a project with thin
customisation, but record it: the final report must then state that the affected lanes were
verified statically only.
If tests are to be written, hand the writing to the codecept-functional skill (and
cypress-migration for E2E paths) — this skill decides what needs covering, those know the
project's suite conventions. Write them in this order and commit them on their own, before the
upgrade branch diverges — they must be provably green against the current version:
- modules with HIGH risk and business-logic overrides — a test per overridden method's observable
output, not per class;
- dependency providers carrying large vendor plugin stacks — assert the stack contents (class
names, order where it matters), since that is what a replaced stack changes;
- overridden templates — one acceptance/Cypress path per customised page, asserting the project's
own additions are on the page rather than re-testing core markup.
Keep them characterization tests: assert what the code does today, including quirks. The goal is a
tripwire for the upgrade, not a specification. When a test fails on first run, assume the assumption
was wrong before assuming the code is — an absent array-typed transfer field yields [], not null,
and that IS the behaviour to pin.
Split the worklist by what you can actually prove. Overrides in Business/, Service/,
Client/ are usually pure logic, provable green on plain host PHP with mocked constructor
dependencies. Overrides in Communication/ (forms, tables, controllers) and Persistence/ need a
container and a database. Draft the second group, but never report it green from a host that cannot
run it — separate the two counts in the report. Practical notes for a host run:
vendor/bin/console transfer:generate # FIRST — src/Generated is gitignored, so a fresh checkout
# fails every test with "Class Generated\...\* not found"
php -d register_argc_argv=On vendor/bin/codecept build -c tests/PyzTest/<Layer>/<Module>
php -d register_argc_argv=On vendor/bin/codecept run -c tests/PyzTest/<Layer>/<Module>
Codeception refuses to start without register_argc_argv, and a new module suite needs its own
codeception.yml; keep its modules.enabled minimal (Asserts + the project's Environment helper)
so a unit suite does not drag in Propel or locator helpers it does not need.
Phase 1 — Target selection (developer gate #2)
Build the current-state inventory from composer.lock. composer.json is the intent, not the
state. The lock records what is actually installed; the root constraints record what someone once
asked for, and the two diverge routinely — unpinned or wildcard constraints, individual modules
bumped since the feature was pinned, and release notes that never state which version to use. Every
other lock-aware step already works this way (Phase 0 snapshots the lock; list-major-bumps.php
diffs before/after; check-platform-alignment.php reads the lock's platform requirements;
unpin-feature-driven-modules.php reads feature requirements from the lock because the metapackages
install no files). Target selection was the last step reading the intent instead.
# authoritative current state — resolved versions
php -r '$l=json_decode(file_get_contents("composer.lock"),true); foreach($l["packages"] as $p) { if (str_starts_with($p["name"],"spryker-feature/")) { echo $p["name"]," ",$p["version"],PHP_EOL; } }'
# the intent, for comparison only
grep '"spryker-feature/' composer.json
- Report divergence BEFORE the update, one line per feature:
<feature>: lock <resolved> / json <constraint>. Name every constraint that does not pin the resolved version exactly (^, ~,
*, dev-* — a loose constraint is a divergence to state, never a version to infer from) and
every feature present in one file and missing from the other. This is a finding for the developer,
not something to silently normalise — and it is the answer whenever a release note is vague about
which version the project is on. Coverage-matrix row 38 covers the extreme case (a lock stale
enough that composer reports constraints for packages no longer in composer.json); lock-first is
the normal path, that row is the escape hatch.
Failure signature: a target chosen from a ^x.y constraint, so the "current" release named in
the report is not the release the lock installed — later phases then diff against a baseline the
project never ran.
- Find newer releases:
composer show -a spryker-feature/spryker-core | grep versions.
- Ask with AskUserQuestion — never pick silently:
- Next release group (recommended) — smallest reviewable step; repeat the skill per release.
- Latest release — one big jump; more conflicts per pass.
Phase 1.5 — Constraint style (do this BEFORE the first composer update)
First: resolve for the project's platform, not the machine you are sitting on
Do this before the first composer update, because every lock produced without it may be
uninstallable and you will not find out until something tries to deploy.
php -v | head -1 # the machine you would resolve on
grep -hoE 'tag: spryker/php:[0-9.]+' deploy*.yml | sort -u # what the project actually runs
python3 -c "import json;print(json.load(open('composer.json')).get('config',{}).get('platform','ABSENT'))"
If the host PHP minor differs from the image and config.platform is absent, stop and fix that
first:
"config": { "platform": { "php": "8.3.2" } }
require.php does not protect you — ">=8.3" is satisfied by a host on 8.5, so composer will
happily select dependencies (usually dev tooling: doctrine/instantiator, symfony/*,
phpunit) that require a PHP the container does not have. Choose the lowest patch that satisfies
the lock rather than the image's current patch, because spryker/php:8.x is a floating tag and
environments pull it at different times.
Then run composer inside the container anyway:
script -q /dev/null docker/sdk cli composer update …
script -q /dev/null docker/sdk cli composer check-platform-reqs # must be 0 failures
The platform pin fixes the PHP version; it does not give the host the project's extensions. A
developer machine without ext-redis or ext-pgsql cannot resolve spryker/redis at all. Never
reach for --ignore-platform-req to get past that — it re-creates exactly the unrunnable lock the
pin was added to prevent.
On the validation run this was found only when docker/sdk up failed at composer install, several
phases after the damage was done, and it also meant the whole characterization suite had been
running on the wrong PHP minor.
Then: the constraint style itself
A Spryker project pins hundreds of individual modules next to the spryker-feature/*
meta-packages. Any module pinned with ~x.y.z (patch-only) or an exact version will produce
"conflicts with your root composer.json require" instead of upgrading, so bumping the feature
packages alone cannot resolve. Same trap for ^0.x — caret on a 0.x package is major-locked.
php $UP/check-constraint-style.php # report patch-locked constraints
php $UP/check-constraint-style.php --relax # rewrite ~/exact -> ^ (review the diff)
Then bump the feature constraints to the target release and let the resolver iterate — conflicts
arrive in waves, each bump revealing the next transitive layer:
php $UP/resolve-constraints.php --max-rounds=8
It bumps root constraints to what the tree demands, round by round, logs every change to
.spryker-upgrade/state/constraint-resolution-log.json, and flags each bump that crosses a major boundary —
that flagged list IS the Lane 0 migration-guide worklist. Review git diff composer.json.
OSCILLATION / "would LOWER" means a cohort must move together — two halves of a plugin family
demanding different majors of a shared module. Per-package bumping cannot break that tie; hand it to
the un-pinner so the feature meta-packages decide, then resolve again:
php $UP/unpin-feature-driven-modules.php --match=<substr,substr> --dry-run
php $UP/unpin-feature-driven-modules.php --match=<substr,substr>
php $UP/resolve-constraints.php --max-rounds=8
Modules not governed by any feature package are never unpinned, so bump those explicitly to a
version whose own requirements match the cohort (find it from packagist metadata).
UNRESOLVED entries need a human decision and must be surfaced, never worked around:
- a third-party/eco package pinning an old core major — before offering options, check what it
actually uses from the blocking module: if it references a class the new major removed, no
constraint change can help and it needs upstream code work (Lane 5);
- a transitive dependency blocked by a security advisory. Read the advisory's affected range first:
usually a safe newer version exists and the real blocker is an exact root pin. Never set
policy.advisories.block: false or add ignore-ids to get past this — it is a security decision
for the developer, and disabling it hides real vulnerabilities.
Phase 2 — Composer update
- Confirm every
spryker-feature/* constraint is on the chosen release.
script -q /dev/null docker/sdk cli composer update "spryker-feature/*" "spryker/*" "spryker-shop/*" "spryker-eco/*" --with-all-dependencies
- On resolution failure read
composer why-not, adjust the blocking constraint, retry.
No --ignore-platform-reqs inside docker; on host it IS needed.
- Record every constraint the resolver changed — the composer.json diff is part of the review.
Phase 3 — Detection (run all, collect before resolving)
php $UP/list-major-bumps.php # majors/new/removed classification
php $UP/check-typed-members.php src/Pyz # exit 1 = PHP 8.3 typed-member fatals
php $UP/check-dead-overrides.php verify # exit 1 = conflicts
php $UP/twig-shadow-map.php diff # exit 1 = conflicts
php $UP/check-plugin-usage.php # exit 1 = missing plugins
php $UP/check-config-constants.php # exit 1 = broken config refs
script -q /dev/null docker/sdk cli php -d memory_limit=2048M vendor/bin/phpstan analyze -c phpstan.neon src/ -l 6
Run check-typed-members.php FIRST: typed-member fatals abort vendor/bin/console itself, so
nothing else in Phase 5 can run until they are fixed. If the project uses composer-merge-plugin,
scan the merged tree too — its bundles break the console just as effectively:
php $UP/check-typed-members.php src/Pyz vendor/<vendor>/<pkg>/Bundles
Subtract the Phase 0 baseline findings from plugin/config reports (only NEW findings are
upgrade fallout). Present a summary table: packages moved (majors highlighted), conflicts per
lane, NEW packages.
Reading PHPStan on a host without infrastructure. Most of its output will be environment
noise, not damage. Categorise before drawing conclusions — errors mentioning Orm\Zed\* or
Spy*EntityTransfer need propel:install + a database, Generated\Shared\Search\*IndexMap needs
a search backend, and Constant APPLICATION_* needs the Spryker bootstrap. Only what remains after
removing those is upgrade damage. In the reference run that residue was 4 constructor-arity
errors — real breakage no reflection-based detector can see.
Lane 0 — Migration guides (MANDATORY for every major bump)
For each package in the MAJOR list of .spryker-upgrade/state/lock-diff-report.json:
-
Locate the guide — use the spryker-docs-research skill, or WebSearch
site:docs.spryker.com upgrade the <Module> module directly. Guides live
as "Upgrade the module" pages (e.g.
docs.spryker.com/docs/pbc/all/<pbc>/<version>/base-shop/install-and-upgrade/upgrade-modules/upgrade-the-<module>-module.html).
-
WebFetch the page and extract the sections covering the crossed major boundary (a guide
covers multiple majors; only the crossed range applies, e.g. 10.x → 11.0 section).
-
Sort the steps into two piles before executing any of them — guides freely mix them, and the
distinction is the Scope rule in practice:
- Required to keep working — interface swaps, constant→config-method moves, plugin rewiring,
schema/transfer adjustments, console commands. Execute these; they restore existing behaviour.
- New capability the guide offers — "to use the new X, register Y", "enable the new Z by
adding this config". Do not execute these. They are Phase 6 material: list them for the
developer as separate opt-in work, even when the guide presents them as ordinary numbered steps
alongside the mandatory ones.
When a step is ambiguous, ask what breaks if it is skipped. If the answer is "nothing the project
currently does", it is a new capability, not a migration step. Cross-check the required pile
against the detectors' findings — guide steps usually explain WHY a detector fired; a step no
detector corroborates and no existing behaviour needs is a strong candidate for the second pile.
-
If no guide exists (some modules have none), fall back to the module's CHANGELOG.md and the
GitHub compare URL from the report; extract the [BC]/breaking notes for the crossed majors.
-
If neither yields clarity, STOP for that module and surface it to the developer — never
invent migration steps.
-
Record per module: guide URL (or "none published"), steps applied, steps deliberately not
applied because they introduce a new capability (with what they would have added), and steps
skipped for other reasons + why.
This table goes into the final report verbatim.
Lanes 1–4 — Conflict resolution
Work lane by lane; commit each lane separately. Re-run all detectors after every lane.
Lane 1 — Dead overrides & broken classes (dead-overrides-report.json, typed-members-report.json, PHPStan)
Fix in this order, because each unblocks the next:
1a. Typed members (check-typed-members.php) — the console will not start until these are gone.
1b. Constructor arity (PHPStan constructor invoked with N parameters, M required) — reflection
cannot see this. Compare the project factory's create*() against core's and mirror the current
argument list including order; core both appends and reorders. Prefer delegating to parent:: and
adding only the project's extra value, so future core additions flow through by themselves.
1c. Signature changes on overridden methods. When core narrows a return type to a bridge that
drops methods the project needs (e.g. a session client exposing only set/remove), do not fight
it: register the raw dependency under a project key (PYZ_*) and expose it via a distinctly named
accessor, leaving core's contract intact. Check whether the project also overrides the registration
of that key — if so it may now be handing core the wrong type.
1d. Dead overrides. For each OVERRIDE_ORPHANED / CLASS_BROKEN entry:
- Check Lane 0 first: the migration guide for that module usually names the replacement.
Otherwise diff the vendor class between versions (GitHub compare URL / composer cache).
- Resolution preference order:
a. New extension point exists → move business logic into a project plugin, wire it, delete override.
b. Logic moved to another method → re-anchor the override against the new structure.
c. No seam → override the (larger) calling method; mark
// upgrade-debt:; tell the
developer an extension-point request to Spryker is warranted.
d. New core already covers the business requirement → delete the override.
- Every touched flow needs a test proving behavior survived — write a characterization test
BEFORE porting if none exists.
Lane 2 — Shadowed frontend/presentation files (twig-conflicts-report.json)
Batch the mechanical part first:
php $UP/merge-shadowed-files.php --dry-run # classify
php $UP/merge-shadowed-files.php --apply # write the clean merges
It sorts every conflict into CLEAN (applied), IDENTICAL (the override carried no customisation at
all — delete it), CONFLICTED (left untouched; the merged result is written beside the file as
<file>.merge-conflict for review) and REMOVED. Never commit the .merge-conflict files —
they are gitignored, but a stray git add -A will pick them up anyway.
Expect the clean-merge rate to be low. In the reference run only 15 of 163 merged cleanly: the rest
conflict because the override restructured the component rather than tweaking it, so no textual
merge can carry the vendor change across. Those need a design decision each, and a worklist is more
useful than a bad merge — generate one rather than forcing it.
- VENDOR_FILE_CHANGED: run the report's three-way merge command
(
git merge-file -p <project> <baseline> <vendor-new>). Clean merge → review and apply;
conflict markers → resolve semantically (vendor structural changes win, project business
content wins).
- Components are triplets — if a
.twig changed, check sibling .scss/.ts entries.
- Small customizations: convert the full override to a block-level extension
(
{% extends organism/molecule(...) %} + {% block %}) — permanently shrinks the surface.
- VENDOR_FILE_REMOVED → changelog/guide for the rename; re-point or drop the override.
- NEW_VENDOR_FILE (info) → check whether the overridden parent template must now include it.
- Zed Presentation entries (Backoffice twig, OMS mail templates) follow the same merge flow.
- Rebuild:
script -q /dev/null docker/sdk cli npm run yves — zero tsc/webpack errors.
Lane 3 — Plugin stacks (plugin-usage-report.json)
MISSING is the only category that is upgrade damage. DEPRECATED plugins still work; treat them
as separate maintenance so they cannot destabilise the upgrade.
- MISSING vendor plugins — the replacement comes from the Lane 0 guide or the old class's
@deprecated note (read it from the composer-cache copy of the OLD package, since the class is
gone from the new one). Rewire in the position/order the guide specifies.
When a whole module was removed in favour of a feature, check whether the replacement widgets are
registered globally now — the per-page widget-plugin lists that held the old ones may simply be
deleted. Watch for empty override methods left behind: an override returning [] suppresses
core's own defaults, so delete the method rather than leaving it empty.
Also carry over the extension points the removed module's dependency provider wired — core
frequently registers nothing by default there, so those plugins are silently lost otherwise.
- DEPRECATED vendor plugins — classify before touching anything:
- replacement on the same extension-point interface, single import, single registration →
mechanical swap, safe;
- replacement already imported and registered next to the deprecated one → delete the
deprecated entry; substituting duplicates the import and double-registers;
- two deprecated plugins naming the same successor → a consolidation across two lists; pick
which one keeps it;
- replacement on a different extension point → move it to the other key, and treat it as
porting because the semantics change;
- no replacement named → decide whether the behaviour is still wanted.
Do not batch-apply this category. Compare the two classes'
Extension/Dependency\Plugin
interfaces to tell "same extension point" from "different", and lint every touched file.
- PROJECT plugins needing porting: characterization test → port to new interface (granularity
often changes: one plugin may become several strategy plugins) → wire → verify test.
Lane 4 — Config constants & transfer definitions
Config (config-constants-report.json): for each TYPE_MISSING / CONSTANT_MISSING the migration
guide names the replacement (typical pattern: XConstants::FOO moves to XConfig::getFoo() — then
the value belongs in a Pyz config class override, not config_default.php). Apply, and verify with
script -q /dev/null docker/sdk cli vendor/bin/console config:convert-check || true plus a
console boot smoke test (... vendor/bin/console list >/dev/null).
Transfer XML. transfer:generate refuses to merge a definition whose strict attribute differs
from any other definition of the same thing, and it reports one violation per run — so scan for
all of them at once instead of iterating. Check both levels, because they are separate failures:
# property level: <property name="x" strict="true"/>
# transfer level: <transfer name="X" strict="true">
For each project transfer/property that core also declares, match core's strict value. Note a
strict transfer generates typed constants (public const string FOO = 'foo') rather than untyped
ones — the constants still exist, so Transfer::FOO references keep working.
Lane 5 — A dependency with no compatible release (developer decision)
When resolve-constraints.php reports UNRESOLVED for a third-party/eco package, first establish
why, because it changes the options:
- scan its versions on packagist for one that allows the target majors;
- if none, check what it actually uses from the blocking module. If it only touches stable APIs,
a constraint widening upstream is enough (that is a PR to them). If it references a class the new
major removed, no constraint change can help — it needs upstream code work.
Present that finding with the options (drop / fork / wait) and let the developer choose. Never drop a
feature unilaterally — measure its footprint first (grep -rl the module name across src/ and
config/) and report the file count, because that is what makes the decision.
If the decision is to drop: remove the package, its plugin registrations, the project module, and
every asset/twig/JS reference. Two traps — keep project functionality that merely hosted an AI/
vendor hook (e.g. a form field whose only AI link was a template_path attribute), and when a
removed partial was {% embed %}-ed around project markup, unwrap it rather than deleting the
block, or the wrapped fields disappear with it.
Phase 4.9 — Boot the environment (attempt this; do not assume it is impossible)
Everything from here on needs a running stack. Delegate the boot itself to the local-dev-env /
boot-and-verify sibling skills where they apply; what follows is what an upgrade specifically
needs from the boot.
git submodule update --init --depth 1 docker # see the Environment note above
script -q /dev/null docker/sdk boot deploy.dev.yml # generation only, cheap, validates the deploy file
script -q /dev/null docker/sdk up --build --assets --data
Read the deploy file before booting — it changes the whole verification plan:
- Which applications exist.
grep -oE "application: [a-z_]+" deploy.*.yml | sort -u. A project
with no yves entry is headless: there is no storefront to check, and the Back Office is the
only UI. Do not write a storefront checklist for a project that has no storefront (the validation
run nearly did).
- How many stores. A 9-store deploy with 8 workers per application will not fit in a laptop's
Docker memory alongside OpenSearch, MariaDB, RabbitMQ and Jenkins. If
docker info reports less
than ~16 GB, expect to verify against one store. Prefer an additive
deploy.upgrade-verify.yml (copy, one region/store, optional dev services dropped) over editing
the project's own deploy files — and say in the report that verification ran on a trimmed topology,
because a single-store boot does not exercise store-resolution paths.
docker.testing.store — the store the test suites will use.
boot prints a sudo … /etc/hosts command. You cannot run it (it needs the developer's
password): surface it verbatim and continue — the build and the whole CLI/test path do not need it,
only browser access by hostname does.
Run the build in the background with a monitor covering failure signatures, not just progress:
a filter that greps only for success markers stays silent through an OOM kill or an image-pull
failure, and silence is indistinguishable from "still building".
If the boot genuinely cannot complete (insufficient memory, an image the project has no credentials
for), that is a real limit — record which checks it costs, using the Phase 5/5.5/8 lists below as
the inventory of what is now unproven. "Could not boot" is only acceptable after git submodule status and docker info have both been shown.
Phase 5 — Regenerate artifacts & verify
script -q /dev/null docker/sdk cli vendor/bin/console transfer:generate
script -q /dev/null docker/sdk cli vendor/bin/console propel:migration:diff # inspect; NEVER auto-migrate
script -q /dev/null docker/sdk cli vendor/bin/console search:setup:index-map
script -q /dev/null docker/sdk cli vendor/bin/console navigation:build-cache
script -q /dev/null docker/sdk cli vendor/bin/evaluator evaluate
script -q /dev/null docker/sdk cli php -d memory_limit=2048M vendor/bin/phpstan analyze -c phpstan.neon src/ -l 6
script -q /dev/null docker/sdk cli vendor/bin/codecept build
script -q /dev/null docker/sdk cli vendor/bin/codecept run -x Acceptance
script -q /dev/null docker/sdk cli npm run yves
Check git status after every Phase 5 command. They are not read-only: propel:install runs
migration cleanup and deletes tracked src/Orm/Propel/*/Migration_* files (gitignore does not
protect files committed before the ignore rule), propel:config:convert writes generated config into
a path the project may not ignore, and npm/asset builds rewrite lock files. Restore deletions with
git checkout --, gitignore new generated paths, and keep genuine lock updates in their own commit.
Command names differ between projects and releases — read console list instead of trusting this
list. On the validation project the commands were propel:diff and search:setup:source-map;
propel:migration:diff and search:setup:index-map did not exist.
- Non-empty
propel:migration:diff → show the developer BEFORE anything touches a database.
- A clean
propel:diff on a freshly-created database proves almost nothing. up --data builds the
DB from the post-upgrade schema, so an XML↔DB comparison necessarily agrees. It does not tell you
what migration an existing production database needs. To answer that, boot the pre-upgrade
lock, import, then upgrade the code and diff against that database. Report which question you
actually answered.
- Compare PHPStan/evaluator results against the Phase 0 baseline — only regressions block.
- Compare the suite result against
.spryker-upgrade/state/codecept-baseline.txt, per suite, and split the failures
into two kinds — they have different fixes and belong in different parts of the report:
app damage (a test fails because behaviour changed) and harness damage (a test fails
because a vendor SprykerTest Tester/Helper/fixture moved or changed signature — codecept build
usually surfaces this first). Do not "fix" harness damage by deleting assertions.
- Any test written in Phase 0.5 that now fails is the gate doing its job: that is a real behaviour
change to explain, not a test to relax.
- Check release notes (Lane 0) for infra requirement changes (ES/Redis/PHP versions) and diff
against
deploy.*.yml; propose updates, developer confirms.
- Bump the project's own
php constraint to match core. Count what the lock requires
(composer.lock → most common require.php among spryker packages); if core is on >=8.3 the
project must be too, and the typed-member fixes need it anyway.
Running console commands outside docker (fallback when docker/sdk is absent): the environment
must be supplied explicitly, and the default memory limit is too low for transfer:generate —
it dies at 128M with a misleading fatal after partially generating.
APPLICATION_ENV=development SPRYKER_CURRENT_REGION=GLOBAL DYNAMIC_STORE_MODE=true \
php -d memory_limit=3072M vendor/bin/console transfer:generate
Confirm the real exit code with stdout/stderr separated — a nonzero status can come from a shutdown
handler after the work succeeded, so check the artifacts too (ls src/Generated/Shared/Transfer | wc -l).
PHPStan needs src/Generated/Client/Ide/AutoCompletion.php, produced by
dev:ide-auto-completion:generate. If that command is not registered in the project's console, run
PHPStan against a copy of phpstan.neon with the bootstrapFiles block removed rather than
skipping static analysis — it is the only thing that catches constructor-arity breakage.
Phase 5.5 — The full suite against the final code, in the real environment
Host PHP can run pure-logic suites. It cannot run anything needing a database, a search backend, a
broker or a webdriver — and an unrunnable suite is not a passing suite. Until the suite has run
in the booted environment against the final upgraded code, the tests have not verified the upgrade;
they have verified the subset of it that fits on a laptop without infrastructure.
script -q /dev/null docker/sdk cli vendor/bin/codecept build
script -q /dev/null docker/sdk testing codecept run 2>&1 | tee .spryker-upgrade/state/codecept-final.txt
Run it after Phase 5's artifact regeneration, never before: missing Generated\ transfers and a
stale Propel model produce a wall of failures that have nothing to do with the upgrade.
Compare against .spryker-upgrade/state/codecept-baseline.txt per suite, and classify every
failure before fixing anything — the three kinds have different fixes and belong in different parts
of the report:
| Kind | Signature | Correct response |
|---|
| App damage | behaviour changed; the test's assertion is still what the project wants | fix src/, not the test |
| Harness damage | a vendor SprykerTest Tester/Helper/fixture moved, changed signature, or a _support class vanished; codecept build usually fails first | fix the test-side usage. Report separately — this is damage in the harness, not the app |
| Environment noise | fails identically on the pre-upgrade baseline; missing fixture data, no webdriver | not upgrade fallout. Do not "fix" it into the upgrade commit |
A characterization test from Phase 0.5 that now fails is the gate working. It means a real
behaviour change. Explain it, get the developer to confirm the new behaviour is intended, and only
then move the expectation — in its own commit, with the reason in the message. Never relax an
assertion to get green, and never delete a test to get green.
State the numbers honestly in the report: suites run, suites skipped and why, tests passed,
failures per class above. "All tests pass" is only true if every suite actually executed.
Phase 6 — New features gate (developer gate #3)
Two kinds of "new" need this gate, and only the first is visible in the lock diff:
- New packages arriving with the release — the list below.
- New capabilities inside modules the project already had. No new package appears, so nothing
flags them; they surface as a migration guide step, a
class_exists branch (matrix #64), or a new
extension point. These are governed by the same rule and are the easier ones to wave through by
accident. Per the Scope section, a version bump is never consent to integrate them.
From the NEW list in .spryker-upgrade/state/lock-diff-report.json: fetch each package's description
(composer show <pkg> + docs.spryker.com release notes). Present with AskUserQuestion
(multiSelect): integrate now / defer / never. NOTHING new gets wired without an explicit yes.
Each accepted feature: follow its official feature-integration guide, separate commit.
REMOVED packages: explain each (replaced by what?) in the final report.
Phase 6.5 — Browser verification gate
Tests assert what someone thought to assert. A release that crosses a CSS framework major or
touches Back Office chrome breaks things no PHP test can see: a stylesheet that no longer defines a
class, a table that renders but paginates wrongly, a layout that double-wraps, a JS bundle that
throws on load and silently disables a widget. Derive the page list from what the upgrade actually
touched, not from a generic smoke list.
Drive the app through the spryker-runtime sibling skill (it owns login, session and Chrome
mechanics). Per page, capture a screenshot and read the browser console — a clean-looking page
with Uncaught TypeError in the console is a failure.
Build the checklist from the run's own findings:
| If the upgrade touched… | Verify |
|---|
a *-gui / CSS framework major | every page whose template you merged in Lane 2, and any page using a legacy class the detector marked KEEP (JS) — that is where vendor JS meets project markup |
AbstractTable or any table override | one table with filter + sort + paging applied together, and the footer row count while a filter is active. Table bugs hide until the three interact |
| a login / layout template | the login page and one authenticated page, checking the project's own delta (a font, a logo) actually survived |
| a shadowed template you merged | that exact page, comparing against the pre-upgrade screenshot if Phase 0 captured one |
the asset toolchain (oryx-*, webpack, node) | that the build ran, and that the built bundle is what the page loads (a stale bundle in public/ can mask a broken build) |
Console errors, 500s and missing styling are upgrade damage. Record what you could not reach and why
— an unvisited page is not a passing page. If the environment cannot boot, say which of these checks
that costs rather than omitting the section.
Phase 7 — Report & handoff
Final summary: versions moved (majors highlighted with their migration-guide table from
Lane 0), conflicts found/resolved per lane with file links, remaining upgrade-debt markers,
test results vs baseline, pending DB migrations, features accepted/deferred, deploy file
changes. Ask before committing; suggest one PR per release group.
Separate what is proven from what merely has not failed yet, and give each claim its scope —
"243 unit tests green on host PHP" and "the suite passes" are different statements. For every check
that did not run, name it and name the damage class it would have caught.
Hard rules
- The four non-negotiables live at the top of this file — § Non-negotiables (versions not
architecture; Lane 0 not skippable; re-run the detectors after every lane; never suppress a red
detector), together with the mandatory plan task list. Deliberately not duplicated here: a rule
read only at line 800 of an 800-line file has already been paged out by the time it matters.
- All state lives in
.spryker-upgrade/state/ (gitignored); never commit baselines, reports or
*.merge-conflict artifacts. Prefer explicit git add <path> over git add -A while a Lane 2
pass is in flight.
- Offline / lookup failure → say so and stop for that module; never invent migration steps.
- Distinguish damage from maintenance in every report: MISSING plugins, fatals and shadowed vendor
changes are damage; DEPRECATED plugins still work. Never let the second destabilise the upgrade.
- Never drop a feature to make the upgrade resolve. Measure its footprint, state the options, and
let the developer decide (Lane 5).
- Distinguish "verified" from "unverified" when reporting. Constraint resolution and static checks
prove a lot; without a database, a search backend and a booted Back Office, the visual and
data-migration results are unverified — say so plainly rather than implying a green run.
- Never claim tests verify the upgrade when they were already red, could not run, or do not touch
the customised modules. Phase 0.5 exists so that claim can be made honestly — and if the developer
declined new tests, the report says which lanes are static-only.
- Characterization tests belong BEFORE the upgrade. Written afterwards they pin the upgraded
behaviour and can no longer detect that it changed.