Laravel Migration Tips with Detailed Examples

By Maulik Paghdal

10 Dec, 2024

•  7 minutes to Read

Laravel Migration Tips with Detailed Examples

Introduction

Laravel migrations are one of those features that make you wonder how you ever managed databases without them. They're essentially version control for your database schema, letting you track changes, share modifications across teams, and roll back when things go wrong.

1. Creating Your First Migration

The make:migration command is your starting point for any database changes. Laravel follows a naming convention that helps it understand what you're trying to do.

php artisan make:migration create_users_table

When you create a migration with create_ prefix, Laravel automatically generates boilerplate code for creating a new table. Here's what you get:

<?php

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

class CreateUsersTable extends Migration
{
    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->timestamps();
        });
    }

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

The up() method defines what happens when you run the migration, while down() defines how to reverse it. Laravel's migration system is bidirectional by design, which saves you from database disasters.

php artisan migrate

💡 Tip: Use descriptive migration names. Instead of update_users_table, try add_phone_number_to_users_table. Your future self will thank you.

Migration Naming Conventions

Laravel's smart enough to guess your intentions based on naming:

  • create_posts_table - Creates a new table
  • add_status_to_posts_table - Adds a column
  • drop_posts_table - Drops a table
  • rename_posts_to_articles_table - Renames a table

2. Renaming Columns Safely

Column renaming is trickier than it looks. You need to be careful about existing data and foreign key constraints.

php artisan make:migration rename_name_to_full_name_in_users_table
public function up()
{
    Schema::table('users', function (Blueprint $table) {
        $table->renameColumn('name', 'full_name');
    });
}

public function down()
{
    Schema::table('users', function (Blueprint $table) {
        $table->renameColumn('full_name', 'name');
    });
}

⚠️ Warning: Column renaming requires the doctrine/dbal package. Install it first: composer require doctrine/dbal

The renameColumn method handles the heavy lifting, but there are gotchas. If you have indexes or foreign keys on that column, they might break. Always test your migrations on a copy of production data.

📌 Note: SQLite has limitations with column operations. If you're using SQLite for testing, some operations might behave differently than in production with MySQL or PostgreSQL.

3. Modifying Column Types

Sometimes you realize that VARCHAR(255) isn't enough for user bios, or you need to change an integer to a big integer. The change() method handles this:

# Install the required package first
composer require doctrine/dbal

# Create the migration
php artisan make:migration modify_bio_column_in_users_table
public function up()
{
    Schema::table('users', function (Blueprint $table) {
        $table->text('bio')->nullable()->change();
    });
}

public function down()
{
    Schema::table('users', function (Blueprint $table) {
        $table->string('bio', 255)->nullable()->change();
    });
}

You can modify multiple aspects at once:

// Change type, make nullable, and add default
$table->string('status', 50)->nullable()->default('active')->change();

⚠️ Warning: Changing column types can cause data loss. Converting from TEXT to VARCHAR(50) will truncate long content. Always backup your data and test thoroughly.

Common Type Changes

// String length modifications
$table->string('title', 500)->change(); // Expand from default 255

// Numeric precision changes
$table->decimal('price', 10, 2)->change(); // 10 digits, 2 decimal places

// Nullability changes
$table->string('middle_name')->nullable()->change();

4. Removing Columns

Dropping columns is straightforward, but you should consider the impact on your application first.

php artisan make:migration remove_unused_columns_from_users_table
public function up()
{
    Schema::table('users', function (Blueprint $table) {
        $table->dropColumn(['email_verified_at', 'remember_token']);
    });
}

public function down()
{
    Schema::table('users', function (Blueprint $table) {
        $table->timestamp('email_verified_at')->nullable();
        $table->rememberToken();
    });
}

You can drop multiple columns at once by passing an array, which is more efficient than individual dropColumn calls.

💡 Tip: Before dropping columns in production, deploy a version of your app that doesn't use those columns. Wait a few days to ensure nothing breaks, then run the migration to actually drop them.

5. Mastering Database Indexes

Indexes are crucial for performance, but they're not free. Each index takes up storage space and slows down write operations slightly.

Basic Indexing

// Single column index
$table->string('email')->index();

// Unique constraint (automatically creates an index)
$table->string('email')->unique();

// Composite index for common query patterns
$table->index(['user_id', 'created_at'], 'user_posts_timeline');

Advanced Indexing Strategies

// Partial index for soft deletes
$table->index(['deleted_at', 'status']); // Only index non-deleted records

// Custom index names for clarity
$table->index('email', 'users_email_lookup');

// Full-text search index
$table->fullText(['title', 'content']);

Managing Indexes

// Drop indexes when they're no longer needed
public function up()
{
    Schema::table('posts', function (Blueprint $table) {
        $table->dropIndex('posts_slug_index');
        $table->dropIndex(['user_id', 'status']); // Composite index
    });
}

📌 Note: Laravel automatically names indexes. For composite indexes, provide a custom name to make them easier to manage later.

6. Foreign Key Relationships

Foreign keys enforce data integrity, but they can also bite you during development if you're not careful about the order of operations.

Creating Foreign Keys

// The modern, clean approach
Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->text('content');
    $table->foreignId('user_id')->constrained()->onDelete('cascade');
    $table->timestamps();
});

The foreignId()->constrained() combo is Laravel's shorthand. It creates an unsigned big integer column and sets up the foreign key constraint automatically.

Manual Foreign Key Setup

Sometimes you need more control:

Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->unsignedBigInteger('author_id'); // Custom column name
    
    $table->foreign('author_id')
        ->references('id')
        ->on('users')
        ->onUpdate('cascade')
        ->onDelete('restrict'); // Prevent deletion of users with posts
});

Handling Foreign Key Constraints

// Drop foreign key constraints before dropping columns
public function up()
{
    Schema::table('posts', function (Blueprint $table) {
        $table->dropForeign(['user_id']); // Drop the constraint first
        $table->dropColumn('user_id');     // Then drop the column
    });
}

⚠️ Warning: Foreign key constraint failures in production can lock up your application. Always test with realistic data volumes and consider using database transactions for complex migrations.

Foreign Key Best Practices

// Use meaningful constraint names for easier debugging
$table->foreign('user_id', 'posts_user_fk')
    ->references('id')
    ->on('users')
    ->onDelete('cascade');

// Consider the cascade behavior carefully
// CASCADE: Delete related records
// RESTRICT: Prevent parent deletion
// SET NULL: Set foreign key to null (column must be nullable)

7. Advanced Migration Techniques

Running Migrations in Transactions

// Wrap risky operations in transactions
DB::transaction(function () {
    // Multiple schema changes that should succeed or fail together
    Schema::table('users', function (Blueprint $table) {
        $table->string('new_field');
    });
    
    // Data manipulation
    DB::table('users')->update(['new_field' => 'default_value']);
});

Conditional Migrations

public function up()
{
    // Only add column if it doesn't exist
    if (!Schema::hasColumn('users', 'timezone')) {
        Schema::table('users', function (Blueprint $table) {
            $table->string('timezone')->default('UTC');
        });
    }
}

Data Seeding Within Migrations

public function up()
{
    Schema::create('roles', function (Blueprint $table) {
        $table->id();
        $table->string('name')->unique();
        $table->timestamps();
    });
    
    // Seed essential data
    DB::table('roles')->insert([
        ['name' => 'admin', 'created_at' => now(), 'updated_at' => now()],
        ['name' => 'user', 'created_at' => now(), 'updated_at' => now()],
    ]);
}

Migration Command Reference

TaskCommandKey Methods
Create Migrationphp artisan make:migration nameSchema::create, Schema::table
Run Migrationsphp artisan migrateExecutes pending migrations
Rollbackphp artisan migrate:rollbackRuns down() methods
Reset Allphp artisan migrate:resetRollback all migrations
Refreshphp artisan migrate:refreshReset + migrate (rebuilds DB)
Statusphp artisan migrate:statusShows migration status
Column OperationsVariousrenameColumn, dropColumn, change()
Index ManagementVariousindex(), unique(), dropIndex()
Foreign KeysVariousforeign(), constrained(), dropForeign()

💡 Pro Tip: Use migrate:fresh during development to drop all tables and re-run migrations. It's faster than migrate:refresh and catches issues with your down() methods.

Common Pitfalls and How to Avoid Them

Migration Order Matters

Dependencies between tables must be created in the right order. Create parent tables before child tables with foreign keys.

Testing Your Rollbacks

Always test your down() methods. Nothing's worse than a migration that can't be rolled back in production.

Large Table Modifications

For tables with millions of rows, consider:

  • Running modifications during low-traffic periods
  • Using chunked updates for data changes
  • Creating new tables and swapping them instead of modifying in place

Environment Differences

What works in MySQL might not work in PostgreSQL or SQLite. Test your migrations across all environments you support.

Mastering Laravel migrations takes time, but they're one of the most valuable skills in your Laravel toolkit. They keep your database changes organized, reversible, and shareable across your entire team. The key is starting simple and gradually incorporating more advanced techniques as your applications grow in complexity.

About Author

I'm Maulik Paghdal, the founder of Script Binary and a passionate full-stack web developer. I have a strong foundation in both frontend and backend development, specializing in building dynamic, responsive web applications using Laravel, Vue.js, and React.js. With expertise in Tailwind CSS and Bootstrap, I focus on creating clean, efficient, and scalable solutions that enhance user experiences and optimize performance.