Product Decisions This Supports
- Scaling decision: single-runner vs. many replicas. The core product question this package answers is "can we safely run more than one app instance that boots and migrates?" Today many teams sidestep this with a dedicated migration job or a serialized deploy step. This package is the alternative that lets every replica call
doctrine:migrations:migrate on boot without corruption — it makes "scale out freely" a viable roadmap item rather than an operational hazard.
- Deploy-pipeline maturity roadmap. If the team is moving from manual/blue-green releases to autoscaling rollouts, parallel CI runners, or Kubernetes deployments, concurrent migration execution becomes a real failure mode. This is the "make the boring thing safe" investment that unblocks that roadmap step.
- Build vs. buy vs. orchestrate. Three viable options for the same problem, and this package is one of them:
- Orchestrate — guarantee a single migrator (Kubernetes
Job with --wait, a deploy lock in the pipeline). Zero code, but requires the platform team to enforce it everywhere and fails when someone runs a hotfix by hand.
- Buy — this bundle: one Composer dependency, one bundle registration, one config block. Cheapest path if you're on Symfony + Doctrine Migrations.
- Build — a ~30-line lock wrapper around the migrate command. Needed anyway if the stack is Laravel (see below).
- Platform decision: standardizing on a shared lock store. Adopting this makes a distributed lock store (Redis/Postgres/DB) a hard dependency of the deploy path. That's an infrastructure and reliability decision with its own budget — pick the store the app already depends on, not a new one.
- Use cases that justify it: multi-pod rolling deploys, HPA/autoscaling where pods boot-migrate, parallel CI jobs against a shared staging DB, blue-green cutovers where both colors briefly run, and any team that has ever seen two processes race on
doctrine_migration_versions.
- Explicitly not on the roadmap: this is not a zero-downtime schema-change tool. It serializes migrations; it does not make destructive DDL safe, and it does not replace expand-contract discipline or seeded/rollback orchestration.
Laravel caveat up front: this is a Symfony bundle, not a Laravel package. There is no service provider, no Artisan integration, no publishable config. For a Laravel product, the decision is "copy the pattern," not "adopt the dependency" — Cache::lock('migrations:'.config('database.default'), 300)->block(60, fn () => Artisan::call('migrate', ['--force' => true])) in the deploy job gets you 90% of the value. Note Laravel's migrator already takes a cache lock in newer versions, so verify you have the problem before adding code.
When to Consider This Package
Adopt it when all of these are true:
- The app is Symfony 6/7 with Doctrine Migrations (this is a hard gate — Laravel stacks should stop here).
- You run more than one process that can reach the migrate command: multiple replicas, autoscaling pods, parallel CI runners, or humans hotfixing alongside automation.
- You have — or are willing to own — a shared, highly available lock store. Redis or Postgres advisory locks, not
flock or semaphores. Host-local stores give you zero protection across containers, and that is the #1 silent failure mode of this package.
- The blast radius of the change is acceptable: it's additive, decorates the existing executor, and requires no changes to migration classes. Rollback is "remove the dependency," a same-day change.
- You accept a single-maintainer, low-star, low-release-cadence component on the deploy path — or you mitigate it by vendoring/documenting the native alternative so the team is never blocked.
Look elsewhere when:
- The stack is Laravel. No architectural fit; reimplement with a cache lock. Same idea, no dependency, and you control the failure behaviour.
- You already enforce a single migrator via a dedicated Kubernetes Job, a deploy-pipeline mutex, or a one-at-a-time release process. Then this is defense-in-depth at best, and you should decide whether "someone might run it by hand" is a real threat worth a dependency.
- No HA lock store exists and creating one isn't on the table — you'd be adding a critical-path dependency you can't guarantee.
- You need vendor SLA, security-response guarantees, or commercial support. Small surface area cuts both ways: cheap to fork, but nobody is contractually on the hook.
- You need more than migrate-time serialization — schema-change safety, seeding, rollback orchestration, or multi-database coordination are out of scope.
Version note for the adoption decision: 1.0.4 is maintenance-only (PHP 8.4 in CI, widened dependency constraints, a Compose file for local testing). Nothing breaking. But "allow new dependency versions" means composer update can now resolve symfony/lock and doctrine/migrations combinations the maintainer does not test. Treat composer.lock as the contract, and gate any bump of those two packages on a staging two-replica concurrency test.
How to Pitch It (Stakeholders)
For executives — one paragraph:
We run migrations from more than one place, and today nothing prevents two of them from running at the same time and corrupting the schema. This is a small, MIT-licensed Symfony component that puts a shared lock around migration execution so the second caller waits instead of racing. Cost is one dependency, a config block, and a half-day of validation; it's reversible same-day by removing the dependency. The real decision isn't the code — it's whether we're willing to make our lock store (Redis/Postgres) a guaranteed part of the deploy path. If we are, this buys us the ability to scale replicas freely. If we'd rather not, the alternative is enforcing a single migration runner in the deploy pipeline, which is free but depends on nobody ever bypassing it. Recommendation: adopt on the Symfony app, validate with a two-replica staging test, and keep the no-dependency fallback documented.
For engineering — the short version:
- What it does: decorates Doctrine's migration executor so
doctrine:migrations:migrate acquires a Symfony Lock before running and releases after. Blocking or fail-fast is configurable. Existing custom executors and subscribers keep working.
- What it doesn't do: it does not make destructive DDL safe, and it does not replace a single-runner guarantee — it's a guard against a race, not a substitute for expand-contract discipline.
- The one thing that will bite us:
flock/semaphore stores are host-local. Configuring one in Docker/K8s gives a false sense of safety with zero protection. Production must point at Redis, Postgres advisory locks, or another genuinely shared store.
- Second gotcha: the lock TTL must exceed our slowest migration. If it expires mid-run, a second process starts migrating concurrently — the exact failure we're preventing. Also verify the lock releases reliably on error, and prefer a non-DB store if any migration drops/recreates the database.
- Validation plan: deploy to staging with two concurrent replicas calling the migrate command; assert exactly one acquires the lock, the other blocks then no-ops, and no duplicate DDL appears. Re-run that test after any
symfony/lock or doctrine/migrations bump. Add a check that a failed deploy never leaves the lock held (redis-cli keys '*migration*' for Redis).
- Governance: single maintainer, low stars, maintenance-only releases. Acceptable only because the surface is tiny and the fallback is ~30 lines. Record the pinned version in our dependency inventory and confirm someone owns the fork decision if the project stalls.
- If the product in question turns out to be Laravel: don't adopt this. Wrap
Artisan::call('migrate') in Cache::lock() in the release job and skip the dependency entirely.