NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Testing for Laravel

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

MethodDefaultPurpose
packageProviders()abstractThe 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.
keepsMigratedSchema()falseReturn true to migrate once per schema signature and reset only the data between tests — see Keeping the migrated schema below.
schemaSignatureExtras()[]Anything else your migrations read that the signature can’t see — an env var, a static flag. Only matters when the schema is kept.
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 and stopping at the package root (its composer.json) — so the same call works for a path-symlinked package and a VCS install, and a provider with no migrations of its own fails loudly instead of loading the host app’s set. 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: the connection already is the reset, so since 1.6 the rollback Testbench would run for each migrationSources() entry is skipped as well — it only deleted the migrations rows of a database about to vanish, and cost about a fifth of a SQLite suite’s time (tickets-for-laravel: 83 s to 60 s). A migrator a test adds itself (loadMigrationsFrom(), loadLaravelMigrations()) still rolls back. 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.

Keeping the migrated schema (opt-in)

Since 1.6. By default every test migrates every source and every table is dropped after it. With many migration sources, or on a real engine, that is most of the suite’s time: tickets-for-laravel ran 1061 migrates over 9 sources, and the per-test migrate was about 90 % of its MySQL leg. Opt in and a worker migrates once per schema signature, then resets only the data:

abstract class TestCase extends PackageTestCase
{
    protected function keepsMigratedSchema(): bool
    {
        return true;
    }

    // Only if a migration reads something the signature cannot see:
    protected function schemaSignatureExtras(): array
    {
        return ['tenancy' => env('PKG_TENANCY')];
    }
}

When to opt in

  • Many migration sources — a package whose suite migrates its own schema plus several dependencies’ (tickets-for-laravel: 9 sources, about 35 tables).
  • Real-engine legs (test-pgsql, test-mysql) — a migrate there costs DDL round trips per table, per test. The tickets-for-laravel MySQL leg went from 32 min to 1 min 40 and its Postgres leg from 5 min 10 to 1 min 26, with every race test still running.
  • Not worth it for a suite with one small migration source on SQLite only — the default reset is already cheap there.

What each test still gets

PromiseHow
Starts on empty tablesAfter each test every table it wrote is emptied — DELETE on MySQL and SQLite, TRUNCATE … CASCADE on Postgres — with foreign keys handled.
Ids as a fresh migrate gives themEvery key counter goes back to where the migrate left it — MySQL AUTO_INCREMENT, Postgres sequences (setval, startingValue() included), SQLite sqlite_sequence — including counters a rolled-back insert advanced.
No wrapping transactionThe test’s transactionLevel() is its own, so LockRecorder’s transactionDepth and forked racers behave exactly as with the drop reset.
A changed schema is never reusedA catalogue check after each test compares the schema with the post-migrate snapshot; any difference throws the schema away and the next test migrates from scratch.
Seeded rows surviveIf the migrations write rows, the harness sees them after the first migrate and keeps the drop-per-test reset for that schema — no test loses its seeded rows.
The database ends emptyWhen the worker exits it drops its tables, as the drop reset left them; a fresh migrate also drops leftovers from a killed run first.

Schema signature and supported engines

The signature is the connection (driver, worker database, options), the migration directories in order, the loaded providers in order, the whole config repository, the schema builder’s static defaults (defaultStringLength, defaultMorphKeyType, defaultTimePrecision) and schemaSignatureExtras(). Config objects compare by class — an enum by case — so two closures look alike. A test with another signature — a uuid or ulid key leg, a swapped model, other config — throws the kept schema away and migrates its own. A worker keeps one schema at a time.

Supported on MySQL, MariaDB, PostgreSQL and in-memory SQLite (:memory:). A file or shared-cache SQLite database, SQL Server, a connection with a table prefix and any suite using RefreshDatabase keep the drop-per-test reset — the opt-in is then a no-op.

A test case with no migrationSources() has nothing to keep and stays on the drop-per-test reset too, so a fixture table a test builds by hand is still gone before the next test. Fixed in 1.6.1: 1.6.0 failed every database test there with “no such table: migrations” — on 1.6.0, override keepsMigratedSchema() to false in that test case.

What the catalogue check covers

Scoped to the connection’s own schema (Postgres) or database (MySQL):

  • Every engine — each table and its columns (position, type, nullability, default), indexes, primary, unique, foreign and check constraints, views, triggers and every row of the migrations table.
  • PostgreSQL — also each relation’s kind, identity and generated columns, view definitions and each sequence’s type, bounds, start and step.
  • MySQL — also each table’s engine and collation, and each column’s collation and extra.
  • SQLite — the DDL of every object in sqlite_master and sqlite_temp_master (a create temp table included), the foreign_keys switch and attached databases.

Not covered — a test that changes one of these must undo it itself, exactly as with the drop reset, which doesn’t remove them either: Postgres types (enums, domains), functions, extensions and other schemas; MySQL stored procedures, functions, events and grants; and on in-memory SQLite the rest of the shared connection’s session state — other pragmas (recursive_triggers, case_sensitive_like, …) and functions registered on the PDO. On SQLite the kept database is one PDO handed to every test; on a real engine each test’s session closes after it, so session state can’t leak there.

Risks and rules

  • Forked children must end with SIGKILL. A racer forked with pcntl_fork() shares the parent’s database sessions — the test’s and the kept schema’s. A child that calls exit() or returns runs its destructors, which close those sessions on the server. End it with posix_kill(posix_getpid(), SIGKILL) after writing its result, and give it a connection of its own (DriverMatrix::connectionConfig() under another name). If a child closes the kept session anyway, the next reset refuses and the next test migrates from scratch — never a half-reset schema.
  • State a migration reads that the signature can’t see — an env var, a static flag, an object’s state — belongs in schemaSignatureExtras(). Otherwise two tests that would migrate differently share a schema, and the catalogue check can’t tell: it compares against the first.
  • Other test cases on the same real-engine database: a test that doesn’t keep its schema discards the kept one before it migrates, so mixed suites work — as long as every test case goes through PackageTestCase::defineDatabaseMigrations(). One that bypasses it on the same worker database sees the kept tables.
  • Seeding migrations get no speed-up — they keep the drop reset. Seed in the test or in defineDatabaseSeeders() instead if you want one.

Pitfalls

The first two are silent: the suite stays green and correct, it just migrates for every test, as if it had never opted in — the symptom is a real-engine leg that is no faster. The third turns a test red.

A random config value per test. The signature holds the whole config, so a configBeforeBoot() that generates a fresh app.key — or any other random value — for every test gives every test a new signature, and nothing is ever kept: advertisements-for-laravel migrated 443 times in 443 tests. Generate the value once per process:

private static ?string $appKey = null;

protected function configBeforeBoot(): array
{
    return ['app.key' => self::$appKey ??= 'base64:'.base64_encode(random_bytes(32))];
}

Fixture tables built outside migrationSources(). A defineDatabaseMigrations() override that creates fixture tables (or migrates more) after parent::defineDatabaseMigrations(), or a #[DefineDatabase] attribute, changes the schema after the snapshot, so the catalogue check sees drift and throws the schema away after every test. Put the fixture tables in a migration directory of their own and list it in migrationSources() — addresses-for-laravel moved its test_models table to tests/database/migrations/:

protected function migrationSources(): array
{
    return [AddressesServiceProvider::class, __DIR__.'/database/migrations'];
}

Event::fake() plus assertNothingDispatched(). Such a test can pass only because the per-test migrate booted the package’s models before Event::fake() ran. With the schema kept, the test’s own queries boot them while the fake is active, and the fake records Eloquent’s internal eloquent.booting and eloquent.booted events — so assertNothingDispatched() fails on events the code under test never fired. Fake only the events you care about (Event::fake([OrderPlaced::class])), or fake right before the action under test and assert right after it.

CI for real-engine legs

A kept schema removes the per-test migrate; the engine’s own durability is the next cost. Start the engine with docker run as the job’s first step — it boots while PHP installs — on tmpfs with durability off. The planner, isolation levels, locks and limits are unchanged; nothing a test leg proves depends on surviving a power cut:

test-pgsql:
  runs-on: ubuntu-latest
  name: P8.4 - pgsql real-engine
  steps:
    - name: Start postgres
      run: >-
        docker run -d --name postgres --tmpfs /var/lib/postgresql/data
        -e POSTGRES_DB=testing -e POSTGRES_USER=testing -e POSTGRES_PASSWORD=secret
        -p 5432:5432 postgres:16
        -c fsync=off -c synchronous_commit=off -c full_page_writes=off
    # … checkout, setup-php (pgsql, pdo_pgsql, pcntl; coverage: none), composer update …
    # The image's init server listens on the socket only, so a TCP answer means the real one is up.
    - name: Wait for postgres
      run: |
        for _ in $(seq 1 90); do
          docker exec postgres psql -h 127.0.0.1 -U testing -d testing -c 'select 1' >/dev/null 2>&1 && exit 0
          sleep 1
        done
        docker logs postgres
        exit 1
    - name: Execute tests against postgres
      env:
        TESTING_DB_DRIVER: pgsql
        TESTING_DB_HOST: 127.0.0.1
        TESTING_DB_PORT: 5432
        TESTING_DB_DATABASE: testing
        TESTING_DB_USERNAME: testing
        TESTING_DB_PASSWORD: secret
      run: vendor/bin/pest --ci --parallel

MySQL, where a package needs a test-mysql leg — performance_schema stays on, because race tests read data_lock_waits:

- name: Start mysql
  run: >-
    docker run -d --name mysql --tmpfs /var/lib/mysql
    -e MYSQL_DATABASE=testing -e MYSQL_ROOT_PASSWORD=secret
    -p 3306:3306 mysql:8
    --skip-log-bin --sync-binlog=0 --innodb-flush-log-at-trx-commit=0 --innodb-doublewrite=OFF
# wait: docker exec mysql mysql -h 127.0.0.1 -uroot -psecret -e 'select 1' testing
# env: TESTING_DB_DRIVER: mysql, TESTING_DB_PORT: 3306, TESTING_DB_USERNAME: root

Drop --skip-log-bin if the suite tests binary-log behaviour (grep the tests for log_bin or binlog) — lifecycle’s StatementBinlogTest skips silently without a binary log — and keep --sync-binlog=0. Compare the real-engine skip count before and after switching; that is how it was caught.

--parallel gives each worker its own database (<database>_<token>), so each keeps its own schema. The package’s own pgsql leg adds one step after the tests: it fails when a table is left in the database, which is what proves the worker’s drop on exit.

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 crypto

By 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.