Skip to content
All posts

Databases

Reversible Migrations Are a Team Sport

Md Shamim Hossain September 22, 2026 4 min read 1,410 views Updated Oct 26, 2026

A down() method that drops the column it just created is not reversible. It is a rollback that loses data, which is worse than no rollback at all.

More ways to share

Link copied to clipboard

Every migration generator emits a down() stub. Most teams never exercise it. The ones that do often discover, mid-incident, that rolling back destroys the data the migration was carefully preserving.

The stub is not a rollback

public function up(): void
{
    Schema::table('users', function (Blueprint $table) {
        $table->string('timezone')->nullable()->after('email');
    });
}

public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('timezone');
});
}
```

This is fine for a column nobody has used yet. It is data loss the moment anyone has.

Reversibility is about data, not schema

A migration is reversible when running down() leaves the system in the same state it was in before up(). Schema shape is the easy half. The hard half is: what happened to the data, and what happens to it on the way back?

Three categories, with different answers:

Additive and derivable — genuinely reversible

Adding a nullable column, or a column with a default. Rolling back drops it and loses only what was written. If nothing has been written, or the value is recomputable, the rollback is safe.

Additive and non-derivable — reversible with a backup step

Adding a column users have since filled in. Rolling back must preserve those values somewhere recoverable:

public function up(): void
{
    Schema::create('users_timezone_backup', function (Blueprint $table) {
        $table->foreignId('user_id')->primary();
        $table->string('timezone')->nullable();
        $table->timestamp('captured_at');
    });

Schema::table('users', function (Blueprint $table) {
$table->string('timezone')->nullable()->after('email');
});
}

public function down(): void
{
DB::table('users')
->whereNotNull('timezone')
->orderBy('id')
->chunk(500, function ($users): void {
DB::table('userstimezonebackup')->upsert(
$users->map(fn ($user) => [
'userid' => $user->id,
'timezone' => $user->timezone,
'captured
at' => now(),
])->all(),
['userid'],
['timezone', 'captured
at'],
);
});

Schema::table('users', function (Blueprint $table) {
$table->dropColumn('timezone');
});
}
```

The backup table survives the rollback, so no value is lost. It is dropped in a separate, deliberate migration once the rollback window has closed.

Destructive — not reversible, and should say so

Renaming a column, changing a type, dropping a table. The honest answer is that these cannot be rolled back automatically, and the migration should refuse rather than pretend:

public function down(): void
{
    throw new RuntimeException(
        'This migration cannot be reversed automatically: the old '
        .'`location` column was dropped. Restore from the pre-deploy '
        .'snapshot, or re-create the column manually.'
    );
}

A migration that throws on rollback is better than one that silently loses data, because the deployment pipeline stops instead of proceeding into an inconsistent state.

The expand / contract pattern

For genuinely irreversible changes, split them so no single step is irreversible:

| Step | Migration | Reversible |
| --- | --- | --- |
| 1. Expand | Add location_new, backfill from location | Yes, drops the new column |
| 2. Migrate reads | Application reads location_new, falls back to location | Yes, revert code |
| 3. Migrate writes | Application writes both columns | Yes, revert code |
| 4. Verify | Assert no writes have touched location for N days | n/a |
| 5. Contract | Drop location | No — and by now, no reason to |

The critical property is that steps 1 through 3 are deployable independently and each is reversible. The irreversible step happens only after the data has been proven to flow through the new path.

Making it a team practice

The tooling only helps if someone exercises it. Two cheap habits:

Run the rollback in CI. Every migration is applied, rolled back, and reapplied as part of the pipeline:

php artisan migrate --no-interaction
php artisan migrate:rollback --step=1 --no-interaction
php artisan migrate --no-interaction

This catches the "down() references a table that up() renamed" class of bug immediately, on every pull request, instead of during an incident.

Require the rollback note. Every migration pull request answers one question in the description: what happens to the data on rollback? The answer is either "nothing is written yet," "it is preserved in <table>," or "it is irreversible, here is the recovery procedure." Writing it down is what turns a stub into a policy.

The point

Reversibility is not a property of the down() method. It is a property of the migration and the data and the deployment sequence together. Treating it as a checkbox on the method is how teams end up with rollback procedures they are afraid to run — which is the same as having none.

Keep reading

Discussion

3 comments

Sign in to join the discussion. Comments are published under your name.

Markdown-style emphasis and `code` are supported. 0 / 2000
Held for moderation
  1. P

    Priya Raman

    Do you have a follow-up on how this interacts with soft deletes? Curious whether the counters self-heal there too.
    1. M

      Marcus Webb

      reply
      Yes — recount-based sync means it self-heals. I walk through it in the reconciliation section.
  2. C

    Clara Nowak

    I appreciate that you named the failure mode instead of just the pattern. Most architecture writing skips that.
Enter your credentials to continue to your workspace.
Email address
Remember me
Don't have an account? Sign up
Enter your details below to create your account
Name
Email address
Password
Confirm password
Already have an account? Log in
Enter your email and we will send you a reset link
Email address
Or, return to log in