From Schema Dumps to Migrations: Laravel Package Testing
- Date
- Takeshi Yu
In enterprise application development, code modularity and testability are crucial. When I began refactoring a large, complex legacy system into modular packages based on PHP 8.4, I assumed setting up the testing environment would be a simple step. However, this seemingly mundane task consumed an entire day and taught me valuable lessons about testing Laravel packages.
My starting point was straightforward:
Refactor an overly complex legacy system into modular packages based on PHP 8.4 Gradually migrate functionality into independent packages for better maintainability Add comprehensive tests for each component to ensure system stability Make these modules reusable across existing and future projects
Since I needed compatibility with the existing system, I needed to use the exact same database structure in the testing environment as in production. The most intuitive approach was to use Laravel's schema dump feature to export the existing database structure and then rebuild it in the testing environment.
First Attempt: Schema Dump Loading Challenges
As a Laravel developer, I naturally chose Orchestra Testbench as my testing tool. It can simulate a Laravel application environment, making it perfect for package development. My initial plan was direct:
- Use Laravel's php artisan
schema:dump
to export the database structure - Save the
mysql-schema.sql
file in thedatabase/schema
directory - Load this schema file when starting tests
But I quickly discovered that things weren't as simple as I had imagined.
The Environment Variables Puzzle
The first issue appeared when reading environment variables:
// In TestCase.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', ''), // Couldn't read password from .env!
'prefix' => '',
]);
});
}
I was confused when the env()
function couldn't read the database credentials I had set in my .env file. Later I found out this is actually expected behavior for Laravel package development tools. According to the official documentation:
env limitation
The env environment variables are only applied when using the CLI and will not be used when running tests.
This explanation from the Laravel Package Development official documentation clearly explains why my environment variables weren't working — they simply aren't applied when running tests! This meant Orchestra Testbench doesn't automatically load the .env
file from the package root directory, and I needed to find another solution. After trying several approaches, I eventually used Dotenv to load it directly:
protected function setUp(): void
{
\Dotenv\Dotenv::createImmutable(__DIR__.'/../')->load();
parent::setUp();
// Other initialization...
}
The Schema Loading Dilemma
After solving the environment variables issue, I encountered a more challenging problem: loading the schema file in the TestCase. I naively thought this should be as simple as in a standard Laravel application. However, that wasn't the case. I tried loading the schema directly in the setUp method:
protected function loadDBSchema()
{
$schemaPath = __DIR__.'/../database/schema/mysql-schema.sql';
if (file_exists($schemaPath)) {
\Illuminate\Support\Facades\DB::unprepared(file_get_contents($schemaPath));
}
}
But this led to a confusing error:
RuntimeException: A facade root has not been set.
This indicated that the DB Facade wasn't properly initialized when I tried to use it. More challenging still, when I solved the Facade issue, I encountered foreign key constraint problems:
QueryException: SQLSTATE[HY000]: General error: 1824 Failed to open the referenced table 'tenants'
This happened because the table creation order in the schema file didn't match the foreign key constraints.
The RefreshDatabase Revelation
After multiple attempts, I began to suspect that the RefreshDatabase trait might be conflicting with my schema loading method. Indeed, this was the core of the problem.
// pest.php
pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('*');
The RefreshDatabase
trait resets the database structure during testing, which means:
- Even if I successfully loaded the schema,
RefreshDatabase
might reset it afterward - Or,
RefreshDatabase
might initialize the database before my schema was loaded
The Final Solution: Using Migrations Instead of Schema Dumps
Orchestra Testbench actually provides a better way to handle this situation — using standard Laravel migrations instead of directly executing schema dump files.
The key was using Testbench's defineDatabaseMigrations
method and the workbench_path
helper function:
/**
* Define database migrations.
*
* @return void
*/
protected function defineDatabaseMigrations()
{
$this->loadMigrationsFrom(
workbench_path('database/migrations')
);
}
This method allows me to separate test-specific migrations from the package's own migrations, placing them in the workbench/database/migrations
directory. Specifically, I created a migration file named 0000_00_00_000000_import_schema.php
to ensure it would 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
{
//
}
};
This migration file is simple yet effective: it reads and executes the schema dump file in the up()
method, without needing to do anything in the down()
method.
My solution included:
- Creating migration files in the
workbench/database/migrations
directory to rebuild the same database structure as production - Using the
defineDatabaseMigrations
method inTestCase.php
to load these migrations - Setting environment variables in
phpunit.xml.dist
, and copying it tophpunit.xml
for local development or CI/CD environments
<!-- 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=""/>
</php>
The advantages of this approach are:
- Fully following Laravel's migration system, avoiding the difficulties of directly executing SQL
- Seamless integration with the
RefreshDatabase
trait - Separation of test-specific migrations from the package's own migrations, maintaining a clear structure
- Environment variables set through
phpunit.xml
, avoiding hardcoded sensitive information
Lessons Learned and Best Practices
This day, though frustrating, gave me a deep understanding of Laravel package testing best practices:
- Use Migrations Instead of Schema Dumps: Although using schema dumps directly might seem more straightforward, using standard Laravel migrations avoids many hidden issues.
- Properly Separate Test Environments: Use workbench_path to separate test-specific migrations from the package's own functionality.
- Handle Environment Variables Correctly: Set environment variables through phpunit.xml, rather than relying on .env files.
- Understand Testbench's Lifecycle: Orchestra Testbench has subtle but crucial differences from standard Laravel applications.
- Bootstrap Process Differences: Testbench creates a streamlined version of a Laravel application that doesn't execute all the startup processes of a standard Laravel application.
- Facade Initialization Timing: In Testbench, Facade initialization occurs at specific times. Using them too early (like in the setUp method) can lead to "A facade root has not been set" errors.
- RefreshDatabase Execution Order: This trait resets the database before each test method executes, but the actual database reset happens before the first test (initial migration) and before each subsequent test (rolling back to the initial state and re-running migrations).
- Environment Variable Handling: As mentioned earlier, the env() function behaves differently in tests compared to normal Laravel applications.
- Service Provider Loading Timing: The registration and booting of your package service providers also differs from standard Laravel applications and must be explicitly specified through the getPackageProviders method.
Understanding these differences can help you avoid many difficult-to-diagnose issues and design more stable and reliable tests.