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:
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:
protected function createDotenv($app)
{
return Dotenv::create(
Env::getRepository(),
$app->environmentPath(),
$app->environmentFile()
);
}environmentPath() defaults to basePath(), and under Testbench, basePath() is:
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:
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:
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:
laravel: ./skeletonThis 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:
<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.
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:
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:
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:
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.
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
RefreshDatabasereset it afterwards RefreshDatabaseinitialized 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.
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
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:
<!-- 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.xmlinstead 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.