Squashing Laravel Migrations: 5 Ways schema:dump --prune Bites a Real Team
schema:dump --prune turns hundreds of migrations into one file. I reproduced the five ways it quietly breaks a team, and the fix for each.
A recent r/laravel thread was titled "250 migrations in 3 years. What's yours at?" The standard answer is one command:
php artisan schema:dump --prune
It dumps your current schema into database/schema/mysql-schema.sql, deletes your migration files, and from then on a fresh database loads the dump instead of replaying years of history. The docs cover it in five paragraphs. The posts about it mostly date from Laravel 8, when the command shipped, and repeat those paragraphs.
What they don't show is what happens on a team. So I built a small Laravel 13.33 app on MySQL with the kind of history a real app has (a data migration, a foreign key, a column added later), squashed it, and then broke it the ways a team would. Five things went wrong. Most of them fail silently, with a green success message.
What migrate actually does once a dump exists
Every trap below makes sense once you know the rule, so here it is from MigrateCommand. When you run migrate, Laravel checks whether the migrations table has any rows. If it does, the dump is ignored completely and only pending migration files run. If it's empty, Laravel looks for database/schema/{connection}-schema.sql, loads it through your database's command-line client, then runs any migration files the dump doesn't already list as run.
And if that file doesn't exist? It carries on without a word.
The file is picked by connection name, not by driver. And the dump holds your schema plus the rows of the migrations table, nothing else.
Trap 1: --prune deletes migrations you never ran
This is the one that loses work. The --prune flag doesn't compare anything. Here's the implementation in DumpCommand:
(new Filesystem)->deleteDirectory(
$path = database_path('migrations'), preserve: false
);
It deletes the whole database/migrations directory. The dump, meanwhile, reflects whatever your local database looks like. So if you pulled a teammate's migration this morning and didn't run it yet, the file is gone and it's not in the dump either.
I reproduced it with one pending migration:
$ php artisan migrate:status
2026_09_26_092550_add_timezone_to_users_table .......... Pending
$ php artisan schema:dump --prune
INFO Database schema dumped and pruned successfully.
$ ls database/migrations
ls: database/migrations: No such file or directory
The timezone column isn't in mysql-schema.sql and the file doesn't exist anymore. Git still has it, so you can recover. But only if someone notices, and nothing tells you.
The fix is procedural. Squash on a clean main, run php artisan migrate:status first and make sure nothing is pending, then dump.
Trap 2: teammates who are behind main
Squashing rewrites history that other people are standing on. Two things break.
The first is a teammate whose local database is a migration behind. Say they hadn't run add_archived_at_to_projects_table when you squashed. They pull, run migrate, and get this:
$ php artisan migrate
INFO Running migrations.
2026_09_26_100000_ensure_default_plans_exist ........... DONE
(That migration is the fix from trap 4 below.) No error. Their migrations table has rows, so the dump is skipped, and the file they needed is gone. migrate:status doesn't even list it anymore. The archived_at column simply isn't in their database, and they find out when a query fails.
The second is an open branch that edits a migration you pruned:
$ git merge feature/archive-reason
CONFLICT (modify/delete): database/migrations/2026_01_15_110000_add_archived_at_to_projects_table.php
deleted in HEAD and modified in feature/archive-reason.
That one at least fails loudly. The fix is to move the change into a new migration, never to restore the old file.
The fix for both is coordination, and it's worth being strict about. Announce the squash, get every open branch merged or rebased first, and have everyone run migrate on main before the squash lands. On a solo project none of this matters. On a team of five it's the whole job.
Trap 3: your tests suddenly have no tables
A stock Laravel 13 app runs its tests on SQLite in memory. That's in phpunit.xml out of the box:
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
After squashing on MySQL, my test with RefreshDatabase failed on its first query:
SQLSTATE[HY000]: General error: 1 no such table: users
(Connection: sqlite, Database: :memory:, ...)
Follow the rule from earlier. The test connection is named sqlite, so migrate looks for sqlite-schema.sql. It doesn't exist, so Laravel skips it without a word, and there are no migration files left to run. The database stays empty.
You can't point SQLite at the MySQL file either. It's MySQL syntax, backticks and ENGINE=InnoDB included.
The docs do cover this, in one paragraph. If your tests use a different connection, dump a schema for that connection too. Their example uses a connection called testing. With the stock setup it's sqlite, and the dump has to come from a real, migrated SQLite file, which means doing it before you prune. This is what worked for me, run from the last commit before the squash:
touch database/dump.sqlite
DB_CONNECTION=sqlite DB_DATABASE=$PWD/database/dump.sqlite \
php artisan migrate --force
DB_CONNECTION=sqlite DB_DATABASE=$PWD/database/dump.sqlite \
php artisan schema:dump --database=sqlite
Override DB_DATABASE inline like that. The default sqlite connection reads DB_DATABASE too, so with your MySQL database name in .env it would otherwise point at a SQLite file called squash_demo.
With sqlite-schema.sql committed next to mysql-schema.sql, the stock test suite passed again. For :memory: databases Laravel loads the file straight through PDO, so you don't need the sqlite3 binary for this.
The cost is that you now have two dumps to regenerate at every future squash. If that bothers you, the other fix is to run your tests on the same engine you run in production. I'd take that trade anyway. SQLite tests passing says little about MySQL behaviour, as my post on Eloquent patterns that silently break MySQL indexes shows. There's more on setting up the test database in the Pest 4 guide.
Trap 4: data migrations disappear
Plenty of apps insert reference data from a migration. Plans, roles, countries. Mine had this:
DB::table('plans')->insert([
['slug' => 'starter', 'price_cents' => 900],
['slug' => 'pro', 'price_cents' => 2900],
]);
The dump contains CREATE TABLE plans but none of the rows. What it does contain is this line:
INSERT INTO `migrations` (`id`, `migration`, `batch`)
VALUES (5,'2025_03_10_090100_seed_default_plans',1);
So on a fresh database, Laravel loads the empty plans table, reads that the seed migration already ran, and never runs it again. My test on a fresh MySQL test database failed with Attempt to read property "id" on null, because there was no starter plan.
This bites everywhere a database is created fresh. That means CI, a new developer's laptop, a staging rebuild, and every new tenant database if you use database-per-tenant multi-tenancy.
The fix is a new migration, dated after the dump, that restores the rows and is safe to run on databases that already have them:
return new class extends Migration
{
public function up(): void
{
DB::table('plans')->insertOrIgnore([
['slug' => 'starter', 'price_cents' => 900],
['slug' => 'pro', 'price_cents' => 2900],
]);
}
};
On a fresh database it runs after the dump loads and inserts both rows. On an existing database it runs once and the unique slug index makes it a no-op. I tested both.
My first attempt used upsert() with update: [], meaning "insert, don't touch existing rows". It blew up on the existing database with a duplicate-key error. Look at Builder::upsert() and you'll see why. An empty update array doesn't mean "update nothing". It falls back to a plain insert(). Use insertOrIgnore(), which needs a unique index on the column you match on.
Longer term, reference data belongs in a seeder that tests and new environments call explicitly. But the migration above is what keeps existing deploys and fresh databases in agreement on the day you squash.
Trap 5: CI needs the database client, not just the database
Both halves of this feature shell out. schema:dump runs mysqldump (or pg_dump, or sqlite3), and loading a MySQL dump runs the mysql client with the file piped in. Your CI job can reach the database fine through PDO and still fail the moment migrate hits a dump:
database/schema/mysql-schema.sql ...................... FAIL
The command "mysql --user=... --database=... < ..." failed.
Exit Code: 127(Command not found)
Where this bites depends on the image. GitHub's hosted ubuntu-24.04 runner ships MySQL 8.0, PostgreSQL 16 and sqlite3 clients, according to its image manifest, so the GitHub Actions setup I use is fine as it is. The official php:8.4-cli Docker image ships none of them. I checked. If your CI, or your production container that runs migrate on deploy, is built from a slim PHP image, add the client package before you squash, not after the pipeline goes red.
There's also a version edge. The dump is produced by whatever mysqldump is on the machine that ran it. Mine was a MySQL 9.2 client. Loading it with an older client or server is usually fine for plain DDL, but I didn't test that across versions, so dump with a client that matches production if you can.
The squash, in the order that avoids all five
This is the procedure I'd follow on a team:
- Announce it. Get open branches that touch migrations merged first.
- On an up-to-date
main, runphp artisan migrateand checkmigrate:statusshows nothing pending. - If tests run on another connection, generate that dump now, while the files still exist.
- Run
php artisan schema:dump --prune. - Find every migration that inserted data and add one new
insertOrIgnoremigration that restores it. - Run the full test suite, then
migrate:freshon a scratch database with the same engine as production. - Make sure CI and your deploy image have the database client.
- Commit the dump files and the deletions in one commit. Tell everyone to pull and run
migrate.
FAQ
Does schema:dump --prune delete migrations that haven't run?
Yes. It deletes the entire database/migrations directory regardless of what your database has run, and the dump only contains what your database has. Check migrate:status shows nothing pending before you prune.
Why are my tests failing with "no such table" after squashing?
Your tests probably use a different connection, like the stock SQLite :memory: setup. Laravel looks for database/schema/{connection}-schema.sql, silently skips it when it's missing, and there are no migration files left to run. Dump a schema for the test connection before pruning, or run tests on the production engine.
Does the schema dump include seed data?
No. It includes the schema and the rows of the migrations table. Data inserted by migrations is lost on fresh databases, and those migrations are recorded as already run, so they never replay. Restore the rows with a new idempotent migration or move them to a seeder.
Can I roll back past a squashed migration?
No. The pruned migrations and their down() methods are gone, so there's nothing for migrate:rollback to run. Only migrations created after the squash can be rolled back.
Which databases support migration squashing?
MariaDB, MySQL, PostgreSQL and SQLite, according to the Laravel docs. Each uses its command-line client to dump and load. SQL Server isn't supported, and migrate skips schema loading on it entirely.
Should you squash at all?
Squashing buys you a fresh-database path that doesn't replay years of history, and a shorter database/migrations folder. It costs you history in the working tree (git keeps it), rollbacks past the squash point, and the coordination above.
If you're solo or on a small team with no tenant databases and your fresh migrate takes a few seconds, I wouldn't bother. The folder being long isn't a problem that needs solving. If fresh databases get created constantly, in CI, per tenant or per preview environment, and replaying history has become slow or fragile, squash. Do it at a quiet moment, like right after the Laravel 12 to 13 upgrade, and follow the eight steps above in order.
About Hafiz
Senior Full Stack Developer. I build production software with Laravel, Filament, Vue, and AI integrations, and write about the real decisions behind shipping it.
Get in touch →Get web development tips via email
Join 50+ developers • No spam • Unsubscribe anytime
Related Articles
6 Eloquent Patterns That Silently Break MySQL Index Usage (And How to Fix Each One)
Six Eloquent patterns that look innocent but silently bypass your MySQL indexes....
Laravel Pest 4 Testing: The Complete Guide for Laravel 13 Developers
Zero tests, zero confidence. This guide covers Pest 4 with Laravel 13 from insta...
Effortlessly Dockerize Your Laravel & Vue Application: A Step-by-Step Guide
Dockerize your Laravel and Vue.js application with PHP 8.3, MySQL 8.4, Nginx, an...