othmanhaba / laravel-test-container
Run your Laravel test suite in parallel against a disposable MySQL or PostgreSQL container.
Package info
github.com/OthmanHaba/laravel-test-container
pkg:composer/othmanhaba/laravel-test-container
Requires
- php: ^8.2
- illuminate/console: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
- symfony/process: ^7.0
- testcontainers/testcontainers: ^1.0
Requires (Dev)
- phpunit/phpunit: ^11.0
README
Run your Laravel test suite in parallel against a disposable MySQL or PostgreSQL container. Boots the database, runs the tests, throws the container away.
php artisan test:container
Starting mysql:8.4 ...
Ready on 127.0.0.1:32771
PASS Tests\Feature\UserTest
✓ it registers a user
Tests: 42 passed (108 assertions)
No container left behind — including when you hit Ctrl-C.
Install
composer require --dev othmanhaba/laravel-test-container
Docker must be running. That's the whole setup — the config file is optional.
Usage
Every option it doesn't recognise is forwarded to artisan test, so what you
already know keeps working:
php artisan test:container --filter=UserTest php artisan test:container -p 8 php artisan test:container --driver=pgsql php artisan test:container --tag=8.0
Its own options:
| Option | Effect |
|---|---|
--driver= |
mysql or pgsql. Defaults to config. |
--tag= |
Image tag, e.g. --tag=8.0. Defaults to mysql:8.4 / postgres:16. |
--reuse |
Keep the container alive for the next run. |
--no-parallel |
Single process, for when parallel output makes debugging hard. |
--skip-container-check |
Skip the post-run check described below. |
Publish the config if you want different defaults:
php artisan vendor:publish --tag=test-container-config
How it works
One container, many databases. Laravel's --parallel mode already gives
each worker its own schema — testing_test_1, testing_test_2, and so on —
created and migrated for you. So this package boots one database server and
lets Laravel do the rest. Eight containers for eight workers would cost a minute
of boot time and several gigabytes of RAM for no isolation you don't already
have.
Because Laravel creates those databases at runtime, the container's superuser is
used. Don't swap it for a restricted account; parallel mode needs
CREATE DATABASE.
The connection is injected through a service provider, not DB_* env vars.
This is the part that isn't obvious. A typical phpunit.xml contains:
<env name="DB_CONNECTION" value="sqlite" force="true"/>
With force="true", PHPUnit overwrites any environment variable we export. The
suite then runs green against in-memory SQLite while a MySQL container sits idle
next to it — passing, and testing nothing you booted. So the connection is
applied in a service provider instead, which registers when the app boots
inside each worker, after PHPUnit is done setting env vars. It also layers
over cached config, so config:cache is harmless.
And it's verified afterwards. Once the suite finishes, the command checks
that worker databases actually exist in the container. If nothing was written,
it fails with a pointer at your phpunit.xml rather than reporting a green run.
Disposable means disposable. The data directory is a tmpfs and durability is
turned off (innodb-flush-log-at-trx-commit=0, fsync=off), which is free
speed when the data is discarded regardless.
The host port is chosen by Docker, never guessed. The container is published on port 0, which means "allocate a free port and bind it" in one atomic step. Your locally-installed MySQL keeps 3306, your Postgres keeps 5432, other projects' containers keep theirs, and two runs of this command at the same time cannot collide. Nothing probes a port and hopes it is still free a moment later.
Cleanup
| Situation | Handled by |
|---|---|
| Tests finish, pass or fail | finally |
| Uncaught exception | finally |
Ctrl-C, kill |
Signal handler |
| PHP fatal error | Shutdown backstop |
kill -9, machine loses power |
Nothing — run test:container:prune |
php artisan test:container:prune
Removes anything this package left running. Also the reset button for a stale
--reuse container.
--reuse
Skips the ~8s boot by keeping the container between runs:
php artisan test:container --reuse
The trade-off is real and worth stating: state survives from the previous run.
If you change migrations while using --reuse, expect confusing failures.
test:container:prune clears it.
Requirements
- PHP 8.2+
- Laravel 11 or 12
- Docker
On Windows, pcntl is unavailable, so Ctrl-C won't clean up. test:container:prune
covers it.
Testing this package
composer install vendor/bin/phpunit --testsuite=Unit # fast, no Docker vendor/bin/phpunit --testsuite=Integration # boots a real container
License
MIT