Phase Gate & Rollback Guard
Expand/contract migration safety, phase ordering rules, and the rollback guard that consults Release before allowing a rollback.
Phase Gate & Rollback Guard
Two checks stand between a deploy plan and disaster: the phase gate, which refuses to plan a deploy while a contract migration is still pending, and the rollback guard, which refuses to roll back to a build whose schema requirements aren't actually satisfied by what's currently applied.
Expand/contract, briefly
A schema change that's safe during a rolling or blue-green deploy is split into two migrations:
- Expand — additive only (add a column, add a table). Both old and new code can run against the expanded schema.
- Contract — destructive (drop a column, drop a table). Only safe once every instance is running code that no longer needs the old shape.
Running expand and contract as a single migration during one deploy means there's a window — while old and new instances coexist during the rollout — where one of them is wrong about the schema. Splitting them across two deploys removes that window.
The phase gate
PhaseGate::assertNoPendingContract() runs as the very first step of planning, before a strategy is even consulted:
public function plan(DeployContext $context): DeployPlan
{
$this->phaseGate->assertNoPendingContract($context->currentState);
// ...
}If CurrentDeployState::$pendingContractMigrations is non-empty, planning throws ContractInSameDeployException immediately. In practice this means: if your last deploy ran an expand migration and the matching contract migration hasn't shipped yet, the next deploy that tries to bundle a new expand phase with that lingering contract is refused — you have to ship the contract on its own first.
How a migration's phase is determined
A migration is Contract when its class carries #[DeployPhase(MigrationPhase::Contract)]. A migration with no attribute is not blindly treated as safe: its up-SQL is scanned for destructive DDL (DROP TABLE/COLUMN/INDEX/CONSTRAINT, ALTER … TYPE, SET NOT NULL, RENAME, DROP DEFAULT, TRUNCATE), and if any is found it is classified Contract — fail-closed. Only an additive, un-annotated migration defaults to Expand. To ship an intentional rewrite in the expand phase, annotate it #[DeployPhase(MigrationPhase::Expand)] with #[AllowFullTableRewrite].
At deploy-runtime, an un-annotated destructive migration is refused with a precise DestructiveMigrationUnannotatedException (distinct from the generic contract error) telling you to annotate it. The phase reader is a total function — it never throws on an unrecognised migration id (a hand-written App\Migrations\* migration is classified by the same rules), so a deploy is never aborted merely because it can't name a migration.
deploy:doctor runs the same analysis ahead of time (schema.pending-phase check): it fails if any pending migration is destructive and un-annotated, so you catch it before a deploy is attempted rather than at cutover. This complements the CI vortos:migrate:analyze gate.
Phase ordering
PhaseOrderPolicy::assertValid() checks the phase sequence a strategy produced, regardless of which strategy produced it. Two rules are enforced today:
RollWorkersmust precedeCutover— if workers roll after traffic has already cut over, you risk a message being consumed twice (old worker still draining, new worker already started, both pointed at the same queue).RollWorkersmust precedeStageColor— both the old and new worker code must be able to tolerate the expanded schema before the new color is even staged, otherwise there's a window where a worker can't process a job correctly.
A violation throws a \LogicException naming exactly which rule failed and why — at plan time, before any step executes.
The rollback guard
Rolling back is not the inverse of deploying — it's a separate operation with its own legality check. RollbackGuard::assertLegal() is what deploy:rollback calls before it touches anything:
final class RollbackGuard
{
public function assertLegal(BuildManifest $target, EnvironmentName $env): void
{
$applied = $this->appliedReader->currentApplied();
$known = $this->manifestReadModel->knownMigrationSetForEnvironment($env->value);
$decision = RollbackInvariant::evaluate($target->schemaFingerprint, $applied, $known);
if (!$decision->legal) {
throw new RollbackRefusedException($decision);
}
}
}This delegates directly to Release's RollbackInvariant — Deploy doesn't reimplement schema-safety math, it asks Release the same question Release answers everywhere else in the framework. If the target build's schema fingerprint isn't a subset of what's currently applied, the rollback is refused with a RollbackRefusedException that carries the same actionable RollbackDecision::explain() message Release produces.
php bin/console deploy:rollback --env=production --to=build-20260601-1Error: Rollback refused: target requires migration(s) [20260605_add_orders_index]
that are not in the currently applied set. Recovery: roll forward to apply the
missing migration(s), or create a compensating expand migration.This is why rollback isn't always available
If you're tempted to treat deploy:rollback as an emergency undo button, know that it will refuse outright rather than risk corrupting the schema. The recovery path in a true emergency is usually rolling forward with a fix, not backward past a contract migration.
Where this fits
deploy --env=production
│
▼ DeployPlanner::plan()
└── PhaseGate::assertNoPendingContract() ← refuses if a contract migration is owed
└── PhaseOrderPolicy::assertValid() ← refuses on unsafe phase ordering
deploy:rollback --env=production
│
▼ RollbackGuard::assertLegal()
└── Release\RollbackInvariant::evaluate() ← refuses if target schema isn't a safe subsetBoth checks are pure decision logic with the actual I/O (reading current state, reading the manifest read model) kept at the boundary — see Release for the underlying SchemaFingerprint and RollbackInvariant math.