Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Spectator Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.
  • The paths key of your spec — the exact strings must line up (see Gotchas).

Implementation Patterns

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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
dakiquang/laravel-oauth-refresh-lock
woda/laravel-worktrees
dalefranks/ramasite
faghy/laracashbook
roundly-consulting/google-places-for-laravel
roundly-consulting/kubernetes-api-for-laravel
roundly-consulting/plausible-for-laravel
gvp/gvp-laravel-template
metasyncsite/laravel-metasync-client
roundly-consulting/onboarding-for-laravel
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
adachsoft/changelog-linter