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:
| Requirement | Recommended Laravel mechanism |
|---|---|
| Do not run this pending migration yet | Define shouldRun(): bool |
| Add a column only if it is missing | Schema::whenTableDoesntHaveColumn() or an explicit Schema::hasColumn() guard |
| Repair known legacy schema differences | Inspect the schema, validate the state, then reconcile it |
| Use vendor-specific SQL | Check DB::connection()->getDriverName() and keep each branch tested |
| Modify a non-default database | Set the migration $connection or use Schema::connection() |
| Migrate separate tenant databases | Run the tenant migration path once per tenant connection |
| Change a large table safely | Use expand, backfill, verify and contract across several deployments |
| Wait for data cleanup before adding a constraint | Fail clearly when the prerequisite is unmet; do not silently skip |
| Prevent two deployment nodes migrating together | Run 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:
- Is this an expected alternative state?
- 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.”
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:
| Method | Purpose |
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_codecolumn. - 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 state | Action |
| Table missing | Stop; a prerequisite or database selection is wrong |
| Column missing | Add the expected column |
| Column exists and is compatible | Record migration as complete without changing it |
| Column exists but is incompatible | Stop with a diagnostic error or convert it through a reviewed branch |
| Unexpected duplicate index | Stop 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:
- One migration path per owned database.
- A resumable synchronization command or job.
- A checkpoint table recording copied ranges.
- Reconciliation queries comparing both sides.
- 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:freshdoes not produce the same structure everywhere.- Later migrations may assume the table exists and fail outside production.
- A renamed environment such as
prodorproduction-bluecan 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:
- Add compatible schema.
- Deploy code that supports both states.
- Backfill and verify.
- Switch reads and writes.
- 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:
| Change | Typical recovery strategy |
| Add nullable column | Roll back application code; keep harmless column |
| Add index | Drop index if safe and necessary |
| Rename column | Prefer compatibility phase; do not depend on immediate rollback |
| Backfill transformed data | Keep original field until verification period ends |
| Add non-null constraint | Remove constraint if old code still writes nulls |
| Drop column or table | Restore from backup or roll forward; automatic rollback may be impossible |
| Cross-database data move | Reconcile 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:
- Table exists and column is missing.
- Table and compatible column already exist.
- Table is missing unexpectedly.
- Column exists with an incompatible definition.
- Migration is retried after partial non-transactional DDL.
- 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 --pretendhas been reviewed.php artisan migrate:statusis checked before and after deployment.- Only one controlled runner executes production migrations.
--isolateduses 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 truehasTable(),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
$withinTransactionfor operations whose transaction requirements differ--isolatedto 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.