DEV Community

Cover image for 5 Laravel Migration Mistakes That Made It Into Production (and How to Repair Them)
Dinesh Wijethunga
Dinesh Wijethunga

Posted on • Originally published at dineshstack.com

5 Laravel Migration Mistakes That Made It Into Production (and How to Repair Them)

TL;DR: A migration I shipped created a column literally named 36. It is still in production. Here are five migration mistakes from a live Laravel e-commerce store, what each one actually did to the schema, and how to repair them without downtime.

Every schema article you have read shows you the clean version — including one of mine. The tables are tidy, the indexes are deliberate, and the author implies they got it right the first time.

Here is the other half. Seven weeks after that schema went live, the cart tables had picked up four follow-up migrations, one silent bug that is still sitting in production today, and a repair migration that quietly contradicts the one before it. None of this is hypothetical: I pulled the column list off the live database while writing this.

Mistake #1: calling char() without a column name

This is the one that still makes me wince. The migration was named add_oId_selected_user_id_to_cart_items_table. The intent was obvious — add a 36-character UUID column called oId. This is what shipped:

Schema::table('cart_items', function (Blueprint $table) {
    $table->foreignId('user_id')->nullable()->constrained();
    $table->char(36)->nullable();          // <-- the bug
    $table->json('selected')->nullable();
});

Why it is wrong

Laravel's signature is char(string $column, ?int $length = null). The first argument is the column name, not the length. Passing 36 makes it the name — PHP happily coerces the integer 36 to the string "36", no error, no warning, migration exits green.

So instead of a 36-character column named oId, the database got a column named 36 at the default length of 255. Here it is on the live server right now:

mysql> SHOW COLUMNS FROM cart_items;

Field                | Type            | Null | Key
---------------------+-----------------+------+-----
row_id               | varchar(255)    | NO   | MUL
oId                  | varchar(255)    | YES  |
...
36                   | char(255)       | YES  |
selected             | json            | YES  |

Note that oId exists too — added six weeks later by a different migration, as a varchar(255). So the table carries both the mistake and its replacement, and the replacement is not even the type that was originally intended.

The migration ran green because there is nothing invalid about a column named 36. MySQL allows it. Every test passed. Nobody noticed for six weeks.

The correct way

Schema::table('cart_items', function (Blueprint $table) {
    $table->char('oId', 36)->nullable()->after('row_id');
});

And to clean up after the fact — note that a numeric column name has to be quoted in raw SQL, though Laravel's builder handles it for you:

Schema::table('cart_items', function (Blueprint $table) {
    if (Schema::hasColumn('cart_items', '36')) {
        $table->dropColumn('36');
    }
});

Check it is empty before you drop it. On this store it is entirely NULL, because nothing ever wrote to a column no code knew existed:

SELECT COUNT(*) AS non_null FROM cart_items WHERE `36` IS NOT NULL;

There is a final twist. When I ran that check while writing this, 36 came back with zero non-null rows — expected, since no code ever referenced it. But so did oId. The replacement column is empty too. Six weeks of migration churn, a bug that reached production, and a repair migration to fix it, all for a feature that was never finished. That is worth sitting with: the cost of a sloppy migration is not only the bad column, it is every migration you write afterwards to work around it.

Mistake #2: promising the schema is final

My original write-up of this schema ended with a confident claim: get the composite indexes and nullable columns right on day one, and you will never need a painful migration during a peak-traffic sale.

The migration log disagrees. Between 12 November and 29 December:

Date Migration What it added
Nov 12 add_order_id_status_to_cart_table order_id, status
Nov 16 add_oId_selected_user_id_to_cart_items_table user_id, 36, selected
Dec 29 add_user_columns_to_cart_items_table selected, oId (both guarded)
Dec 29 add_user_shipping_method_columns_to_carts_table shop_shipping_method_id

Why it is wrong

Not because the schema was careless — the four core tables and their composite uniques have never changed. It is wrong because you cannot know your query patterns before the product has users. status only became obviously necessary once someone asked for a report grouping carts by active, converted, abandoned, and expired. Nobody thinks of that on day one.

The correct way

Design for additive change instead of claiming finality. Concretely: add a status column from the start even if it only ever holds one value, because every reporting query eventually wants one and backfilling it later means inferring history you no longer have. Keep new columns nullable so the migration never locks a large table rewriting rows. And treat "we needed another column" as normal, not as failure.

Mistake #3: commenting out a block instead of deleting it

The December repair migration shipped like this:

Schema::table('cart_items', function (Blueprint $table) {
    // User support
//  $table->foreignId('user_id')
//      ->nullable()
//      ->after('cart_id')
//      ->constrained()
//      ->cascadeOnDelete();

    if (!Schema::hasColumn('cart_items', 'selected')) {
        $table->json('selected')->nullable()->after('options');
    }
});

Why it is wrong

A commented-out block carries no information. Six months later nobody can tell whether it means "not yet", "already handled by the November migration", or "this broke production and we backed it out". All three are plausible readings of the same five lines, and they imply completely different next actions.

Git already stores the history. The commented code is not a record, it is ambiguity.

The correct way

Delete it. If the reason matters, say it in one line:

Schema::table('cart_items', function (Blueprint $table) {
    // user_id was already added by the 2025_11_16 migration.
    if (!Schema::hasColumn('cart_items', 'selected')) {
        $table->json('selected')->nullable()->after('options');
    }
});

Mistake #4: hasColumn() guards used to paper over drift

Look at that December migration again. It adds selected — a column the November migration already added — wrapped in a Schema::hasColumn() check. Same story for oId, and for shop_shipping_method_id on the carts table.

Why it is wrong

To be fair to the pattern: these guards are legitimate in real situations — squashed migration histories, per-tenant schemas that genuinely diverge, or a shared package migration that may or may not have run. That is not what happened here.

Here they appear because the author was not sure whether the November migration had fully applied. The guard makes the migration safe to re-run, which is good, but it also makes environment drift invisible, which is the actual problem. A migration that works whether or not the previous one ran is a migration that has stopped telling you the truth about your schema.

The tell is that the guarded oId was added as string (varchar 255) while the November attempt was char. Two different intentions for the same column, six weeks apart, neither aware of the other.

The correct way

Find out what actually ran before writing the repair:

php artisan migrate:status

Then write a migration that states one intention plainly, and reserve hasColumn() for schema that is genuinely conditional rather than merely uncertain.

Mistake #5: repairing with the wrong type

The column that finally landed is oId varchar(255). The name and the original char(36) attempt both say the same thing: this holds a UUID.

Why it is wrong

A UUID string is always exactly 36 characters. Declaring it varchar(255) gives up a fixed-width guarantee for nothing, and if the column is ever indexed the index entry is sized for the declared maximum, not the actual data. On a hot table like cart_items that is real cost for zero benefit.

The correct way

// A UUID is fixed-width. Say so.
$table->char('oId', 36)->nullable();

// Or, if you control the write path and want it compact:
$table->uuid('oId')->nullable();

Changing an existing column's type needs doctrine/dbal on older Laravel versions, and on a large table it is a rewrite — do it in a maintenance window or with an online schema change tool, not casually during a sale.

Conclusion

Four of these five are the same underlying failure: a migration that ran successfully while doing the wrong thing. char(36) exited green. The guarded re-adds exited green. The commented block exited green. Nothing in CI can catch any of them, because nothing about them is invalid — they are merely wrong.

The habits that would have caught them are unglamorous:

  1. Read the generated SQL, not just the migration. php artisan migrate --pretend would have shown add `36` char(255) in about one second.
  2. Diff the live schema against what you think you have. A SHOW COLUMNS in code review is cheaper than a six-week-old mystery column.
  3. Treat a hasColumn() guard as a question, not an answer. If you needed one, find out why first.
  4. Assume you will need more columns. Additive change is normal; pretending otherwise is what makes it painful.

The schema itself held up fine — the four tables and both composite uniques are untouched since launch, and I would design them the same way again. It was the follow-up migrations, the ones nobody reviews as carefully, that did the damage.

If you want the schema those migrations were built on, I wrote it up in full: the complete shopping cart database design, including the two composite unique constraints that turned out to be the only decisions I did not have to revisit.

Top comments (0)