hotmeteor/spectator
Spectator brings lightweight OpenAPI contract testing to your Laravel test suite. Write tests that guarantee your API spec never drifts from your implementation, with artisan validation commands, operation coverage tracking, and JSON error output for CI.
Install and publish the config
composer require hotmeteor/spectator --dev
php artisan vendor:publish --tag=spectator-config
Spectator is a dev-only dependency — it never belongs in require. The config lands at config/spectator.php and points at a local spec path by default (typically base_path('openapi.yaml')). If your spec lives elsewhere (docs/openapi.yaml, api/openapi.json), fix the path there once rather than passing paths into every test.
Sanity-check the spec before writing any tests
php artisan spectator:validate
php artisan spectator:routes
spectator:validate lints the document (bad $refs, malformed schemas, wrong OpenAPI version). spectator:routes cross-references every operation in the spec against your actual Laravel route table — this is the fastest way to find the endpoints that are documented but not implemented, or implemented but not documented. Run both before you write a single assertion; most "Spectator is broken" reports are actually "the spec was invalid all along."
On v3.0.4+ the spectator:routes output shows the resolved path with the route prefix applied, so the string it prints (/api/users) is the literal string that must appear in your spec's paths. That single column removes most of the guesswork described in the Gotchas section — copy from it rather than from php artisan route:list.
Your first use case: one endpoint you already test
Do not start by bolting contract assertions onto your whole suite. Pick a single feature test that already passes, and add two lines:
use Spectator\Spectator;
public function test_users_index(): void
{
Spectator::using('openapi.yaml');
$this->getJson('/api/users')
->assertValidRequest()
->assertValidResponse(200);
}
Spectator::using() resolves the spec for the current test. assertValidRequest() validates the outgoing request (path params, query string, body) against the operation's parameters/requestBody; assertValidResponse() validates the returned status code, headers, and body against the declared response schema. If both pass, the endpoint's contract holds. Then run php artisan test --filter=test_users_index and fix whichever side is wrong — usually the spec.
Where to look first
config/spectator.php — spec path/source, validator bindings, error format.php artisan spectator:routes — the resolved, prefix-aware URIs Spectator will match against (v3.0.4+).php artisan route:list --path=api — the raw route table, for comparison.paths key of your spec — the exact strings must line up (see Gotchas).Set the spec once in your base TestCase
Per-test Spectator::using() calls are noise. Set it in setUp() so every feature test is contract-checked by default:
protected function setUp(): void
{
parent::setUp();
Spectator::using('openapi.yaml');
}
This is the highest-leverage change you can make: existing tests silently gain contract coverage, and drift surfaces as failures in tests you already trust.
Multiple specs via named sources
Most real apps have more than one document — public API, admin API, partner/webhook surface. Register each in the config sources array and switch per test class:
class AdminUserTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Spectator::using('admin.yaml');
}
}
Keep each spec's paths genuinely disjoint, or the resolver will pick the wrong operation.
Keep requests and responses as separate assertions
assertValidRequest() is cheap and fails loudly with the offending field. assertValidResponse() is the one that catches serializer drift. Use them independently when debugging — comment one out to isolate which side broke. For responses, pass the status code explicitly when the endpoint can return several:
$this->postJson('/api/users', $payload)
->assertValidRequest()
->assertValidResponse(201);
$this->postJson('/api/users', [])
->assertValidResponse(422);
The explicit status forces you to declare the error shape in the spec, which is exactly the discipline you want.
Drive it with data providers, not more tests
Contract testing pays off on variation: query filters, pagination params, enum values, optional-vs-required body fields. A PHPUnit data provider over [$uri, $payload, $status] gives you broad schema coverage without duplicating test bodies.
Use spectator:stubs to bootstrap, then rewrite
php artisan spectator:stubs emits skeleton test classes per operation. Treat the output as scaffolding for paths/status codes, then replace the payloads with your model factories and the assertions with meaningful ones. Generated stubs on their own inflate your test count without testing behaviour.
Authenticated endpoints
actingAs()/Sanctum works fine, but Spectator validates request shape, not authorization. Security schemes in the spec do not cause assertions to fire. What matters is that auth headers, tenant identifiers, and similar required headers appear in the spec's parameters if you want them enforced — otherwise leave them undeclared so they're ignored.
Coverage gating in CI
v3's SpectatorExtension tracks which operations your run actually exercised. Register it in phpunit.xml and set a minimum threshold so a new endpoint can't ship without at least one contract-hitting test. Pair with machine-readable output for your CI dashboard:
SPECTATOR_ERROR_FORMAT=json php artisan test --log-junit=build/junit.xml
php artisan spectator:coverage --format=json > build/spectator-coverage.json
--format=json on the artisan commands and SPECTATOR_ERROR_FORMAT=json (or Spectator::useJsonErrors()) both matter — ANSI-coloured assertion diffs are unreadable in CI logs and unparseable by tooling.
Remote spec sources
If the spec is owned by another team or lives in a docs repo, configure a remote source in config/spectator.php and point Spectator::using() at the named source. That lets CI validate against the published contract rather than a stale copied file, which is the whole point of contract testing across team boundaries.
Fail hard, then validate
Add $this->withoutExceptionHandling() while you're bringing an endpoint under contract. Laravel's handler will happily convert a validation failure into a 422 with a shape the spec may not declare; bypassing it surfaces the real exception, and once the happy path is green you can add the error-path assertions deliberately.
Route prefixes must match the spec exactly
This is the number one source of confusion. Spectator matches the request path against the spec's paths keys literally. If your routes are Route::prefix('api') and your spec says /users, nothing matches — you'll get "no matching operation" style errors that look like schema failures. Either document /api/users in the spec or keep the servers base path and spec paths aligned with reality. Since v3.0.4, php artisan spectator:routes prints the resolved path including the prefix, so use that column as the source of truth when updating the spec — it's the exact string the matcher will compare against, whereas route:list gives you the raw URI plus a separate prefix column you have to join yourself.
Empty x-www-form-urlencoded bodies (v3.0.4)
Before v3.0.4, posting an empty form body ($this->post('/api/thing', [])) against an object schema blew up with a 500 instead of validating, because the decoded body was an array where the validator expected an object. v3.0.4 returns stdClass for the empty case. If you're on 3.0.3 or earlier and see an opaque 500 on a form post that has no fields, this is the cause — either upgrade or send at least one field in the test. Note this only affects application/x-www-form-urlencoded; empty JSON bodies are handled separately, so postJson(..., []) behaves differently from post(..., []). If your spec says the body is required, an empty form post should fail validation — assert that deliberately rather than relying on the old crash.
additionalProperties: false vs Laravel resources
API Resources and ->toArray() frequently emit fields you forgot about — timestamps, pivot, computed attributes, links/meta wrappers. Under a strict schema these fail immediately. Decide deliberately: relax the schema, or trim the resource. Do not reflexively flip additionalProperties to true to silence the failure; that's how spec drift starts.
Nullable vs optional is a real distinction
nullable: true (present, may be null) and "not in required" (may be absent) are different things, and Laravel's conditional-attribute patterns produce both. A model attribute that's null serialized as "field": null will fail a schema that means "optional." Read the actual JSON, don't infer it from the schema intent.
Empty and 204 responses
assertValidResponse(204) on a genuinely empty body is fine if the spec declares no content. If the spec declares a schema and Laravel returns an empty body (or vice versa), you get a confusing content-type/parse error. Check the spec's content block for that status before debugging the app.
Exception handler transforms responses
Global middleware, Handler::render, and response macros can reshape output after your controller returns. Spectator validates what the client receives, not what your controller produced — so an error envelope injected by the handler must be in the spec. This is a feature, but it's surprising the first time a passing controller test fails on contract.
Spec parse cost
Specs are resolved and cached per path, but large documents still cost real time if you re-resolve per test method. Set the spec once in setUp() (or in the base class) rather than inside individual test methods, and avoid switching sources mid-test.
Debugging a failure
Read the exception message first — it names the JSON pointer and the failing keyword (type, required, enum). Switch to JSON error format when reading from CI, and isolate with assertValidRequest() alone before blaming the response schema. When in doubt, dump the actual response and diff it mentally against the spec's responses.<status>.content.<media-type>.schema; nine times out of ten the mismatch is a missing required entry, not a Spectator bug.
Version pinning matters
v3 requires PHP 8.3+ and Laravel 12+. On an older stack, pin ^2 — the assertion API is largely the same, but the artisan commands, coverage extension, and JSON error format simply don't exist, and neither do the corresponding config keys. Don't copy v3 snippets into a v2 install and assume silence means success. Within v3, pin at least ^3.0.4 if you test form-encoded endpoints or rely on spectator:routes for debugging prefixed APIs: 3.0.4 carries the empty-form-body fix and the prefix-aware resolved path output, both of which otherwise cost you an afternoon.
Extension points
If your organization has its own schema dialect or an internal spec registry, swap the validator implementation via the config bindings rather than monkey-patching assertions. That's the supported seam — everything else (TestResponse macros, source resolution) is wired around it.
Don't let the spec become the test
The failure mode of contract testing is a suite that asserts assertValidResponse() and nothing else: it verifies shape, not correctness, and passes happily when the endpoint returns [] forever. Keep your behavioural assertions; add contract assertions alongside them.
How can I help you explore Laravel packages today?