Build Custom Pest Expectations for Your Laravel Domain Language

Write Pest custom expectations with expect()->extend so your Laravel tests read like domain rules. Covers $this->value, test()->fail, intercept and pipe.

Steven Richardson
Steven Richardson
· 10 min read

Open any Laravel test suite that has been alive for two years and you will find the same four lines pasted across a dozen files: check the status column, check the timestamp is not null, check nothing got refunded, check a shipment row exists. It reads like plumbing, and when the business rule changes you have to find every copy. Pest's expect() is extensible, so you can name that block once and never paste it again.

Find a repeated assertion block worth naming#

Start by grepping your suite for the assertion cluster you have copied the most times. The candidate you want is a group of expectations that always travel together and that collectively answer one business question — not a random pile of unrelated checks. Here is the one I keep finding in order-processing code.

it('fulfils an order once payment clears', function (): void {
    $order = Order::factory()->paid()->create();

    FulfilOrder::run($order);

    $order->refresh();

    expect($order->status)->toBe(OrderStatus::Fulfilled);
    expect($order->fulfilled_at)->not->toBeNull();
    expect($order->refunded_at)->toBeNull();
    expect($order->shipments)->toHaveCount(1);
});

Four expectations, one question: is this order fulfilled? That is the signal you are looking for. If the block differs slightly between files — one place checks shipments, another does not — reconcile that first, because the inconsistency is usually a bug in the tests rather than a real difference. This is the same instinct behind enforcing Laravel architecture rules with Pest's arch() helper: encode the rule once, in one place, and let the suite police it.

Register the expectation in tests/Pest.php#

Chain extend() onto a bare expect() call — no value passed — in tests/Pest.php. Pest loads that file for every test, so the expectation is available suite-wide the moment you save it.

// tests/Pest.php

use App\Enums\OrderStatus;
use App\Models\Order;

expect()->extend('toBeFulfilled', function () {
    return $this->toBeInstanceOf(Order::class);
});

Once tests/Pest.php grows past a handful of these, move them into tests/Expectations.php and pull that file in from Pest.php:

// tests/Pest.php

require_once __DIR__.'/Expectations.php';

Two things to know before you get comfortable. Expectation names live in one global registry, so toBeFulfilled registered by a Composer package and toBeFulfilled registered by you will collide, and which one wins depends on load order — prefix anything remotely generic. And an expect()->extend() call made inside a single test file only exists for that file, which is occasionally useful but far more often a confusing accident.

Read the subject with $this->value#

Inside the closure, $this->value holds whatever was handed to expect($value). Use it whenever you need custom logic rather than a chain of built-ins, and guard the type on the way in because $this->value is mixed as far as static analysis is concerned.

expect()->extend('toBeFulfilled', function () {
    /** @var Order $order */
    $order = $this->value;

    expect($order)->toBeInstanceOf(Order::class);

    expect($order->status)->toBe(OrderStatus::Fulfilled)
        ->and($order->fulfilled_at)->not->toBeNull()
        ->and($order->refunded_at)->toBeNull();

    return $this; // Keep the chain fluent for the caller.
});

That @var docblock is doing real work — without it PHPStan sees mixed and every property access below it is an error at level 6 and up. If you are pushing towards fully typed tests, Pest's type coverage plugin will flag these closures, and it is worth annotating them properly rather than baselining them away.

Chain built-in expectations inside your own#

Prefer $this->toBeSomething() over hand-rolled boolean checks. Chaining into the built-ins means you inherit their diff output and their failure messages for free, and it means negation works without you writing a line of code.

expect()->extend('toBeWithinDispatchWindow', function () {
    return $this->toBeInstanceOf(Order::class)
        ->and($this->value->fulfilled_at->diffInHours($this->value->paid_at))
        ->toBeLessThanOrEqual(48);
});

Because the assertions are ordinary expectations, expect($order)->not->toBeWithinDispatchWindow() works immediately. The one case where free negation lets you down is an expectation with side effects — one that hits the database, fakes a queue, or mutates the subject — because not still runs the whole closure. Keep custom expectations read-only. The same rule applies when you compose them with heavier built-ins such as the ones in Pest 4 snapshot testing for Laravel API responses.

Fail with a message that names the domain rule#

When you need to abort with your own wording, call test()->fail(). There is no $this->fail() on the expectation object — that is the single most common mistake with this API. Write the message so it describes the broken business rule and includes the actual value, because a failure that says "false is not true" tells the next developer nothing.

expect()->extend('toBeFulfilled', function () {
    $order = $this->value;

    if (! $order instanceof Order) {
        test()->fail(sprintf(
            'toBeFulfilled() expects an Order, got %s.',
            get_debug_type($order),
        ));
    }

    if ($order->status !== OrderStatus::Fulfilled) {
        test()->fail(sprintf(
            'Order #%d is not fulfilled — status is "%s".',
            $order->id,
            $order->status->value,
        ));
    }

    return $this->and($order->fulfilled_at)->not->toBeNull()
        ->and($order->refunded_at)->toBeNull();
});

Reserve test()->fail() for genuinely invalid input, like the wrong type arriving. Leave the domain checks themselves as chained built-ins, because a hard fail() fires unconditionally and will torpedo ->not->toBeFulfilled() in a way a normal failed assertion would not.

Accept arguments to make the expectation configurable#

Closure parameters become expectation arguments, so a parameterised expectation is just a closure with a signature. This is where custom expectations start paying for themselves — one named check covering a whole family of assertions.

expect()->extend('toHaveLineItemTotalling', function (int $cents) {
    $totals = $this->value->lineItems->pluck('total_cents');

    if (! $totals->contains($cents)) {
        test()->fail(sprintf(
            'No line item totalling %d. Found: %s.',
            $cents,
            $totals->implode(', ') ?: 'no line items at all',
        ));
    }

    return $this;
});

The call site now reads like the acceptance criterion it came from:

expect($invoice)
    ->toHaveLineItemTotalling(4_999)
    ->toHaveLineItemTotalling(1_200);

Type the parameters properly. Pest passes arguments straight through, so an untyped $cents will happily accept the string '4999' and then fail a strict comparison somewhere confusing three lines later.

Override a built-in expectation with intercept()#

intercept() replaces a built-in expectation entirely when the subject matches a type. The canonical Laravel use is toBe() on Eloquent models: by default it compares object identity, which is almost never what you mean when you have re-fetched the same row.

use Illuminate\Database\Eloquent\Model;

// tests/Expectations.php

expect()->intercept('toBe', Model::class, function (Model $expected) {
    expect($this->value->is($expected))->toBeTrue();
});

is() is the right comparison here because it checks the primary key, the table and the connection, rather than comparing loaded attributes — so a model with an eager-loaded relation still matches the same row fetched bare. You can also pass a closure as the second argument instead of a class name when the decision depends on the value rather than its type:

expect()->intercept('toBe', fn (mixed $value): bool => is_string($value), function (string $expected) {
    expect(trim($this->value))->toEqual(trim($expected));
});

Use this sparingly. Silently changing what toBe() means for every test in the suite is exactly the kind of magic that costs someone an afternoon.

Pipe an expectation when you only want to bend it#

pipe() is the safer sibling of intercept(). Your closure receives a $next callable, so you handle the case you care about and hand everything else back to Pest's original implementation.

use Closure;
use Illuminate\Database\Eloquent\Model;

expect()->pipe('toBe', function (Closure $next, mixed $expected) {
    if ($this->value instanceof Model && $expected instanceof Model) {
        return expect($this->value->is($expected))->toBeTrue();
    }

    return $next(); // Fall through to the built-in toBe().
});

Reach for pipe() whenever the override is conditional, and for intercept() only when you genuinely want the built-in gone for that type. In the model-comparison case above, pipe() is the better choice — it leaves toBe() untouched for strings, ints and everything else.

Add IDE autocompletion with a @method stub#

Custom expectations are registered at runtime, so neither PhpStorm nor PHPStan knows they exist and both will complain about an undefined method. Fix it with a stub file that declares the methods via @method annotations and is never actually executed.

<?php

// tests/expectations.stub — indexed by the IDE, excluded from autoload.

namespace Pest;

/**
 * @method self toBeFulfilled()
 * @method self toBeWithinDispatchWindow()
 * @method self toHaveLineItemTotalling(int $cents)
 */
class Expectation {}

Give the file a non-.php extension and keep it out of composer.json's autoload-dev — if it ever gets loaded you will get a "cannot redeclare class" fatal. Point your IDE at it as an included stub and the red squiggles disappear. For the static-analysis half of the problem, PestStan and PHPStan generics for Pest 4 tests covers getting the analyser to understand the expectation chain properly.

Extract the expectations into a shareable package#

Once two projects want the same expectations, move them into a small Composer package with a files autoload entry. The file runs on autoload, which is exactly the hook you need to call expect()->extend() before any test boots.

{
    "name": "acme/pest-order-expectations",
    "type": "library",
    "require": {
        "php": "^8.4",
        "pestphp/pest": "^4.0"
    },
    "autoload": {
        "files": [
            "src/Expectations.php"
        ]
    }
}

Guard the registration so the file is inert outside a test run, because a files autoload is loaded by your application too, not just by Pest:

<?php

// src/Expectations.php

if (! function_exists('expect')) {
    return; // Pest is not loaded — do nothing.
}

expect()->extend('toBeFulfilled', function () {
    // ...
});

Install it as a require-dev dependency in consuming projects and prefix every name so it cannot clash with a plugin. Before you write any of this, check whether it already exists: spatie/pest-expectations (1.14) bundles a pile of Laravel-flavoured expectations, and defstudio/pest-plugin-laravel-expectations (v2.6, Pest 4 compatible) covers HTTP responses, database rows, mail and notifications. Reserve your own package for genuinely domain-specific rules — the ones nobody outside your business would ever name.

Start with a single expectation for the assertion block you paste most often, and give it a week. If your test diffs get shorter and your failure messages start naming business rules instead of booleans, keep going. From there, mutation testing with Pest will tell you whether those newly-readable assertions are actually catching anything, and testing Livewire 4 components with Pest is a good place to apply the same treatment to component tests.

FAQ#

How do I create a custom expectation in Pest?

Call expect() with no arguments and chain extend() onto it, passing a name and a closure: expect()->extend('toBeFulfilled', function () { ... }). Put that call in tests/Pest.php so it runs before every test. The expectation is then available anywhere in your suite as expect($value)->toBeFulfilled().

Where should I put expect()->extend in a Laravel project?

tests/Pest.php is the default home, because Pest loads it automatically for the whole suite. Once you have more than a handful, move them to tests/Expectations.php and require_once that file from tests/Pest.php. Registering an expectation inside an individual test file works, but it is only visible in that one file, which usually causes more confusion than it saves.

How do I access the expectation value inside a custom expectation?

Use $this->value — it holds whatever was passed to expect($value). Because it is typed as mixed, add a @var docblock or an explicit instanceof guard before you access properties on it, otherwise PHPStan will flag every line that follows. Always return $this at the end so callers can chain further expectations.

How do I make a custom Pest expectation fail with a useful message?

Call test()->fail('your message') from inside the closure. Note that it is test()->fail(), not $this->fail() — the expectation object has no fail() method. Build the message with sprintf() so it includes the actual value and names the business rule that was violated, for example "Order #42 is not fulfilled — status is pending".

What is the difference between expect()->extend() and expect()->intercept()?

extend() adds a brand new expectation under a name that does not exist yet. intercept() replaces an existing built-in expectation with your implementation whenever the subject matches a given type or closure predicate, which affects every test in the suite. If you only want to change the behaviour conditionally and fall back to the original, use pipe() instead — it hands you a $next callable to invoke the built-in.

Can I share custom Pest expectations across multiple projects?

Yes. Put the expect()->extend() calls in a PHP file and register it under autoload.files in a small Composer package, then install that package as a require-dev dependency. Wrap the registrations in a function_exists('expect') guard so the file is a no-op when your application autoloads it outside a test run, and prefix the expectation names to avoid collisions with other plugins.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.