pestphp/pest
Pest is an elegant PHP testing framework with a clean, expressive syntax built on PHPUnit. Write readable tests with higher-order expectations, datasets, and architecture testing, plus first-class Laravel support.
Pest is a thin, ergonomic layer over PHPUnit. Your existing phpunit.xml, test suites, and PHPUnit assertions keep working — you just get a functional, expressive API on top. On a Laravel project this is nearly a drop-in.
Install (Laravel projects):
composer require pestphp/pest pestphp/pest-plugin-laravel --dev --with-all-dependencies
./vendor/bin/pest --init
--init scaffolds tests/Pest.php and hooks your existing tests/TestCase.php. Laravel starter kits ship with Pest already, so often you only need step 2.
Where to look first — in this order:
tests/Pest.php — the single most important file. It's where uses(...)->in(...) binds your Laravel TestCase and traits to test directories. Everything breaks or works based on this file.phpunit.xml — still the source of truth for test suites, env vars (DB_CONNECTION=sqlite, DB_DATABASE=:memory:), and coverage include paths.tests/Feature, tests/Unit — the directories you'll write in.First use case — convert one existing PHPUnit feature test. Take a class-based test and rewrite:
// tests/Feature/InvoiceTest.php
use App\Models\Invoice;
use App\Models\User;
it('marks an invoice as paid', function () {
$user = User::factory()->create();
$invoice = Invoice::factory()->for($user)->create();
$this->actingAs($user)
->post("/invoices/{$invoice->id}/pay")
->assertRedirect();
expect($invoice->fresh()->status)->toBe('paid');
});
Run ./vendor/bin/pest --filter=paid. Notice $this is the Laravel TestCase (thanks to tests/Pest.php), so actingAs, post, and assertDatabaseHas are all available. That's the whole migration story: same harness, less boilerplate.
Bind the framework harness once, in tests/Pest.php:
uses(Tests\TestCase::class, RefreshDatabase::class)->in('Feature');
uses(Tests\TestCase::class)->in('Unit');
This is the first thing to get right on any Laravel codebase — it turns every closure test into a full application boot.
Hooks over base-class setUp: beforeEach, afterEach, and afterAll can be declared globally (in Pest.php), per directory, per describe block, or per file. Scope them tightly — a global beforeEach that seeds data is a classic source of mysterious slowdowns.
describe('subscriptions', function () {
beforeEach(fn () => $this->user = User::factory()->create());
it('renews on the billing date', function () { /* ... */ });
});
Dataset values are injected into hooks too, so beforeEach(fn (string $plan) => ...) with ->with(['pro', 'team']) works.
Datasets for table-driven tests: define shared sets in tests/Datasets/*.php and reference them by name with ->with('datasetName'). Prefer named arguments in datasets — the repeat/retry + named-dataset-argument interactions have been patched repeatedly, so pin your version.
it('validates emails', function (string $input, bool $valid) {
expect(validator(['email' => $input], ['email' => 'email'])->passes())->toBe($valid);
})->with([
'valid' => ['[email protected]', true],
'missing' => ['not-an-email', false],
]);
Expectations vs. $this assertions: use expect() for value assertions (toBe, toBeInstanceOf, toMatchArray, toHaveCount), and $this->assert* / $this->get()... for HTTP/database/queue concerns. Both live in the same test — that's expected, not a smell. expect($user)->toHaveName('...') style higher-order expectations are also available via model accessors/methods.
Architecture tests pay for themselves immediately. Put them in tests/ArchTest.php and run them in CI:
arch()->preset()->laravel()->preset()->php()->preset()->security();
arch('no debugging leftovers')->expect(['dd', 'dump', 'ray', 'var_dump'])->not->toBeUsed();
preset()->laravel() encodes a lot of framework conventions (e.g. controllers suffixed correctly, no App\Http in providers). Adopt it first, then tighten with ->toExtend(), ->toUseTrait(), and wildcards — presets are opinionated and will flag legacy code, so roll them out per-directory rather than big-bang.
Day-to-day loop:
--dirty — only run tests with uncommitted changes. The fastest inner-loop command.--filter="checkout" / --group=payments — target by name or group (->group('payments')).--profile — prints your slowest tests. Run it before blaming CI.--parallel — Paratest under the hood; combine with per-process SQLite or a dedicated test DB.--tia — test-impact analysis: runs only tests reachable from changed source lines. Excellent locally, needs a coverage driver (PCOV/Xdebug), and caches a graph. Don't gate merges on it.CI patterns:
--coverage --min=80 --compact (the --compact flag keeps output readable and hides >100% type coverage). Coverage requires PCOV/Xdebug; forked parallel runs can be memory-hungry.--shard=1/5 distributes by file count by default. For balanced wall-clock time, run ./vendor/bin/pest --parallel --update-shards once, commit tests/.pest/shards.json, and shard-by-time becomes automatic.--flaky retries and reports rather than failing the build outright. Use it to identify flakiness, not to hide it permanently.toMatchSnapshot() for serialized payloads/HTML; update with --update-snapshots, which respects --filter and file args.uses(Tests\TestCase::class)->in('...') in tests/Pest.php. Check the directory name — a nested folder like tests/Feature/Admin still matches in('Feature'), but a directory outside the listed ones does not.composer update pestphp/pest as a security action, and keep phpunit/phpunit unpinned so Pest's constraints resolve.--parallel + RefreshDatabase is the most common source of "random" CI failures. Each worker needs its own database (or SQLite file per process). In-memory SQLite with parallel workers works only because each worker is a separate process with its own connection — but any shared database, Redis, or cache store will collide. Use --parallel with a Laravel-specific DB per process, or keep the suite sequential.beforeEach is now correctly treated as finished by the TIA engine, but historically has been a bug-rich area. Assert on hook behavior rather than assuming.--shard combined with --parallel were fixed in 5.1.2, and shard filters could match classes outside their partition until 5.3.2. If you're sharding in CI, stay on a recent patch — a green shard matrix that silently skipped tests is worse than a red one.database/migrations, generated code, and vendor-ish directories explicitly.expect()->extend() in Pest.php for domain-specific assertions, custom uses() trait bindings for shared behavior, and plugin packages (pest-plugin-laravel for artisan/actingAs/freezeTime, pest-plugin-mutation, pest-plugin-type-coverage, the v4 Browser plugin) rather than hand-rolling. Keep tests/Pest.php deterministic — it's loaded for every run, so no side effects, no network, no env() mutation there.dd()/dumps. Run the specific test sequentially with --filter before reaching for a debugger.How can I help you explore Laravel packages today?