ssdev / laravel-api-contracts
API response contract snapshot testing for Laravel — detects breaking changes before they reach production.
Package info
github.com/SeadSilajdzic/ssdev-laravel-api-contracts
pkg:composer/ssdev/laravel-api-contracts
Requires
- php: ^8.2
- illuminate/console: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- pestphp/pest: ^2.0|^3.0|^4.0
This package is auto-updated.
Last update: 2026-07-17 12:09:49 UTC
README
API response contract snapshot testing for Laravel — catch breaking changes before they hit production.
How it works
- You write contract tests that hit your API endpoints and call
assertMatchesApiContract() - On first run, the response shape is saved as a JSON snapshot (committed to git)
- On every subsequent
git push, the hook re-runs those tests and compares against the snapshots - If a field was removed or a type changed, the push is blocked — you see exactly what broke
- New fields (additive changes) are reported but never block a push
Snapshots are plain JSON files committed alongside your code. Any contract change is a visible git diff.
Installation
composer require ssdev/laravel-api-contracts --dev
php artisan api:contract:install
This sets up:
tests/snapshots/api/— snapshot directory (commit this).githooks/pre-commit— warns about routes with no snapshot coverage.githooks/pre-push— blocks push if existing contracts are brokengit config core.hooksPath .githooks
Generating test stubs
Scan your API routes and generate a test file automatically:
php artisan api:contract:generate --prefix=api/v1
This produces tests/Feature/ApiContractTest.php with one test per GET route, ready to fill in:
<?php use Ssdev\ApiContracts\Testing\InteractsWithApiContract; uses(InteractsWithApiContract::class); uses(Illuminate\Foundation\Testing\RefreshDatabase::class); // TODO: fill in your auth headers function contractHeaders(): array { return ['X-API-KEY' => '...', 'Accept' => 'application/json']; } // --------------------------------------------------------------------------- // GET /api/v1/products // --------------------------------------------------------------------------- it('GET /api/v1/products matches contract', function () { $response = $this->withHeaders(contractHeaders())->getJson('/api/v1/products'); $response->assertStatus(200); $this->assertMatchesApiContract('GET_api_v1_products', $response->json()); }); // --------------------------------------------------------------------------- // GET /api/v1/products/{id} // --------------------------------------------------------------------------- it('GET /api/v1/products/{id} matches contract', function () { $id = 1; // TODO: replace with a valid id $response = $this->withHeaders(contractHeaders())->getJson("/api/v1/products/{$id}"); $response->assertStatus(200); $this->assertMatchesApiContract('GET_api_v1_products_show', $response->json()); });
Non-GET routes are generated as commented-out stubs for you to implement manually.
After filling in the auth setup, generate the initial snapshots:
php artisan api:contract:update
Adding tests for new routes
When you add new routes later, run with --merge to append only the missing tests without touching existing ones:
php artisan api:contract:generate --merge
Git hooks
pre-commit — coverage check
Every commit checks whether any API routes are missing snapshot coverage and warns you:
[api:contract] Coverage warning:
→ POST /api/v1/orders
→ DELETE /api/v1/orders/{id}
Run php artisan api:contract:generate --merge to add test stubs.
(this is a warning only — commit is not blocked)
pre-push — contract enforcement
Every push runs your contract tests. If a breaking violation is detected, the push is blocked:
Running API contract tests...
[GET_api_v1_products] BREAKING API contract violation:
✖ [BREAKING] Field removed: 'data.products[].sku' (was 'string')
✖ [BREAKING] Type changed: 'data.products[].price' was 'integer', now 'string'
+ [NEW] New field: 'data.products[].discount'
If INTENTIONAL: run php artisan api:contract:update
then commit the snapshot files and push again.
If ACCIDENTAL: fix the code before pushing.
Accept these changes and update snapshots now? (y/N)
If you answer y, snapshots are regenerated in place. You commit them and push again — the updated contract becomes part of the same push.
CI enforcement
core.hooksPath is local git config — it isn't tracked in your repo, so a new contributor has
no protection until they run api:contract:install themselves, and the hook can always be
skipped with git push --no-verify. For real, server-side enforcement, generate a CI workflow
alongside the hooks:
php artisan api:contract:install --ci=github # → .github/workflows/api-contract.yml php artisan api:contract:install --ci=bitbucket # → bitbucket-pipelines.yml php artisan api:contract:install --ci=gitlab # → .gitlab-ci.yml
Any of these run the same contract test suite on every pull/merge request. Won't overwrite an existing workflow file without asking first.
Violations
| Type | Meaning | Blocks push? |
|---|---|---|
REMOVED |
Field existed in snapshot, now missing | Yes |
TYPE_CHANGED |
Field type changed (e.g. integer → string) |
Yes |
NEW |
Field added, not in snapshot | No |
A null value — in the snapshot or in the response — is treated as "unknown type" and never flags a type change on its own, in either direction. This covers nullable fields that happen to be null on the first captured request, or null on a later request.
If a field's captured shape is an empty array, its element shape couldn't be determined — a warning is printed (test still passes) so it doesn't silently go unvalidated:
[GET_api_v1_products] Warning: empty array(s) at: data.products, tags — item shape not captured, will not be validated until a non-empty response is snapshotted.
Strict mode
By default NEW never blocks — that's the point, additive changes shouldn't stop your workflow.
Some teams want the opposite in certain branches (e.g. a versioned public API where every field
addition needs a deliberate snapshot update before merge). Turn that on with:
// config/api-contract.php 'strict' => true,
With strict enabled, NEW is treated as breaking too — any response change at all fails the
test until you run api:contract:update and commit the new snapshot. This is a config flag only,
ApiContractSnapshot::compare() itself doesn't change.
Commands
| Command | Description |
|---|---|
api:contract:install |
Install hooks, snapshot dir, git config |
api:contract:install --ci=github |
Also generate a GitHub Actions workflow |
api:contract:install --ci=bitbucket |
Also generate a Bitbucket Pipelines config |
api:contract:install --ci=gitlab |
Also generate a GitLab CI config |
api:contract:generate --prefix=api/v1 |
Generate test stubs from routes |
api:contract:generate --merge |
Add tests for new routes only |
api:contract:update |
Regenerate all snapshots |
api:contract:coverage --prefix=api/v1 |
Report routes with no snapshot coverage |
Writing tests manually
If you prefer to write tests by hand, use the InteractsWithApiContract trait directly:
Pest:
use Ssdev\ApiContracts\Testing\InteractsWithApiContract; uses(InteractsWithApiContract::class); it('products index matches contract', function () { $response = $this->getJson('/api/v1/products'); $response->assertStatus(200); $this->assertMatchesApiContract('GET_products', $response->json()); });
PHPUnit:
use Ssdev\ApiContracts\Testing\InteractsWithApiContract; class ApiContractTest extends TestCase { use InteractsWithApiContract; public function test_products_index(): void { $response = $this->getJson('/api/v1/products'); $this->assertMatchesApiContract('GET_products', $response->json()); } }
Testing multiple response variants
assertMatchesApiContract() takes an arbitrary string as the snapshot name, so if the same
endpoint returns a different shape depending on who's calling it (e.g. an admin sees extra
fields), you can track each variant as its own independent contract — just give each one a
distinct name:
it('GET /products matches contract (regular user)', function () { $response = $this->actingAs($regularUser)->getJson('/api/v1/products'); $this->assertMatchesApiContract('GET_api_v1_products', $response->json()); }); it('GET /products matches contract (admin)', function () { $response = $this->actingAs($admin)->getJson('/api/v1/products'); $this->assertMatchesApiContract('GET_api_v1_products__admin', $response->json()); });
This produces two separate snapshot files, each validated independently — no conflict, no flip-flopping NEW/REMOVED noise depending on which variant ran last.
Snapshot format
Snapshots capture the type shape of your response, not the actual values:
{
"success": "boolean",
"data": {
"products": [
{
"id": "integer",
"name": "string",
"price": "double",
"status": "string",
"brand": {
"id": "integer",
"name": "string",
"logo_url": "null"
}
}
],
"pagination": {
"current_page": "integer",
"per_page": "integer",
"total": "integer",
"last_page": "integer"
}
},
"message": "string"
}
Arrays are represented by the shape of their first element. Committing these files gives you a permanent, reviewable record of your API contract — any change is visible as a git diff.
Configuration
php artisan vendor:publish --tag=api-contract-config
// config/api-contract.php return [ 'snapshot_dir' => 'tests/snapshots/api', // where snapshots are stored 'test_path' => 'tests/Feature/ApiContractTest.php', // used by update + hook 'test_flags' => '--no-coverage', // extra flags for test runner 'update_env' => 'API_CONTRACT_UPDATE', // env var that triggers snapshot write 'route_prefix' => 'api', // prefix for generate + coverage commands 'hooks_dir' => '.githooks', // where hooks are installed 'auth' => null, // closure returning a bearer token, see below 'strict' => false, // when true, NEW fields fail tests too ];
Auto-authenticating generated tests
If your API routes require auth, set auth to a closure that returns a bearer token. api:contract:generate will then emit a contractHeaders() that calls it automatically, instead of a manual TODO:
// config/api-contract.php 'auth' => fn () => \App\Models\User::factory()->create()->createToken('test')->plainTextToken,
The closure is called at test runtime (not at generation time), so it can freely touch the database.
---
## Requirements
- PHP 8.2+
- Laravel 10, 11, 12, or 13
---
## License
MIT