Skip to content
Back to Blog
Laravel Migrations MySQL Production Deployment

How to Fix a Migration That Already Ran in Production

Nur Ikhwan Idris ·

Never edit a migration that already ran. Write a new one that checks the current state before it changes anything. A stale settings row once returned a 500 on a live form for us, and the guarded catch-up migration below repaired every environment without a manual step.

Here is why the obvious fix makes things worse, and what to write instead.


1. How environments drift

Someone writes a migration that seeds a settings table. It runs on the developer machine, then on staging, then on production. Weeks later a new key is needed, so the same file gets edited to add it.

Every environment that already ran that file will not run it again. Laravel records the filename in the migrations table and skips it forever. The developer who edited it sees the new key locally, because they rebuilt their database. Production never sees it.

The schemas now differ, and nothing reports it. That is drift, and it stays invisible until code reads the key that only exists in one place.

2. How it surfaces

Ours surfaced as a 500 on a form that had worked for months. The blade read a settings array by key:

{{ Setting::getTextValue($moduleId)['guide'] }}

On production that row was missing, so the array had no guide key, and PHP stopped the request with Undefined array key. The code was correct. The data was not there.

3. Why editing the old file is the wrong fix

The instinct is to correct the original migration and redeploy. Three reasons not to.

  • It does nothing. The file is recorded as run, so nobody executes it again.
  • It hides the drift. A future reader sees a file that claims to have created the row, on a database where the row does not exist.
  • It breaks fresh installs later. The edited file now assumes a state that only some environments reached.

A migration is a record of what happened, not a description of what should be true. Editing it rewrites history that other databases already lived through.

4. The guarded catch-up migration

Write a new migration that inspects the database before it touches anything. Guard every single step, because you cannot know which environment reached which state.

public function up(): void
{
    if (! Schema::hasColumn('module_settings', 'guide')) {
        Schema::table('module_settings', function (Blueprint $table) {
            $table->text('guide')->nullable();
        });
    }

    DB::table('module_settings')
        ->where('module_id', self::MODULE_ID)
        ->whereNull('guide')
        ->update(['guide' => self::DEFAULT_GUIDE]);
}

Two properties make this safe. It is idempotent, so running it twice changes nothing the second time. It is state-driven, so it works on a database that already has the column and on one that never got it.

Use Schema::hasColumn, Schema::hasTable and Schema::hasIndex liberally here. In a normal migration those guards are noise. In a catch-up migration they are the entire point.

5. Defend the read as well

The migration repairs today's data. It does not stop the next missing row from returning a 500. Fix that at the point of use:

{{ Setting::getTextValue($moduleId)['guide'] ?? '' }}

We now treat this as three changes rather than one, and we ship all three together:

  1. Guard the read, so a missing key degrades instead of stopping the page.
  2. Seed the row, so new environments get it from the start.
  3. Add a test that renders the page with the row deleted.

The test is the part people skip, and it is the only one that stops the bug coming back.

6. Catching drift earlier

Two habits make this class of bug rare.

  • Compare the schema across environments on a schedule, and treat any difference as a defect.
  • Rebuild a scratch database from scratch regularly. A migration set that cannot build an empty database has already drifted.

The takeaway

Treat a run migration as immutable. When you need to change what it did, add a new migration that asks the database what it looks like first, then guard the code that reads the result. The extra file costs you nothing and it is the only version that works everywhere.