A migration is a PHP file that describes one change to your database structure, so every copy of your app can build the exact same tables.
๐๏ธThink of migrations as numbered building instructions for a LEGO set. Anyone who follows steps 1, 2, 3 in order ends up with the same model, and if step 3 was a mistake, you can undo just that step.
Your schema deserves version control too
Teammates stay in sync
Pull the latest code, run php artisan migrate, and your local database matches everyone else's. No more emailing SQL dumps.
Changes have history
Migrations live in Git next to your code, so you can see when a column was added, by whom, and why.
Deploys are repeatable
The same files that built your laptop database build the production one. Laravel remembers which ones already ran.
From PHP file to real table
migrationsmigrations table, so it never runs the same file twice.Migrations live in database/migrations/. The filename starts with a timestamp, which is how Laravel knows the order to run them in.
database/migrations/ โโโ 0001_01_01_000000_create_users_table.php โ ships with Laravel โโโ 0001_01_01_000001_create_cache_table.php โโโ 0001_01_01_000002_create_jobs_table.php โโโ 2025_03_10_120000_create_categories_table.php โ yours โโโ 2025_03_10_120500_create_posts_table.php โ runs after categories
Build the blog's tables
We'll create the categories and posts tables for our running blog example.
-
Generate a migration
Artisan creates the file for you. If the name follows the pattern
create_xxx_table, Laravel even pre-fills the table name.php artisan make:migration create_categories_table php artisan make:migration create_posts_tableโน๏ธ Shortcutphp artisan make:model Post -mcreates the model and its migration in one go. You'll meet models in the next lesson. -
Understand
up()anddown()Every migration has two methods.
up()makes the change, anddown()undoes it exactly. TheSchemafacade and aBlueprintobject ($table) let you describe columns in PHP instead of SQL.<?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('categories', function (Blueprint $table) { $table->id(); // BIGINT auto-increment primary key $table->string('name'); // VARCHAR(255) $table->string('slug')->unique(); // no two categories share a slug $table->timestamps(); // created_at + updated_at }); } public function down(): void { Schema::dropIfExists('categories'); } }; -
Pick the right column types
Each
$table->...call adds one column. Chain modifiers such as->nullable()or->default()to fine-tune it.Method Stores Blog example id()Auto-increment big integer primary key idstring('x', 255)Short text (VARCHAR) title,slugtext('x')Long text bodyboolean('x')true / false is_featuredunsignedInteger('x')Whole numbers โฅ 0 viewstimestamp('x')Date + time published_atjson('x')Structured data metaforeignId('x')A link to another table's id user_id -
Link tables with foreign keys
A foreign key is a column that points at a row in another table.
foreignId('user_id')->constrained()creates the column and tells the database it must match a realusers.id. Laravel guesses the table from the column name.public function up(): void { Schema::create('posts', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->foreignId('category_id')->nullable()->constrained()->nullOnDelete(); $table->string('title'); $table->string('slug')->unique(); $table->text('body'); $table->unsignedInteger('views')->default(0); $table->timestamp('published_at')->nullable(); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('posts'); }cascadeOnDelete()means "delete a user, and their posts go too".nullOnDelete()means "delete a category, and its posts simply become uncategorised". Becausepostspoints atcategories, the categories migration must run first, which is why its timestamp is earlier. -
Run, roll back, start fresh
These commands are your daily tools. Rollback runs
down()on the most recent batch of migrations.php artisan migrate # run every migration that hasn't run yet php artisan migrate:status # see which ones have run php artisan migrate:rollback # undo the last batch php artisan migrate:fresh # DROP ALL tables, then migrate from scratch php artisan migrate:fresh --seed # same, then fill with seed dataโ ๏ธmigrate:freshdeletes everythingIt's perfect on your laptop and catastrophic in production. Never run it against a database holding real users' data.
-
Change an existing table with a new migration
Once a migration has run anywhere other than your own machine, treat it as frozen. To add an
excerptcolumn later, write a new migration that alters the table.php artisan make:migration add_excerpt_to_posts_tablepublic function up(): void { Schema::table('posts', function (Blueprint $table) { $table->string('excerpt', 300)->nullable()->after('title'); }); } public function down(): void { Schema::table('posts', function (Blueprint $table) { $table->dropColumn('excerpt'); }); }Note
Schema::table()(change an existing table) versusSchema::create()(make a new one).
Avoid this, do that
// Editing create_posts_table.php
// after it already ran on staging
$table->string('excerpt');
Laravel thinks that file already ran, so the column never appears on any server that migrated before.
php artisan make:migration add_excerpt_to_posts_table
php artisan migrate
A new migration is picked up everywhere, and its down() lets you undo it cleanly.
public function down(): void
{
//
}
An empty down() means rollback silently does nothing, leaving your schema half-changed.
public function down(): void
{
Schema::dropIfExists('posts');
}
Make down() the exact mirror of up() so rollbacks are always safe.
While a migration exists only on your machine and hasn't been pushed, it's fine to edit it and run php artisan migrate:fresh. The rule "never edit a migration" kicks in the moment someone else could have run it.
Extend the blog schema:
- Create a
commentstable withid,post_id(cascade on delete),user_id, abodytext column and timestamps. - Create a
tagstable with a uniquename. - Run
php artisan migrate, thenphp artisan migrate:statusto confirm they ran. - Roll back once with
migrate:rollbackand check which tables disappeared. - Add a
is_featuredboolean (defaultfalse) topostsusing a new migration.
Key takeaways
- A migration describes one schema change;
up()applies it anddown()reverses it. - Files run in timestamp order, and Laravel logs each one so it never runs twice.
foreignId('user_id')->constrained()adds the column and the foreign key in one line.migratemoves forward,migrate:rollbackundoes the last batch,migrate:freshwipes and rebuilds.- Never edit a migration that has already run elsewhere; write a new one with
Schema::table().