Here's the constraint that makes migrations different from every other kind of code change: for at least a few seconds, and sometimes for the whole deploy window if you migrate before the code swap, the schema you're changing is still being read by the release that's still serving requests.
That rules out a whole category of "obviously fine" migrations. Renaming a column isn't fine, the old release doesn't know the new name exists. Dropping a column isn't fine, the old release might still write to it. The fleet's own migrations lean on a simple guard instead: check before you change, so the migration is safe to run whether or not it's already partially applied.
// database/migrations/2026_06_04_120000_create_smoke_runs_table.php
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('smoke_runs')) {
return;
}
Schema::create('smoke_runs', function (Blueprint $table): void {
$table->id();
$table->timestamp('started_at');
$table->boolean('ok');
$table->string('stage', 32);
// ...remaining columns
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('smoke_runs');
}
};
The Schema::hasTable() check up front means this migration can run twice, on two different servers, in whatever order your deploy pipeline gets around to them, and the second run is a no-op instead of a fatal "table already exists" error. The same discipline shows up a few migrations later, adding columns instead of a table:
// database/migrations/2026_06_04_123134_add_activation_metrics_to_smoke_runs_table.php
public function up(): void
{
Schema::table('smoke_runs', function (Blueprint $table): void {
if (! Schema::hasColumn('smoke_runs', 'license_at')) {
$table->timestamp('license_at')->nullable()->after('teardown_ms');
$table->unsignedInteger('license_ms')->nullable()->after('license_at');
// ...remaining columns
}
});
}
Every new column here is nullable and additive. The old release, still running while this migration executes, has no idea license_at exists and doesn't need to. It keeps reading and writing the columns it already knows about. The new release, once traffic swaps over, finds the columns already there. Nobody has to coordinate the exact millisecond the schema and the code change together, because the schema change alone doesn't break anything that was already working.
In production, migrations run with php artisan migrate --force, the flag that skips the interactive "are you sure, this is production" prompt, because a deploy pipeline can't answer a prompt. Don't confuse that with --graceful, a flag this fleet's own composer.json uses in its post-create-project-cmd step, when a brand-new project's database might not exist yet: "Return a successful exit code even if an error occurs." That's the right behavior for bootstrapping a project that has never seen a schema before. It's the wrong behavior for a production deploy. A migration failing in production should stop the deploy loudly, not exit 0 and let broken code start serving traffic on a schema it never got.