Here's my position, stated up front: any schema migration that runs against a production database without a lock_timeout is a bug, even if it succeeds. Not a style issue, not a nice-to-have — a bug. A Medium post making the rounds this week describes the failure mode that convinced me of this years ago, and it's worth restating for everyone who runs php artisan migrate or doctrine:migrations:migrate as a deploy step, because our tooling does very little to protect us here.

The mechanics, in short. On PostgreSQL, adding a nullable column to a table — even one with 50 million rows — doesn't rewrite any data. The catalog gets updated, missing values are treated as NULL, and the operation itself costs almost nothing. What it does cost is a lock: ALTER TABLE needs an ACCESS EXCLUSIVE lock, the strictest one Postgres has. And Postgres lock acquisition is a fair queue. If some reporting query has been reading that table for twenty minutes, your ALTER waits behind it — fine so far — but every query that arrives after your ALTER waits behind the ALTER. Reads included. Your app doesn't slow down; it stops. The migration that took a millisecond on staging becomes the cork in the bottle on production.

The defense is embarrassingly small: SET lock_timeout = '2s' before the ALTER. Now the migration either gets its lock quickly or gives up, the pent-up traffic drains, and your users saw a two-second blip instead of an incident channel filling up. Your deploy fails, sure — but it fails loudly, retryably, and without collateral damage. A failed migration is a Tuesday. A wedged users table is a postmortem.

So why isn't this the default everywhere? This is where I want to point the finger at our own ecosystem, gently. Laravel's migration runner will happily send an ALTER TABLE with no lock_timeout at all. Doctrine Migrations likewise leaves it to you. Both are behaving reasonably — they're database-agnostic, and lock semantics differ wildly between Postgres, MySQL, and friends — but the practical result is that thousands of PHP shops deploy schema changes with an unbounded wait baked in, and most of them will never know until the day an analyst's query and a deploy happen to overlap. Staging can't catch it, because staging doesn't have that analyst.

Let me take the counter-argument seriously, because it's a real one: a strict lock_timeout means your deploys become flaky. Migration fails, pipeline goes red, someone gets paged for a deploy that would have gone through fine ten seconds later. If your process treats a red pipeline as an emergency, you've traded a rare catastrophic failure for frequent annoying ones, and teams under pressure will respond by deleting the timeout. That's a legitimate objection — and it tells you the timeout can't stand alone. It needs a retry loop around it: attempt the lock with a short timeout, back off, try again, give up for real after a few minutes and page a human. That's maybe thirty lines in a custom migration base class or a wrapper around your deploy step. Once that exists, the flakiness argument evaporates, because transient lock contention resolves itself without anyone waking up.

There's a deeper shift hiding under the syntax, though. We tend to file migrations under "code deployment" — versioned, reviewed, merged, done. But a migration is also a live operation against a system under traffic, and live operations need budgets: how long am I allowed to wait, how long am I allowed to run, what happens when I exceed that. We already think this way about HTTP calls — nobody ships a Guzzle client without a timeout anymore — and about queue jobs with their $timeout and retry settings. Schema changes deserve the same discipline. A migration file that says ADD COLUMN but doesn't say how long it may block is only half a specification.

The concrete ask, then: make the lock budget structural, not tribal knowledge. Put the SET lock_timeout into a shared migration base class or into the connection config your migration runner uses, add the retry wrapper, and put a CI check on it if you can. Don't rely on the one person who read the right blog post being in the review. The whole point of the fix is that it works precisely on the day nobody is thinking about it.

And here's what I genuinely want to know from you, because I've seen teams land in very different places: where do you set the number? Two seconds is defensible, but so is 500ms on a hot OLTP table, and so is 30 seconds on a system with chunky but bounded transactions. And who owns the retry — the migration tool, the deploy script, or the orchestrator? If you've got this wired into a Laravel or Symfony pipeline in a way that survived contact with production, tell us how in the comments. That's the part no framework can decide for you.