Migrations & Seeders Best Practices

Learn to write maintainable migrations and efficient seeders for development and production

Migrations are version control for your database schema. They allow you to define and share your database structure in code. Seeders populate your database with test or initial data. Together, they make database setup reproducible and team collaboration seamless.

Why Migrations Matter:
• Version Control: Track database changes in git
• Collaboration: Team members get same schema
• Deployment: Automated database updates
• Rollback: Undo schema changes safely
• Environment Parity: Dev, staging, prod have same structure

Best Practices:
• Never edit old migrations in production
• Use foreign keys for referential integrity
• Add indexes for frequently queried columns
• Use descriptive migration names
• Keep migrations focused (one table or change)

Seeders:
• Development: Use factories for realistic fake data
• Production: Use seeders for initial required data (roles, settings)
• Never seed sensitive data in version control

Code Examples

Well-Structured Migration
💡 Solution php
<?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::create('posts', function (Blueprint $table) {
            $table->id();
            
            // Foreign key (correct way)
            $table->foreignId('user_id')
                  ->constrained()
                  ->onDelete('cascade');  // Delete posts when user deleted
            
            $table->foreignId('category_id')
                  ->constrained()
                  ->onDelete('set null')  // Keep post, remove category
                  ->nullable();
            
            // String columns with proper lengths
            $table->string('title', 200);
            $table->string('slug', 200)->unique();
            
            // Text for long content
            $table->text('content');
            $table->text('excerpt')->nullable();
            
            // Boolean with default
            $table->boolean('published')->default(false);
            $table->boolean('is_featured')->default(false);
            
            // Timestamps
            $table->timestamp('published_at')->nullable();
            $table->timestamps();  // created_at, updated_at
            $table->softDeletes();  // deleted_at
            
            // Indexes for performance
            $table->index('published');  // Single column
            $table->index(['user_id', 'published']);  // Composite
            $table->index('created_at');
        });
    }
    
    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
};

// Common column types:
$table->id();                          // Auto-increment primary key
$table->uuid('id')->primary();        // UUID primary key
$table->string('name', 100);          // VARCHAR(100)
$table->text('description');         // TEXT
$table->integer('views');             // INT
$table->decimal('price', 8, 2);      // DECIMAL(8,2) for money
$table->boolean('active');            // BOOLEAN
$table->date('birth_date');           // DATE
$table->datetime('published_at');    // DATETIME
$table->timestamp('created_at');     // TIMESTAMP
$table->json('metadata');             // JSON column
$table->enum('status', ['draft', 'published']);  // ENUM

💡 Well-structured migrations use proper foreign keys, indexes, and data types. They ensure data integrity and query performance.

Modifying Existing Tables
💡 Solution php
<?php
// Create migration: php artisan make:migration add_status_to_posts_table

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('posts', function (Blueprint $table) {
            // Adding columns
            $table->string('status', 20)->default('draft')->after('content');
            $table->integer('views')->default(0)->after('status');
            
            // Adding index
            $table->index('status');
        });
    }
    
    public function down(): void
    {
        Schema::table('posts', function (Blueprint $table) {
            // Always provide down() to rollback
            $table->dropIndex(['status']);
            $table->dropColumn(['status', 'views']);
        });
    }
};

// Changing column types (requires doctrine/dbal)
// composer require doctrine/dbal
return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            // Change column type
            $table->string('email', 255)->change();
            
            // Make column nullable
            $table->string('phone')->nullable()->change();
            
            // Rename column
            $table->renameColumn('name', 'full_name');
        });
    }
    
    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('email', 100)->change();
            $table->string('phone')->nullable(false)->change();
            $table->renameColumn('full_name', 'name');
        });
    }
};

// Complex migration example:
return new class extends Migration
{
    public function up(): void
    {
        // Add foreign key to existing table
        Schema::table('posts', function (Blueprint $table) {
            $table->foreignId('editor_id')
                  ->nullable()
                  ->after('user_id')
                  ->constrained('users')
                  ->onDelete('set null');
        });
    }
    
    public function down(): void
    {
        Schema::table('posts', function (Blueprint $table) {
            $table->dropForeign(['editor_id']);
            $table->dropColumn('editor_id');
        });
    }
};

💡 Use separate migrations to modify existing tables. Always provide a down() method to rollback changes safely.

Efficient Database Seeders
💡 Solution php
<?php
namespace Database\Seeders;

use App\Models\User;
use App\Models\Post;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        // Environment-specific seeding
        if (app()->environment('production')) {
            // Production: Only essential data
            $this->call([
                RoleSeeder::class,
                SettingSeeder::class,
            ]);
        } else {
            // Development: Fake data for testing
            $this->call([
                RoleSeeder::class,
                SettingSeeder::class,
                DevelopmentDataSeeder::class,
            ]);
        }
    }
}

// Production seeder (required data)
class RoleSeeder extends Seeder
{
    public function run(): void
    {
        $roles = [
            ['name' => 'admin', 'label' => 'Administrator'],
            ['name' => 'editor', 'label' => 'Editor'],
            ['name' => 'user', 'label' => 'User'],
        ];
        
        foreach ($roles as $role) {
            Role::firstOrCreate(['name' => $role['name']], $role);
        }
    }
}

// Development seeder (test data using factories)
class DevelopmentDataSeeder extends Seeder
{
    public function run(): void
    {
        // Create users with posts
        User::factory()
            ->count(10)
            ->has(Post::factory()->count(5))  // Each user has 5 posts
            ->create();
        
        // Or more control:
        $admin = User::factory()->create([
            'email' => 'admin@example.com',
            'name' => 'Admin User',
        ]);
        
        Post::factory()
            ->count(20)
            ->for($admin)  // All posts by admin
            ->create();
    }
}

// Efficient bulk seeding (for large datasets)
class LargeDataSeeder extends Seeder
{
    public function run(): void
    {
        // ❌ SLOW: Creating one by one
        for ($i = 0; $i < 10000; $i++) {
            User::create(['name' => "User $i"]);  // 10,000 queries!
        }
        
        // ✅ FAST: Bulk insert
        $users = [];
        for ($i = 0; $i < 10000; $i++) {
            $users[] = [
                'name' => "User $i",
                'email' => "user$i@example.com",
                'created_at' => now(),
                'updated_at' => now(),
            ];
        }
        
        // Insert in chunks
        foreach (array_chunk($users, 1000) as $chunk) {
            User::insert($chunk);  // Bulk insert 1000 at a time
        }
        
        // Or use factories with chunking:
        User::factory()->count(10000)->create();  // Laravel handles chunking
    }
}

// Commands:
// php artisan db:seed                    // Run DatabaseSeeder
// php artisan db:seed --class=RoleSeeder // Run specific seeder
// php artisan migrate:fresh --seed       // Reset DB and seed
// php artisan migrate:refresh --seed     // Rollback, migrate, seed

💡 Use seeders for initial data. Separate production data (roles, settings) from development data (test users). Use bulk inserts for performance.