Keeping a Laravel test suite fast with schema:dump
A deep dive on squashing migrations with php artisan schema:dump --prune in Laravel — how schema dumps work, why they speed up tests, and the cross-database SQLite gotcha.
This one's a deep dive into a humble Artisan command that pays for itself every single test run: php artisan schema:dump --prune.
The problem: migrations are O(history)
A migration is a forward-only edit to your schema. That's great for production — every change is recorded and replayable — but it means the cost of building a fresh schema grows with your project's age. Spin up a test database and Laravel replays every migration ever written, in order, including the column you added in week one and renamed in week three.
For a test suite using RefreshDatabase, that replay happens constantly. Two months in, a meaningful slice of your test time is re-enacting historical schema edits nobody cares about anymore.
What schema:dump actually does
php artisan schema:dump --prune
Two things happen:
- Laravel introspects the current database and writes a single SQL file to
database/schema/{connection}-schema.sql— the entire schema as it stands, no history. --prunedeletes the migration files that are now represented by the dump.
From then on, migrate (and RefreshDatabase) loads the schema file in one shot, records those migrations as already-run, and then only executes migrations created after the dump. The replay cost resets to near-zero.
It's not a destructive rewrite of history — it's a checkpoint. Old migrations are gone from the repo, but the schema they produced is preserved verbatim in the dump.
The cross-database gotcha
Here's the detail that bites teams (and bit me later): a schema dump is database-specific.
The dump is raw SQL for whatever engine you introspected. Lookout runs on MySQL in development and production, but the test suite runs on in-memory SQLite for speed. A MySQL schema dump is full of AUTO_INCREMENT, backtick quoting, and types SQLite doesn't understand — load it into SQLite and it explodes.
So a single dump isn't enough. You need the schema represented for both engines:
database/schema/mysql-schema.sql # loaded by MySQL
database/schema/sqlite-schema.sql # loaded by the test suite
Laravel loads {connection}-schema.sql by connection name, so as long as both exist, MySQL and SQLite each get their own. The trap is that they drift: you regenerate the MySQL dump after a batch of migrations and forget the SQLite one, and suddenly tests are building an old schema (or failing to build at all). The fix is discipline — regenerate both whenever you squash — or a tiny converter script that derives the SQLite dump from the MySQL one.
The workflow that works
- Let migrations accumulate while iterating.
- Every so often (and definitely before the folder gets unwieldy):
schema:dump --prune, then regenerate the SQLite dump too. - New migrations pile up on top until the next squash.
Why bother this early
Test speed is a compounding cost. A 200ms-slower boot doesn't sound like much until it's multiplied by every test, every run, every day, for the life of the project. Squashing early keeps the feedback loop tight while iteration is fastest — which is exactly when a slow suite does the most damage to momentum.
Boring infrastructure, real leverage. Next: API-only mode and making the SDK installable for real.