Migrations & Seeding: Version Control for Your Database
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:
- Make changes in phpMyAdmin
- Export the SQL
- Email it to teammates
- Hope everyone runs it
- Someone forgets
- 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_tableadd_avatar_to_users_tablecreate_post_tag_pivot_tableadd_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
-
migrate:fresh --seedis your friend. Reset everything and start clean instantly. -
Factories can create related models.
'user_id' => factory(User::class)creates the user automatically. -
Seeders should be idempotent. Running twice shouldn’t break things. Check if data exists first, or use
migrate:fresh. -
Keep production seeders separate. Have a seeder for essential data (roles, settings) that runs in production, separate from test data seeders.
-
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.
Saurav Sitaula
Software Architect • Nepal