Migrations

A migration is a PHP file that applies one schema change and knows how to undo it, tracked in a migrations table.

Syntaxphp artisan make:migration create_posts_table --create=posts

A migration is a PHP file that describes one change to the database schema and knows how to reverse it. Schema then lives in version control beside the code that depends on it, and any machine can be brought to the current schema with one command instead of a pasted SQL script.

Creating one

php artisan make:migration create_posts_table writes a file into database/migrations with a date-and-time prefix, such as 2026_03_11_104500_create_posts_table.php. That prefix is the running order. Add --create=posts and the stub arrives with a Schema::create body; add --table=posts and you get Schema::table for an existing table.

Since Laravel 9 the file returns an anonymous class instead of declaring a named one, so two migrations may share a descriptive name without colliding. Laravel 13 keeps that stub.

What makes it repeatable

php artisan migrate runs the pending files in filename order, calling up() on each, then records the filename and a batch number in a migrations table. That table is the whole of Laravel's memory: a filename listed there is never run again, a file not listed is pending. php artisan migrate:status prints the comparison.

php artisan migrate:rollback finds the highest batch number and calls down() on those migrations, newest filename first. Because the record is the filename, renaming a migration that has already run makes it pending again, and up() runs a second time against a table that already exists.

Example

Example · php
<?php
// database/migrations/2026_03_11_104500_create_posts_table.php

declare(strict_types=1);

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('title', 160);
            $table->string('slug', 180)->unique();
            $table->text('body');
            $table->timestamp('published_at')->nullable();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
};

When to use it

  • Add a nullable published_at column to posts without touching the database by hand.
  • Bring a new developer's empty database up to the current schema with one command.
  • Undo last night's schema change on staging before trying a different column type.
  • Show a reviewer exactly which columns and indexes a feature branch introduces.

More examples

Adding a column to a table that already exists

Never edit the create_posts_table migration to add a column. Everyone else has already run it, so the change would never reach their database. Add a second migration instead. Note that down() is the exact inverse: Schema::table to add, Schema::table to drop. A down() you cannot write is a sign the change is not reversible, which matters most on a production box.

Example · php
<?php
// database/migrations/2026_03_19_081000_add_pinned_to_posts_table.php
// Generated with: php artisan make:migration add_pinned_to_posts_table --table=posts

declare(strict_types=1);

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) {
            $table->boolean('pinned')->default(false);
        });
    }

    public function down(): void
    {
        Schema::table('posts', function (Blueprint $table) {
            $table->dropColumn('pinned');
        });
    }
};

The migrations table is the only record

Worth looking at once, because every confusing migration situation is explained by these three columns. A file missing from this table is pending. A file listed here with no matching file on disk is what produces a rollback error, which is what happens when someone deletes a migration that has already run on a shared database.

Example · sql
-- After a first `php artisan migrate` on an empty database, then a
-- second `php artisan migrate` that found one new file:

select id, migration, batch from migrations order by id;

-- id | migration                               | batch
--  1 | 0001_01_01_000000_create_users_table    |     1
--  2 | 2026_03_11_104500_create_posts_table    |     1
--  3 | 2026_03_19_081000_add_pinned_to_posts   |     2

-- `migrate:rollback` reverts batch 2 only: one migration.
-- `migrate:rollback --step=2` ignores batches and reverts ids 3 and 2.

Why the filename prefix is the order

This is plain Collection work on an array of names, not a database call, and it explains a real failure. Two developers branch on Monday, both generate a migration, and the one with the earlier clock timestamp runs first on merge. If the later file creates the table the earlier one adds a foreign key to, the merge breaks. Checking the prefixes is the fix.

Example · php
<?php

// Laravel sorts migration filenames as strings and runs them in that order.
// A zero-padded date prefix sorts the same way as a date, which is the whole
// trick - there is no ordering column anywhere.
$files = [
    '2026_03_19_081000_add_pinned_to_posts_table.php',
    '0001_01_01_000000_create_users_table.php',
    '2026_03_11_104500_create_posts_table.php',
];

collect($files)
    ->sort()
    ->values()
    ->each(fn (string $file) => print($file . "\n"));

// Output:
// 0001_01_01_000000_create_users_table.php
// 2026_03_11_104500_create_posts_table.php
// 2026_03_19_081000_add_pinned_to_posts_table.php

Discussion

  • Be the first to comment on this lesson.