Testing is the safety net that lets you ship with confidence. But letâs be honest â traditional PHPUnit tests can feel verbose, rigid, and frankly, a bit boring. Enter Pest PHP, the testing framework that treats developer experience as a first-class citizen. If you havenât tried it yet, youâre about to see why the PHP community is buzzing.
What is Pest PHP?
Pest PHP is a modern testing framework built on top of PHPUnit. It doesnât replace PHPUnit â it wraps it with a cleaner, more expressive syntax that makes tests easier to read and faster to write. Think of it as the difference between writing vanilla JavaScript and using a framework like Vue or React. The underlying engine is the same, but the developer experience is worlds apart.
Pest was created by Nuno Maduro and has quickly become one of the most exciting projects in the PHP ecosystem. It takes inspiration from Jest (JavaScriptâs beloved testing framework) and brings that same elegance to PHP. The result is a testing framework that feels natural, intuitive, and dare I say, enjoyable to use.
Why Pest? The Problem with Traditional PHPUnit
PHPUnit is powerful and battle-tested. Itâs been the standard for PHP testing for over a decade. But it has pain points:
Verbose boilerplate. Every test class needs a class declaration, method visibility, docblocks, and oftentimes setUp methods. You write more scaffolding than actual test logic.
Scattered assertions. PHPUnit forces you to think in terms of class hierarchies and method names rather than behaviors and expectations.
Mediocre defaults. Output formatting isnât great out of the box, and features like parallel testing require third-party packages.
Pest addresses all of this. It gives you:
- Clean, standalone test functions instead of classes
- Expressive
expect()API inspired by Jest - Beautiful, color-coded output by default
- First-class parallel testing support
- Built-in architecture testing
- A fluent, chainable assertion syntax
Installation
Getting started with Pest is straightforward. You can install it in any PHP project using Composer.
For a new project, create a directory and require Pest:
mkdir pest-demo && cd pest-demo
composer init --name="demo/pest-demo" --type="project" --require="php:^8.1" -n
composer require pestphp/pest --devThis installs Pest along with PHPUnit under the hood. Youâll get a phpunit.xml configuration file and a tests directory scaffolded automatically.
For an existing project, just run:
composer require pestphp/pest --dev
./vendor/bin/pest --initThe --init flag sets up Pest in your project, creating the tests/Pest.php file where you define global configurations and custom helpers.
You can verify everything works with:
./vendor/bin/pestIf you see the green âTests: 0 skipped, 0 passedâ message, youâre ready to go.
Writing Your First Test
Pest tests are just functions. No classes, no method visibility modifiers, no boilerplate.
Create a file called tests/Unit/ExampleTest.php:
<?php
test('the application returns a successful response', function () {
$response = true;
expect($response)->toBeTrue();
});You can also use the it() alias for a more natural reading flow:
<?php
it('returns true', function () {
expect(true)->toBeTrue();
});Run your tests:
./vendor/bin/pestYouâll see a clean, colorized output with a green checkmark. Each test name reads like a sentence, so your terminal output doubles as living documentation.
Pest also supports test descriptions as strings, which means you skip the testSomething camelCase convention entirely. No more test_it_returns_the_correct_total_when_calculating_the_sum_of_two_numbers. Just write what you mean:
test('calculate total returns the sum of two numbers', function () {
// ...
});Pest vs PHPUnit: A Side-by-Side Comparison
Letâs look at the same test written in both frameworks.
PHPUnit:
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class MathTest extends TestCase
{
public function test_addition()
{
$result = 1 + 1;
$this->assertEquals(2, $result);
}
public function test_subtraction()
{
$result = 5 - 3;
$this->assertEquals(2, $result);
}
}Pest:
<?php
test('addition', function () {
$result = 1 + 1;
expect($result)->toEqual(2);
});
test('subtraction', function () {
$result = 5 - 3;
expect($result)->toEqual(2);
});The Pest version ditches the class, the namespace boilerplate, the method visibility, and the $this-> prefix on assertions. Every line serves a purpose.
Hereâs a quick reference map of common assertions:
| PHPUnit | Pest |
|---|---|
$this->assertEquals($expected, $actual) | expect($actual)->toEqual($expected) |
$this->assertTrue($condition) | expect($condition)->toBeTrue() |
$this->assertNull($value) | expect($value)->toBeNull() |
$this->assertCount($n, $array) | expect($array)->toHaveCount($n) |
$this->assertContains($value, $array) | expect($array)->toContain($value) |
$this->assertInstanceOf($class, $obj) | expect($obj)->toBeInstanceOf($class) |
$this->expectException($exception) | expect(fn() => ...)->toThrow($exception) |
$this->assertGreaterThan($n, $value) | expect($value)->toBeGreaterThan($n) |
The expect() API chains beautifully too:
expect($response)
->toBeInstanceOf(Response::class)
->status->toBe(200)
->json->toHaveKey('data');Each assertion returns the same expectation instance, so you can chain as many checks as you need. This eliminates the repetitive $this-> prefix and makes your intentions crystal clear.
Higher-Order Tests
This is where Pest truly shines. Higher-order tests let you describe test scenarios without writing callback functions. You chain assertions directly off the test() or it() return value using ->assert.
Consider a typical Laravel controller test. Instead of writing:
test('guests are redirected to login', function () {
$this->get('/dashboard')
->assertRedirect('/login');
});You can write:
test('guests are redirected to login')
->get('/dashboard')
->assertRedirect('/login');No callback function. No curly braces. The test reads exactly like the sequence of operations it performs.
Higher-order tests work because Pest leverages PHP 8âs named arguments and reflection to wire everything together. You can chain arbitrary methods on the test result, and Pest treats them as operations on the object returned by the previous call.
Hereâs a more complex example:
test('authenticated user can view their profile')
->actingAs($user)
->get('/profile')
->assertOk()
->assertSee($user->name)
->assertSee($user->email);The flow is linear and obvious. You act as a user, you hit a route, you assert the response. No indirection, no confusion.
You can even use tap() to inspect values mid-chain:
test('the dashboard loads with correct data')
->actingAs($user)
->get('/dashboard')
->assertOk()
->tap(fn ($response) => expect($response['posts'])->toHaveCount(10));These higher-order tests are a game-changer for feature and integration tests. They make the testing experience feel like writing a specification, not debugging a program.
Custom Helpers and Expectations
Pest lets you extend the expect() function with your own custom assertions. This is invaluable when you find yourself repeating the same assertion logic across multiple tests.
Define custom expectations in your tests/Pest.php file:
<?php
use Pest\Expectation;
expect()->extend('toBeValidEmail', function () {
return $this->toMatch('/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/');
});
expect()->extend('toBeBetween', function (int $min, int $max) {
return $this->toBeGreaterThanOrEqual($min)
->toBeLessThanOrEqual($max);
});Now use them in your tests:
test('email validation', function () {
expect('[email protected]')->toBeValidEmail();
expect(25)->toBeBetween(18, 99);
});You can also define global helper functions in tests/Pest.php:
<?php
function createUser(array $attributes = []): User
{
return User::factory()->create($attributes);
}
function actingAsUser(): TestCase
{
$user = createUser();
return test()->actingAs($user);
}These helpers are automatically available in all your test files without imports. This keeps your tests clean and your setup logic centralized.
For more advanced use cases, you can create custom expectation classes that implement Pest\Contracts\HasExpectations. But for 90% of projects, the extend() method is all you need.
Architecture Testing
Architecture testing is one of Pestâs killer features. It lets you enforce coding standards, naming conventions, and structural rules programmatically. Think of it as automated code review that runs as part of your test suite.
Pest ships with several built-in architectural assertions through the arch() function:
test('strict types are enforced')
->arch()
->expect('App')
->toUseStrictTypes();
test('controllers extend base controller')
->arch()
->expect('App\Http\Controllers')
->toExtend(Illuminate\Routing\Controller::class);
test('services are final')
->arch()
->expect('App\Services')
->toBeFinal();
test('global facades are not used')
->arch()
->expect('App')
->not->toUse('Illuminate\Support\Facades');These tests prevent architectural drift. When a new developer joins the team and accidentally uses a facade instead of dependency injection, the architecture test catches it before the PR is merged.
You can combine multiple expectations:
test('application architecture')
->arch()
->expect('App')
->toUseStrictTypes()
->toBeFinal()
->not->toUse(['die', 'var_dump', 'dd']);The not property inverts any assertion, so not->toUse('Facades') means âmust not use facades.â This declarative style makes your architectural rules self-documenting.
Pest also includes arch presets for common PHP frameworks. For Laravel:
arch()
->preset()
->laravel();This single line enforces dozens of Laravel best practices: controllers extend the base controller, models use proper traits, service providers are registered correctly, and so on. You can customize any preset or build your own.
Architecture tests run alongside your regular tests. Theyâre fast because Pest caches the reflection results. Thereâs no excuse not to have them.
Parallel Testing
As your test suite grows, execution time becomes a bottleneck. Pest solves this with built-in parallel testing powered by the brianium/paratest library under the hood.
Run your tests in parallel with a single flag:
./vendor/bin/pest --parallelPest automatically detects the number of CPU cores available and spawns that many worker processes. Each process runs a subset of your tests, cutting execution time dramatically.
You can control the process count manually:
./vendor/bin/pest --parallel --processes=8Parallel testing works with PHPUnitâs built-in database migrations and transactions, as long as each process uses its own database connection. Pest integrates with Laravelâs RefreshDatabase trait to handle this automatically.
The output from parallel runs is collected and displayed coherently â no garbled interleaving. Each testâs result is attributed correctly, and failures include the process number for debugging.
For CI pipelines, parallel testing is a godsend. A suite that takes 10 minutes sequentially can drop to under a minute with 16 parallel processes. Thatâs the difference between blocking a deploy and letting it sail through.
Testing Laravel Applications with Pest
Pest has first-class support for Laravel through the pestphp/pest-plugin-laravel package. If youâre starting a new Laravel project, use the official Laravel installer with the Pest flag:
laravel new my-app --pestFor existing Laravel projects:
composer require pestphp/pest-plugin-laravel --dev
php artisan pest:installThe plugin provides Laravel-specific test helpers and integrates seamlessly with Laravelâs testing utilities. Hereâs what a typical feature test looks like:
<?php
use App\Models\User;
use App\Models\Post;
uses(Tests\TestCase::class)->in('Feature');
test('authenticated user can create a post', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->post('/posts', [
'title' => 'My First Post',
'body' => 'This is the body of my post.',
]);
$response->assertRedirect('/posts');
$response->assertSessionHas('success');
$this->assertDatabaseHas('posts', [
'title' => 'My First Post',
'user_id' => $user->id,
]);
});The uses() function at the top tells Pest to use the Tests\TestCase class for all tests in the Feature directory. This gives you access to Laravelâs $this->get(), $this->post(), $this->actingAs(), and all the other HTTP testing methods.
You can also use the refreshDatabase trait easily:
<?php
uses(Tests\TestCase::class)
->in('Feature')
->beforeEach(fn () => $this->refreshDatabase());
test('users can register', function () {
$response = $this->post('/register', [
'name' => 'John Doe',
'email' => '[email protected]',
'password' => 'password',
'password_confirmation' => 'password',
]);
$response->assertRedirect('/dashboard');
expect(User::count())->toBe(1);
});The beforeEach() and afterEach() hooks let you set up and tear down state per test, just like PHPUnitâs setUp() and tearDown() methods. You can chain them globally for whole test directories or use them inline for specific test files.
Pestâs Laravel integration also supports model factories, assertDatabaseHas, HTTP sessions, mail faking, notification faking, and every other testing utility that Laravel provides. Nothing is lost; everything is enhanced.
TDD Workflow with Pest
Test-driven development (TDD) follows the red-green-refactor cycle: write a failing test, make it pass, then clean up the code. Pest makes this cycle faster and more enjoyable.
Hereâs a typical TDD session with Pest:
Step 1 â Write a failing test (Red):
<?php
test('calculate total price with tax', function () {
$calculator = new PriceCalculator;
$total = $calculator->calculate(100.00);
expect($total)->toEqual(121.00);
});Run Pest:
./vendor/bin/pestYou get a red failure: Class "PriceCalculator" not found. Perfect â we havenât written the class yet.
Step 2 â Write the minimum code to pass (Green):
<?php
class PriceCalculator
{
public function calculate(float $price): float
{
return $price * 1.21;
}
}Run Pest again. Green. The test passes.
Step 3 â Refactor:
Now you can clean up the implementation â extract the tax rate to a constant, add type hints, or inject the tax rate as a dependency. Run Pest after each change to make sure you havenât broken anything.
The tight feedback loop is what makes TDD powerful. Pestâs fast startup time (it doesnât need to boot a full framework for unit tests) keeps the loop under a second. You write, test, and iterate without losing focus.
Combine this with Pestâs watch mode:
./vendor/bin/pest --watchPest watches your test and source files for changes and re-runs automatically. This is the ultimate TDD companion â you edit a file, save it, and see results in your terminal instantly without switching contexts.
IDE Support and Community
Pest has excellent IDE support. The pestphp/pest package ships with PhpStorm metadata that enables autocompletion for expect(), test(), it(), beforeEach(), and all other Pest globals. No extra plugins required.
For VS Code users, the Pest Test Explorer extension provides a visual test runner with pass/fail indicators, code lenses to run individual tests, and debugging support through the VS Code PHP debug adapter.
The Pest community is vibrant and growing. The framework has over 8,000 stars on GitHub, an active Discord server, and regular releases. The ecosystem includes:
- pestphp/pest-plugin-faker â Generate fake data in tests
- pestphp/pest-plugin-mock â Mock objects without Mockery
- pestphp/pest-plugin-livewire â Test Livewire components
- pestphp/pest-plugin-inertia â Test Inertia responses
Third-party packages are joining the ecosystem too. Spatieâs ray debugger has Pest support. Database testing tools like lazylegacy/factory-generator generate Pest-compatible factories. The community is all-in.
Conclusion
Pest PHP is more than a testing framework â itâs a statement about what PHP development can be. Clean, expressive, and joyful. It doesnât compromise on power; underneath the elegant syntax lies the full might of PHPUnit. Every assertion, every mock, every data provider you know from PHPUnit still works. Pest just removes the friction.
If youâre starting a new Laravel project, thereâs no reason not to use Pest. If youâre maintaining an existing PHPUnit suite, you can adopt Pest incrementally â mixed test suites run just fine. Install Pest, write your next test in it, and see how it feels. I suspect you wonât go back.
Pest proves that developer experience matters at every level of the stack. Testing shouldnât feel like a chore. It should feel like a safety net that youâre excited to use. Thatâs what Pest delivers.
Now go write some tests â and enjoy it.