marein/symfony-lock-doctrine-migrations-bundle
A Symfony bundle that integrates the Lock component with Doctrine Migrations, using locks to prevent concurrent migration runs. Keeps deployments safe across multiple servers or containers by ensuring only one process executes migrations at a time.
Architecture fit: The framing matters more than the code here. This is marein/symfony-lock-doctrine-migrations-bundle — a Symfony bundle that decorates the Doctrine Migrations executor with a symfony/lock lock. It is not a Laravel package: there is no service provider, no config/migrations.php publish tag, no Artisan hook, and Laravel's own migrator never passes through Doctrine's migration lifecycle. So if the target is Laravel, the architectural fit is zero as a dependency. What is reusable is the pattern: an atomic, store-backed lock serializing migrate across deploy replicas. In Laravel that is Cache::lock(...) around Artisan::call('migrate', ['--force' => true]) in the release job — roughly 30 lines, no third-party code, no supply-chain surface. If the target is Symfony 6/7 on Doctrine Migrations, the fit is excellent and the bundle is the correct, low-blast-radius insertion point.
Integration feasibility: Symfony: high. composer require, bundle registration (automatic under Flex), one config block naming a lock store, and the existing bin/console doctrine:migrations:migrate --no-interaction invocation becomes lock-aware without touching migration classes. Laravel: reimplement natively. No Symfony class, config key, or framework.lock stanza is portable, and a port is not worth the engineering time given the alternative is trivial and better supported.
Technical risk: Low on code. The 1.0.4 delta is maintenance-only — PHP 8.4 added to CI, dependency constraints widened and tested, a Compose file for local dev. No breaking changes, no deprecations, no config surface change. Two real risks, both non-code. First, widened dependency constraints: "allow and test new dependency versions" means Composer may resolve symfony/lock / doctrine/migrations combinations the maintainer's CI has not exercised, so composer.lock becomes the contract and any bump of those two packages needs the concurrency test re-run. Second, governance: 8 stars, a single maintainer, modest release cadence, and a narrow user base. That is acceptable for a small, well-understood decorator on a deploy path we can replace same-day, and unacceptable as an unexamined critical-path dependency — vendor, fork, or keep the native fallback documented.
Key questions:
Job, one-off ECS task)? If yes, this is defense in depth, not a requirement, and should be prioritized accordingly.Stack fit: Adopt directly for Symfony + Doctrine Migrations deployed as more than one replica, or where CI runners and rolling deploys can overlap. Do not adopt for Laravel, Lumen, or Phinx-based stacks. Lock store selection is the integration decision that actually matters: flock and semaphore stores are host-local and provide no protection across containers — this is the #1 silent failure mode in Docker/Kubernetes. Production must point at Redis, Memcached, Postgres advisory locks, or the DB store, and must avoid a DB-store lock if any migration drops or recreates the database that holds the lock.
Migration path: No data migration. The change is confined to the deployment entrypoint. For Laravel, the equivalent entrypoint change is:
$lock = Cache::lock('doctrine-migrations:'.config('database.default'), 300);
if (! $lock->get()) {
$this->error('Another deployment is migrating.');
return 1;
}
try {
Artisan::call('migrate', ['--force' => true]);
} finally {
$lock->release();
}
Key the lock on the connection name so parallel migrations against different databases do not block each other. Note Laravel's migrator already takes an internal cache lock in recent versions — verify whether the extra guard is needed before adding it, so we are not stacking redundant locks and confused failure modes.
Compatibility: PHP 8.4 is now tested and safe; 8.1–8.3 support is unchanged. The widened symfony/lock and doctrine/migrations ranges are the one item requiring a deliberate decision rather than an automatic composer update. Pin resolved versions in composer.lock, confirm they match what we actually run, and re-run the two-replica staging test after any bump of those two packages. The added compose.yaml is developer experience only, with no production implication.
Sequencing:
composer.lock resolution against the widened constraints; upgrade in staging first, never straight to production.SIGKILLed process, not a bundle bug).Maintenance: Symfony path: near-zero code maintenance — one dependency, one registration, one config block. The real recurring cost shifts to the lock store, which becomes a hard dependency of every deploy and must be covered by the same on-call and HA posture as the database. Budget a standing review of the relaxed dependency constraints: automated dependency PRs touching symfony/lock or doctrine/migrations should require the staging concurrency test as a blocking check, not a nice-to-have. Laravel path: the native implementation is our code, so we own it outright — cheaper to reason about, marginally more to maintain, and immune to upstream abandonment.
Support: Single maintainer, 8 stars, low cadence. Fine for a small decorator on a replaceable path; document the ~30-line native alternative so the team is never blocked by upstream, record the exact pinned version in the dependency inventory, and pre-agree that "remove the bundle and wrap the command ourselves" is a same-day change requiring no architecture review. Internal support load should be near zero either way.
Scaling: Scales the way we need it to — the store absorbs contention, so dozens of simultaneously booting pods serialize correctly instead of racing on the migrations table. Two scaling-adjacent caveats to own: lock TTL must exceed the slowest migration (long DDL on a large table is the realistic worst case), and the deploy timeout plus readiness probes must tolerate a replica that is blocked waiting on the lock rather than killing it mid-wait. If a blocked replica is killed by a probe, verify the lock is released on process termination, or you trade a race condition for a stale lock and a wedged deploy.
Failure modes to instrument: store unavailable (migrations halt — alert on deploy failures, not just app health); lock held past TTL mid-migration (duplicate DDL — alert on concurrent migrate invocations); stale lock after SIGKILL (deploy wedged — document redis-cli keys '*migration*' triage and a manual clear runbook); and host-local store misconfiguration in a containerized environment (no protection at all, silent — assert the configured store is non-local as a config test in CI).
Ramp-up: Low. An engineer familiar with Symfony bundles or Laravel's cache can be productive in under a day; the conceptual load is "one lock around one command." The ramp-up cost that matters is organizational, not individual: the deploy pipeline owner must understand that migrations now depend on the lock store, and the on-call runbook must say what to do when a deploy hangs on a held lock. Write that down before rollout, not after the first stuck release.
TPM recommendation: For a Symfony/Doctrine estate with multi-replica deploys, adopt — the pattern is correct, the code surface is small, and the exit is cheap. For a Laravel estate, do not adopt; implement Cache::lock around Artisan::call('migrate'), which delivers the same guarantee with no single-maintainer dependency, and reuse the operational runbook above unchanged.
How can I help you explore Laravel packages today?