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']);
});
Source: Laravel — Migration Structure and Foreign Key Constraints
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', …)
Source: Laravel — Running Migrations
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 |
|
Text and numbers |
|
Dates |
|
Structured |
|
Modifiers |
|
Indexes |
|
Source: Laravel — Available Column Types and Column Modifiers
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" ()
Source: Laravel — Modifying Columns
-
change()sets the column to exactly the definition given: the PostgreSQL SQL dropsnot null, the default and the comment unless they are restated. Forgetting->nullable()or->default()in achange()silently changes the column. Nodoctrine/dbalis needed since Laravel 11. -
SQLite cannot alter columns, so Laravel rebuilds the table (
__temp__loans, copy, drop, rename).--pretendcannot show that rebuild faithfully, because it does not run the introspection queries that build it: thecreate table "__temp__loans" ()with no columns is a pretend artefact. The realmigratesucceeded on all three databases. -
renameColumn()anddropColumn()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 |
|---|---|
|
Runs pending migrations as one batch (the |
|
Runs |
|
Roll everything back (and re-run); depend on every |
|
Drops all tables and migrates from scratch; never against a database with data you need. |
|
Takes a cache lock first, so two servers deploying at once do not both migrate. |
|
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.
Source: Laravel — Squashing Migrations
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
Source: Laravel — Database: Seeding
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()]);
}
}
Source: Laravel — Defining Model Factories and Factory States
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, butauthor_id=1: theAuthor::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' => nulloverridden. -
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()inReviewFactory: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. -
Sequencecycles through attribute sets, or computes them from$sequence->index. -
The ULID IDs from
HasUlidsare 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 breakmigrate. -
$factory->define(Book::class, function (Faker $faker) {...})closures indatabase/factories(Stauffer ch. 5) became class-based factories in Laravel 8;factory(Book::class, 3)->create()isBook::factory()->count(3)->create(). -
$table->increments('id')andunsignedInteger('author_id')with a separateforeign()call areid()andforeignId()->constrained()(Laravel 7). -
composer require doctrine/dbalto change columns is unnecessary since Laravel 11, which also requireschange()to restate every modifier. -
$this->fakerinside factories still works, but thefake()helper (Laravel 9) is the documented form.