NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Testing for Laravel

Migration-order pin

The structural migration-order pin parses the foreign keys out of your migration source and asserts that every referenced table is created before the migration that references it:

expect($migrationsDir)->toHaveRunnableMigrationOrder(
    ?int $foreignKeys = null,
    array $tableResolvers = [],
    array $externalTables = [],
);
it('has a runnable migration order', function (): void {
    expect(database_path('migrations'))->toHaveRunnableMigrationOrder(foreignKeys: 19);
});

Why structural

SQLite happily creates a table that points at a missing parent and only complains at insert time, so “migrate on SQLite and see” stays green on an order that cannot install on Postgres or MySQL. Five of our own packages shipped exactly that under green SQLite suites. Reading the order from source makes the check engine-independent — it goes red on SQLite too.

Order is per Schema block

Order is tracked per Schema block — create(), table(), drop(), dropIfExists() and rename(), on the facade or on Schema::connection(…) — not per file: files run in directory order (the same *.php list the runner applies), blocks in source order within a file. A migration that creates teams and then team_members (constrained onto teams) is runnable; the same two blocks the other way round are not.

A table’s life, from up() only

Each table carries its lifetimes, from CREATE to drop, and every ALTER and key is checked against the lifetime live at its own position. A forward-only rebuild — create, alter, then dropIfExists() and create again — passes. A second CREATE with no drop in between fails, and so does a key onto, or an ALTER of, a table already dropped or renamed away. Schema::rename('a', 'b') ends a and begins b; its source must exist at that point or be declared external. down() is removed before parsing — it never runs on the way up, so a CREATE or key inside it doesn’t count.

Foreign-key forms understood

FormParent table
->constrained('users'), ->constrained('users', 'id'), ->constrained(table: 'users')The named table — positional or named argument.
->constrained(), ->constrained(null, 'id'), ->constrained(column: 'uuid')Derived from the column, as Laravel does: foreignId('author_id') → authors, foreignUuid('owner_uuid')->constrained(column: 'uuid') → owners.
->foreignIdFor(Author::class)->constrained()The model’s own getTable(), resolved through the file’s use imports — so a model with a custom $table resolves correctly.
->references('id')->on('users')The on() table.
->constrained($table), Schema::create(Model::table(), …), foreignIdFor($model)Any non-literal is mapped through tableResolvers; an unmapped one fails rather than guessing.
Schema::table('x')An ALTER must run after its CREATE and before any drop.
Schema::drop('x'), Schema::dropIfExists('x')Ends the table’s lifetime; dropping a table the set never created ends nothing.
Schema::rename('from', 'to')Ends from and begins to; from must exist at that point.
Self-referencing keySorts with its own table, not before it.

Commented-out declarations are ignored.

Resolving non-literal table names

When a migration names its table through a variable or a static method, map the raw expression — exactly as it appears in the source — to the table name:

it('has a runnable migration order', function (): void {
    expect(__DIR__.'/../../database/migrations')->toHaveRunnableMigrationOrder(
        foreignKeys: 4,
        tableResolvers: [
            '$tableName' => 'books',                        // Schema::create($tableName, …)
            'PermissionRegistrar::rolesTable()' => 'roles', // ->constrained(PermissionRegistrar::rolesTable())
        ],
    );
});

Guarding the guard

  • An unparseable declaration fails the test instead of being silently dropped: the parsed foreign keys must equal the ->constrained( and ->references( calls in each file, comments and down() excluded.
  • foreignKeys: pins the total edge count. Pin it wherever you adopt the assertion.
  • An empty parse always fails, pinned or not — foreignKeys: 0 included: a directory with no *.php files (a publish-only *.php.stub directory reads as empty, since a stub isn’t a migration until it’s published), or migrations with no Schema::create() or Schema::table() block. Point the assertion at the directory that holds your migrations.
  • A missing migrations directory fails rather than passing over nothing.

Tables the set does not own

A package migration that constrains onto the host app’s users, or an app migration that alters a table a vendor package created, references a table no file in the directory creates. Declare it in externalTables:

expect(__DIR__.'/../../database/migrations')->toHaveRunnableMigrationOrder(
    foreignKeys: 3,
    externalTables: ['users'],
);

Each externalTables entry is rot-checked: some key or ALTER must reference it, and the set must not create it.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.