Migration Guidelines
Core Principles
Migrations are a critical part of database management in Laravel applications. Following these guidelines ensures smooth database evolution and prevents common issues.
Well-structured migrations make your database changes reliable, reversible, and maintainable across environments.
Migration Best Practices
1. Single Responsibility Principle
Each migration should perform only one logical operation.
- Good Practice
- Bad Practice
public function up()
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
}
public function up()
{
// Creating table
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamps();
});
// Inserting data in the same migration
DB::table('users')->insert([
'name' => 'Admin User',
'email' => 'admin@example.com',
'created_at' => now(),
'updated_at' => now(),
]);
}
Common single-responsibility operations include:
- Creating a new table
- Adding a column to an existing table
- Modifying existing data
- Creating indexes
2. Descriptive Naming
- Good Naming
- Bad Naming
public function up()
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamps();
});
}
public function up()
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamps();
});
}
3. Always Define Down Methods
- Complete Down Method
- Incomplete Down Method
public function up()
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('users');
}
public function up()
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamps();
});
}
4. Separate Different Types of Operations
- Structure Changes
- Data Operations
public function up()
{
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->timestamps();
});
}
public function up()
{
$categories = [
['name' => 'Technology', 'slug' => 'technology'],
['name' => 'Health', 'slug' => 'health'],
['name' => 'Finance', 'slug' => 'finance'],
];
DB::table('categories')->insert($categories);
}
5. Handle Timeouts Properly
Laravel doesn't automatically detect timeouts in migrations, which can lead to inconsistent states.
If a migration times out, Laravel might mark it as completed even if it didn't finish executing.
Solutions:
- Optimize large data operations
- Split large migrations into smaller ones
- Use chunk processing for large datasets
public function up()
{
User::chunk(1000, function ($users) {
foreach ($users as $user) {
// Process each user
$user->update(['status' => 'active']);
}
});
}
6. Immutability of Executed Migrations
Once a migration has been applied to a production database, it should never be modified.
Instead of modifying an existing migration, create a new one to make additional changes.
7. Hardcode Enums in Migrations
Always hardcode enum values directly in migrations instead of referencing enum classes.
This ensures that migrations remain stable and independent of code changes. If an enum class is modified or removed in the future, the migration will still work correctly.
- Good Practice
- Bad Practice
public function up()
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->enum('status', ['pending', 'processing', 'completed', 'cancelled']);
$table->timestamps();
});
}
use App\Enums\OrderStatus;
public function up()
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->enum('status', OrderStatus::values());
$table->timestamps();
});
}
Why this matters:
- Migrations are historical records and should be immutable
- Enum classes may change over time, breaking old migrations
- Hardcoded values ensure migrations can be run reliably at any point in time
8. Foreign Key Management Strategies
The Two-Phase Approach for Existing Tables
Never introduce a new foreign key to an existing table in the same migration that creates its column. Always deploy it over two distinct migrations to preserve rollback safety and avoid lock escalation.
This approach involves two separate migrations:
- Migration 1: Add the Column: The new column (e.g.,
customer_id) is added but madenullable(). - Migration 2: Add the Constraint: The foreign key constraint is applied to the column.
- Migration 1: Add the Column
- Migration 2: Add the Constraint
public function up()
{
Schema::table('orders', function (Blueprint $table) {
$table->unsignedBigInteger('customer_id')->nullable()->after('id');
});
}
public function down()
{
Schema::table('orders', function (Blueprint $table) {
$table->dropColumn('customer_id');
});
}
public function up()
{
Schema::table('orders', function (Blueprint $table) {
$table->foreign('customer_id')
->references('id')
->on('customers')
->cascadeOnDelete();
});
}
public function down()
{
Schema::table('orders', function (Blueprint $table) {
$table->dropForeign(['customer_id']);
});
}
Splitting the change allows for data backfilling between deployments, minimizes downtime, and upholds the single responsibility principle.
Exception: Initial Table Creation
When creating a brand new table, it is safe to define foreign keys within the same migration. MySQL processes the entire CREATE TABLE statement, including constraints, within a single transaction. If any part fails, the entire operation is rolled back.
public function up()
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id');
$table->string('title');
$table->text('body');
$table->timestamps();
// This is safe because it's part of the initial table creation
$table->foreign('user_id')->references('id')->on('users');
});
}
General Rule for Modifications
For any other scenario involving existing tables, you must break down operations into separate, atomic migrations. Each of the following changes requires its own dedicated migration file:
- Adding a new foreign key constraint.
- Renaming a column that will have a foreign key.
- Changing the data type of a column that will have a foreign key.
If you need to perform multiple schema modifications on a column (e.g., rename, then add index, then add foreign key), create a separate migration for each action. This ensures maximum safety and reversibility.
9. Pre-Constraint Data Cleanup
Before adding a foreign key or unique constraint to an existing column with data, you must first run a data cleanup migration. Deploying without this step can lead to catastrophic failures in production.
Assume that the target column contains invalid data. Your cleanup migration must identify and resolve these issues before any constraint is applied.
- For Foreign Keys: Invalid data means a value in the column that does not correspond to an existing ID in the referenced table (e.g., a
customer_idof999when no customer with that ID exists). - For Unique Keys: Invalid data means duplicate values in the column you intend to make unique.
This issue is especially critical across multiple projects that share a similar database structure but have different data. A migration might succeed in a staging environment with clean data but fail during production deployment where invalid data has accumulated.
Here is a safe, three-step process to add a foreign key to a column that already contains data:
- Migration 1: Clean Up Data
- Migration 2: Add Column (If Needed)
- Migration 3: Add Constraint
public function up()
{
// Find orders where customer_id does not exist in the customers table
// and set them to null. Another strategy could be to delete them.
DB::table('orders')
->whereNotNull('customer_id')
->whereNotExists(function ($query) {
$query->select(DB::raw(1))
->from('customers')
->whereColumn('customers.id', 'orders.customer_id');
})
->update(['customer_id' => null]);
}
public function down()
{
// This operation is typically irreversible, as we don't know the original invalid IDs.
// Document this clearly.
}
This step is identical to the first phase of the two-phase creation. If the column already exists, you can skip this migration.
public function up()
{
Schema::table('orders', function (Blueprint $table) {
// Ensure the column is nullable to allow for a smooth transition
$table->unsignedBigInteger('customer_id')->nullable()->after('id');
});
}
public function down()
{
Schema::table('orders', function (Blueprint $table) {
$table->dropColumn('customer_id');
});
}
After cleaning the data and ensuring the column exists, you can safely apply the foreign key constraint.
public function up()
{
Schema::table('orders', function (Blueprint $table) {
$table->foreign('customer_id')
->references('id')
->on('customers')
->nullOnDelete(); // or cascadeOnDelete(), depending on the desired behavior
});
}
public function down()
{
Schema::table('orders', function (Blueprint $table) {
$table->dropForeign(['customer_id']);
});
}
Common Migration Patterns
Adding a Column
- Add Column
- Add Multiple Columns
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->string('phone_number')->nullable()->after('email');
});
}
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('phone_number');
});
}
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->string('address')->nullable()->after('email');
$table->string('city')->nullable()->after('address');
$table->string('country')->nullable()->after('city');
});
}
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['address', 'city', 'country']);
});
}
Renaming a Column
- Rename Column
- Rename Multiple Columns
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->renameColumn('email', 'email_address');
});
}
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->renameColumn('email_address', 'email');
});
}
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->renameColumn('first_name', 'given_name');
$table->renameColumn('last_name', 'family_name');
});
}
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->renameColumn('given_name', 'first_name');
$table->renameColumn('family_name', 'last_name');
});
}
Creating an Index
- Single Index
- Unique Index
public function up()
{
Schema::table('posts', function (Blueprint $table) {
$table->index(['user_id', 'created_at']);
});
}
public function down()
{
Schema::table('posts', function (Blueprint $table) {
$table->dropIndex(['user_id', 'created_at']);
});
}
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->unique('username');
});
}
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropUnique('users_username_unique');
});
}
Testing Migrations
- Basic Commands
- Advanced Commands
# Reset the database and run all migrations
php artisan migrate:fresh
# Rollback the last batch of migrations
php artisan migrate:rollback
# Rollback all migrations and run them again
php artisan migrate:refresh
# Rollback and re-run the last 5 migrations
php artisan migrate:refresh --step=5
# Reset the database without running migrations
php artisan db:wipe
# Show migration status
php artisan migrate:status
Conclusion
Following these migration guidelines will help maintain a clean, reliable database structure throughout your application's lifecycle. Remember that migrations serve as a historical record of your database's evolution, so clarity and precision are essential.