Laravel Magazine

Write Better Laravel Tests with Pest PHP

Eric Van Johnson · Tutorials
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.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.