DEV Community

Cover image for Laravel Migrations: Add & Change Columns Without Downtime
devTalk
devTalk

Posted on Originally published at dev-talk.com

Laravel Migrations: Add & Change Columns Without Downtime

Part 1 covered indexes. The next place teams get surprised in production is the up() method sitting right above the index migration: adding a column, backfilling it, and eventually making it required. Each of those three steps can lock a table for a different reason, and the fix for one can be the wrong fix for another.

This part covers what's actually safe to run directly, what needs to be split into stages, and why ->change() deserves more suspicion than ->index() ever did.

Adding a column: usually cheap, except when it isn't

A plain nullable column with no default is the safe case everywhere. Postgres treats it as a metadata-only change; recent MySQL and MariaDB can add it "instantly" without touching existing rows.

Schema::table('orders', function (Blueprint $table) {
   $table->string('tracking_number')->nullable();
});
Enter fullscreen mode Exit fullscreen mode

Two things turn this cheap operation into a table rewrite:

ANOT NULLcolumn with no default, on old MySQL/MariaDB. MySQL 8.0.12+ and MariaDB 10.3.2+ support ALGORITHM=INSTANT for this case, so it's fast. Anything older rewrites the whole table under a lock while every row gets a value, which blocks reads and writes for minutes on a large table.

A volatile default on PostgreSQL. ADD COLUMN approved_at TIMESTAMPTZ DEFAULT now() looks as trivial as DEFAULT false, but it isn't. A constant default (false, 0, a literal string) is metadata-only on Postgres 11+: the value is returned lazily until the table is rewritten anyway. A volatile default — now(), random(), a subquery — returns a different value per row, so Postgres has no choice but to rewrite every row immediately, under an ACCESS EXCLUSIVE lock, on every version.

The safe pattern for both cases is the same: split the default out of the column addition.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
   public function up(): void
   {
       // Step 1: add the column with no default, no NOT NULL.
       // Metadata-only on Postgres and modern MySQL/MariaDB.
       Schema::table('orders', function (Blueprint $table) {
           $table->timestampTz('approved_at')->nullable();
       });

       // Step 2: set the default for *future* inserts only.
       // Existing rows stay NULL — no rewrite, no backfill here.
       DB::statement('ALTER TABLE orders ALTER COLUMN approved_at SET DEFAULT NULL');
   }

   public function down(): void
   {
       Schema::table('orders', function (Blueprint $table) {
           $table->dropColumn('approved_at');
       });
   }
};
Enter fullscreen mode Exit fullscreen mode

Backfilling the existing rows is a separate concern — covered in Part 5 of this series — because looping an UPDATE over millions of rows in a migration's up() has its own locking and transaction-length problems.

Instant column operations (Laravel 12/13, MySQL)

Laravel's schema builder can now ask MySQL explicitly for its instant algorithm:

Schema::table('orders', function (Blueprint $table) {
   $table->string('tracking_number')->nullable()->instant();
});
Enter fullscreen mode Exit fullscreen mode

Chaining instant() onto a column requests MySQL's INSTANT algorithm, which performs certain schema changes without a full table rebuild regardless of table size. It only works for columns appended to the end of the table, so it can't be combined with after() or first(), and not every column type or operation supports it — if the operation is incompatible, MySQL raises an error rather than silently falling back to a slower algorithm.

It can be combined with lock() the same way index operations can:

$table->string('tracking_number')->nullable()->instant()->lock('none');
Enter fullscreen mode Exit fullscreen mode

This doesn't change anything about PostgreSQL, which has no equivalent concept — on Postgres, "fast" simply means metadata-only, as described above.

->change(): the one that looks safe and usually isn't

change() is the method most likely to cause an incident, because the code reads like a one-line fix:

Schema::table('users', function (Blueprint $table) {
   $table->string('bio', 1000)->change(); // was VARCHAR(255)
});
Enter fullscreen mode Exit fullscreen mode

Three separate problems hide behind that one line.

1. It silently drops every modifier you don't repeat

As of Laravel 11, changing a column requires you to include every modifier you want to keep — any attribute not explicitly restated is dropped, even if a previous migration set it. This is a behavior change from Laravel 10, where unlisted modifiers were retained.

// If `votes` was unsigned, had a default of 0, and a comment —
// and you only write this — all three are gone after the migration runs.
Schema::table('users', function (Blueprint $table) {
   $table->integer('votes')->nullable()->change();
});

// Correct: restate everything you want to keep.
Schema::table('users', function (Blueprint $table) {
   $table->integer('votes')->unsigned()->default(0)->comment('my comment')->nullable()->change();
});
Enter fullscreen mode Exit fullscreen mode

This isn't a locking issue, it's a data-definition footgun, but it belongs in this article because it's the thing most likely to bite you the first time you touch change() in a Laravel 11+ app. Laravel 11 also removed the doctrine/dbal dependency that older versions needed for change() to work at all — if you're upgrading from Laravel 10 or earlier, you can drop that package from composer.json once you're on 11+.

2. It's usually a full table rewrite

Changing a column's type — string to text, integer to bigInteger, resizing a varchar — forces most databases to rewrite every row to verify the new type fits, under an exclusive lock for the duration. There's no CONCURRENTLY or LOCK=NONE equivalent for a type change in either MySQL or PostgreSQL; this is one of the few operations covered in this series that doesn't have an online-DDL escape hatch within plain SQL.

The practical workaround is to not change the column's type in place at all: add a new column with the correct type, backfill it, cut the application over to it, then drop the old column in a later migration. That's slower to ship but it never blocks the table.

3. PostgreSQL needs an explicit cast for some type changes

When the new type can't be implicitly cast from the old one, Postgres needs to be told how to convert each existing value, using the using modifier:

Schema::table('users', function (Blueprint $table) {
   $table->date('birthday')->using('birthday::date')->change();
});
Enter fullscreen mode Exit fullscreen mode

Leaving this off when the implicit cast doesn't exist fails the migration outright — which is actually the safer failure mode, since you find out in CI rather than mid-table-rewrite in production.

Adding NOT NULL to an existing column

This is a different operation from adding a new NOT NULL column, and it's the one most commonly underestimated because the column already exists and already has data.

MySQL: ALTER TABLE ... MODIFY COLUMN ... NOT NULL scans the table to confirm no existing row is null, and on InnoDB this is an online operation in modern versions — but it still has to scan, so it takes time proportional to table size and can still be blocked by the metadata lock issue from Part 1.

PostgreSQL: SET NOT NULL scans the entire table to verify no nulls exist, holding the lock for the whole scan. On Postgres 12+, there's a two-step way around this: add a NOT VALID check constraint first (which doesn't scan existing rows), validate it separately (which scans but only takes a lighter lock that doesn't block writes), then SET NOT NULL — which, once a validated check constraint already proves the column has no nulls, skips its own table scan entirely.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
   public $withinTransaction = false;

   public function up(): void
   {
       // Step 1: add the constraint without checking existing rows.
       DB::statement("
           ALTER TABLE orders
           ADD CONSTRAINT orders_status_not_null
           CHECK (status IS NOT NULL) NOT VALID
       ");

       // Step 2: validate separately — scans the table, but only
       // takes a SHARE UPDATE EXCLUSIVE lock, not an exclusive one.
       DB::statement('ALTER TABLE orders VALIDATE CONSTRAINT orders_status_not_null');

       // Step 3: now cheap, because the validated constraint already
       // proves no row is null.
       DB::statement('ALTER TABLE orders ALTER COLUMN status SET NOT NULL');

       // Step 4: the CHECK constraint is now redundant.
       DB::statement('ALTER TABLE orders DROP CONSTRAINT orders_status_not_null');
   }

   public function down(): void
   {
       DB::statement('ALTER TABLE orders ALTER COLUMN status DROP NOT NULL');
   }
};
Enter fullscreen mode Exit fullscreen mode

Run steps 1–2 in one migration and steps 3–4 in a later one if the table is large enough that you want the validation to happen during a quiet period, separately from the final constraint change.

MySQL's lock modifier applies here too

The lock() modifier from Part 1 isn't index-specific — it applies to column and foreign key definitions as well, with the same four modes: none (concurrent reads and writes), shared (reads only), exclusive (nothing), and default (MySQL picks). If the requested lock mode is incompatible with the specific operation, MySQL raises an error rather than silently using a stricter lock.

$table->string('tracking_number')->nullable()->lock('none');
Enter fullscreen mode Exit fullscreen mode

As with inplace() on indexes, this is explicit opt-in: MySQL won't downgrade an operation to fit your requested lock level, it'll just fail, which is what you want in a migration you're about to run against production.

Renaming and dropping columns: the deploy-ordering problem

This isn't a database locking problem — it's a rolling-deploy problem, and it catches teams who've already solved the locking issues above.

Dropping a column the application still queries doesn't break the database. It breaks whichever of your app's processes are still running old code that references the now-gone column, during the window between when the migration runs and when every server has picked up the new deploy.

The sequence that avoids this:

  1. Deploy code that stops reading or writing the old column.
  2. Wait until every old process has fully drained — confirm via your deploy tooling, not a guess.
  3. Only then ship the migration that drops the column.

Renaming a column is the same problem twice: it's effectively a drop of the old name and an add of the new one from the point of view of any code still running the old version.

// Don't do this in one step on a live table:
Schema::table('users', function (Blueprint $table) {
   $table->renameColumn('full_name', 'display_name');
});
Enter fullscreen mode Exit fullscreen mode

The safer sequence: add display_name as a new column, write to both columns from the application for one deploy cycle, backfill display_name from full_name, switch all reads to display_name, then drop full_name in a later migration once old code has drained.

Quick reference

Operation Risk Safe pattern
Add nullable column, no default None Run directly
Add column with constant default None on Postgres 11+/modern MySQL Run directly
Add column with volatile default (now()) Full table rewrite Add column first, set default separately
Add NOT NULL column, no default Full rewrite on MySQL < 8.0.12 Add nullable, backfill, then constrain
->change() type change Full table rewrite, dropped modifiers New column → backfill → cutover → drop old
SET NOT NULL on existing column Full table scan under exclusive lock (Postgres) NOT VALID check → VALIDATE → SET NOT NULL
Drop/rename column Breaks old app processes mid-deploy Stop referencing → drain → drop, in that order

Before you run it in production

Run php artisan migrate --pretend and read the generated SQL for every modifier you expect to still be there after a change(). Split any volatile default into "add column" then "set default" as two statements. For NOT NULL on an existing Postgres column, use the NOT VALID constraint path rather than a bare SET NOT NULL. Never drop or rename a column in the same deploy that stops using it — give old processes time to drain first.

Related reading: if you haven't locked down index creation yet, start with Part 1: Adding Indexes to Large Tables in Laravel Without Locking Them, which covers CREATE INDEX CONCURRENTLY and MySQL's ALGORITHM=INPLACE, LOCK=NONE in detail.


Originally published on DEV Talk.

Top comments (1)

Collapse
 
beusebiu profile image
Eusebiu Balan •

The dropped modifiers deserve the top spot. A ->change() meant only to widen a column also takes the nullable and the default with it, and nothing complains until the first insert that relied on them.

Running it with --pretend first and reading the actual ALTER is the cheapest check I know.