Advanced Use Cases: Conditional Migrations in Laravel

Laravel migrations are normally deterministic: every environment receives the same migration files, Laravel runs each pending migration once, and the migrations table records what has completed.

Real production systems are not always that tidy.

You may need to introduce a table only when an optional module is enabled, repair databases created before migrations were adopted, use different SQL for PostgreSQL and MySQL, migrate hundreds of tenant databases, or delay a constraint until a large backfill has finished.

These situations lead developers towards conditional migrations.

Conditional migrations can be useful, but they can also create a more serious problem than the one they solve: two environments running the same application version with different and undocumented schemas.

This guide explains the advanced Laravel patterns that make conditional migrations safer, where Laravel 13’s shouldRun() method fits, and when a condition should be replaced by a staged deployment instead.

The examples use current Laravel 13 syntax. Most schema-guard and connection patterns also work in earlier Laravel versions, but first-class migration skipping and some schema inspection methods depend on the framework version installed in your project.


Quick Answer

A conditional migration is a migration whose behaviour depends on a known condition such as:

  • Whether a feature or module is enabled
  • Whether a legacy table or column already exists
  • Which database driver is active
  • Which database connection is being migrated
  • Whether a prerequisite migration or backfill has completed
  • Which tenant database is currently selected

Use the narrowest mechanism that matches the requirement:

RequirementRecommended Laravel mechanism
Do not run this pending migration yetDefine shouldRun(): bool
Add a column only if it is missingSchema::whenTableDoesntHaveColumn() or an explicit Schema::hasColumn() guard
Repair known legacy schema differencesInspect the schema, validate the state, then reconcile it
Use vendor-specific SQLCheck DB::connection()->getDriverName() and keep each branch tested
Modify a non-default databaseSet the migration $connection or use Schema::connection()
Migrate separate tenant databasesRun the tenant migration path once per tenant connection
Change a large table safelyUse expand, backfill, verify and contract across several deployments
Wait for data cleanup before adding a constraintFail clearly when the prerequisite is unmet; do not silently skip
Prevent two deployment nodes migrating togetherRun php artisan migrate --isolated --force with a shared lock-capable cache

The most important rule is:

A migration condition should represent stable deployment state, not random request-time behaviour.

Do not base schema creation on the current user, a percentage rollout, a temporary API response, the time of day, or whichever application server happens to run the command.


What Is a Conditional Migration?

A normal migration always performs the same change:

<?php

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('users', function (Blueprint $table) {
            $table->string('timezone')->nullable();
        });
    }

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

A conditional migration checks something before deciding whether or how to make the change:

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

The second example looks safer because it will not fail when the column already exists. However, it changes the meaning of the migration.

If the column exists with the wrong type, length, collation or nullability, the guard silently accepts it. The application may then fail later even though Laravel reports that every migration has run.

Conditional logic should therefore answer two questions:

  1. Is this an expected alternative state?
  2. If it exists, is it compatible with what the application requires?

Merely checking that an object exists is not always enough.


Three Different Meanings of “Skip”

Many migration bugs happen because three different behaviours are all described as “skipping the migration.”

Table of Contents

1. Skip the Migration Before up() Runs

Current Laravel migrations may define shouldRun():

public function shouldRun(): bool
{
    return (bool) config('features.audit_log_schema');
}

When the method returns false, Laravel skips that migration. It is not treated like a completed schema change, so it can be considered again during a later migration run when the condition becomes true.

Laravel documents shouldRun() under Skipping Migrations.

This behaviour is suitable when the migration genuinely should remain pending.

2. Enter up() but Return Without Changing Anything

An older and still useful pattern is a guard inside up():

public function up(): void
{
    if (Schema::hasColumn('users', 'timezone')) {
        return;
    }

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

Laravel called the migration successfully, so it records the migration as completed even when the method returned before issuing DDL.

This is useful for a reconciliation migration: an imported database may already contain the required column, and the migration exists to bring every database to one recorded baseline.

It is not appropriate when you want Laravel to try again later.

3. Run the Migration but Choose a Different Branch

Sometimes every environment must complete the migration, but the implementation differs:

$driver = DB::connection($this->getConnection())->getDriverName();

match ($driver) {
    'pgsql' => $this->createPostgresIndex(),
    'mysql', 'mariadb' => $this->createMySqlIndex(),
    default => throw new RuntimeException(
        "Unsupported database driver: {$driver}"
    ),
};

This is not really a skipped migration. It is a portable migration with explicit database-specific implementations.

The distinction matters when diagnosing migrate:status, rolling back, adding later migrations and comparing environments.


Use Case 1: Feature-Gated Migrations with shouldRun()

Suppose an audit-log module is present in the codebase but will be enabled during a later release window.

Create a stable configuration value:

// config/features.php

return [
    'audit_log_schema' => env('AUDIT_LOG_SCHEMA_ENABLED', false),
];

The migration can define shouldRun():

<?php

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

return new class extends Migration
{
    public function shouldRun(): bool
    {
        return (bool) config('features.audit_log_schema');
    }

    public function up(): void
    {
        Schema::create('audit_events', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->nullable()->index();
            $table->string('event');
            $table->string('auditable_type')->nullable();
            $table->unsignedBigInteger('auditable_id')->nullable();
            $table->json('metadata')->nullable();
            $table->ipAddress('ip_address')->nullable();
            $table->timestamps();

            $table->index(
                ['auditable_type', 'auditable_id'],
                'audit_events_auditable_index'
            );
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('audit_events');
    }
};

When This Is Appropriate

shouldRun() is useful when:

  • A module is deployed before it is activated.
  • A customer-specific installation includes an optional component.
  • A phased infrastructure change requires a schema migration at a controlled time.
  • A Laravel package publishes migrations that only apply when its integration is enabled.

The Flag Must Be Stable and Global

A database schema normally applies to the complete application, not to one HTTP request or one user.

Avoid this type of condition:

public function shouldRun(): bool
{
    return Feature::active('new-checkout');
}

if new-checkout is a percentage-based or user-scoped feature flag. The result could depend on the current scope, cached resolution or machine running the migration.

A schema gate should be one of the following:

  • A deployment configuration deliberately set for the whole environment
  • A globally scoped, persistently stored feature state
  • A release manifest or module registry shared by every deployment node
  • An explicit command option processed by a controlled migration runner

Do not use a random rollout calculation for DDL.

Configuration Cache Matters

Production Laravel applications commonly cache configuration. Code should read the condition through config(), not call env() directly inside the migration.

After changing the environment variable, rebuild the application’s configuration cache before expecting shouldRun() to see the new value:

php artisan config:cache
php artisan migrate --isolated --force

Also verify that every deployment node has the same configuration. A migration should not run on one node merely because its release directory contains a different cached configuration file.


Use Case 2: Add a Column Only When It Is Missing

Laravel’s schema builder can inspect tables, columns and indexes.

Current schema builder methods include:

MethodPurpose
Schema::hasTable('users')Check whether a table exists
Schema::hasColumn('users', 'timezone')Check one column
Schema::hasColumns('users', ['timezone', 'locale'])Check several columns
Schema::hasIndex('users', ['email'], 'unique')Check an index by columns and optional type
Schema::getColumnType('users', 'timezone')Read a column type
Schema::getColumns('users')Inspect column metadata
Schema::getIndexes('users')Inspect index metadata
Schema::getForeignKeys('posts')Inspect foreign-key metadata

Laravel 13’s API also exposes conditional helpers such as whenTableHasColumn, whenTableDoesntHaveColumn, whenTableHasIndex and whenTableDoesntHaveIndex. See the current Schema Builder API.

Concise Conditional Helper

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

This is concise and expressive when existence is the only compatibility requirement.

Explicit Guard with a Clear Failure

For advanced migrations, an explicit check is often easier to audit:

public function up(): void
{
    if (! Schema::hasTable('users')) {
        throw new RuntimeException(
            'Cannot add users.timezone because the users table is missing.'
        );
    }

    if (Schema::hasColumn('users', 'timezone')) {
        $type = Schema::getColumnType('users', 'timezone');

        if ($type !== 'varchar') {
            throw new RuntimeException(
                "Expected users.timezone to be varchar; found {$type}."
            );
        }

        return;
    }

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

The exact type names returned by schema inspection can vary by database platform and framework version. Test type assertions against every supported driver instead of assuming MySQL and PostgreSQL return identical strings.

Guard the Rollback Too

If the up() method tolerates an already-existing column, the down() method requires careful thought.

This rollback is dangerous:

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

The migration may have found a column created manually years earlier. Rolling back would delete a column the migration did not create.

For a legacy reconciliation migration, an intentionally irreversible rollback may be safer:

public function down(): void
{
    throw new RuntimeException(
        'This reconciliation migration cannot be reversed safely.'
    );
}

Another option is to create a separate, explicit removal migration after confirming ownership and taking a backup.

Do not pretend every production schema change has a safe automatic reverse operation.


Use Case 3: Repair Inconsistent Legacy Databases

Conditional migrations are often introduced when an application existed before its schema was fully managed by Laravel.

For example:

  • Production has a manually created customer_code column.
  • Staging has the same column but with a different length.
  • New developer databases do not have the column.
  • One customer’s self-hosted installation uses a different index name.

The wrong approach is to add if (! Schema::hasColumn(...)) everywhere until migrations stop failing. That hides drift instead of repairing it.

Use a reconciliation migration with an explicit state table:

Observed stateAction
Table missingStop; a prerequisite or database selection is wrong
Column missingAdd the expected column
Column exists and is compatibleRecord migration as complete without changing it
Column exists but is incompatibleStop with a diagnostic error or convert it through a reviewed branch
Unexpected duplicate indexStop and inspect before deleting anything

Example:

<?php

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

return new class extends Migration
{
    public function up(): void
    {
        if (! Schema::hasTable('customers')) {
            throw new RuntimeException(
                'Expected the customers table before reconciling customer_code.'
            );
        }

        if (! Schema::hasColumn('customers', 'customer_code')) {
            Schema::table('customers', function (Blueprint $table) {
                $table->string('customer_code', 32)->nullable();
            });

            return;
        }

        $column = collect(Schema::getColumns('customers'))
            ->firstWhere('name', 'customer_code');

        if ($column === null) {
            throw new RuntimeException(
                'customer_code exists but its metadata could not be inspected.'
            );
        }

        if (($column['nullable'] ?? null) !== true) {
            throw new RuntimeException(
                'customers.customer_code must be nullable before this release.'
            );
        }
    }

    public function down(): void
    {
        throw new RuntimeException(
            'Legacy schema reconciliation is intentionally irreversible.'
        );
    }
};

This migration does not silently rewrite an unknown column. It makes a known missing change, accepts a known compatible state and fails on an unexpected state.

That failure is useful. It prevents the deployment from claiming success while the database remains incompatible.


Use Case 4: Database-Driver-Specific Migrations

Laravel’s schema builder is database-agnostic for common operations, but not every database offers the same features or DDL behaviour.

Common differences include:

  • PostgreSQL partial indexes
  • PostgreSQL CONCURRENTLY
  • MySQL generated columns and online DDL options
  • SQL Server filtered or online indexes
  • SQLite limitations during table alteration
  • Different JSON, enum, collation and full-text behaviour

Detect the Active Driver

use Illuminate\Support\Facades\DB;

$driver = DB::connection($this->getConnection())->getDriverName();

Use an explicit match statement and fail on unsupported databases:

public $withinTransaction = false;

public function up(): void
{
    $connection = DB::connection($this->getConnection());
    $driver = $connection->getDriverName();

    match ($driver) {
        'pgsql' => $connection->statement(
            'CREATE INDEX CONCURRENTLY IF NOT EXISTS '
            .'orders_open_created_at_index '
            .'ON orders (created_at) '
            .'WHERE completed_at IS NULL'
        ),

        'mysql', 'mariadb' => Schema::table(
            'orders',
            function (Blueprint $table) {
                if (! Schema::hasIndex(
                    'orders',
                    ['completed_at', 'created_at']
                )) {
                    $table->index(
                        ['completed_at', 'created_at'],
                        'orders_completed_created_index'
                    );
                }
            }
        ),

        default => throw new RuntimeException(
            "Unsupported database driver: {$driver}"
        ),
    };
}

Laravel’s base migration class exposes $withinTransaction, which controls whether Laravel wraps a migration in a transaction when the database grammar supports transactional schema changes. Its current API is documented in Illuminate\Database\Migrations\Migration.

PostgreSQL does not allow CREATE INDEX CONCURRENTLY inside a normal transaction, which is why that migration opts out. Once transaction wrapping is disabled, partial failure becomes more important: inspect the resulting index before retrying.

Laravel 13 Online Index Creation

Laravel 13 can express online index creation for PostgreSQL and SQL Server:

Schema::table('orders', function (Blueprint $table) {
    $table->index('created_at')->online();
});

For PostgreSQL, Laravel emits CONCURRENTLY; for SQL Server, it emits the platform’s online option. The current behaviour and supported databases are documented under Online Index Creation.

Do not add online() to a cross-database migration without testing each supported connection. MySQL online DDL has different syntax and operational restrictions.

Prefer Capability Checks Over Environment Names

This is fragile:

if (app()->environment('production')) {
    // PostgreSQL SQL
}

Production may change database vendors, and staging should normally reproduce production behaviour.

Check the capability that actually matters—the driver, server version, existing index or installed extension—and fail clearly when it is unsupported.


Use Case 5: Non-Default Database Connections

A migration can target a named connection by setting $connection:

return new class extends Migration
{
    protected $connection = 'analytics';

    public function up(): void
    {
        Schema::connection('analytics')->create(
            'daily_metrics',
            function (Blueprint $table) {
                $table->date('metric_date');
                $table->string('metric_name');
                $table->decimal('metric_value', 18, 4);

                $table->primary(['metric_date', 'metric_name']);
            }
        );
    }

    public function down(): void
    {
        Schema::connection('analytics')->dropIfExists('daily_metrics');
    }
};

Laravel documents both the migration $connection property and Schema::connection() in its migration documentation.

Avoid Cross-Database Atomicity Assumptions

A migration that updates two different database connections is not automatically one atomic transaction:

DB::connection('mysql')->table('users')->update(...);
DB::connection('analytics')->table('identities')->insert(...);

The first operation may commit while the second fails. Normal Laravel transactions do not create a distributed transaction across unrelated connections.

Prefer:

  1. One migration path per owned database.
  2. A resumable synchronization command or job.
  3. A checkpoint table recording copied ranges.
  4. Reconciliation queries comparing both sides.
  5. An explicit cutover after verification.

Conditional connection logic should choose the correct database; it should not disguise a distributed data migration as one atomic schema change.


Use Case 6: Optional Modules and Package Migrations

A reusable application may contain optional modules such as:

  • Audit logging
  • Billing
  • Inventory
  • SAML authentication
  • Search analytics
  • Customer-specific integrations

There are two clean patterns.

Pattern A: Separate Migration Paths

Store module migrations separately:

database/migrations/
database/migrations/modules/billing/
database/migrations/modules/audit/

Run only the selected module path:

php artisan migrate \
    --path=database/migrations/modules/audit \
    --force

This is explicit in the deployment pipeline and works well when installations intentionally have different products.

Pattern B: Published Package Migrations

Laravel packages can publish migration files with publishesMigrations():

public function boot(): void
{
    $this->publishesMigrations([
        __DIR__.'/../database/migrations' => database_path('migrations'),
    ]);
}

Laravel updates published migration timestamps so they are ordered correctly. See the official Package Development documentation.

Once a package migration has been published and run, do not change that old migration in a later package version. Create a new migration just as you would in the host application.

Do Not Let Module Conditions Become Invisible

If two installations intentionally use different module schemas, record that fact outside the migration code:

  • Keep a module registry table.
  • Store the enabled module set in deployment configuration.
  • Include it in support diagnostics.
  • Test upgrades from every supported module combination.
  • Ensure backups and restore procedures include module-owned tables.

Without an inventory, support engineers cannot tell whether a missing table is intentional or broken.


Use Case 7: Multi-Tenant Database Migrations

Multi-tenancy requires two very different designs.

Shared Database, Shared Schema

If every tenant shares the same tables and rows are separated by tenant_id, the schema is global.

Do not conditionally add a column for only one tenant. Adding a column changes the table for every tenant even if only one tenant’s code uses it.

Use runtime feature flags, tenant configuration or a separate extension table instead.

Separate Database Per Tenant

If every tenant has a separate database, each database needs its own migration history.

A controlled Artisan command can select each database and run a dedicated tenant migration path:

<?php

namespace App\Console\Commands;

use App\Models\Tenant;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;

class MigrateTenants extends Command
{
    protected $signature = 'tenants:migrate
        {--tenant=* : Tenant IDs to migrate}
        {--force : Run without a production confirmation}';

    protected $description = 'Run pending migrations for tenant databases';

    public function handle(): int
    {
        $query = Tenant::query()->where('status', 'active');

        if ($ids = $this->option('tenant')) {
            $query->whereIn('id', $ids);
        }

        $failed = [];

        $query->orderBy('id')->each(function (Tenant $tenant) use (&$failed) {
            Config::set(
                'database.connections.tenant.database',
                $tenant->database_name
            );

            DB::purge('tenant');

            $exitCode = Artisan::call('migrate', [
                '--database' => 'tenant',
                '--path' => 'database/migrations/tenant',
                '--force' => (bool) $this->option('force'),
            ]);

            if ($exitCode !== self::SUCCESS) {
                $failed[] = $tenant->id;
            }

            $this->output->write(Artisan::output());
        });

        DB::purge('tenant');

        if ($failed !== []) {
            $this->error(
                'Failed tenant IDs: '.implode(', ', $failed)
            );

            return self::FAILURE;
        }

        return self::SUCCESS;
    }
}

This is a simplified example. A production tenant migrator should also provide:

  • A distributed lock so two operators cannot migrate the same tenant together
  • An allowlist or trusted registry for database names and connection details
  • Per-tenant start, finish, duration and error logs
  • Retry policy that does not repeat already successful migrations
  • A canary group before the complete tenant fleet
  • Batching so hundreds of tenants do not overload the database server
  • A report of pending migrations by tenant
  • A way to pause on excessive failure rate
  • Backups or snapshots suitable for the hosting model

Do not place one large foreach loop inside a schema migration and switch connections invisibly. Tenant orchestration is an operational command; individual tenant migrations should remain normal and auditable.


Use Case 8: Conditional Indexes and Constraints

Indexes and constraints often require stronger checks than columns.

Add an Index Only When Missing

public function up(): void
{
    Schema::whenTableDoesntHaveIndex(
        'orders',
        ['account_id', 'created_at'],
        function (Blueprint $table) {
            $table->index(
                ['account_id', 'created_at'],
                'orders_account_created_index'
            );
        }
    );
}

An explicit version is helpful when index names differ across legacy databases:

if (! Schema::hasIndex('orders', ['account_id', 'created_at'])) {
    Schema::table('orders', function (Blueprint $table) {
        $table->index(
            ['account_id', 'created_at'],
            'orders_account_created_index'
        );
    });
}

Check the columns and uniqueness type, not only a conventional name. Two indexes can have different names but provide the same column ordering.

Do Not Silently Skip a Required Constraint

Suppose orders.account_id must become non-null after a backfill.

This is unsafe:

if (! DB::table('orders')->whereNull('account_id')->exists()) {
    // Add the constraint.
}

If bad rows remain, the migration does nothing but can still be recorded as complete.

Fail clearly instead:

public function up(): void
{
    $missing = DB::table('orders')
        ->whereNull('account_id')
        ->count();

    if ($missing > 0) {
        throw new RuntimeException(
            "Cannot require orders.account_id; {$missing} rows are null."
        );
    }

    Schema::table('orders', function (Blueprint $table) {
        $table->unsignedBigInteger('account_id')
            ->nullable(false)
            ->change();
    });
}

This is a precondition, not an optional feature. A failed precondition should stop the release and explain what must be completed.


Use Case 9: Large Data Backfills

Developers sometimes make a migration conditional on table size:

if (DB::table('orders')->count() < 100000) {
    // Backfill here.
}

This produces the opposite of a controlled deployment. Small databases receive the change while large, important databases silently do not.

For large changes, separate schema evolution from data movement.

Phase 1: Expand the Schema

Add a nullable column that old code can ignore:

Schema::table('orders', function (Blueprint $table) {
    $table->string('status_v2', 32)->nullable()->index();
});

Deploy code that can read the old representation and dual-write both representations.

Phase 2: Backfill in Resumable Batches

Use an Artisan command or queue jobs rather than one long schema migration:

DB::table('orders')
    ->whereNull('status_v2')
    ->orderBy('id')
    ->chunkById(1000, function ($orders) {
        foreach ($orders as $order) {
            DB::table('orders')
                ->where('id', $order->id)
                ->whereNull('status_v2')
                ->update([
                    'status_v2' => match ($order->legacy_status) {
                        0 => 'pending',
                        1 => 'paid',
                        2 => 'cancelled',
                        default => 'unknown',
                    },
                ]);
        }
    });

Laravel recommends chunkById() when records are being updated during iteration because ordinary chunk offsets can produce inconsistent results. See the Query Builder documentation.

The extra whereNull() on the update makes the operation restartable and avoids overwriting a newer dual-written value.

For higher throughput, perform set-based updates where the database can express the transformation safely. The row-by-row example prioritises readability.

Phase 3: Verify

Check at least:

  • Remaining null or invalid values
  • Counts grouped by old and new state
  • Records changed after the backfill began
  • Application error rate
  • Query latency and database locks
  • A sample of business-critical records

Phase 4: Switch Reads

Deploy code that treats status_v2 as authoritative. Keep the old field temporarily if rollback requires it.

Phase 5: Contract

In a later release, add the non-null constraint and eventually remove the old field.

The condition belongs in release orchestration: “Has the backfill and verification completed?” It should not be an invisible if that produces different final schemas.


Avoid Eloquent Models Inside Old Migrations

This may work today:

Order::query()->whereNull('status_v2')->each(...);

It may fail next year when a new developer creates a database from scratch.

The current Order model could then contain:

  • Global scopes that expect columns not yet created
  • Attribute casts incompatible with old data
  • Observers that dispatch jobs or external calls
  • Relationships depending on later tables
  • Events and accessors that did not exist when the migration was written

Prefer the query builder and explicit table names in migrations:

DB::table('orders')->whereNull('status_v2')->update(...);

For a large or operationally sensitive data change, put the backfill in a versioned command or job. Keep the schema migration focused on DDL and precondition checks.


Environment-Conditional Migrations: Usually a Warning Sign

This pattern is common:

if (app()->environment('production')) {
    Schema::create('reporting_snapshots', ...);
}

It creates a production-only schema that staging and automated tests cannot reproduce.

Problems include:

  • Bugs appear only during the production migration.
  • Developers cannot create a faithful local database.
  • migrate:fresh does not produce the same structure everywhere.
  • Later migrations may assume the table exists and fail outside production.
  • A renamed environment such as prod or production-blue can change behaviour.

Better options are:

  • Use the same schema everywhere and disable behaviour with a runtime flag.
  • Put infrastructure-specific migrations in an explicit path.
  • Target a named database connection.
  • Test production-specific database capabilities in a matching staging database.
  • Keep local substitutes for external warehouses or analytics systems.

An environment condition is defensible only when schema divergence is an intentional product requirement and is documented, inventoried and tested.


Conditions That Should Never Control a Migration

Avoid conditions based on:

The Current User

Artisan migration commands do not have a meaningful authenticated web user, and database schema is normally global.

Random Percentage Rollouts

A 10% feature flag must not create a table with a 10% probability.

Current Traffic or Load Alone

If load is too high, pause the release through orchestration. Do not let the migration report success without performing the required change.

A Remote API That Can Temporarily Fail

A timeout should not determine the permanent schema. Resolve remote configuration before the deployment and store the decision locally.

The Presence of Application Data Without a Defined State Model

“Run only when there are users” creates different schemas for new and established databases. Define a precondition or backfill phase instead.

Hostname or Pod Name

Choose one migration runner explicitly. Do not make schema behaviour depend on an ephemeral server name.


Safe Production Deployment Pattern

Conditional migrations still require a controlled deployment process.

1. Review the Generated SQL

Laravel can display the SQL without applying it:

php artisan migrate --pretend

--pretend is useful for review, but it is not proof that the migration will complete within the required time or locking budget.

2. Check Migration Status

php artisan migrate:status

Record the pending migrations in the deployment evidence.

3. Back Up and Test Restore

A backup is only useful if the team can restore it within the application’s recovery target. For destructive or long-running DDL, consider a database snapshot, replica or point-in-time recovery position.

4. Deploy Backward-Compatible Code

Old and new application instances may overlap during a rolling or zero-downtime deployment. The schema change must be safe for both versions.

Use the expand-and-contract sequence:

  1. Add compatible schema.
  2. Deploy code that supports both states.
  3. Backfill and verify.
  4. Switch reads and writes.
  5. Remove old schema in a later release.

5. Run One Migration Process

For multi-server deployments:

php artisan migrate --isolated --force

Laravel’s --isolated option obtains an atomic lock through the configured cache driver. Other migration commands that cannot obtain the lock do not run, but Laravel documents that they still exit successfully. See Isolating Migration Execution.

This produces two operational requirements:

  • Deployment nodes must use the same lock-capable cache store.
  • A successful shell exit does not prove that a particular node ran the migrations.

Prefer one designated migration job, and verify migrate:status afterwards.

6. Monitor Database and Application Health

Watch:

  • Lock waits and blocked queries
  • Replication lag
  • CPU, I/O and temporary space
  • Migration duration
  • Error rate and failed jobs
  • Query latency
  • Business-level correctness metrics

7. Restart Long-Lived Workers

Laravel queue workers and Octane processes keep booted application state in memory. Restart or reload them according to the deployment platform after compatible schema and code are active.


Rollback Versus Roll-Forward

down() is valuable during development, but a production rollback is not always equivalent to restoring the previous system.

Consider a migration that adds a column, deploys code that writes data into it, and then runs for two hours. Dropping that column during rollback destroys the new data.

Before deployment, classify the change:

ChangeTypical recovery strategy
Add nullable columnRoll back application code; keep harmless column
Add indexDrop index if safe and necessary
Rename columnPrefer compatibility phase; do not depend on immediate rollback
Backfill transformed dataKeep original field until verification period ends
Add non-null constraintRemove constraint if old code still writes nulls
Drop column or tableRestore from backup or roll forward; automatic rollback may be impossible
Cross-database data moveReconcile and resume rather than assume atomic rollback

For many production failures, a roll-forward migration is safer than attempting to run a destructive down() method under pressure.

Conditional down() methods must not hide uncertainty. If the migration cannot know whether it owns an existing object, fail with a clear message and require an explicit recovery procedure.


Testing Conditional Migrations

A conditional migration needs more tests than a normal migration because it has more possible starting states.

Test the Empty Database Path

A new database must reach the expected final schema:

php artisan migrate:fresh --env=testing

Never run migrate:fresh against a database containing valuable data. It drops all tables before recreating them.

Test Every Supported Starting State

For a guarded column migration, create tests for:

  1. Table exists and column is missing.
  2. Table and compatible column already exist.
  3. Table is missing unexpectedly.
  4. Column exists with an incompatible definition.
  5. Migration is retried after partial non-transactional DDL.
  6. Rollback is attempted after each successful path.

Test Both Flag Values

For shouldRun():

  • Run with the stable flag disabled and confirm no object is created.
  • Enable the flag and run migrations again.
  • Confirm the object is created exactly once.
  • Run migrations once more and confirm there is no further change.

Test the Real Database Engines

SQLite is useful for fast tests but does not reproduce all MySQL, MariaDB, PostgreSQL or SQL Server DDL behaviour.

If production uses PostgreSQL and the migration has a PostgreSQL branch, run it against the supported PostgreSQL version in CI or a pre-production environment.

Verify the Result, Not Only the Exit Code

Assertions should inspect:

  • Tables and columns
  • Column type, nullability and default
  • Index columns, order and uniqueness
  • Foreign keys and actions
  • Expected row counts or reconciliation totals
  • Pending migration status

An exit code of zero says that Laravel did not report an error. It does not prove the schema matches the application’s contract.


Common Mistakes

1. Wrapping Every Migration in hasTable()

if (! Schema::hasTable('orders')) {
    Schema::create('orders', ...);
}

For a normal application-created table, “already exists” may indicate a duplicate migration, wrong database, manual change or naming collision. Automatically accepting it can mask the root cause.

Use this guard only for a documented alternative state.

2. Checking Only a Column Name

A price column can exist as an integer in one database and a decimal in another. Inspect the attributes that matter to the application.

3. Using shouldRun() for a Required Prerequisite

If a constraint is required for the release, do not leave it pending silently. Fail with a diagnostic message when the backfill is incomplete.

4. Using a User-Scoped Feature Flag

Schemas are normally environment-wide. Use a global deployment decision.

5. Performing Millions of Updates in up()

Long transactions, locks, timeouts and unresumable failures make this dangerous. Use a resumable backfill command.

6. Editing a Migration That Already Ran

Laravel will not rerun it merely because its file changed. Create a new migration.

7. Assuming --isolated Means This Node Migrated

Another node may own the lock, while this command exits successfully without running. Use one migration runner and verify status.

8. Assuming Every DDL Operation Rolls Back

Transaction support differs by database and operation. Test failure halfway through the migration.

9. Catching Every Exception and Continuing

This is especially dangerous:

try {
    Schema::table(...);
} catch (Throwable $exception) {
    // Ignore it.
}

Laravel may record the migration as successful while the schema change failed. Catch only errors you can identify, validate and recover from; otherwise let the deployment stop.

10. Making Production the First Real Test

Database-specific branches and online DDL must be exercised against production-like engine versions and realistic table sizes.


A Practical Decision Framework

Before adding an if statement to a migration, ask these questions.

Question 1: Should Every Installation Have the Same Final Schema?

  • Yes: prefer a deterministic migration. Use conditions only to reconcile known starting states.
  • No: document the supported schema variants and how the module or tenant model selects them.

Question 2: Should Laravel Try Again Later?

  • Yes: use shouldRun() with a stable global condition.
  • No: enter up(), validate the current state, reconcile it and let Laravel record completion.

Question 3: Is the Condition Optional or Required?

  • Optional feature: skipping may be valid.
  • Required prerequisite: fail clearly rather than skip.

Question 4: Is the Difference About Capability?

  • Different database driver or version: branch on the capability and test every branch.
  • Different environment name: reconsider the design.

Question 5: Does the Change Move Large Amounts of Data?

  • Yes: separate the schema migration from a resumable backfill.
  • No: a small deterministic data update may be acceptable, preferably through the query builder.

Question 6: Can Old and New Code Run Together?

  • No: redesign the migration as expand and contract or use a planned maintenance window.
  • Yes: define how long compatibility must remain.

Question 7: What Happens Halfway Through?

Know whether DDL is transactional, which objects may already exist on retry, how to verify the state and whether recovery is roll-forward or rollback.


Production Checklist

Before approving a conditional migration, confirm that:

  • The condition represents a documented, expected state.
  • The condition is stable for the complete migration run.
  • Every deployment node sees the same global configuration.
  • shouldRun() is used only when the migration should remain pending.
  • An up() guard is used intentionally when a no-op should still be recorded.
  • Existing objects are checked for compatibility, not only existence.
  • Unsupported database drivers fail clearly.
  • Every database-specific branch is tested on the real engine.
  • Large backfills are resumable and separate from DDL.
  • Required prerequisites stop the migration instead of silently skipping.
  • The migration does not depend on current Eloquent models or observers.
  • Old and new application versions can coexist with the expanded schema.
  • The down() method will not delete an object the migration did not create.
  • The team has a backup, restore plan and recovery decision.
  • php artisan migrate --pretend has been reviewed.
  • php artisan migrate:status is checked before and after deployment.
  • Only one controlled runner executes production migrations.
  • --isolated uses a shared cache driver that supports atomic locks.
  • Monitoring covers locks, replication lag, errors and business correctness.
  • Long-lived queue and application workers are restarted or reloaded.
  • The final schema is verified directly after the command succeeds.

Frequently Asked Questions

What is a conditional migration in Laravel?

It is a migration whose execution or implementation depends on a condition, such as an enabled module, an existing schema object, the active database driver, a selected connection or a completed prerequisite.

How do I skip a migration conditionally in Laravel 13?

Define a public shouldRun(): bool method on the migration. Laravel calls it before running the migration. Return true when the migration should execute and false when it should be skipped.

What is the difference between shouldRun() and returning early from up()?

shouldRun() prevents the migration from running and leaves it available for a later attempt. Returning early from up() completes the migration call without schema work, so Laravel records it as run. Use the first for deferred execution and the second for deliberate reconciliation.

Should every migration use Schema::hasTable()?

No. For normal deterministic migrations, an unexpected existing table should usually stop the process so the cause can be investigated. Existence guards are appropriate only for documented legacy or optional states.

Can I check whether a column exists before adding it?

Yes. Use Schema::hasColumn() or Schema::whenTableDoesntHaveColumn(). For legacy databases, also validate important attributes such as type, nullability, default and indexes.

Can a migration use a feature flag?

Yes, but the flag must represent a stable global deployment decision. Do not use a user-scoped, random or percentage-based flag to control database schema.

Should I use app()->environment() inside a migration?

Usually no. It creates schema differences that automated tests and staging may not reproduce. Prefer a named connection, explicit migration path, capability check or runtime feature flag.

How do I run different SQL for MySQL and PostgreSQL?

Use DB::connection()->getDriverName(), branch explicitly, test both paths and throw an exception for unsupported drivers. Use Laravel’s schema builder where it expresses the required operation correctly.

Can conditional migrations make a migration idempotent?

They can make specific operations safe to retry, but a few hasColumn() checks do not prove the complete migration is idempotent. You must account for indexes, constraints, data changes and partial DDL failure.

Should I put a large data backfill inside a migration?

Normally no. Add compatible schema first, backfill through a resumable command or jobs, verify the result, switch application reads and then add constraints or remove old fields in later migrations.

Should a migration silently wait when data is not ready?

If the schema change is required for the release, no. Throw a clear exception stating which prerequisite remains incomplete. Silent no-ops can be recorded as completed and leave the application unprotected.

How do I migrate separate tenant databases?

Use a controlled orchestration command that selects each tenant connection and runs a dedicated migration path. Keep a migration history per tenant database, log results and use locks to prevent concurrent runs.

Is php artisan migrate --isolated enough for multi-server deployment?

It prevents concurrent migration execution when every node shares a cache store that supports atomic locks. However, a node that does not obtain the lock can still exit successfully. Use one designated migration job and verify pending status afterwards.

Are migration rollbacks always safe?

No. Dropping a newly used column can destroy data, and some database DDL is not transactional. For additive changes, rolling application code back while keeping the column is often safer. Plan the recovery method before deploying.

Can I edit an old migration to add a new condition?

Do not edit a migration that has already been shared or deployed. Existing databases will not rerun it. Create a new reconciliation or corrective migration.


Final Summary

Conditional migrations are most valuable when they make an expected schema difference explicit.

Laravel provides several useful mechanisms:

  • shouldRun() for a migration that should remain pending until a stable condition becomes true
  • hasTable(), hasColumn(), hasIndex() and metadata inspection for known legacy states
  • Conditional schema helpers for concise, guarded changes
  • Named connections for separate databases
  • Driver detection for database-specific SQL
  • $withinTransaction for operations whose transaction requirements differ
  • --isolated to prevent concurrent production migration runners

The framework cannot decide whether a condition represents a legitimate variant or hidden schema drift. That remains an architectural decision.

Use conditions to select between states you deliberately support. Fail when an unexpected state would violate the application’s database contract. For large or breaking changes, avoid a clever one-file migration and use a staged sequence: expand the schema, deploy compatible code, backfill, verify, switch traffic and contract later.

The safest conditional migration is not the one with the most defensive if statements. It is the one whose starting states, final state, retry behaviour and recovery path are all known before production runs it.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *