Wu-Hsien Yu/ Writing
Writing

From Schema Dumps to Migrations: Laravel Package Testing

I recently tried to refactor our ERP system into a standalone package, so that it would no longer be exposed to whatever a Laravel upgrade might break, and to clean up the tangle between the existing modules while I was at it.

The conclusion first: the plan failed. I did the thing Uncle Bob describes, standing up a Tiger Team to rewrite the system, except the team was one person. Predictably, I failed. This post is not a record of that failure, though. It is a record of what was worth keeping from the migration to a package.

To give the refactor a chance, the first thing I set up was the test environment. The idea was that the package's tests could be cross-checked against the existing system's tests, which meant the test database had to match the current system. But I had not kept the migrations accumulated over the years. I had mostly used Laravel's Squashing Migrations to remove them and generate a database schema instead.

So the idea was simple:

  • Use Orchestra Testbench as the testing tool for package development
  • Put the schema inside the package, and rebuild the database from it on every test run

It did not go as smoothly as expected.

Problem: .env is not being read

While setting up the database connection in TestCase.php, the environment variables came back empty:

php
protected function defineEnvironment($app): void
{
    tap($app['config'], function (Repository $config) {
        $config->set('database.default', 'testing');
        $config->set('database.connections.testing', [
            'driver' => 'mysql',
            'host' => env('DB_HOST', '127.0.0.1'),
            'port' => env('DB_PORT', 3306),
            'database' => env('DB_DATABASE', 'testbench'),
            'username' => env('DB_USERNAME', 'root'),
            'password' => env('DB_PASSWORD', ''),
            'prefix' => '',
        ]);
    });
}

DB_PASSWORD always returned an empty string. Tracing it line by line led to Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables:

php
protected function createDotenv($app)
{
    return Dotenv::create(
        Env::getRepository(),
        $app->environmentPath(),
        $app->environmentFile()
    );
}

environmentPath() defaults to basePath(), and under Testbench, basePath() is:

php
public static function applicationBasePath()
{
    return static::applicationBasePathUsingWorkbench() ?? default_skeleton_path();
}

default_skeleton_path() points at vendor/orchestra/testbench-core/laravel, the Laravel skeleton Testbench uses to simulate an application. So the bootstrapper looks for vendor/orchestra/testbench-core/laravel/.env, not the .env in my package directory. And that skeleton directory will never hold a .env: its .gitignore already excludes .env and .env.*, and the whole thing is managed by Composer and can be rebuilt at any time. There is nowhere in there to keep a .env.

Testbench overrides createDotenv() itself:

php
final class LoadEnvironmentVariables extends \Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables
{
    protected function createDotenv($app)
    {
        if (! is_file(join_paths($app->environmentPath(), $app->environmentFile()))) {
            return Dotenv::create(
                Env::getRepository(),
                (string) realpath(join_paths(__DIR__, 'stubs')),
                '.env.testbench'
            );
        }

        return parent::createDotenv($app);
    }
}

stubs/.env.testbench is an empty file, so Dotenv always has a valid path to point at and never throws for a missing file.

So the .env does get loaded. It is just not the .env in my package. What gets loaded, in the end, is an empty file inside Testbench.

How to get a .env

Looking at applicationBasePath() again:

php
return static::applicationBasePathUsingWorkbench() ?? default_skeleton_path();

In other words, if there is a base path, default_skeleton_path() never runs, and there is somewhere to put a .env. There are a few ways to get one.

testbench.yaml

Set laravel in testbench.yaml:

yaml
laravel: ./skeleton

This points at a skeleton directory you maintain yourself, and ./skeleton/.env will load normally. The TestCase also has to use the WithWorkbench trait, otherwise the laravel setting in testbench.yaml has no effect inside tests. It only applies to the vendor/bin/testbench CLI.

phpunit.xml

Alternatively, set the environment variable directly in phpunit.xml. It takes precedence over the previous option and is not subject to the WithWorkbench trait, because what gets read is $_ENV:

xml
<env name="APP_BASE_PATH" value="./skeleton"/>

I went with this one in the end, because it means I do not have to maintain an extra directory.

Dotenv

If you really do want the .env in the package directory, loading Dotenv yourself works too.

php
protected function setUp(): void
{
    \Dotenv\Dotenv::createImmutable(__DIR__.'/../')->safeLoad();

    parent::setUp();

    // Other initialization...
}

One thing to note here: calling $app->useEnvironmentPath() inside setUp() does nothing, because the bootstrapper has already run by then.

Problem: A facade root has not been set

With the environment variables sorted out, the next step was loading the schema in the TestCase:

php
protected function setUp()
{
    $schemaPath = __DIR__.'/../database/schema/mysql-schema.sql';

    if (file_exists($schemaPath)) {
        \Illuminate\Support\Facades\DB::unprepared(file_get_contents($schemaPath));
    }

    parent::setUp();
}

Putting it in setUp() was meant to build the existing database structure before the migrations ran. Instead:

bash
RuntimeException: A facade root has not been set.

Why would the Facade be empty? That can only happen while the container has not finished initializing. But I had already swapped \PHPUnit\Framework\TestCase for Tests\TestCase, so this should not have happened. It turned out I had written it backwards. parent::setUp() has to be called first so that createApplication() completes, and only then can I use a Facade:

php
protected function setUp()
{
    parent::setUp();

    $schemaPath = __DIR__.'/../database/schema/mysql-schema.sql';

    if (file_exists($schemaPath)) {
        \Illuminate\Support\Facades\DB::unprepared(file_get_contents($schemaPath));
    }
}

Problem: schema conflicts

The order in which the schema file creates tables does not line up with the foreign key constraints.

bash
QueryException: SQLSTATE[HY000]: General error: 1824 Failed to open the referenced table 'tenants'

After a few attempts, the likely cause was a conflict between the RefreshDatabase trait and the timing of the schema load:

  • the schema loaded successfully, but RefreshDatabase reset it afterwards
  • RefreshDatabase initialized the database before the schema was loaded

This was genuinely awkward, since it meant controlling the order between the two by hand. Which raised the question: how does Laravel do it? From the documentation:

when you attempt to migrate your database and no other migrations have been executed, Laravel will first execute the schema file's SQL statements 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

Source: Database: Migrations (Squashing Migrations)

What if I moved the schema loading step into Laravel's own flow?

The fix

Testbench provides the mechanism for that: the defineDatabaseMigrations method and the workbench_path helper.

php
protected function defineDatabaseMigrations()
{
    $this->loadMigrationsFrom(
        workbench_path('database/migrations')
    );
}

This keeps the test-only migrations separate from the package's own migrations, in workbench/database/migrations. Next, a migration named 0000_00_00_000000_import_schema.php, so it is guaranteed to run first:

php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration {
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        $schemaPath = __DIR__.'/../schema/mysql-schema.sql';

        if (file_exists($schemaPath)) {
            DB::unprepared(file_get_contents($schemaPath));
        }
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        //
    }
};

As mentioned earlier, the database connection settings go into phpunit.xml.dist, which local machines and CI each copy into their own phpunit.xml:

xml
<!-- phpunit.xml.dist -->
<php>
    <env name="DB_CONNECTION" value="mysql"/>
    <env name="DB_HOST" value="127.0.0.1"/>
    <env name="DB_PORT" value="3306"/>
    <env name="DB_DATABASE" value="testbench"/>
    <env name="DB_USERNAME" value="root"/>
    <env name="DB_PASSWORD" value="PASSWORD_HERE"/>
</php>

What this approach buys:

  • it goes through Laravel's migration flow, sidestepping the timing problem of executing SQL directly
  • it is compatible with RefreshDatabase
  • test-only migrations stay separate from the package's own migrations
  • connection settings live in phpunit.xml instead of being hardcoded

Closing

Looking back, what the day was actually worth was getting clear on the difference between Testbench and a standard Laravel application. I had assumed a package's test environment was a smaller Laravel application. It is not. It is a skeleton managed by Composer that can be rebuilt at any time, and any mechanism that depends on a file sitting in the project root does not hold there.

Related writing

2026.08.04From Migrations Back to Schema Dumps: The Laravel-Native Path2026.04.20Kindie - Devlog 012026.01.21Building a Custom Label Printing System: From Loftware to GoDex