Write Better Laravel Tests with Pest PHP
If you have ever looked at a wall of PHPUnit boilerplate and thought "there has to be a better way," there is. Pest PHP is a testing framework built on top of PHPUnit that gives you a more expressive, less ceremonious syntax for writing tests — and it ships as the default testing framework in new Laravel applications.
This tutorial covers Pest from installation through the features that make it genuinely worth using over plain PHPUnit.
Installation
New Laravel projects include Pest by default. For existing projects, install it and run the migration command:
composer require pestphp/pest --dev
composer require pestphp/pest-plugin-laravel --dev
./vendor/bin/pest --init
Run your tests with:
php artisan test
# or
./vendor/bin/pest
The Basic Syntax
In PHPUnit, every test is a class with a method. In Pest, a test is a function call:
// PHPUnit
class UserTest extends TestCase
{
public function test_a_user_can_be_created(): void
{
$user = User::factory()->create();
$this->assertInstanceOf(User::class, $user);
}
}
// Pest
it('can create a user', function () {
$user = User::factory()->create();
expect($user)->toBeInstanceOf(User::class);
});
Less boilerplate, same coverage. Both it() and test() work; it() reads more naturally for behavior descriptions.
The Expectations API
Instead of PHPUnit's $this->assertEquals() style, Pest uses a fluent expect() API:
it('calculates the correct order total', function () {
$order = Order::factory()
->hasItems(3, ['price' => 1000])
->create();
expect($order->total())
->toBe(3000)
->toBeInt()
->toBeGreaterThan(0);
});
Chaining expectations on the same value keeps assertions readable and avoids the repetition of re-calling $this->assert*() for every check.
Some particularly useful expectations for Laravel:
// Collections
expect($users)->toHaveCount(5);
expect($activeUsers)->not->toBeEmpty();
// Strings
expect($response->body())->toContain('Invoice #123');
// Arrays / JSON
expect($data)->toHaveKeys(['id', 'name', 'email']);
// Exceptions
expect(fn () => $service->processInvalidInput())
->toThrow(ValidationException::class);
// Models
expect(User::find(1))->not->toBeNull();
Datasets — Parameterized Tests Without the Boilerplate
PHPUnit has data providers, but they require a separate method and a docblock annotation. Pest datasets are inline:
dataset('valid email addresses', [
'alice@example.com',
'bob+filter@subdomain.example.org',
'user.name@example.co.uk',
]);
it('validates email addresses', function (string $email) {
expect(filter_var($email, FILTER_VALIDATE_EMAIL))->not->toBeFalse();
})->with('valid email addresses');
Or inline with descriptions:
it('calculates the correct VAT', function (float $price, float $rate, float $expected) {
expect(calculateVat($price, $rate))->toBe($expected);
})->with([
'standard rate' => [100.0, 0.20, 20.0],
'reduced rate' => [100.0, 0.05, 5.0],
'zero rated' => [100.0, 0.00, 0.0],
]);
Pest generates one test per dataset entry, and each entry's description shows up clearly in the output when a test fails.
Higher-Order Tests — Test Syntax That Reads Like a Sentence
Higher-order tests let you chain methods that act on the subject without writing an explicit function body. This works especially well for testing model relationships and casts:
it('has timestamps')
->expect(fn () => new User())
->toHaveProperty('created_at');
it('casts email_verified_at to a Carbon instance')
->expect(fn () => User::factory()->create()->email_verified_at)
->toBeInstanceOf(Carbon::class);
These are best for simple, single-assertion tests. For anything with setup steps, use the full function syntax.
Architecture Tests — Enforce Code Structure Automatically
This is Pest's standout feature for Laravel teams. Architecture tests let you write rules about your codebase structure and run them as part of your test suite:
arch('controllers should not use Eloquent directly')
->expect('App\Http\Controllers')
->not->toUse('Illuminate\Database\Eloquent\Model');
arch('all service classes should be final')
->expect('App\Services')
->toBeFinal();
arch('no debugging functions left in code')
->expect(['dd', 'dump', 'var_dump', 'ray'])
->not->toBeUsed();
arch('commands should extend the correct base class')
->expect('App\Console\Commands')
->toExtend('Illuminate\Console\Command');
Run these in CI and you get automated enforcement of architectural decisions. No more finding dd() calls in a PR review. No more controllers that quietly reach around the service layer.
Using the $this Context with Laravel Testing Traits
Pest works with all of Laravel's testing traits. To use helpers like actingAs(), get(), or post(), just use $this inside the test function:
use function Pest\Laravel\{get, post, actingAs};
it('shows the dashboard to authenticated users', function () {
$user = User::factory()->create();
actingAs($user)
->get('/dashboard')
->assertOk()
->assertSee('Welcome back');
});
The Pest Laravel plugin adds actingAs(), get(), post(), assertDatabaseHas(), and all the standard Laravel test helpers as global functions so you do not even need $this.
Pest is the default in Laravel now for a reason. If you are still writing PHPUnit classes, take one test file and rewrite it in Pest. The reduction in noise usually convinces people faster than any documentation can.