Laravel Migrations Explained — Structure, Rollbacks & Real-World Patterns
Your database schema is living, breathing code. It changes with every new feature, every pivot, every stakeholder request that comes in on a Friday afternoon. Without a disciplined system to track those changes, you end up with the classic nightmare: a production database that looks nothing like your local one, a colleague who manually added a column nobody else has, and a deploy that breaks spectacularly because a table column your code expects simply doesn't exist on the server. Laravel migrations exist to make that chaos impossible.
The problem they solve is deceptively simple: how do you keep database structure changes in sync across every environment — local, staging, production — and across every developer on a team? Raw SQL scripts dumped into a shared folder don't cut it. You need version control for your schema, with the ability to move forward and backward through changes, the same way Git lets you move through code commits. Migrations give you exactly that, built directly into the framework with a clean, expressive API.
By the end of this article you'll know how to design thoughtful migrations for real features, understand the difference between up() and down() and why down() is more important than most developers think, handle foreign keys and indexes correctly, avoid the three mistakes that bite intermediate developers, and feel confident talking about migrations in a technical interview.
How Migrations Work Under the Hood — The migrations Table Explained
When you run php artisan migrate for the very first time, Laravel does something before it touches any of your own migrations: it creates a table called migrations in your database. This table has two important columns — migration (the filename, minus the .php extension) and batch (an integer that groups migrations run together into one batch).
Every time you run migrate, Laravel reads the migrations table, compares it against the migration files in your database/migrations folder, and runs only the files it hasn't seen before. That's the entire tracking mechanism — dead simple, brutally effective.
The batch number is the key to rollbacks. When you run php artisan migrate:rollback, Laravel finds the highest batch number in the table and calls down() on every migration in that batch — in reverse order. So if you migrated three files in one migrate call, a single rollback undoes all three. Understanding this batch concept is what separates developers who use migrations correctly from those who accidentally roll back changes they didn't mean to.
This also means migrations are environment-agnostic. You run the exact same commands on local, CI, and production. The migrations table on each machine tracks its own state independently.
<?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; // Each migration is a class that extends Migration. // Laravel auto-discovers it — no registration needed. return new class extends Migration { /** * Run the migration — this is what `php artisan migrate` executes. * Build the table exactly as your application needs it right now. */ public function up(): void { Schema::create('articles', function (Blueprint $table) { $table->id(); // Unsigned BIGINT primary key, auto-increment $table->string('title', 200); // VARCHAR(200) — cap it, don't leave it unbounded $table->string('slug', 200)->unique(); // Unique index created automatically by ->unique() $table->text('body'); // TEXT column — no length limit $table->boolean('is_published')->default(false); // Sensible default — articles start as drafts $table->timestamp('published_at')->nullable(); // NULL until the article goes live $table->timestamps(); // Adds created_at and updated_at (both nullable TIMESTAMP) }); } /** * Reverse the migration — this is what `php artisan migrate:rollback` executes. * dropIfExists prevents an error if someone manually dropped the table already. */ public function down(): void { Schema::dropIfExists('articles'); } };
INFO Running migrations.
2024_01_15_000001_create_articles_table ..................... 28ms DONE
$ php artisan migrate:rollback
INFO Rolling back migrations.
2024_01_15_000001_create_articles_table ..................... 12ms DONE
Writing Migrations for Real Features — Foreign Keys, Indexes, and Column Modifiers
A create migration is just the beginning. Real applications constantly need to modify existing tables — adding columns, creating relationships, dropping obsolete fields. These are alter migrations, and they're where most intermediate developers make mistakes.
Foreign keys deserve special attention. Laravel's foreignId() method creates the column AND the index in one call. But you still need to call constrained() if you want the actual foreign key constraint at the database level. Without constrained(), you just have an integer column — no enforcement. The database will happily let you insert a user_id that points to a user who doesn't exist.
Indexes are equally important and equally overlooked. Every column you filter by in a WHERE clause or join on should have an index. Adding ->index() to a column in your migration is the right time to think about this — not after your queries start timing out in production with 500,000 rows.
Column modifiers like ->nullable(), ->default(), ->after(), and ->unsigned() are how you express business rules at the schema level. A price column should always be ->unsigned(). A deleted_at column for soft deletes should always be ->nullable(). These aren't just database hygiene — they're guards against bad data entering your system.
<?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 { // --- Creating a table with a foreign key relationship --- Schema::create('comments', function (Blueprint $table) { $table->id(); // foreignId() creates an UNSIGNED BIGINT column named 'article_id' // constrained() adds the actual FK constraint pointing to articles.id // cascadeOnDelete() means: if the article is deleted, delete its comments too $table->foreignId('article_id') ->constrained() ->cascadeOnDelete(); // Same pattern for the user who wrote the comment $table->foreignId('user_id') ->constrained() ->cascadeOnDelete(); $table->text('body'); // Composite index: we almost always query comments BY article, ordered by date // A composite index on (article_id, created_at) makes that query fast $table->index(['article_id', 'created_at'], 'comments_article_date_index'); $table->timestamps(); $table->softDeletes(); // Adds deleted_at TIMESTAMP NULL — enables Eloquent soft deletes }); } public function down(): void { // Foreign key constraints must be dropped BEFORE the table that holds them // dropIfExists handles both the FK constraints and the table in the correct order Schema::dropIfExists('comments'); } }; // ---------------------------------------------------------------- // SEPARATE FILE: Adding a column to an EXISTING table // ---------------------------------------------------------------- // 2024_02_10_000001_add_excerpt_to_articles_table.php return new class extends Migration { public function up(): void { Schema::table('articles', function (Blueprint $table) { // ->after() places the column right after 'title' in the column order // This is MySQL-only — it's cosmetic but makes DESCRIBE output readable $table->string('excerpt', 500) ->nullable() // Existing rows have no excerpt — nullable prevents errors ->after('title'); // Column ordering hint for readability }); } public function down(): void { Schema::table('articles', function (Blueprint $table) { // Always drop the column in down() — make rollback a clean undo $table->dropColumn('excerpt'); }); } };
INFO Running migrations.
2024_01_15_000002_create_comments_table ..................... 45ms DONE
2024_02_10_000001_add_excerpt_to_articles_table ............. 18ms DONE
Rollbacks, Refresh, and the Seeder Workflow — Keeping Your Dev Loop Fast
Here's where migrations become genuinely powerful as a development tool, not just a deployment mechanism. The trio of migrate:rollback, migrate:refresh, and migrate:fresh are commands every intermediate developer should have muscle memory for — but each does something distinct.
migrate:rollback undoes the last batch only. It's surgical. Use it when you realise a migration you just ran has a mistake and you want to fix it before pushing.
migrate:refresh rolls back ALL migrations, then re-runs them all from scratch. It also accepts a --seed flag, which runs your database seeders after migrating. This is the command you run when you want a clean but fully-seeded local database — perfect at the start of a work session.
migrate:fresh is more aggressive. It drops every table (using DROP TABLE, not your down() methods) and re-runs all migrations. It's faster than refresh because it skips the rollback logic, but it means your down() methods are never tested. That's why you should run migrate:refresh occasionally — it proves your down() implementations actually work.
The --pretend flag on migrate is an underused gem: it prints the raw SQL that would be executed without actually running it. Essential for auditing migrations before they touch a production database.
<?php // database/seeders/DatabaseSeeder.php // Seeders fill your freshly migrated database with realistic test data. // They pair with migrations to give you a reproducible, usable dev environment. namespace Database\Seeders; use App\Models\Article; use App\Models\Comment; use App\Models\User; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder { public function run(): void { // Create 5 users using the User factory (defined in database/factories/) $users = User::factory(5)->create(); // Create 20 articles, each assigned to a random one of those 5 users $articles = Article::factory(20) ->recycle($users) // Reuse existing users rather than creating new ones ->create(); // Create 3–8 comments per article, each from a random user $articles->each(function (Article $article) use ($users) { Comment::factory( rand(3, 8) // Realistic variation — not every article has the same engagement ) ->recycle($users) ->for($article) ->create(); }); // Report to the console so you can see the seeder is working $this->command->info('Database seeded with 5 users, 20 articles, and their comments.'); } } /* * USEFUL COMMANDS: * * php artisan migrate:fresh --seed * Drops all tables, re-runs all migrations, then runs DatabaseSeeder. * Use this at the start of each dev session for a clean slate. * * php artisan migrate:refresh --seed * Same result, but uses your down() methods — tests rollback logic too. * * php artisan migrate --pretend * Prints the SQL that WOULD run. Great for reviewing before production deploys. */
INFO Dropping all tables.
INFO Running migrations.
2024_01_15_000001_create_articles_table ..................... 28ms DONE
2024_01_15_000002_create_comments_table ..................... 45ms DONE
2024_02_10_000001_add_excerpt_to_articles_table ............. 18ms DONE
INFO Running seeders.
Database\Seeders\DatabaseSeeder ............................ 340ms DONE
Database seeded with 5 users, 20 articles, and their comments.
Squashing Migrations and Multi-Environment Deployment — Production-Grade Patterns
After a year of active development, a project might have 80–120 migration files. Running them all on a fresh CI environment takes time, and the sheer number of files makes the database/migrations folder intimidating. Laravel's schema:dump command solves this.
php artisan schema:dump exports your current database schema as a raw SQL file stored in database/schema/. On future migrate runs, Laravel loads that SQL dump first (getting you to the current state instantly), then runs any migrations created after the dump. You get the speed of a single SQL file combined with the precision of individual migration files for recent changes.
For production deployments, the golden rule is: migrate goes in your deploy pipeline, not migrate:fresh. You'd never drop all tables on production. Most teams run php artisan migrate --force in their CI/CD pipeline — the --force flag bypasses the confirmation prompt that Laravel shows when it detects you're running in a production environment.
If your application has significant traffic, consider running migrations during a maintenance window and checking for zero-downtime migration patterns: adding columns as nullable first, backfilling data in a separate job, then adding constraints in a third migration. This approach prevents table locks from taking your app offline mid-deploy.
<?php /** * ZERO-DOWNTIME MIGRATION PATTERN * * Scenario: You need to add a NOT NULL 'reading_time_minutes' column to a table * that already has 50,000 rows in production. * * Naive approach (DANGEROUS on live traffic): * $table->integer('reading_time_minutes'); // Locks the table while backfilling NULL values! * * Safe 3-step approach below: */ // ── STEP 1: Add the column as NULLABLE (no lock, instant) ──────────────────── // 2024_03_01_000001_add_reading_time_to_articles_nullable.php return new class extends Migration { public function up(): void { Schema::table('articles', function (Blueprint $table) { // Nullable = existing rows get NULL immediately, no table scan needed $table->integer('reading_time_minutes')->nullable()->after('body'); }); } public function down(): void { Schema::table('articles', function (Blueprint $table) { $table->dropColumn('reading_time_minutes'); }); } }; // ── STEP 2: Backfill existing rows in a queued job (NOT in a migration) ─────── // app/Jobs/BackfillArticleReadingTime.php // // class BackfillArticleReadingTime implements ShouldQueue // { // public function handle(): void // { // Article::whereNull('reading_time_minutes') // ->chunkById(500, function ($articles) { // foreach ($articles as $article) { // $wordCount = str_word_count(strip_tags($article->body)); // $article->update([ // 'reading_time_minutes' => (int) ceil($wordCount / 200) // ]); // } // }); // } // } // // Dispatch this job AFTER Step 1 deploys. Let it run in the background. // ── STEP 3: Add the NOT NULL constraint once all rows are backfilled ────────── // 2024_03_05_000001_make_reading_time_not_nullable_on_articles.php return new class extends Migration { public function up(): void { Schema::table('articles', function (Blueprint $table) { // By now every row has a value. Safe to add constraint. $table->integer('reading_time_minutes') ->default(1) // Fallback for any edge cases missed by the backfill ->nullable(false) // Remove the nullable flag — adds NOT NULL constraint ->change(); // ->change() tells Laravel to ALTER the existing column }); } public function down(): void { Schema::table('articles', function (Blueprint $table) { $table->integer('reading_time_minutes')->nullable()->change(); }); } };
ALTER TABLE `articles` ADD `reading_time_minutes` INT NULL AFTER `body`;
-- Step 2: Background job runs asynchronously — no migration output.
-- Step 3 SQL (safe — all rows already have values):
ALTER TABLE `articles`
MODIFY `reading_time_minutes` INT NOT NULL DEFAULT 1;
| Command | What It Does | Destroys Data? | Tests down()? | Best Used When |
|---|---|---|---|---|
| migrate | Runs pending migrations only | No | No | Normal development and production deploys |
| migrate:rollback | Undoes the last batch only | Yes — last batch | Yes | Fixing a mistake in the migration you just ran |
| migrate:refresh | Rolls back all, then re-runs all | Yes — everything | Yes | You need a clean DB but want to verify rollback logic |
| migrate:fresh | Drops all tables, then runs all migrations | Yes — everything | No | Fastest clean slate in local dev — CI pipeline resets |
| migrate --pretend | Prints SQL without executing it | No | No | Auditing migrations before a production deploy |
| schema:dump | Squashes all migrations into one SQL file | No | No | Large projects with 50+ migrations slowing down CI |
🎯 Key Takeaways
- The
migrationstable tracks which files have run and groups them bybatch— understanding batch numbers is the key to predicting whatmigrate:rollbackwill undo. down()isn't optional boilerplate — it's your safety net. Adown()that doesn't cleanly reverseup()will fail loudly in the worst possible moment: a midnight rollback on production.foreignId('user_id')->constrained()does two things: creates the column AND the foreign key constraint. Withoutconstrained(), you just have an unguarded integer column — referential integrity is not enforced.- Never run
migrate:freshon any shared environment. It has no confirmation for existing data and no way to undo it.migrate:freshis a local-development-only command.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Editing a migration that's already been run by teammates — The
migratecommand won't re-run it because the filename already exists in themigrationstable. Your changes are silently ignored. Fix: Always create a new migration file for any correction. Usephp artisan make:migration alter_articles_add_missing_indexand make the change there. - ✕Mistake 2: Writing a
down()method that doesn't actually reverseup()— For example,up()adds a column with->index()butdown()only callsdropColumn()withoutdropIndex()first. On some databases this causes a foreign key or index constraint error during rollback. Fix: Think ofdown()as a mirror image ofup()executed in reverse. Drop indexes before dropping columns, and drop foreign keys before dropping the columns that hold them. - ✕Mistake 3: Using
migrate:freshon a staging environment shared with the client or QA team — It drops every table instantly, with no warning, destroying all test data that's been entered. The--forceflag makes it worse. Fix: Reservemigrate:freshfor your local machine only. On shared environments, always usemigratefor new changes. If a reset is truly needed, get explicit sign-off and make a backup first withphp artisan db:wipepreceded by a database dump.
Interview Questions on This Topic
- QWhat's the difference between `migrate:refresh` and `migrate:fresh`, and when would you choose one over the other in a team environment?
- QIf a colleague tells you they edited an existing migration file to fix a bug, what problems could that cause and how would you have handled it differently?
- QHow would you safely add a NOT NULL column to a table that already has millions of rows in production without causing downtime?
Frequently Asked Questions
What happens if I delete a migration file that has already been run?
Laravel will throw an error if you try to roll back and the file no longer exists, because down() can't be called on a missing file. The entry in the migrations table still exists, but there's no corresponding class to execute. Never delete migration files that have been run in any environment — if you need to undo a change, write a new migration.
Can I run a single specific migration instead of all pending ones?
Not directly via an artisan flag. The standard migrate command runs all pending files in order. If you need to run one specific migration, the common workaround is to use migrate --path=database/migrations/your_file.php. Just be aware this can create batching inconsistencies, so use it sparingly and only in local development.
What's the difference between `$table->string()` and `$table->text()` in migrations?
Both store character data, but string() maps to VARCHAR with a default max of 255 characters and can be indexed (great for searchable fields like slugs or emails). text() maps to a TEXT column with no practical length limit, but TEXT columns cannot be fully indexed in MySQL — only a prefix of the column can be indexed. Use string() for short, searchable values and text() for long-form content like article bodies.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.