Laravel Pest 4 Testing: The Complete Guide for Laravel 13 Developers

php dev.to

Originally published at hafiz.dev


If you've shipped 10, 20, even 50 Laravel projects and never written a single test, you're not alone. Testing in PHP has a reputation for being verbose and a bit joyless. Then Pest came along and made it something developers actually reach for. With Pest 4 and Laravel 13, there's no good reason to keep putting it off.

This guide builds a real tested feature from scratch: an authenticated REST API for blog posts. By the end you'll have feature tests, validation tests using datasets, and arch tests running in parallel. No toy examples. Just the exact setup I'd use in a production Laravel 13 app.

Why Pest Instead of PHPUnit

PHPUnit is the standard. It works. But look at what a basic PHPUnit test actually looks like:

class PostTest extends TestCase
{
    public function test_unauthenticated_user_cannot_access_posts(): void
    {
        $response = $this->getJson('/api/posts');
        $response->assertStatus(401);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now look at the Pest equivalent:

it('blocks unauthenticated users from the posts endpoint', function () {
    getJson('/api/posts')->assertUnauthorized();
});
Enter fullscreen mode Exit fullscreen mode

Same result. Half the code. It reads like a sentence, which matters more than you'd think when you're scanning a failing test suite at 11pm trying to figure out what broke.

But syntax isn't the only reason to switch. The expectation API is where Pest really stands out. Instead of scattered assertEquals calls with arguments in the wrong order (expected or actual first? nobody ever remembers), you write assertions that chain left to right:

expect($user->name)->toBe('Hafiz');
expect($post->published_at)->not->toBeNull();
expect($response->json('data'))->toHaveCount(5);
Enter fullscreen mode Exit fullscreen mode

This reads the way you think about the assertion. The value comes first, the expectation follows. It's a small change that compounds across hundreds of tests and makes the suite much easier to scan at a glance.

Pest 4 runs on PHPUnit 12 under the hood. It's not a different framework. It's a better interface to the same machinery. You can mix Pest and PHPUnit test classes in the same project, so there's no big bang migration. You start using it today on new tests, convert old files when you touch them, and the existing ones keep working.

Pest was created by Nuno Maduro, a Laravel core team member, so the Laravel integration is first-class from the start. actingAs(), RefreshDatabase, HTTP assertions, Livewire testing, Filament testing. Everything works exactly as you'd expect, with no adapter layer in the way.

Installing Pest 4 in a Laravel 13 Project

Requirements: PHP 8.3+ and any of Laravel 11, 12, or 13. The pest-plugin-laravel package supports all three versions.

Start by swapping out PHPUnit. The --with-all-dependencies flag handles any shared dependency resolution:

composer remove phpunit/phpunit
composer require pestphp/pest --dev --with-all-dependencies
composer require pestphp/pest-plugin-laravel --dev
Enter fullscreen mode Exit fullscreen mode

Then initialise Pest in your project:

./vendor/bin/pest --init
Enter fullscreen mode Exit fullscreen mode

This creates tests/Pest.php, the central configuration file for your entire test suite. You can find a full list of Pest-related Artisan commands alongside all the other generator commands in the Laravel Artisan Commands reference.

Run ./vendor/bin/pest and you'll see the two example tests that come with a fresh Laravel install passing immediately under Pest syntax. That's it for setup.

Configure Pest.php Before Writing Anything

The tests/Pest.php file is where global configuration lives. Set it up before writing your first test and you'll avoid repeating boilerplate across every file.

<?php

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

uses(TestCase::class, RefreshDatabase::class)->in('Feature');
uses(TestCase::class)->in('Unit');
Enter fullscreen mode Exit fullscreen mode

RefreshDatabase wraps each test in a database transaction and rolls it back when the test finishes. Your database starts clean for every test. Because it's configured in Pest.php, you never have to declare it in individual test files.

You can also define reusable helper functions here that any test can use:

function authenticatedUser(array $attributes = []): User
{
    return User::factory()->create($attributes);
}
Enter fullscreen mode Exit fullscreen mode

Then anywhere in your feature tests: actingAs(authenticatedUser()). One line, no repetition.

The Feature We're Testing

For this tutorial we're building a simple posts API. Two endpoints: list posts, create a post. Both require Sanctum authentication.

The controller:

// app/Http/Controllers/Api/PostController.php

public function index(): JsonResponse
{
    $posts = Post::latest()->get();
    return response()->json($posts);
}

public function store(StorePostRequest $request): JsonResponse
{
    $post = Post::create($request->validated());
    return response()->json($post, 201);
}
Enter fullscreen mode Exit fullscreen mode

Routes:

Route::middleware('auth:sanctum')->group(function () {
    Route::get('/posts', [PostController::class, 'index']);
    Route::post('/posts', [PostController::class, 'store']);
});
Enter fullscreen mode Exit fullscreen mode

The form request validates incoming data:

// app/Http/Requests/StorePostRequest.php

public function rules(): array
{
    return [
        'title' => ['required', 'string', 'max:255'],
        'body'  => ['required', 'string'],
    ];
}
Enter fullscreen mode Exit fullscreen mode

This is the kind of setup you'll find in almost any Laravel REST API. Simple enough to follow clearly, realistic enough to cover the patterns that actually come up in production work.

Writing Your First Feature Tests

Create the test file:

php artisan make:test Api/PostTest
Enter fullscreen mode Exit fullscreen mode

Because Pest.php already applies the test case and RefreshDatabase, your test file stays completely focused:

<?php

use App\Models\Post;
use App\Models\User;

it('blocks unauthenticated users from listing posts', function () {
    getJson('/api/posts')->assertUnauthorized();
});

it('blocks unauthenticated users from creating posts', function () {
    postJson('/api/posts', [])->assertUnauthorized();
});

it('returns all posts for an authenticated user', function () {
    $user = User::factory()->create();
    Post::factory()->count(3)->create();

    actingAs($user)
        ->getJson('/api/posts')
        ->assertOk()
        ->assertJsonCount(3);
});

it('creates a post with valid data', function () {
    $user = User::factory()->create();

    $payload = [
        'title' => 'How I Finally Shipped My SaaS',
        'body'  => 'It took three rewrites and one epiphany.',
    ];

    actingAs($user)
        ->postJson('/api/posts', $payload)
        ->assertCreated()
        ->assertJsonFragment(['title' => 'How I Finally Shipped My SaaS']);

    expect(Post::count())->toBe(1);
});
Enter fullscreen mode Exit fullscreen mode

A few things worth highlighting here. No class, no public function, no $this for assertions. The HTTP helpers (actingAs(), getJson(), postJson()) come in globally through the Pest Laravel plugin.

Use named assertions over status codes. assertUnauthorized() tells you what the test expects. assertStatus(401) tells you a number. Both pass on a 401 response, but only one communicates intent to the developer reading it six months later. The same logic applies to assertOk(), assertCreated(), and assertUnprocessable().

The expect(Post::count())->toBe(1) line at the end is important. It verifies the database was actually written to, not just that the response body looked right. HTTP assertions alone don't prove persistence happened.

What Actually Deserves a Test

New developers often freeze on this question. The answer isn't about percentages. It's about behaviour.

Test the things users would notice if they broke. Authentication, authorisation, validation, business logic, queue jobs. If a bug in that code would cause a user to see wrong data, access something they shouldn't, or submit something they shouldn't be able to, it needs a test. That's a reliable filter.

Three categories always worth covering:

Authentication and authorisation. Write tests that prove unauthenticated requests get rejected and that authorised users only access what they're allowed to. This is the most common source of security issues in web apps, and the easiest thing to accidentally break during a refactor.

Validation rules. Form requests with rules need tests, especially for edge cases: empty strings, strings that are one character over the limit, missing required fields. Datasets make this practical without multiplying boilerplate, as you'll see shortly.

Business logic in service classes or actions. If you've extracted logic out of a controller into a dedicated class because it's complex, that class deserves unit tests. The complexity is exactly what makes it risky to change without coverage.

What you can skip: Laravel itself, which is tested thoroughly by its own team. Simple Eloquent accessors that just return a property. One-line controller methods that delegate entirely to a service.

The highest-value tests in a Laravel app are feature-level HTTP tests. One test that calls actingAs($user)->postJson(...) simultaneously exercises the route, middleware, controller, form request, model, and database layer. That's significant coverage for one test function. Start there before reaching for unit tests on individual methods.

Shared Setup with beforeEach

When multiple tests in the same file need the same starting state, use beforeEach() rather than repeating setup code in every test:

<?php

use App\Models\Post;
use App\Models\User;

beforeEach(function () {
    $this->user = User::factory()->create();
});

it('returns posts for the authenticated user', function () {
    Post::factory()->count(5)->create();

    actingAs($this->user)
        ->getJson('/api/posts')
        ->assertOk()
        ->assertJsonCount(5);
});

it('creates a post', function () {
    actingAs($this->user)
        ->postJson('/api/posts', [
            'title' => 'Test Post',
            'body'  => 'Test body.',
        ])
        ->assertCreated();

    expect(Post::count())->toBe(1);
});
Enter fullscreen mode Exit fullscreen mode

beforeEach() runs before every test in the file. State set inside it is available on $this. This keeps tests short and focused: each one only shows what's unique about that particular assertion. The shared setup disappears into the background where it belongs.

Datasets for Validation Testing

This is the feature that makes validation testing actually maintainable at scale. PHPUnit has data providers that do the same thing, but Pest's syntax is significantly cleaner and the test output is far more readable.

Instead of writing a separate test function for every invalid input combination:

it('rejects invalid post data', function (array $payload, string $field) {
    $user = User::factory()->create();

    actingAs($user)
        ->postJson('/api/posts', $payload)
        ->assertUnprocessable()
        ->assertJsonValidationErrors([$field]);
})->with([
    'missing title'  => [['body' => 'Some body text'], 'title'],
    'missing body'   => [['title' => 'Some title'], 'body'],
    'empty title'    => [['title' => '', 'body' => 'Some body text'], 'title'],
    'title too long' => [['title' => str_repeat('a', 256), 'body' => 'Some body text'], 'title'],
]);
Enter fullscreen mode Exit fullscreen mode

Pest runs this four times, once per dataset entry. The named keys ('missing title', 'missing body', and so on) appear directly in the test output, so failures are immediately obvious without digging through a stack trace. You wrote one function. You got four test cases.

For datasets you'll reuse across multiple files, extract them into tests/Datasets/Posts.php:

<?php

dataset('invalid_post_payloads', [
    'missing title' => [['body' => 'Some body text'], 'title'],
    'missing body'  => [['title' => 'Some title'], 'body'],
    'empty title'   => [['title' => '', 'body' => 'Some body text'], 'title'],
]);
Enter fullscreen mode Exit fullscreen mode

Then reference by name from any test file:

it('rejects invalid post data', function (array $payload, string $field) {
    // ...
})->with('invalid_post_payloads');
Enter fullscreen mode Exit fullscreen mode

This pattern works well in larger SaaS applications where the same validation rules appear across multiple endpoints. If you're building something with significant admin-side data management, this kind of test organisation pays off fast. The complete Laravel + Filament SaaS guide shows the kind of app complexity where shared datasets start to matter.

Architecture Testing: The Feature Nobody Covers

Most Pest tutorials skip this section entirely. That's a shame because it's one of the most valuable things in the whole framework.

Arch tests don't test your application's behaviour. They test its structure. Think of them as automated code review that runs on every commit. You define the rules once and Pest enforces them permanently, without anyone needing to remember to check.

Create tests/Arch/AppTest.php:

<?php

arch('no debug statements left in the codebase')
    ->expect('App')
    ->not->toUse(['dd', 'dump', 'var_dump', 'ray']);

arch('models extend Eloquent')
    ->expect('App\Models')
    ->toExtend('Illuminate\Database\Eloquent\Model')
    ->toBeClasses();

arch('jobs implement ShouldQueue')
    ->expect('App\Jobs')
    ->toImplement('Illuminate\Contracts\Queue\ShouldQueue');

arch('controllers stay in the HTTP layer')
    ->expect('App\Http\Controllers')
    ->toOnlyBeUsedIn('App\Http');

arch('strict types declared everywhere')
    ->expect('App')
    ->toUseStrictTypes();
Enter fullscreen mode Exit fullscreen mode

The dd/dump rule catches more than you'd expect. There's always one lurking in a big codebase. Pest finds it instantly and names the exact file and line number. The toUseStrictTypes() rule is the unforgiving one: any file in App missing declare(strict_types=1) breaks the build immediately.

The jobs rule pairs naturally with proper queue architecture. If you're processing background jobs at scale, like the patterns covered in the Laravel queue jobs guide, this arch test makes sure nothing enters the Jobs folder without implementing ShouldQueue.

You can also reach for Pest's built-in presets that bundle sensible defaults into a single call:

arch()->preset()->php();
arch()->preset()->security()->ignoring('md5');
arch()->preset()->laravel();
Enter fullscreen mode Exit fullscreen mode

The security preset catches eval(), system(), shell_exec(), and other calls you never want in production code. The laravel preset enforces framework conventions like keeping Facades out of domain classes. The php preset handles strict_types, suspicious characters in strings, and other language-level concerns.

The ->ignoring() modifier lets you exclude specific namespaces when a rule doesn't apply to your context:

arch()->preset()->security()->ignoring('App\Services\LegacyBridge');
Enter fullscreen mode Exit fullscreen mode

None of the presets are all-or-nothing. Use what fits, skip what doesn't.

Running Tests in Parallel

One flag changes the runtime dramatically:

./vendor/bin/pest --parallel
Enter fullscreen mode Exit fullscreen mode

Pest distributes the suite across multiple processes and runs them simultaneously. On a typical Laravel app with 50-100 tests, this cuts runtime roughly in half. The savings compound as the suite grows.

For CI with GitHub Actions, combine parallel with sharding to split across multiple machines:

./vendor/bin/pest --parallel --shard=1/4
Enter fullscreen mode Exit fullscreen mode

Workflow configuration:

strategy:
  matrix:
    shard: [1, 2, 3, 4]

steps:
  - name: Run tests
    run: ./vendor/bin/pest --parallel --shard=${{ matrix.shard }}/4
Enter fullscreen mode Exit fullscreen mode

Four machines, four shards. A 4-minute test suite becomes a 60-second one. The setup takes about 10 minutes and pays back on the first push.

One thing to check before enabling parallel: your tests need to be database-isolated. RefreshDatabase handles this correctly and is the right choice for parallel runs. If you're managing database state in other ways, verify that tests can't bleed into each other first.

Type Coverage

Install the plugin:

composer require pestphp/pest-plugin-type-coverage --dev
Enter fullscreen mode Exit fullscreen mode

Run it with a minimum threshold:

./vendor/bin/pest --type-coverage --min=90
Enter fullscreen mode Exit fullscreen mode

Pest 4 made the type coverage engine 2x faster on first run and instant on subsequent runs. It also added sharding support, so large codebases don't see a significant CI overhead.

This isn't a replacement for PHPStan or Psalm, but it's a fast sanity check with zero configuration overhead. If you're not running any static analysis yet, this is a practical first step toward it.

Frequently Asked Questions

Does Pest 4 work with my existing PHPUnit tests?

Yes. Pest runs on PHPUnit 12 under the hood, so existing test classes keep working without any changes. Run ./vendor/bin/pest and it picks up both. You can migrate files gradually or leave old ones as-is.

Can I use Pest with Livewire and Filament?

Absolutely. Livewire::test(), Filament's test helpers, actingAs(), and the full Laravel testing API all work exactly as they do in PHPUnit. No adapter, no wrapper. It just works.

What's the difference between it() and test()?

Nothing functional. it('creates a post') reads as a behaviour description. test('post creation') is more PHPUnit-style. I use it() for behaviour tests and test() for unit-level tests, but that's personal preference, not a rule.

Is arch testing worth adding to a small project?

Yes, often more so than on large ones. On a small project the rules act as a forcing function, keeping structure clean from the start instead of creating expensive cleanup work later. The debug statement rule alone has saved me from at least three embarrassing incidents.

How do I run just one test or one file?

Run a specific file:

./vendor/bin/pest tests/Feature/Api/PostTest.php
Enter fullscreen mode Exit fullscreen mode

Filter by test description: ""

./vendor/bin/pest --filter "creates a post"
Enter fullscreen mode Exit fullscreen mode

Run a specific group:

./vendor/bin/pest --group api
Enter fullscreen mode Exit fullscreen mode

The --filter flag accepts partial strings, so --filter "creates" matches any test whose description contains the word.


Testing isn't about achieving 100% coverage. It's about having enough confidence to deploy on a Friday without holding your breath. Pest 4 makes that bar significantly lower to clear, and Laravel 13 gives you a clean enough test setup to actually maintain long term.

If you're building a Laravel SaaS and want a second opinion on your testing strategy before things get complicated, reach out. Happy to take a look at what you've got.

Source: dev.to

arrow_back Back to Tutorials