Wu-Hsien Yu/ Writing
Writing

From Migrations Back to Schema Dumps: The Laravel-Native Path

A year ago I wrote From Schema Dumps to Migrations, documenting a day lost to a seemingly trivial task: loading a production schema dump into an Orchestra Testbench test suite. The conclusion back then was to stop executing the dump directly and wrap it inside a migration instead.

Recently I walked the same ground again while setting up another package's test suite — this time tracing what actually happens inside migrate instead of fighting it. The original diagnosis still stands, and the original fix still works. But if I were doing it today, I would use a mechanism Laravel has shipped and documented all along. This postscript records what changed.


What the original article got right

The root cause I identified was correct: a schema dump cannot be executed around the migration lifecycle; it has to be executed inside it. Calling DB::unprepared() from setUp() fails for reasons that all trace back to timing:

  • It runs on every test rather than once per process.
  • It fights RefreshDatabase: the DDL statements inside the dump trigger MySQL's implicit commits, silently ending the transaction the trait wraps around each test, so state leaks between tests.
  • PDO::exec() on a multi-statement string only reports an error from the first statement — a failure in the middle of the dump dies silently and leaves a half-imported schema.

Wrapping the dump in a 0000_00_00_000000_import_schema.php migration fixed the two timing problems: migrations run once per process, and they run before RefreshDatabase opens its per-test transaction. That is exactly why it worked — and, as it turns out, it is exactly what Laravel's own mechanism does.

What I missed: Laravel already ships this hook

Laravel's migration documentation, under Squashing Migrations, spells out a contract I had never connected to this problem:

When you attempt to migrate your database and no other migrations have been executed, Laravel will first execute the SQL statements in the schema file of the database connection you are using. After executing the schema file's SQL statements, Laravel will execute any remaining migrations that were not part of the schema dump.

The docs even cover the testing scenario explicitly — "so that your tests are able to build your database" — and note that the feature utilizes the database's command-line client.

Three things I had not registered:

  1. The loading is keyed off a conventional path: database_path('schema/{connection}-schema.sql').
  2. It fires when the migrations table is empty — which under migrate:fresh means precisely once per process, before RefreshDatabase starts wrapping tests in transactions.
  3. The file is executed by the mysql CLI client, not by PDO.

In other words: the hook point I hand-rolled as a migration is a built-in. My import migration was its userland re-implementation, with a different execution channel.

Why it never seemed available in Testbench

There is a reason this mechanism looks unusable in package testing. In a Testbench run, the "application" is the skeleton inside vendor/orchestra/testbench-core/laravel/, so database_path() resolves to a directory you cannot commit files into — Composer rebuilds it at will.

The missing glue is one line. Illuminate\Foundation\Application exposes useDatabasePath(), which repoints database_path() (and the path.database container binding) anywhere you like:

php
<?php

declare(strict_types=1);

namespace Vendor\Package\Tests;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Orchestra\Testbench\TestCase as Orchestra;
use Vendor\Package\PackageServiceProvider;

abstract class TestCase extends Orchestra
{
    use RefreshDatabase;

    protected function getPackageProviders($app): array
    {
        return [
            PackageServiceProvider::class,
        ];
    }

    protected function defineEnvironment($app): void
    {
        $app->useDatabasePath(__DIR__.'/database');
    }
}
tests/
├── TestCase.php
└── database/
    └── schema/
        └── mysql-schema.sql

The database credentials still live in phpunit.xml.dist, unchanged from the original article. useDatabasePath() is not something the docs advertise, but it is a stable public API the framework itself relies on. Everything else — the loading condition, the ordering, the RefreshDatabase interplay — is the documented Laravel contract doing its job inside the real Laravel application Testbench boots.

Difference

Both my migration wrapper and the native path load the dump once, at the right point in the lifecycle. The difference is who executes the SQL: my wrapper hands the whole file to PDO::exec(); Laravel's MySqlSchemaState::load() shells out to the real client — mysql ... < schema.sql. That difference has two concrete consequences.

Error reporting. PDO::exec() swallows errors after the first statement of a multi-statement string, so a failure at statement 500 leaves a half-built schema and test failures pointing in the wrong direction. The CLI executes statement by statement and fails loudly with a non-zero exit code.

Size. file_get_contents() loads the whole dump into PHP memory and ships it as one giant packet — bounded by memory_limit on one side and the server's max_allowed_packet on the other. The CLI streams.

It composes with Workbench, too

If your tests use WithWorkbench, the ordering is guaranteed by testbench-core:

  • workbench migration paths and seeders are registered first
  • then RefreshDatabase runs migrate:fresh — dump load, then remaining migrations (including workbench ones)
  • then seeders on DatabaseRefreshed. Workbench material stacks cleanly on top of the dump.

One caveat: the schema file must carry the migrations table state — a file produced by schema:dump does. Otherwise every migration looks pending and re-runs straight into tables the dump already created.

The lesson, one year later

What looked like a missing feature was a path misdirection.

Related writing

2026.04.20Kindie - Devlog 012026.01.21Building a Custom Label Printing System: From Loftware to GoDex2025.11.20laravel-bcmath-cast:A Reusable Package for Precise Decimal Casting in Eloquent