You ship a Laravel API. A junior dev refactors the UserResource and email_address quietly becomes email. Your tests all pass — because the one feature test that hits that endpoint only asserts on name. The mobile team finds out in production three weeks later.
Pest 4 ships with snapshot testing in the box. It is the cheapest possible insurance against this exact bug, and almost nobody in the Laravel space talks about it because the official docs demonstrate it on plain strings rather than API responses.
What snapshot testing actually catches that assertJson misses#
assertJson is an opt-in assertion — you only catch regressions on fields you remembered to write an assertion for. toMatchSnapshot() is the opposite. It records the entire expected payload on the first run, then fails the second you accidentally rename a key, drop a field, or change a cast.
Schema drift is the long-tail of API bugs. A new accessor leaks into a resource. A $casts change converts an int to a string. A factory state quietly adds a column. None of it surfaces until a downstream consumer breaks. If you have spent any time scaling a Laravel codebase — see scaling Laravel queues in production for one variant of the same "silent change in production" problem — you know how expensive these bugs are to hunt down after release.
Adding toMatchSnapshot to your first API test#
Start with the basics. Get Pest 4 installed and write a test that snapshots a JSON response:
composer require pestphp/pest --dev --with-all-dependencies
./vendor/bin/pest --init
Then write the test:
<?php
use App\Models\User;
it('returns the expected user resource shape', function () {
$user = User::factory()->create([
'name' => 'Steven Richardson',
'email' => '[email protected]',
]);
$response = $this->getJson("/api/users/{$user->id}");
$response->assertOk();
expect($response->json())->toMatchSnapshot();
});
Run it once. Pest writes a new file into tests/.pest/snapshots/ — the path mirrors the test file and test name. Commit that file. From now on, any change to the JSON body of that endpoint will fail the test until you either revert the change or accept the new snapshot.
If you want the response headers locked down too — Content-Type, custom rate-limit headers, anything you advertise in your docs — snapshot those separately:
expect($response->headers->all())->toMatchSnapshot();
Snapshotting just the response shape, not the values#
The naive snapshot above will fail every time a timestamp, ID, or token changes. The fix is to normalise the payload before the expectation. Two approaches work — pick whichever fits your team.
The simplest is to strip the volatile keys inline using Laravel's Arr helper:
use Illuminate\Support\Arr;
it('returns the user resource shape', function () {
$response = $this->getJson('/api/users/1');
$payload = Arr::except($response->json(), [
'id',
'created_at',
'updated_at',
]);
expect($payload)->toMatchSnapshot();
});
For repeated use across a whole test suite, register an Expectation Pipe in tests/Pest.php so every snapshot benefits from the same normalisation:
// tests/Pest.php
expect()->pipe('toMatchSnapshot', function (Closure $next) {
if (is_array($this->value)) {
$this->value = $this->normaliseTimestamps($this->value);
}
return $next();
});
expect()->extend('normaliseTimestamps', function (array $payload) {
array_walk_recursive($payload, function (&$value, $key) {
if (in_array($key, ['created_at', 'updated_at', 'deleted_at'], true)) {
$value = '<timestamp>';
}
});
return $payload;
});
Now every toMatchSnapshot() call across the codebase has consistent handling for timestamps. The Pipe pattern is the official Pest mechanism for dynamic data — the same one the Pest snapshot docs use to strip CSRF tokens from HTML.
For the must-have contract bits, combine snapshots with explicit assertions. The snapshot is the safety net for the shape; assertJsonStructure enforces the keys you actively promise to consumers:
$response
->assertOk()
->assertJsonStructure([
'data' => [
'id',
'name',
'email',
],
]);
expect(Arr::except($response->json('data'), ['id', 'created_at', 'updated_at']))
->toMatchSnapshot();
If you are not already running PHPStan against your tests, PHPStan level 10 on Laravel catches a different class of API drift — type changes on the controller side that the snapshot will only catch at runtime.
Updating snapshots when the API genuinely changes#
The whole point is that snapshots fail when the shape changes. When the change is intentional — you added a field, renamed a column, evolved the resource — rebuild them:
./vendor/bin/pest --update-snapshots
This rewrites every stale snapshot in tests/.pest/snapshots/. Review the diff in the PR. That diff is the API change. It is also the artefact your reviewers should be looking at to decide whether the change is backwards-compatible. Treat it the same way you treat a database migration diff.
Never edit snapshot files by hand. They are generated artefacts. Hand-edits drift from what the code actually produces and the next --update-snapshots run will silently overwrite them.
In CI, make sure you do not pass --update-snapshots — the whole value disappears if CI just regenerates the expected output. The test command should be the bare ./vendor/bin/pest. If you bake your test runner into a Makefile alongside the rest of your dev workflow, streamlining Laravel onboarding with a Makefile covers a pattern for keeping the CI and local commands in lockstep.
When to use snapshots vs explicit assertions#
Snapshots are not a replacement for the assertions that document intent. Use them as a layered defence:
assertJson(['status' => 'paid']) says "this endpoint must return status: paid for a settled invoice." That is a business rule. Always assert it explicitly.
expect($response->json())->toMatchSnapshot() says "and nothing else should change about the response without somebody noticing." That is a regression net.
The combination gives you readable tests where the important business rules are right there in the assertion, plus an automatic alarm for every other field. This is the same layering principle behind Pest architecture testing for Laravel — explicit rules for the things that matter, then a sweep for everything else.
Where snapshots fall down: large rendered HTML pages. The diff on a Blade template change is unreadable. Stick to JSON payloads, structured arrays, and small headers maps.
Gotchas and Edge Cases#
A few things bite people on the first real rollout.
Random IDs from factories. If your test creates a User::factory()->create() without seeding the ID, the autoincrement value changes between local and CI. Either seed with a fixed id, or strip the field before snapshotting.
Eloquent serialisation order is not guaranteed. Two PHP runs can emit JSON keys in different orders depending on Eloquent's hidden/visible handling. Pin the order with an API Resource or sort keys before the expectation.
Database state pollution. Snapshot tests that hit RefreshDatabase cleanly are fine. Tests that share state with each other will produce non-deterministic snapshots. Snapshot tests will surface this — treat the noise as a signal that the test isolation is broken, not as a problem with snapshots.
Browser tests are different. Pest 4 introduced first-class browser testing — see Pest 4 browser testing with Playwright in Laravel — and browser snapshots there are visual rather than JSON. Don't conflate the two APIs.
CI auto-update is a footgun. Some teams configure CI to retry tests with --update-snapshots. Don't. The snapshot becomes whatever the code emits, which is exactly the regression the test is supposed to catch.
Wrapping Up#
toMatchSnapshot() is a five-minute addition to any existing Pest test and it pays for itself the first time a contributor accidentally renames a JSON key. Add it to your most-consumed API endpoints today, commit the snapshots, and treat the snapshot diff as part of your code review.
If you want to push your test suite further, GitHub Actions matrix testing across PHP and database versions catches drift across deployment targets, and Pest architecture testing for Laravel catches drift in the codebase itself.
FAQ#
What is snapshot testing in Pest 4?
Snapshot testing in Pest 4 stores the output of a value the first time a test runs, then compares the value to that stored snapshot on every subsequent run. The expectation is toMatchSnapshot() and the snapshot files live in tests/.pest/snapshots/. It is built into Pest 4 with no plugin required, and it works on strings, arrays, response objects, and anything else you can put inside an expect().
How do I write a snapshot test for a Laravel API response?
Make a regular feature test that hits the endpoint with $this->getJson('/api/users/1'), then call expect($response->json())->toMatchSnapshot() on the decoded payload. On the first run Pest writes the JSON into tests/.pest/snapshots/ and the test passes; on every subsequent run it compares the live response to the saved file and fails if anything changed. Snapshot the response body and the headers map separately if you want both locked down.
Should I commit Pest snapshot files to git?
Yes. Snapshot files are the expected output of the test, the same way a JSON fixture or a SQL seed file is. Without the snapshot in the repository, CI has nothing to compare against and the test will pass against whatever the code happens to emit on the first run. Add tests/.pest/snapshots/ to git and treat changes to those files as a deliberate signal in code review.
How do I update a Pest snapshot after intentionally changing the response?
Run ./vendor/bin/pest --update-snapshots locally. Pest rewrites every snapshot file to match the current output. Review the resulting diff carefully — it is the exact change to your API contract — and include those updated snapshot files in the same pull request as the code change. Never run --update-snapshots in CI, because it will silently absorb the regression you wanted to catch.
When should I use snapshot testing vs explicit assertJson assertions?
Use assertJson and assertJsonStructure for the fields that represent business rules or public API contracts — anything a consumer is allowed to rely on. Use toMatchSnapshot() as a safety net on top, to catch every other field that you would not have remembered to assert. Together they give you readable tests that document intent plus an automatic alarm for unintentional schema drift.
How do I exclude volatile fields like timestamps from a Pest snapshot?
Two patterns work. Inline, use Arr::except($response->json(), ['id', 'created_at', 'updated_at']) before passing the array to expect(...)->toMatchSnapshot(). For a project-wide convention, register an Expectation Pipe in tests/Pest.php that replaces known volatile keys with a placeholder like <timestamp> before the snapshot is compared. The Pipe approach keeps individual tests clean and gives you one place to evolve the normalisation rules.