No more verbose class boilerplate. No more hand-rolled $this->assertTrue chains. No more test files that take longer to write than the feature itself. Pest PHP 3 makes the right way the simple way.
Pest PHP 3 is a quiet revolution in the PHP testing ecosystem. It builds on the expressive, functional API of its predecessors and adds features that address real production pain points — mutation testing, team annotations, architecture presets, and a richer expectation API.
Most PHP developers don’t hate writing code. They hate writing tests for that code. Pest was built to fix that. Version 3 is its most polished release yet. Let’s walk through everything that changed.
Why Testing Felt Like a Chore
PHPUnit has served the PHP community faithfully for decades. But its API was designed for a different era of PHP. The verbose class declarations, the rigid inheritance structure, the boilerplate that dwarfs the actual assertion — it makes testing feel like busywork rather than a natural extension of writing code.
Pest PHP was built on a simple premise: tests should read like documentation. The syntax prioritises clarity and brevity without sacrificing any of PHPUnit’s power underneath. Because Pest runs on top of PHPUnit, every test you write compiles to PHPUnit under the hood.
The Core Syntax Difference
Before going into what’s new in version 3, here’s the fundamental shift Pest offers:
PHPUnit — the old way
class UserTest extends TestCase
{
public function test_user_has_name(): void
{
$user = new User('Taylor');
$this->assertSame('Taylor', $user->name);
}
}
Pest — the expressive way
it('has a name', function () {
$user = new User('Taylor');
expect($user->name)->toBe('Taylor');
});
Same test. Dramatically less noise. That ratio — maximum signal, minimum ceremony — is what Pest is about.
Mutation Testing — Built In
The headline addition in Pest 3 is first-class mutation testing via Pest Mutate. Mutation testing asks a hard question: if your code had a bug introduced, would your tests actually catch it?
Traditional code coverage tells you which lines were executed. Mutation testing tells you whether your assertions are meaningful. These are very different things.
Enabling mutation testing
it('calculates tax correctly', function () {
expect(calculateTax(100, 0.2))->toBe(20.0);
})->covers(calculateTax(...));
// Run mutation testing:
// ./vendor/bin/pest --mutate
What mutation testing does: Pest temporarily modifies your source code — flips a > to a >=, removes a return statement, changes a + to a - — then re-runs your tests. If no test fails after the modification, you have a “surviving mutant”: a gap in your coverage that line-based metrics would never surface.
Pest Mutate integrates directly into the test runner. No separate tool, no config file. Run --mutate and get a report showing exactly which mutations survived — and which tests need strengthening.
Team Annotations
In large codebases with multiple teams working on the same suite, Pest 3 introduces ->team() annotations. Tests can be tagged with a team name and filtered on the command line.
Tagging tests by team
it('processes a payment', function () {
// test logic
})->team('payments');
it('sends a welcome email', function () {
// test logic
})->team('notifications');
// Run only the payments team's tests:
// ./vendor/bin/pest --team=payments
This makes it practical to run focused CI pipelines per team, identify coverage ownership, and onboard new developers by pointing them to tests that are relevant to their domain.
Architecture Testing
One of Pest’s most underappreciated features is its arch() API — the ability to write tests about your application’s structure rather than its behaviour. Pest 3 expands this significantly with presets and new rules.
Architecture presets
arch()->preset()->php(); // enforce PHP best practices
arch()->preset()->laravel(); // Laravel-specific conventions
arch()->preset()->strict(); // strict typing everywhere
// Custom rules:
arch('controllers should not depend on repositories')
->expect('App\Http\Controllers')
->not()->toUse('App\Repositories');
Architecture tests run as part of your normal test suite. They fail exactly like any other test — giving you automated enforcement of architectural decisions that would otherwise live only in a README.
Datasets: Data-Driven Tests Without the XML
PHPUnit’s data providers are powerful but awkward — a separate method, a specific naming convention, and a return type that looks nothing like how you think about test inputs. Pest’s ->with() API replaces all of that:
Datasets with ->with()
it('validates email addresses', function (string $email, bool $valid) {
expect(isValidEmail($email))->toBe($valid);
})->with([
['user@example.com', true],
['not-an-email', false],
['another@valid.org', true],
['missing@tld', false],
]);
Datasets can also be defined in separate files and shared across multiple test files — useful when the same set of edge cases applies to several features.
Custom Expectations
Pest lets you extend the expect() API with your own domain-specific assertions. Define them once in your Pest.php configuration file and use them anywhere in your suite:
Extending expect() with domain assertions
// In Pest.php
expect()->extend('toBeActiveUser', function () {
return $this
->toBeInstanceOf(User::class)
->and($this->value->status)->toBe('active')
->and($this->value->email_verified_at)->not()->toBeNull();
});
// Usable in any test file:
expect($user)->toBeActiveUser();
Custom expectations make test files dramatically more readable. Instead of seeing the implementation of what “active user” means in every test, you see the intent — and the implementation lives in one place.
New Matchers in Pest 3
Pest 3 expands the built-in expectation API with several new matchers that cover common assertions more expressively:
New matchers
// HTTP response assertions
expect($response)
->toHaveStatus(200)
->toHaveJson(['status' => 'ok']);
// Collection and type assertions
expect($collection)
->toContainOnlyInstancesOf(User::class)
->toHaveCount(3);
// Enum-style value checks
expect($status)->toBeIn(['pending', 'active', 'closed']);
// Exception handling
expect(fn() => divide(10, 0))
->toThrow(\DivisionByZeroError::class);
Improved Parallel Testing
Pest 3 ships with significantly improved parallel test execution. Tests are distributed across processes with smarter isolation, reducing flaky tests caused by shared state while cutting CI run times on large suites.
Running tests in parallel
# Run tests across 4 parallel processes ./vendor/bin/pest --parallel --processes=4 # Combine with team filter for focused parallel runs ./vendor/bin/pest --parallel --team=payments
In suites with hundreds of tests, parallel execution commonly reduces CI run times by 60–80%. The improved isolation in version 3 means fewer false failures from database state leaking between tests.
Getting Started in Five Minutes
Installation
# Install via Composer composer require pestphp/pest --dev # Initialise (creates Pest.php and example tests) ./vendor/bin/pest --init # Run your suite ./vendor/bin/pest
Your first Pest test
// tests/Unit/MathTest.php
describe('Math helpers', function () {
it('adds two numbers', function () {
expect(add(2, 3))->toBe(5);
});
it('does not divide by zero', function () {
expect(fn() => divide(10, 0))
->toThrow(\DivisionByZeroError::class);
});
});
Laravel users: install pestphp/pest-plugin-laravel to get HTTP testing, database assertions, and the full RefreshDatabase workflow in Pest’s syntax. The plugin is the standard setup for new Laravel projects.
Migrating from PHPUnit
The migration path is low-risk. Pest runs alongside existing PHPUnit tests — you can convert files gradually and leave any you’d prefer not to touch. Existing PHPUnit tests in your suite will run without modification.
PHPUnit test inside a Pest suite — works as-is
// This PHPUnit-style test runs fine inside a Pest suite
class LegacyOrderTest extends TestCase
{
public function test_order_total_is_correct(): void
{
$order = Order::create([...]);
$this->assertEquals(149.99, $order->total);
}
}
One trade-off to know: Pest’s functional style requires you to shift how you think about test organisation. Teams deeply invested in class-based test hierarchies may need to set new conventions. It’s a one-time cost — and it pays dividends in readability for every test written after.
Final Thoughts
Pest PHP 3 is the release where years of developer feedback crystallised into a cohesive answer to the question: why doesn’t testing in PHP feel good?
Mutation testing means your test quality is verifiable, not assumed. Architecture testing means your structural decisions are enforced automatically. Team annotations make large suites navigable. The expanded matcher API means you write what you mean, not what the framework requires.
The result is a test suite that communicates intent clearly, catches more real bugs, and — perhaps most importantly — one that your team actually enjoys maintaining. The friction between “I should write a test” and “I’m writing a test” is as low as it’s ever been in PHP.
