DEV Community

Cover image for Squashing Laravel Migrations: 5 Ways schema:dump --prune Bites a Real Team
Hafiz
Hafiz

Posted on Originally published at hafiz.dev

Squashing Laravel Migrations: 5 Ways schema:dump --prune Bites a Real Team

Originally published at hafiz.dev


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
Enter fullscreen mode Exit fullscreen mode

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.

View the interactive component on hafiz.dev

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
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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.
Enter fullscreen mode Exit fullscreen mode

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:"/>
Enter fullscreen mode Exit fullscreen mode

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:, ...)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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],
]);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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],
        ]);
    }
};
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. Announce it. Get open branches that touch migrations merged first.
  2. On an up-to-date main, run php artisan migrate and check migrate:status shows nothing pending.
  3. If tests run on another connection, generate that dump now, while the files still exist.
  4. Run php artisan schema:dump --prune.
  5. Find every migration that inserted data and add one new insertOrIgnore migration that restores it.
  6. Run the full test suite, then migrate:fresh on a scratch database with the same engine as production.
  7. Make sure CI and your deploy image have the database client.
  8. 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.

Top comments (0)