Package base test case
PackageTestCase replaces the near-identical tests/TestCase.php copied into every Laravel package suite. It extends Orchestra Testbench, runs against in-memory SQLite with foreign-key constraints on, loads migrations by provider class and applies config and model swaps before the providers boot:
use RoundlyConsulting\Testing\PackageTestCase;
abstract class TestCase extends PackageTestCase
{
protected function packageProviders(): array
{
return [CryptoServiceProvider::class, PasskeysServiceProvider::class];
}
protected function migrationSources(): array
{
return [PasskeysServiceProvider::class];
}
protected function configBeforeBoot(): array
{
return ['passkeys.rp.id' => 'example.test'];
}
}Declare your TestCase abstract, never final: Pest generates a class per test file that extends it, so a final TestCase is a PHP fatal the moment the suite loads.
Hooks
| Method | Default | Purpose |
|---|---|---|
packageProviders() | abstract | The service providers under test, in register order. The only method you must implement. |
migrationSources() | [] | Provider classes (resolved to their database/migrations directory by reflection) and/or literal migration directories. |
configBeforeBoot() | [] | Config applied in defineEnvironment(), before the providers boot — the place to pre-seed a key or swap a model. |
connectionAvailable($connection) | — | Public helper: whether a named connection can be reached. Gate real-engine assertions on it. |
Migrations by provider class
migrationSources() accepts provider classes and literal directories. A provider is resolved to its package’s database/migrations directory by reflection, walking up from the provider file — so the same call works for a path-symlinked package and a VCS install. Naming a single migration file is impossible by design; that mistake broke five package suites. A source that is neither a class nor an existing directory fails loudly, and the check costs the test zero assertions:
protected function migrationSources(): array
{
return [
PasskeysServiceProvider::class, // resolved to its database/migrations by reflection
__DIR__.'/Fixtures/database/migrations', // a literal directory works too
];
}Swapping a model before boot
Providers hang observers and listeners on the configured model class at boot, so a model swap must land before boot. Return it from configBeforeBoot(), or call swapModel() in defineEnvironment() before handing over to the parent, which applies every registered swap:
// Either return the swap from the before-boot config window…
protected function configBeforeBoot(): array
{
return ['media.media_model' => CustomMedia::class];
}
// …or register it in defineEnvironment() before the parent applies every swap.
protected function defineEnvironment($app): void
{
$this->swapModel('media.media_model', CustomMedia::class);
parent::defineEnvironment($app);
}State reset without down()
Roundly packages migrate forward only and ship no down() method. Testbench normally resets state by running migrate:rollback after each test — and because Laravel’s migrator skips a missing down() silently, that rollback does nothing on a real engine. The tables survive, and the next test fails with a duplicate-table error that names an innocent migration.
PackageTestCase instead resets a real engine by dropping every table and re-migrating, then purges every connection the test opened, so a Postgres suite doesn’t leak one backend per test. On SQLite :memory: it delegates to Testbench untouched — the connection already is the reset. There is nothing to configure; extending the base case is enough:
abstract class TestCase extends PackageTestCase
{
protected function packageProviders(): array { return [WalletServiceProvider::class]; }
protected function migrationSources(): array { return [WalletServiceProvider::class]; }
}- Suites using RefreshDatabase (or LazilyRefreshDatabase) are left alone — they own their reset.
- Suites that ship no migrations are still reset: a fixture table built by hand in one test never survives into the next.
- Why not RefreshDatabase by default: it wraps each test in a transaction, adding a transaction level — and the lock recorders assert on transactionDepth. A drop is pure DDL and opens no transaction.
Writing your own TestCase
If you don’t extend PackageTestCase, call DriverMatrix::configure($app) in your own defineEnvironment() and reproduce the drop-based reset yourself — it is the one thing a real-engine leg cannot work without.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.