Migrations, Seeders and Factories

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. The migrations add tags and loans to the Bookshelf application and run on SQLite, PostgreSQL 17.11 and MySQL 8.4.11. Migrations are version control for the schema; seeders fill it with known data; factories produce realistic fake models for tests and development. The general theory of evolving a schema without downtime (expand and contract, backfills, locking) is on Evolving the Database Model.

Writing migrations

php artisan make:migration create_loans_table creates a timestamped file in database/migrations that returns an anonymous class, so two migrations can no longer clash on a class name. up() applies the change and down() reverses it:

<?php
// database/migrations/2026_10_05_205021_create_loans_table.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('loans', function (Blueprint $table) {
            $table->ulid('id')->primary();                                   // sortable, not guessable
            $table->foreignId('book_id')->constrained()->restrictOnDelete(); // no deleting books on loan
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->enum('status', ['active', 'returned', 'lost'])->default('active');
            $table->dateTime('due_at');
            $table->dateTime('returned_at')->nullable();
            $table->unsignedInteger('fine_cents')->default(0)->comment('accrued late fee');
            $table->timestamps();

            $table->index(['user_id', 'status']);                            // "my active loans"
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('loans');
    }
};
<?php
// database/migrations/2026_10_05_205020_create_book_tag_table.php (up() only)
use App\Models\Book;
use App\Models\Tag;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

Schema::create('book_tag', function (Blueprint $table) {           // pivot: singular names, alphabetical
    $table->foreignIdFor(Book::class)->constrained()->cascadeOnDelete();
    $table->foreignIdFor(Tag::class)->constrained()->cascadeOnDelete();
    $table->string('added_by')->nullable();                          // an extra pivot column
    $table->primary(['book_id', 'tag_id']);
});

php artisan migrate --pretend prints the SQL without running it, which is the quickest way to see what a Blueprint means on a given database:

$ php artisan migrate --pretend --database=pg
  2026_10_05_205020_create_book_tag_table ....................................
  ⇂ create table "book_tag" ("book_id" bigint not null, "tag_id" bigint not null, "added_by" varchar(255) null)
  ⇂ alter table "book_tag" add constraint "book_tag_book_id_foreign" foreign key ("book_id") references "books" ("id") on delete cascade
  ⇂ alter table "book_tag" add constraint "book_tag_tag_id_foreign" foreign key ("tag_id") references "tags" ("id") on delete cascade
  ⇂ alter table "book_tag" add primary key ("book_id", "tag_id")
  2026_10_05_205021_create_loans_table .......................................
  ⇂ create table "loans" ("id" char(26) not null, "book_id" bigint not null, "user_id" bigint not null, "status" varchar(255) check ("status" in ('active', 'returned', 'lost')) not null default 'active', "due_at" timestamp(0) without time zone not null, …)
  ⇂ alter table "loans" add constraint "loans_book_id_foreign" foreign key ("book_id") references "books" ("id") on delete restrict
  ⇂ alter table "loans" add constraint "loans_user_id_foreign" foreign key ("user_id") references "users" ("id") on delete cascade
  ⇂ create index "loans_user_id_status_index" on "loans" ("user_id", "status")
  ⇂ alter table "loans" add primary key ("id")
  ⇂ comment on column "loans"."fine_cents" is 'accrued late fee'

$ php artisan migrate --pretend --database=my | grep 'create table `loans`'
  ⇂ create table `loans` (`id` char(26) not null, `book_id` bigint unsigned not null, `user_id` bigint unsigned not null, `status` enum('active', 'returned', 'lost') not null default 'active', `due_at` datetime not null, … `fine_cents` int unsigned not null default '0' comment 'accrued late fee', …)

The same enum() is a varchar with a check constraint on PostgreSQL and a native ENUM on MySQL, where adding a value later is an ALTER TABLE (done in place only when the value is appended at the end); a string column validated by a PHP enum (see Eloquent Casts, Accessors and Serialization) is often simpler. foreignId('book_id')->constrained() infers the books table from the column name; foreignIdFor(Book::class) infers the column from the model. PostgreSQL does not index foreign key columns by itself, which is why the composite primary and index matter for joins.

Columns, modifiers and keys

Family Methods

Keys

id() (bigIncrements), ulid(), uuid(), foreignId(), foreignIdFor(), foreignUlid(), morphs(), nullableMorphs(), ulidMorphs()

Text and numbers

string($name, $length), char, text, mediumText, longText, integer and tinyInteger to bigInteger (with unsigned*), decimal($p, $s), float, double, boolean

Dates

date, dateTime, dateTimeTz, time, timestamp, timestamps(), softDeletes(), year

Structured

json, jsonb, enum, set, vector($dimensions), geography, geometry, binary

Modifiers

nullable(), default(), useCurrent(), unsigned(), comment(), after() and first() (MySQL), storedAs()/virtualAs() (generated columns), invisible(), charset(), collation()

Indexes

primary, unique, index, fullText, spatialIndex, vectorIndex; drop with dropUnique('books_isbn_unique') or by column array

Changing tables

A later migration renames, adds and changes columns:

<?php
// database/migrations/2026_10_05_205047_change_loans_fine_and_notes.php (up() only)
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

Schema::table('loans', function (Blueprint $table) {
    $table->renameColumn('fine_cents', 'late_fee_cents');
    $table->string('note', 200)->nullable()->after('status');
});

Schema::table('loans', function (Blueprint $table) {
    $table->string('note', 500)->nullable()->change();    // change() restates every modifier to keep
});
$ php artisan migrate --pretend --database=pg
  ⇂ alter table "loans" rename column "fine_cents" to "late_fee_cents"
  ⇂ alter table "loans" add column "note" varchar(200) null
  ⇂ alter table "loans" alter column "note" type varchar(500), alter column "note" drop not null, alter column "note" drop default, alter column "note" drop identity if exists
  ⇂ comment on column "loans"."note" is NULL
$ php artisan migrate --pretend --database=sqlite
  ⇂ alter table "loans" rename column "fine_cents" to "late_fee_cents"
  ⇂ alter table "loans" add column "note" varchar
  ⇂ select name, type, not "notnull" as "nullable", … from pragma_table_xinfo('loans', 'main') …
  …
  ⇂ create table "__temp__loans" ()
  • change() sets the column to exactly the definition given: the PostgreSQL SQL drops not null, the default and the comment unless they are restated. Forgetting ->nullable() or ->default() in a change() silently changes the column. No doctrine/dbal is needed since Laravel 11.

  • SQLite cannot alter columns, so Laravel rebuilds the table (__temp__loans, copy, drop, rename). --pretend cannot show that rebuild faithfully, because it does not run the introspection queries that build it: the create table "__temp__loans" () with no columns is a pretend artefact. The real migrate succeeded on all three databases.

  • renameColumn() and dropColumn() on a busy table are where expand-and-contract applies: add the new column, write to both, backfill, switch reads, and drop the old column in a later deployment.

Running migrations

$ php artisan migrate:status --database=pg
  2026_10_05_185057_create_reviews_table ............................. [1] Ran
  2026_10_05_204640_add_meta_to_books_table .......................... [2] Ran
  2026_10_05_205019_create_tags_table ................................ [3] Ran
  2026_10_05_205020_create_book_tag_table ............................ [3] Ran
  2026_10_05_205021_create_loans_table ............................... [3] Ran
$ php artisan migrate:rollback --step=1 --database=pg
   INFO  Rolling back migrations.
  2026_10_05_205021_create_loans_table .......................... 10.92ms DONE
Command Effect

migrate

Runs pending migrations as one batch (the [3] above); asks for confirmation in production unless --force.

migrate:rollback (--step=n, --batch=n)

Runs down() for the last batch or the last n migrations.

migrate:refresh, migrate:reset

Roll everything back (and re-run); depend on every down() being correct.

migrate:fresh (--seed)

Drops all tables and migrates from scratch; never against a database with data you need.

migrate --isolated

Takes a cache lock first, so two servers deploying at once do not both migrate.

migrate --pretend, migrate:status

Show the SQL, or which migrations ran in which batch.

In production, migrations run once per deployment, before the new code receives traffic, with --force and --isolated; destructive changes are split across deployments as above. A migration can opt out with shouldRun(): bool, and $connection targets another database.

Schema dumps and squashing

After years of migrations, php artisan schema:dump writes the current schema to database/schema/<connection>-schema.sql (with --prune it also deletes the migration files). migrate on an empty database loads that file first and then runs only the newer migrations. The dump uses the database’s own tools, which caused two failures here:

$ php artisan schema:dump --database=pg
pg_dump: error: aborting because of server version mismatch
pg_dump: detail: server version: 17.11; pg_dump version: 16.14 (Ubuntu 16.14-0ubuntu0.24.04.1)
$ php artisan schema:dump --database=sqlite
  The command "sqlite3 "${:LARAVEL_LOAD_DATABASE}" ".schema --indent"" failed.
Exit Code: 127(Command not found)

$ PATH=./bin:$PATH php artisan schema:dump --database=pg      # bin/pg_dump runs the container's pg_dump 17
   INFO  Database schema dumped successfully.
$ php artisan migrate:fresh --database=pg --force
   INFO  Loading stored database schemas.
  database/schema/pg-schema.sql ................................ 138.27ms DONE
   INFO  Nothing to migrate.

pg_dump must be at least the server’s major version, and SQLite dumps need the sqlite3 command-line tool, which the host did not have. A wrapper script that ran docker exec pg pg_dump "$@" produced the dump; it contains the migrations table’s rows, which is how Laravel knows which migrations it replaces. Commit the dump with the code so CI and new developers load it too.

Seeders

php artisan make:seeder CatalogueSeeder creates a class in database/seeders; DatabaseSeeder calls the others, and php artisan db:seed (or migrate:fresh --seed) runs it:

<?php
// database/seeders/CatalogueSeeder.php
namespace Database\Seeders;

use App\Models\Author;
use App\Models\Book;
use App\Models\Tag;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;

class CatalogueSeeder extends Seeder
{
    use WithoutModelEvents;                                          // no observers, no queued listeners

    public function run(): void
    {
        $sf = Tag::firstOrCreate(['name' => 'science fiction']);
        $classic = Tag::firstOrCreate(['name' => 'classic']);

        $catalogue = [
            'Octavia E. Butler' => [['Kindred', '978-0-8070-8305-4', 1979], ['Parable of the Sower', '978-1-5387-3218-7', 1993]],
            'Ursula K. Le Guin' => [['The Dispossessed', '978-0-06-051275-6', 1974], ['The Lathe of Heaven', '978-1-4165-5696-4', 1971]],
        ];

        foreach ($catalogue as $name => $books) {
            $author = Author::firstOrCreate(['name' => $name]);
            foreach ($books as [$title, $isbn, $year]) {
                $book = Book::updateOrCreate(['isbn' => $isbn], ['author_id' => $author->id, 'title' => $title, 'published_year' => $year]);
                $book->tags()->syncWithoutDetaching([$sf->id => ['added_by' => 'seeder']]);
                if ($year < 1980) {
                    $book->tags()->syncWithoutDetaching([$classic->id => ['added_by' => 'seeder']]);
                }
            }
        }
    }
}
<?php
// database/seeders/DatabaseSeeder.php
namespace Database\Seeders;

use App\Models\Book;
use App\Models\Loan;
use App\Models\Review;
use App\Models\User;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $this->call(CatalogueSeeder::class);                         // idempotent: safe to run twice

        $reader = User::factory()->create(['name' => 'Ada Reader', 'email' => 'ada@example.com']);
        $kindred = Book::where('title', 'Kindred')->firstOrFail();

        Review::factory()->for($kindred)->for($reader)->create(['rating' => 5, 'body' => 'Unforgettable.']);
        Loan::factory()->for($reader)->for($kindred)->overdue(days: 4)->create();

        if (app()->environment('local')) {
            Book::factory(20)->create();                             // bulk fake data for local development only
        }
    }
}
$ APP_ENV=testing php artisan migrate:fresh --database=pg --seed --force
   INFO  Seeding database.
  Database\Seeders\CatalogueSeeder ................................... RUNNING
  Database\Seeders\CatalogueSeeder ................................ 44 ms DONE
$ php artisan db:seed --class=CatalogueSeeder --database=pg --force     # a second run changes nothing
pg books=4 tags=7 loans=1

firstOrCreate, updateOrCreate and syncWithoutDetaching make the catalogue seeder idempotent, so it can also run in production for reference data; the second run left 4 books and 7 tag links. Reference data that the application cannot work without (roles, countries) is often better as a migration, so it exists wherever the schema does.

Factories

php artisan make:factory LoanFactory (or make:model Loan -mf) creates a class whose definition() returns default attributes; states are methods that override some of them:

<?php
// database/factories/LoanFactory.php
namespace Database\Factories;

use App\Enums\LoanStatus;
use App\Models\Book;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

/** @extends Factory<\App\Models\Loan> */
class LoanFactory extends Factory
{
    public function definition(): array
    {
        return [
            'book_id' => Book::factory(),
            'user_id' => User::factory(),
            'status' => LoanStatus::Active,
            'due_at' => now()->plus(days: 21),
        ];
    }

    public function overdue(int $days = 5): static                       // a state
    {
        return $this->state(fn (array $attributes) => [
            'due_at' => now()->minus(days: $days),
            'late_fee_cents' => $days * 25,
        ]);
    }

    public function returned(): static
    {
        return $this->state(['status' => LoanStatus::Returned, 'returned_at' => now()]);
    }
}

Relationships, sequences and recycling, run on PostgreSQL with a fixed Faker seed:

<?php
use App\Models\{Author, Book, Loan, Review, Tag, User};
use Illuminate\Database\Eloquent\Factories\Sequence;
use Illuminate\Support\Facades\DB;

DB::setDefaultConnection('pg');
fake()->seed(194);                                                   // repeatable fake data

$draft = Book::factory()->make();                                    // not saved
echo 'make(): exists=', var_export($draft->exists, true), ' author_id=', var_export($draft->author_id, true), PHP_EOL;

$butler = Author::factory()->has(
    Book::factory()->count(3)->state(new Sequence(
        ['title' => 'Kindred', 'published_year' => 1979],
        ['title' => 'Dawn', 'published_year' => 1987],
        ['title' => 'Wild Seed', 'published_year' => 1980],
    ))
)->create(['name' => 'Octavia E. Butler']);
echo $butler->name, ': ', $butler->books()->orderBy('published_year')->pluck('title')->join(', '), PHP_EOL;

$reader = User::factory()->create(['name' => 'Ada Reader', 'email' => 'ada@example.com']);
$sf = Tag::factory()->create(['name' => 'science fiction']);
$kindred = Book::where('title', 'Kindred')->first();
$kindred->tags()->attach($sf, ['added_by' => 'seeder']);

Loan::factory()->count(2)->for($reader)->for($kindred)->overdue(days: 4)->create();
Loan::factory()->for($reader)->recycle($butler->books)->returned()->create();
echo 'loans: ', Loan::count(), ', overdue fees: ', Loan::sum('late_fee_cents'), ' cents, ids like ', Loan::first()->id, PHP_EOL;

$classics = Book::factory()->classic()->count(2)->hasAttached($sf, ['added_by' => 'factory'])->for($butler)->create();
echo 'classics: ', $classics->pluck('published_year')->join(', '), '; tagged: ', $sf->books()->count(), PHP_EOL;

Review::factory()->count(4)->for($kindred)->state(new Sequence(fn (Sequence $s) => ['rating' => 5 - $s->index]))->create();
echo 'ratings: ', $kindred->reviews()->orderByDesc('rating')->pluck('rating')->join(','), '; users created: ', User::count(), PHP_EOL;
make(): exists=false author_id=1
Octavia E. Butler: Kindred, Wild Seed, Dawn
loans: 3, overdue fees: 200 cents, ids like 01m46xcd0cnr8e6mq27a6k0fvd
classics: 1939, 1861; tagged: 3
ratings: 5,4,3,2; users created: 5
  • make() returned an unsaved book, but author_id=1: the Author::factory() in the definition was created in the database to get an ID (the authors table then held "Dr. Tessie Hartmann DDS"). A "pure" make() needs the parent passed in (->for($author)) or 'author_id' => null overridden.

  • has() creates children for a parent (hasBooks(3) is the magic form); for() sets the parent of a child; hasAttached() creates many-to-many links with pivot data; recycle() picks parents from an existing collection instead of creating new ones.

  • The four reviews created four extra users through User::factory() in ReviewFactory: users created: 5. ->recycle($reader) would have reused one. Unwanted parents are the usual reason a test database fills up or a unique constraint fails.

  • Sequence cycles through attribute sets, or computes them from $sequence->index.

  • The ULID IDs from HasUlids are lower-case and sort by creation time.

Where older books and tutorials are outdated

  • Named migration classes (class CreateBooksTable extends Migration) (Stauffer ch. 5) became anonymous classes (introduced in Laravel 8, generated by default since Laravel 9), so duplicate names no longer break migrate.

  • $factory->define(Book::class, function (Faker $faker) {...}) closures in database/factories (Stauffer ch. 5) became class-based factories in Laravel 8; factory(Book::class, 3)->create() is Book::factory()->count(3)->create().

  • $table->increments('id') and unsignedInteger('author_id') with a separate foreign() call are id() and foreignId()->constrained() (Laravel 7).

  • composer require doctrine/dbal to change columns is unnecessary since Laravel 11, which also requires change() to restate every modifier.

  • $this->faker inside factories still works, but the fake() helper (Laravel 9) is the documented form.