Moon Pixels Limited project availability
Back to articles

Safely adding non-nullable columns in Laravel without breaking production

7 min read

I once watched a perfectly reasonable Laravel release faceplant because we tried to be clever.

We were deploying with Laravel Envoyer. The plan was simple: add a new column and backfill it in the same migration. The backfill was not even complicated, just slow. Envoyer was less patient. The deployment timed out, leaving us in that awkward half-changed state where the database has moved on, but Laravel thinks it has not.

And the best part is what happens next: you rerun migrations, Laravel retries the same migration file, and now the first step (adding the column) blows up because the column already exists.

This post is how I avoid that class of problem now.

By the end, you should be able to choose between:

  • a single-release approach (three small migrations), and
  • a safer two-release approach (nullable column now, backfill separately via an Artisan command, enforce later).

Why a new NOT NULL column is risky on an existing table

Relational databases are strict about NOT NULL. If you add a column that rejects nulls, you either need a default, or you need to prove every existing row has a value.

If the table already has data, those existing rows have no value for your new column yet. So the database has to do one of two things:

  • reject the change, or
  • rewrite / scan / lock enough of the table to make it true.

Either way, it is a bad surprise to discover during a deployment.

PostgreSQL is explicit about it: SET NOT NULL can only happen if there are no nulls, and it may scan the table to check that constraint is satisfied (PostgreSQL ALTER TABLE docs).

MySQL is explicit about the operational cost too. In the MySQL docs, “Making a column NOT NULL” is described as an in-place operation that rebuilds the table and fails if the column contains NULL values (MySQL online DDL operations docs).

The Laravel side of this is simple: migrations are a delivery mechanism, not a magic wand. You still need a rollout plan.

The pattern that keeps you out of trouble: add -> backfill -> enforce

The best practice is boring:

  1. Make the schema change backwards-compatible.
  2. Get the data to match the future constraint.
  3. Enforce the constraint when you can prove it will pass.

If you only remember one line: migrations are cheap, broken deployments are not.

Option 1: one release, three migrations (small datasets)

If the table is small and your backfill will finish quickly, you can do this in a single release. The key is that it is still split into separate migration files so Laravel can track progress cleanly.

Migration 1: add the column as nullable

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('orders', function (Blueprint $table) {
            $table->string('external_reference')->nullable()->after('id');
        });
    }
};

This keeps the schema compatible with your current data: existing rows remain valid.

Your application code must also be updated so it writes a value for the new column.

Migration 2: backfill existing rows

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

return new class extends Migration
{
    public function up(): void
    {
        DB::table('orders')
            ->whereNull('external_reference')
            ->lazyById(1_000)
            ->each(function (object $order): void {
                DB::table('orders')
                    ->where('id', $order->id)
                    ->update([
                        'external_reference' => 'ORD-' . $order->id,
                    ]);
            });
    }
};

Two details matter here:

  • Use lazyById (or chunkById) if you are updating while iterating. Laravel explicitly calls out that this avoids inconsistent results compared to naive chunking.
  • Keep the work small enough that it will reliably finish inside your deployment window.

Note: If you are using Envoyer, they have a 10-15 minute deployment time limit depending on your plan. That is a hard constraint you should design around.

Migration 3: enforce NOT NULL

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('orders', function (Blueprint $table) {
            $table->string('external_reference')->nullable(false)->change();
        });
    }
};

In Laravel, this is the change() step, and when you modify a column you must include all the modifiers you want to keep.

Option 2: two releases + backfill command/jobs (large tables)

If the table is large, or if you do not control your deployment window tightly, option 2 is the one I recommend.

The idea is to move the backfill out of migrations entirely. Migrations stay schema-focused. Backfills become an operational task you can throttle, retry, and monitor.

Release 1: add the column nullable, then start writing it

  1. Add the nullable column (same as option 1).

  2. Update application code so that any new or updated rows write a value for the new column.

That buys you time. From this point onwards, “new data” is compliant. Only “old data” needs backfilling.

Backfill with an Artisan command (and queues if needed)

Make the backfill a first-class command:

php artisan make:command BackfillOrderExternalReference

Then, in the command handler, do one job per chunk (or just do it inline, depending on scale). Laravel job batching is a nice fit if you want visibility and callbacks. When you deploy code that changes jobs, remember to restart workers so they pick up the new code.

Release 2: enforce NOT NULL

Once your backfill command has run and you can prove there are no nulls left:

DB::table('orders')->whereNull('external_reference')->count();

…ship the nullable(false)->change() migration.

This is the boring part, and that is exactly why it works.

How to choose between option 1 and option 2

Option 1 is fine when:

  • the table is small,
  • the backfill is quick,
  • you have a generous deployment window,
  • you can tolerate the database load during deploy.

Option 2 is the safer default when:

  • the table is large,
  • you cannot confidently estimate backfill time,
  • you deploy with strict time limits (Envoyer is a good example),
  • you want the ability to pause / resume / retry without re-running migrations.

If you are torn, pick option 2. The only real downside is that the column stays nullable for a bit longer.

The “don’t do this” section (migration failure mode)

The anti-pattern is a single migration file that:

  1. adds the column, then
  2. does a large update across the table, then
  3. modifies the column to enforce NOT NULL.

If step 2 times out or dies, Laravel will not record the migration as run. Your database may already have the new column, so the next migration attempt fails early with “column already exists” and you now have to clean up manually.

Split migrations. Keep migrations boring.

Also, if you deploy across multiple servers, make sure only one box is running migrations. Laravel has built-in support for this via php artisan migrate --isolated, which acquires an atomic lock via your cache driver.

Wrap-up

Adding a new NOT NULL column is not hard, but doing it safely is a rollout problem, not a syntax problem.

Add the column as nullable, backfill in a way that fits your operational constraints, and only enforce NOT NULL when you can prove your data is clean. If the backfill could be slow or risky, move it out of migrations and into an Artisan command or queued jobs.

If you build the habit of incremental, backwards-compatible database changes, you stop dreading migrations on release day.

© 2026 Moon Pixels Ltd. (Registered in England and Wales)
Company No. 14080344 VAT No. GB511878679
Privacy Policy
Made in Wales