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::create() / Schema::table() block, not per file: files run in directory order, 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.

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.
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 excluded.
  • foreignKeys: pins the total edge count, so the check can never pass over an empty parse. Pin it wherever you adopt the assertion.
  • 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.