ssdev/laravel-api-contracts

API response contract snapshot testing for Laravel — detects breaking changes before they reach production.

Maintainers

Package info

github.com/SeadSilajdzic/ssdev-laravel-api-contracts

pkg:composer/ssdev/laravel-api-contracts

Transparency log

Statistics

Installs: 13

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.3.0 2026-07-17 12:07 UTC

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

  1. You write contract tests that hit your API endpoints and call assertMatchesApiContract()
  2. On first run, the response shape is saved as a JSON snapshot (committed to git)
  3. On every subsequent git push, the hook re-runs those tests and compares against the snapshots
  4. If a field was removed or a type changed, the push is blocked — you see exactly what broke
  5. 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 broken
  • git 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. integerstring) 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