modera/upgrade-bundle
Modera Upgrade Bundle helps you migrate applications between major versions with less friction. It provides upgrade routines, compatibility checks, and guided migration steps so you can move to newer releases safely and with minimal manual effort.
The dependency itself is not the day-to-day tool — the pattern is. modera/upgrade-bundle is a Symfony bundle (registration via AppKernel::registerBundles(), ContainerAwareCommand, Symfony 2.x/3.x event dispatcher signatures), archived since 2016-06-10, with zero dependents and no Laravel integration surface. There is no service provider to publish, no config to merge, and no supported install path on a modern stack. The realistic first step is a one-hour read of the source with three questions, then a reimplementation in your own app.
Read the source in this order:
If you are actually inside a Modera/Symfony 2.8–3.x app with a pinned legacy stack: register the bundle manually, run bin/console list | grep -i upgrade, and verify it discovers your script directory before writing any scripts. If that fails on the PHP/framework version you run, stop — shimming Symfony 2 container patterns onto PHP 8 costs more than the ~200 lines the package contains.
First Laravel use case (the one that justifies the effort). A composer update bumped a package whose data shape changed — a config key was renamed, an upload path moved, a serialized payload gained a field. Composer won't do this, migrations won't do it (it isn't schema), and a runbook step will be forgotten on the next deploy. That is the exact case this pattern exists for, and you can stand it up in an afternoon:
// database/migrations/xxxx_create_package_upgrades_table.php
Schema::create('package_upgrades', function (Blueprint $t) {
$t->id();
$t->string('package'); // e.g. 'acme/billing'
$t->string('step'); // e.g. '002_move_uploads'
$t->string('version')->nullable(); // target package version, if relevant
$t->string('status', 16)->index(); // started | completed | failed
$t->text('error')->nullable();
$t->timestamp('started_at')->nullable();
$t->timestamp('completed_at')->nullable();
$t->unique(['package', 'step']);
});
app/Upgrades/
acme-billing/
001_rename_config_key.php
002_move_uploads.php
003_backfill_invoices.php
// app/Console/Commands/RunUpgrades.php
protected $signature = 'upgrade:run {--package=} {--pretend} {--step=}';
Wire it once into composer.json (post-update-cmd) or your deploy pipeline, and from then on the workflow is: bump a dep → write a numbered script → run php artisan upgrade:run --pretend → run it for real.
Deterministic ordering over lexical convenience. Name scripts with zero-padded numeric prefixes (001_, 002_) so shell-style glob() order matches execution order. If you want semver names instead (1.10.0.php vs 1.9.0.php), you must sort with version_compare, because raw string sort puts 1.10.0 before 1.9.0 and silently runs steps out of order. Pick one scheme and assert it in a test.
Scripts are classes, not closures. The discoverable-script model in the bundle works because each upgrade is a named, identifiable unit. In Laravel, give yourself a contract:
interface UpgradeStep
{
public function package(): string;
public function version(): string; // minimum version this step applies to
public function up(): void;
public function down(): void; // implement for anything destructive
}
Plain classes are testable in isolation, autoloadable, and can be resolved from the container when they need a service — which is the one genuinely useful thing the Symfony bundle gave you for free.
The ledger records intent, not just completion. The naive implementation inserts a row after success. That makes a mid-step crash invisible on the next run: the step simply runs again from an unknown starting state. Record started before executing and completed after; on re-run, a started-but-not-completed row is an alert, not a skip. This is the single highest-value deviation from the original.
Idempotency is a hard requirement, enforced per step. Every up() must be safe to run twice: updateOrInsert instead of insert, if (! File::exists($new)) { rename }, firstOrCreate for seed rows, guarded DDL that doesn't exist. Concurrency is real — two CI runners can both fire post-update-cmd. Wrap execution in Cache::lock('upgrade:run', 600) (or a row lock on the ledger) and take the lock before discovery, not per step.
Version detection comes from composer.lock, not from the runtime. If steps are version-gated, read installed versions by parsing composer.lock (or composer show --format=json) and compare with version_compare. Do not rely on the package's runtime constants; they tell you what's loaded, not what was declared, and they drift in monorepo setups.
Multi-hop upgrades execute every intermediate step. Going 1.1 → 1.4 runs 002, 003, and 004 in sequence. Never jump to "the step matching the target version" — the bundle's ordering model exists precisely so you don't. Sort all candidate steps for the package by version_compare, filter to step_version > max(completed.version), run in order, halt on first failure.
Dry-run and package filter are not optional. --pretend prints the ordered plan (package, step, version, whether it's applied) without mutating anything; --package=acme/billing scopes a run so you can validate one dependency's upgrades in staging without touching others. Both are cheap to add and become the primary way you and reviewers interact with the tool.
Ship scripts with the package, or centralize them — choose on purpose. The bundle assumes upgrade logic travels inside each package, which makes packages responsible for their own migrations but couples every package to the orchestrator and to your release process. The alternative is a central app/Upgrades/<vendor>-<package>/ registry owned by the app team. Central is easier to review and test in a Laravel app; per-package is better if third parties extend your platform. Write the choice down before the directory has twenty files in it.
Keep schema migrations out of it. Any Schema::create / column add belongs in Laravel migrations, which already have a ledger, batching, and rollback. Upgrade steps are for post-schema work: data reshapes, file moves, cache warmups, config rewrites, one-off API bridging calls. If you find yourself writing DDL in a step, you're fighting a solved problem.
Shell sort vs. semver sort is the classic silent skip. glob() returns shell order; version_compare() returns semantic order; the two disagree at 1.9.0/1.10.0 and on prerelease suffixes. A mismatch produces a successful exit code with steps silently skipped. Add an assertion test that the discovered order matches the expected order, and log every skip decision (skipping 003: already completed) rather than only logging executions.
Prerelease tags break naive comparisons. 1.4.0-beta compared with 1.4.0 via version_compare behaves differently than string equality or >=. If your platform ships prereleases, normalize versions before comparison and test that case explicitly — this is exactly the kind of thing that only manifests during a hotfix deploy.
Partial application has no transaction boundary you can rely on. File renames and external HTTP calls aren't transactional. Design steps to be individually resumable and small: two 20-line steps that each finish cleanly beat one 80-line step that can fail halfway. For DB-only steps, wrap in DB::transaction and let it roll back; for mixed steps, order the operations so the DB change is last and the step is re-runnable from the top.
Don't mutate files during post-update-cmd if CI verifies composer.lock. Composer's integrity check can run before or after your hook depending on the Composer version and flags, and any file write that touches vendor/ or the lock-adjacent state produces failures that look unrelated to upgrades. Prefer: composer update → a separate deploy step runs php artisan upgrade:run before the app boots.
A failed step must exit non-zero. The most dangerous failure mode of a "runner" script is swallowing an exception, printing a warning, and exiting 0 — the deploy proceeds on a half-upgraded database and you find out from customers. Wrap up() in a try/catch that writes status = failed, error = $e->getMessage(), and returns self::FAILURE.
Config quirks inherited from the Symfony original. The bundle resolves its script directory and services through container-aware access patterns (getContainer(), service tags) that have no Laravel analogue and don't survive the port anyway. In Laravel, put the directory list and the ledger table name in config/upgrades.php, and make the command resolve them once at boot — reading config per step is a small thing that becomes annoying when you run 40 steps.
Vendoring the 2016 source is a last resort, and then only as a private fork. If you genuinely must run the original code, fork it into an internal repo, pin the commit, rename the package so Packagist can never serve it to you accidentally, and budget a compatibility spike for breakage in DI wiring, ContainerAwareCommand, and event dispatcher signatures on PHP 8. The logic is small; the Symfony scaffolding is not, and it's the scaffolding that will break.
Extension points worth preserving from the original design: a --step selector for running a single script out of order during an incident, a --pretend plan view, a down() on anything destructive (you will rarely run it, but the discipline of writing it catches non-reversible steps before they ship), and a per-package filter. Skip everything else — scheduling, notifications, and a web UI are scope you don't need yet.
Debug the ledger before you debug the code. When a step "didn't run," the answer is almost always in the ledger table or the version comparison, not in the step body: is there a started row without completed? Is the installed version parsed correctly from composer.lock? Does the script name sort where you think it does? Query the ledger first, then read the step.
How can I help you explore Laravel packages today?