Migrations & Seeding: Version Control for Your Database

SS Saurav Sitaula

No more 'run this SQL file I emailed you.' Migrations track database changes in code. Seeders fill your database with test data. Together, they make database management sane and shareable.

The Old Way: SQL File Chaos

Before Laravel, my “database versioning” looked like this:

  1. Make changes in phpMyAdmin
  2. Export the SQL
  3. Email it to teammates
  4. Hope everyone runs it
  5. Someone forgets
  6. Hours of debugging “why does your schema look different?”

It was chaos. And deploying? Manually running SQL scripts on production servers, praying nothing breaks.

Migrations: Database Changes in Code

Migrations are PHP classes that describe database changes. They live in your codebase, go through version control, and run the same way everywhere.

php artisan make:migration create_posts_table

Creates database/migrations/2019_05_15_000000_create_posts_table.php:

<?php

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

class CreatePostsTable extends Migration
{
    public function up()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->increments('id');
            $table->unsignedInteger('user_id');
            $table->string('title');
            $table->string('slug')->unique();
            $table->text('body');
            $table->boolean('published')->default(false);
            $table->timestamp('published_at')->nullable();
            $table->timestamps();

            $table->foreign('user_id')
                  ->references('id')
                  ->on('users')
                  ->onDelete('cascade');
            
            $table->index(['published', 'published_at']);
        });
    }

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

up() runs when you migrate forward. down() runs when you rollback — undoes the migration.

Running Migrations

# Run all pending migrations
php artisan migrate

# Rollback the last batch
php artisan migrate:rollback

# Rollback all migrations
php artisan migrate:reset

# Rollback and re-run all migrations
php artisan migrate:refresh

# Drop all tables and re-run all migrations
php artisan migrate:fresh

Laravel tracks which migrations have run in the migrations table. It only runs new ones.

Schema Builder Methods

Schema::create('users', function (Blueprint $table) {
    // Primary key
    $table->increments('id');           // Auto-increment integer
    $table->bigIncrements('id');        // Auto-increment big integer
    $table->uuid('id');                 // UUID

    // Strings
    $table->string('name');             // VARCHAR(255)
    $table->string('name', 100);        // VARCHAR(100)
    $table->char('code', 2);            // CHAR(2)
    $table->text('description');        // TEXT
    $table->mediumText('content');      // MEDIUMTEXT
    $table->longText('data');           // LONGTEXT

    // Numbers
    $table->integer('count');
    $table->unsignedInteger('views');
    $table->bigInteger('balance');
    $table->decimal('price', 8, 2);     // DECIMAL(8,2)
    $table->float('rating');

    // Boolean
    $table->boolean('active');

    // Dates
    $table->date('birth_date');
    $table->dateTime('starts_at');
    $table->timestamp('verified_at');
    $table->timestamps();               // created_at & updated_at
    $table->softDeletes();              // deleted_at

    // JSON
    $table->json('settings');

    // Binary
    $table->binary('photo');

    // Modifiers
    $table->string('email')->unique();
    $table->string('nickname')->nullable();
    $table->integer('order')->default(0);
    $table->string('status')->after('name');
    $table->string('comment')->comment('Admin notes');
});

Modifying Tables

php artisan make:migration add_avatar_to_users_table --table=users
public function up()
{
    Schema::table('users', function (Blueprint $table) {
        $table->string('avatar')->nullable()->after('email');
    });
}

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

Renaming and Changing Columns

// Rename column
$table->renameColumn('from', 'to');

// Change column type (requires doctrine/dbal package)
$table->string('name', 50)->change();

// Drop column
$table->dropColumn('avatar');

// Drop multiple columns
$table->dropColumn(['avatar', 'nickname']);

Index Operations

// Add indexes
$table->index('email');                    // Regular index
$table->unique('email');                   // Unique index
$table->primary(['first_id', 'second_id']); // Composite primary

// Drop indexes
$table->dropIndex('posts_slug_index');
$table->dropUnique('users_email_unique');
$table->dropPrimary('posts_id_primary');

Foreign Keys

// Add foreign key
$table->foreign('user_id')
      ->references('id')
      ->on('users')
      ->onDelete('cascade')
      ->onUpdate('cascade');

// Shorthand (Laravel 5.6+)
$table->foreignId('user_id')->constrained();

// Drop foreign key
$table->dropForeign('posts_user_id_foreign');
$table->dropForeign(['user_id']);  // By column name

Migration Best Practices

1. Never Edit Deployed Migrations

Once a migration is in production, create a NEW migration to modify:

# BAD: Editing create_posts_table.php after deployment

# GOOD: Create new migration
php artisan make:migration add_summary_to_posts_table

2. Keep Migrations Small

One logical change per migration. Easier to rollback and understand.

3. Always Write down() Methods

Your rollback should undo EXACTLY what up() did.

4. Use Descriptive Names

  • create_posts_table
  • add_avatar_to_users_table
  • create_post_tag_pivot_table
  • add_index_to_orders_status

Seeding: Fake Data for Development

Empty databases are hard to work with. Seeders fill them with data.

php artisan make:seeder UsersTableSeeder
<?php

namespace Database\Seeds;

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

class UsersTableSeeder extends Seeder
{
    public function run()
    {
        // Create specific users
        User::create([
            'name' => 'Admin',
            'email' => 'admin@example.com',
            'password' => bcrypt('password'),
            'is_admin' => true,
        ]);

        // Create many fake users
        factory(User::class, 50)->create();
    }
}

Register in DatabaseSeeder:

class DatabaseSeeder extends Seeder
{
    public function run()
    {
        $this->call([
            UsersTableSeeder::class,
            PostsTableSeeder::class,
            CommentsTableSeeder::class,
        ]);
    }
}

Run seeders:

php artisan db:seed

# Specific seeder
php artisan db:seed --class=UsersTableSeeder

# Fresh migration + seed
php artisan migrate:fresh --seed

Model Factories: Generate Realistic Fake Data

Factories define how to create fake models:

// database/factories/UserFactory.php
<?php

use Faker\Generator as Faker;

$factory->define(App\User::class, function (Faker $faker) {
    return [
        'name' => $faker->name,
        'email' => $faker->unique()->safeEmail,
        'email_verified_at' => now(),
        'password' => bcrypt('password'),
        'remember_token' => Str::random(10),
        'avatar' => $faker->imageUrl(200, 200, 'people'),
        'bio' => $faker->paragraph,
    ];
});

// State for specific variations
$factory->state(App\User::class, 'admin', [
    'is_admin' => true,
]);

$factory->state(App\User::class, 'unverified', [
    'email_verified_at' => null,
]);
// database/factories/PostFactory.php
$factory->define(App\Post::class, function (Faker $faker) {
    return [
        'user_id' => factory(App\User::class),  // Creates a user too!
        'title' => $faker->sentence,
        'slug' => $faker->slug,
        'body' => $faker->paragraphs(5, true),
        'published' => $faker->boolean(70),  // 70% chance true
        'published_at' => $faker->dateTimeBetween('-1 year', 'now'),
    ];
});

Using Factories

// Single model
$user = factory(User::class)->create();

// Multiple models
$users = factory(User::class, 10)->create();

// With state
$admin = factory(User::class)->state('admin')->create();

// Override attributes
$user = factory(User::class)->create([
    'name' => 'Custom Name',
    'email' => 'custom@example.com',
]);

// Just make (don't save to DB)
$user = factory(User::class)->make();

// Create with relationships
$user = factory(User::class)->create();
$posts = factory(Post::class, 5)->create(['user_id' => $user->id]);

Faker: Realistic Fake Data

Faker generates all kinds of realistic data:

$faker->name;                    // 'John Smith'
$faker->firstName;               // 'John'
$faker->lastName;                // 'Smith'
$faker->email;                   // 'john.smith@example.com'
$faker->safeEmail;               // 'john@example.org'
$faker->phoneNumber;             // '555-123-4567'

$faker->address;                 // '123 Main St, City, ST 12345'
$faker->city;                    // 'New York'
$faker->country;                 // 'United States'
$faker->postcode;                // '12345'

$faker->sentence;                // 'Lorem ipsum dolor sit amet.'
$faker->paragraph;               // Multiple sentences
$faker->text(200);               // 200 characters of text

$faker->dateTime;                // DateTime object
$faker->dateTimeBetween('-1 year', 'now');
$faker->date('Y-m-d');           // '2019-05-15'

$faker->boolean;                 // true or false
$faker->boolean(30);             // 30% chance of true

$faker->randomNumber(5);         // 5-digit number
$faker->randomFloat(2, 10, 100); // Float between 10 and 100
$faker->numberBetween(1, 100);

$faker->imageUrl(640, 480);      // Placeholder image URL
$faker->uuid;                    // UUID string
$faker->slug;                    // 'lorem-ipsum-dolor'
$faker->url;                     // 'https://example.com/path'

$faker->creditCardNumber;
$faker->creditCardExpirationDate;

$faker->unique()->email;         // Guaranteed unique
$faker->optional()->company;     // Sometimes null

Complex Seeding Example

class DatabaseSeeder extends Seeder
{
    public function run()
    {
        // Create admin
        $admin = factory(User::class)->state('admin')->create([
            'email' => 'admin@example.com',
        ]);

        // Create regular users
        $users = factory(User::class, 20)->create();

        // Create posts for each user
        $users->each(function ($user) {
            $posts = factory(Post::class, rand(1, 5))->create([
                'user_id' => $user->id,
            ]);

            // Create comments on each post
            $posts->each(function ($post) use ($user) {
                factory(Comment::class, rand(0, 10))->create([
                    'post_id' => $post->id,
                    'user_id' => User::inRandomOrder()->first()->id,
                ]);
            });
        });

        // Create tags
        $tags = factory(Tag::class, 15)->create();

        // Attach random tags to posts
        Post::all()->each(function ($post) use ($tags) {
            $post->tags()->attach(
                $tags->random(rand(1, 4))->pluck('id')
            );
        });
    }
}

Run php artisan migrate:fresh --seed and you have a fully populated database.

Testing with Factories

Factories shine in tests:

public function test_user_can_create_post()
{
    $user = factory(User::class)->create();
    
    $this->actingAs($user)
         ->post('/posts', [
             'title' => 'Test Post',
             'body' => 'Test body',
         ])
         ->assertStatus(201);
    
    $this->assertDatabaseHas('posts', [
        'title' => 'Test Post',
        'user_id' => $user->id,
    ]);
}

public function test_admin_can_delete_any_post()
{
    $admin = factory(User::class)->state('admin')->create();
    $post = factory(Post::class)->create();
    
    $this->actingAs($admin)
         ->delete('/posts/' . $post->id)
         ->assertStatus(200);
    
    $this->assertDatabaseMissing('posts', ['id' => $post->id]);
}

What I Wish I’d Known Earlier

  1. migrate:fresh --seed is your friend. Reset everything and start clean instantly.

  2. Factories can create related models. 'user_id' => factory(User::class) creates the user automatically.

  3. Seeders should be idempotent. Running twice shouldn’t break things. Check if data exists first, or use migrate:fresh.

  4. Keep production seeders separate. Have a seeder for essential data (roles, settings) that runs in production, separate from test data seeders.

  5. Faker is incredibly powerful. Explore the documentation — it has generators for almost everything.

The Journey Continues

Migrations and seeding made database management sane. Changes were tracked in code, test data was generated automatically, and the whole team could stay in sync.

But I was still manually uploading files and managing them in the database. Laravel had a cleaner solution for that too.


P.S. — The first time I ran migrate:fresh --seed on a new machine and had a fully populated database in 10 seconds — with users, posts, comments, tags, and relationships all set up — I realized how much time I’d wasted in the past manually creating test data.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism