amjadiqbal / refresher
Faster Laravel tests: a drop-in for RefreshDatabase that caches the migrated (and seeded) test database as a snapshot and restores it instead of running migrate:fresh. MySQL, MariaDB and SQLite, PHPUnit and Pest.
Requires
- php: ^8.3
- composer-runtime-api: ^2.2
- illuminate/console: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- symfony/process: ^7.2|^8.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.20
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.8|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Refresher: faster Laravel tests without migrate:fresh
A drop-in replacement for Laravel's RefreshDatabase and LazilyRefreshDatabase test traits. The first
test process migrates (and seeds) your test database as usual and saves it as a snapshot. Every
process after that restores the snapshot instead of running migrate:fresh again, until a migration,
seeder or factory changes. Works with PHPUnit and Pest, MySQL, MariaDB and SQLite.
// tests/Pest.php pest()->extend(TestCase::class)->use(AmjadIqbal\Refresher\Testing\RefreshesWithRefresher::class)->in('Feature');
That is the whole setup. Your tests, factories and assertions do not change.
Docs and demo: amjad.com.pk/laravel/packages/refresher
What it saves
Measured on two real open-source Laravel apps, with their own unchanged test suites, swapping only the
trait. Each number is the wall time of one pest <one test file> process (what you pay every time
you run a single test while you work), median of alternating runs on the same machine.
| App | Database | Setup per process | RefreshDatabase |
Refresher | Saved |
|---|---|---|---|---|---|
| Pinkary (Laravel 13.35) | MySQL 9.7 | 60 migrations | 3.95 s | 1.87 s | 2.1 s (53%) |
| Filament Demo (Laravel 12.69) | SQLite | 37 migrations | 1.37 s | 1.18 s | 0.19 s (14%) |
Filament Demo, tests seeded with its own DatabaseSeeder |
SQLite | 37 migrations + seeders | 43.3 s | 0.80 s | 42.5 s (98%) |
- The saving is per test process: every single-test run, every CI job and every
--parallelworker. The rest of the suite runs exactly as before; in one long serial run the saving happens once. - Both suites gave identical results with either trait: Pinkary 1,604 tests (serial and with
--parallel --processes=8), Filament Demo 475 tests and 2,707 assertions. - The bigger your migrations and seeders, the bigger the saving. A small SQLite schema with no seeders saves a fraction of a second.
How it was measured, and the raw numbers: BENCHMARKS.md.
Why this package
RefreshDatabase runs migrate:fresh (plus your seeders, if you seed) once in every test
process: every php artisan test --filter=... while you work, every worker of --parallel, every CI
job. On a real app that is hundreds of CREATE TABLE statements before the first assertion runs.
- plannr/laravel-fast-refresh-database
(last release April 2024) skips
migrate:freshwhen a checksum file instorage/matches. It never looks at the database itself, so a fresh CI container or a database you dropped by hand can be left empty while the checksum says "already migrated". It does not cover seeders. - Laravel's
schema:dumpsquashes old migrations into one SQL file that you commit. It shortensmigrate:freshbut still runs it, plus every seeder, in every process. - code-distortion/adapt does snapshots and much more, with more setup, and does not support PHP 8.5.
Refresher always puts the database into a known state: it restores a real snapshot file, or migrates and saves one. It never trusts that the database "is probably still migrated".
Requirements
- PHP 8.3, 8.4 or 8.5
- Laravel 12 or 13
- SQLite (file database) or MySQL / MariaDB. For MySQL and MariaDB, the
mysqldumpandmysqlclient programs on the machine that runs the tests (the same ones your CI image already has forschema:dump).
Installation
composer require amjadiqbal/refresher --dev
Usage
Pest
// tests/Pest.php use AmjadIqbal\Refresher\Testing\RefreshesWithRefresher; pest()->extend(Tests\TestCase::class) ->use(RefreshesWithRefresher::class) // was: RefreshDatabase::class ->in('Feature');
PHPUnit
use AmjadIqbal\Refresher\Testing\RefreshesWithRefresher; class OrderTest extends TestCase { use RefreshesWithRefresher; // was: use RefreshDatabase; }
LazilyRefreshDatabase
use AmjadIqbal\Refresher\Testing\LazilyRefreshesWithRefresher; pest()->extend(Tests\TestCase::class)->use(LazilyRefreshesWithRefresher::class)->in('Feature');
The database is only restored once a test actually runs a query, exactly like LazilyRefreshDatabase.
Everything else works as before
Each test still runs inside a transaction that is rolled back afterwards, so nothing changes between
tests. The traits only replace the one migrate:fresh call at the start of a process, so these keep
working unchanged:
protected $seed = true;andprotected $seeder = ...(and, on Laravel 13,#[Seed]and#[Seeder(...)]): the seeded database is what gets saved, so seeders run once instead of once per process.$dropViews,$dropTypes,$connectionsToTransact.php artisan test --parallel: each worker restores into its own database.
When does it re-migrate?
A snapshot is stored under a hash of:
- every migration file (
database/migrations, plus any path a package or module registers withloadMigrationsFrom()), - your seeders and factories, when the tests seed,
- the database driver and the
migrate:freshoptions.
Change any of those and the next run migrates fresh and saves a new snapshot. The hash uses file names and contents, not modification times or absolute paths, so the same commit gives the same hash on every laptop and CI runner.
To force a fresh migration at any time:
php artisan refresher:clear
Caching snapshots in CI
Snapshots are written to storage/framework/refresher (change it with REFRESHER_SNAPSHOT_PATH).
Without caching that directory, a CI job migrates once and saves a snapshot that only the job's later
processes reuse (still a win with --parallel). To reuse it across jobs, cache the directory, for
example in GitHub Actions:
- uses: actions/cache@v4 with: path: storage/framework/refresher key: refresher-${{ hashFiles('database/migrations/**', 'database/seeders/**', 'database/factories/**') }}
A stale cache is harmless: if the hash does not match, Refresher migrates and saves a new snapshot.
Commands
| Command | What it does |
|---|---|
refresher:restore |
Restore the snapshot for the current migrations, or run migrate:fresh and save one. This is what the traits call. |
refresher:snapshot |
Save the current state of the database as the snapshot for the current migrations. |
refresher:clear |
Delete every saved snapshot. |
All of them take --connection=. restore and snapshot also take --fresh-options='{"--seed":true}'.
Configuration
Publish the config file if you need to change anything:
php artisan vendor:publish --tag=refresher-config
| Key | Env | Default |
|---|---|---|
connection |
REFRESHER_CONNECTION |
the default connection |
snapshot_path |
REFRESHER_SNAPSHOT_PATH |
storage/framework/refresher |
migration_paths |
database/migrations (package paths are added automatically) |
|
seed_paths |
database/seeders, database/factories |
|
mysql.dump_binary |
REFRESHER_MYSQLDUMP_BINARY |
mysqldump |
mysql.restore_binary |
REFRESHER_MYSQL_BINARY |
mysql |
To use a different connection from a test case, override refresherRestoreOptions():
protected function refresherRestoreOptions(): array { return ['--connection' => 'mysql_testing']; }
Drivers
SQLite. A snapshot is a copy of the database file. In-memory SQLite (:memory:) has no file to
copy, so it is migrated as usual in every process, exactly like RefreshDatabase; use a file database
(for example DB_DATABASE=database/testing.sqlite) to get snapshots.
MySQL and MariaDB. Snapshots are made with mysqldump and restored with the mysql client.
Before restoring, every table and view is dropped, like migrate:fresh does. The password goes to the
clients through MYSQL_PWD, never on the command line, and MySQL dumps use --set-gtid-purged=OFF, so
a test user without SUPER can restore on a server with GTIDs or binary logging switched on.
PostgreSQL is not supported yet. Any other driver fails straight away with a clear
UnsupportedDriverException.
Limits
- The snapshot is taken right after
migrate:fresh(and seeding). Data that your tests commit outside the per-test transaction is not in it, the same as withRefreshDatabase. - Only the database is in the snapshot. If a seeder also writes files (uploaded images in
storage/, for example), those files are not recreated on restore. - Restoring is not free: it is a file copy for SQLite and a
mysqlclient import for MySQL. With a small schema and no seeders, MySQL saves little; see the numbers above.
Testing
composer test scripts/ci-local.sh # PHP 8.3 / 8.4 / 8.5 x Laravel 12 / 13
The MySQL round-trip test runs when REFRESHER_MYSQL_HOST (and _PORT, _USERNAME, _PASSWORD,
_DATABASE) point at a throwaway database.
Need this built into your app, or something custom?
I speed up Laravel test suites and CI pipelines, and build Laravel packages for teams. If you want Refresher (or parallel testing, or a faster CI) set up on your project, or need something this package doesn't do, get in touch.
Support
- 🐛 Bug or feature request: open an issue
- 🔒 Security issue: please report privately via GitHub's security tab
Need this customised, or something built?
I'm available for custom development, package integration, and technical consulting.
More packages
Part of a family of open-source packages. See all of them.
| kiln | Deploy-time OPcache control for Laravel |
| laravel-driver-js | Product tours and onboarding for Laravel |
| laravel-logpulse | Log health monitoring and alerting |
Credits
License
MIT. See LICENSE.md.