Real-engine migrations
The structural pin is engine-independent; the definitive proof runs the migrations against a live database. Two expectations do that — a runner and its negative control:
expect($migrationsDir)->toApplyOnConnection(string $connection, ?int $migrations = null);
expect($migrationsDir)->toRejectBrokenOrderOnConnection(Closure $reorder, string $connection);it('applies on postgres', function (): void {
expect(database_path('migrations'))->toApplyOnConnection('pgsql', migrations: 14);
})->skip(fn (): bool => ! test()->connectionAvailable('pgsql'), 'no postgres');
it('rejects a broken order on postgres', function (): void {
expect(database_path('migrations'))->toRejectBrokenOrderOnConnection(
fn (array $files): array => array_reverse($files),
'pgsql',
);
})->skip(fn (): bool => ! test()->connectionAvailable('pgsql'), 'no postgres');The negative control
A green foreign-key test proves nothing until you have watched the engine reject the broken order. toRejectBrokenOrderOnConnection passes only if the engine refuses the reordered set, and fails loudly if the engine accepts it — a driver that doesn’t enforce foreign keys, like SQLite, would make the check vacuous. The $reorder closure receives the sorted migration files and must return a permutation of them; dropping, adding or renaming a file fails the assertion.
A refusal only counts when it is an ordering error: a missing table or column, or a foreign key the engine could not create. A set that fails in every order — invalid SQL, a permission error — fails the negative control instead of passing it.
Guards on the runner
- migrations: pins the file count, so the check cannot pass over an empty or relocated directory.
- A set that applies without creating a single table fails — an empty up() also “applies cleanly”.
- Both assertions fail on an engine they cannot reach — “connection refused” would otherwise read as “the engine rejected the order”. Gate them rather than relying on the engine to be up.
- The runner points the default connection at the target for the run, drops the target clean before and after, and always restores the previous default — including after a run that could not start.
Because the runner drops every table on its target connection, point it only at a dedicated test database. Under DriverMatrix::configure() the pgsql and mysql connections are isolated probes in their own testing_probe schema or database, so a probe run can never reach the suite’s own tables.
Skip visibly — then count the skips
Gate both assertions on connectionAvailable() (on PackageTestCase) or MigrationRunner::connectionIsAvailable() (anywhere), so a run without Postgres skips visibly instead of failing on the unreachable engine:
use RoundlyConsulting\Testing\Assertions\Migrations\MigrationRunner;
// Without PackageTestCase, gate on the runner's own availability probe.
it('applies on postgres', function (): void {
expect(database_path('migrations'))->toApplyOnConnection('pgsql', migrations: 14);
})->skip(fn (): bool => ! MigrationRunner::connectionIsAvailable('pgsql'), 'no postgres');Check the skip count, not the colour. These gates skip when the engine is unreachable, so a misconfigured CI leg reports green having asserted nothing. On a leg that exists to run them, their skip count must be zero.
Forward-only migrations
Nothing in this package asks for a down() method. Roundly packages migrate forward only — a rollback path is dead code that drifts out of sync with up() — so there is deliberately no rollback assertion. Uninstalling a package is the host app’s business: it drops the tables the package documents.
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 cryptoBy 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.