hotmeteor/spectator is a Laravel package for testing helpers for your openapi spec.
It currently has 304 GitHub stars and 1.545.505 downloads on Packagist (latest version v3.0.2).
Install it with composer require hotmeteor/spectator.
Discover more Laravel packages by hotmeteor
or browse all Laravel packages to compare alternatives.
Last updated

Spectator provides light-weight OpenAPI contract testing tools that work within your existing Laravel test suite.
Write tests that guarantee your API spec never drifts from your implementation.
spectator:validate lints your spec file; spectator:coverage lists every operation defined in the spec; spectator:routes cross-references spec operations against Laravel routes; spectator:stubs generates skeleton test classes from a spec. All commands support --format=json for machine-readable output.SpectatorExtension tracks which spec operations are exercised during a test run and can enforce a minimum coverage threshold in CI.SPECTATOR_ERROR_FORMAT=json (or call Spectator::useJsonErrors()) to get structured {"errors": [...]} output from failed assertions instead of ANSI-coloured text.readonly properties, and match expressions throughout.Spectator::withPathPrefix('v1') as an alternative to the config key.composer require hotmeteor/spectator --dev
Publish the config file:
php artisan vendor:publish --provider="Spectator\SpectatorServiceProvider"
The published config lives at config/spectator.php. The most important setting is the spec source, which tells Spectator where to find your OpenAPI spec files.
Specs are read from the local filesystem.
SPEC_SOURCE=local
SPEC_PATH=/path/to/specs
Specs are fetched over HTTP. Useful for remote-hosted specs or raw GitHub file URLs.
SPEC_SOURCE=remote
SPEC_PATH=https://raw.githubusercontent.com/org/repo/main/specs
SPEC_URL_PARAMS="?token=abc123" # optional query params appended to the URL
Specs are fetched from a private GitHub repository using a Personal Access Token.
SPEC_SOURCE=github
SPEC_GITHUB_REPO=org/repo
SPEC_GITHUB_PATH=main/specs # branch + path to the directory
SPEC_GITHUB_TOKEN=ghp_yourtoken
If your API is mounted under a prefix (e.g. /v1), configure it here so Spectator strips it before matching spec paths.
SPECTATOR_PATH_PREFIX=v1
Or set it at runtime:
Spectator::withPathPrefix('v1');
By default, validation errors are rendered as human-readable, coloured terminal output. For CI pipelines and LLM toolchains that parse test output programmatically, switch to JSON:
SPECTATOR_ERROR_FORMAT=json
Or toggle it per test:
Spectator::useJsonErrors(); // emit {"errors": [...]}
Spectator::useTextErrors(); // revert to coloured text
Functional tests verify that your application behaves correctly — validation passes, controllers respond, events fire.
Contract tests verify that your requests and responses conform to your OpenAPI spec. The data doesn't have to be real; the shape does.
The two test types complement each other. Keep them in separate test classes.
Call Spectator::using() with the spec filename before making any requests. You can call it once in setUp() or per test.
use Spectator\Spectator;
class UserApiTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Spectator::using('Api.v1.yml');
}
#[Test]
public function test_using_different_spec(): void
{
Spectator::using('OtherApi.v1.yml');
// ...
}
}
Spectator adds these methods to Laravel's TestResponse:
| Method | Description |
|---|---|
| assertValidRequest() | Assert the request matches the spec. |
| assertInvalidRequest() | Assert the request does not match the spec. |
| assertValidResponse(?int $status) | Assert the response matches the spec (optionally at a specific status code). |
| assertInvalidResponse(?int $status) | Assert the response does not match the spec. |
| assertValidationMessage(string $message) | Assert the validation error message contains the given string. |
| assertErrorsContain(string\|array $errors) | Assert one or more strings appear in the validation errors. |
| assertPathExists() | Assert the requested path exists in the spec. |
| dumpSpecErrors() | Dump current spec errors without failing (useful for debugging). |
use Spectator\Spectator;
class UserApiTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Spectator::using('Api.v1.yml');
}
#[Test]
public function test_create_user(): void
{
$this->postJson('/users', ['name' => 'Alice', 'email' => '[email protected]'])
->assertValidRequest()
->assertValidResponse(201);
}
#[Test]
public function test_missing_required_field_is_invalid(): void
{
$this->postJson('/users', ['name' => 'Alice']) // missing email
->assertInvalidRequest()
->assertValidationMessage('required');
}
}
You can chain Spectator assertions with Laravel's built-in assertions, but keeping concerns separate is cleaner:
// Works, but mixes concerns
$this->actingAs($user)
->postJson('/posts', ['title' => 'Hello'])
->assertCreated()
->assertValidRequest()
->assertValidResponse(201);
Spectator::reset();
When a validation fails, Spectator renders the schema with errors annotated inline:
---
The properties must match schema: data
object++ <== The properties must match schema: data
status*: string
data*: array
object <== The required properties (name) are missing
id*: string
name*: string
email: string?
---
Symbol legend:
++ — object allows additionalProperties* — property is required? — property is nullableUse dumpSpecErrors() to inspect errors without failing the test:
$this->postJson('/users', $payload)
->dumpSpecErrors()
->assertValidRequest();
spectator:validateValidate that a spec file parses without errors. Useful as a pre-test lint gate in CI.
php artisan spectator:validate --spec=Api.v1.yml
php artisan spectator:validate --spec=Api.v1.yml --format=json
Text output:
✔ Api.v1.yml is valid.
JSON output (--format=json):
{
"valid": true,
"spec": "Api.v1.yml",
"errors": []
}
Returns exit code 0 on success, 1 on failure.
spectator:coverageList every operation defined in the spec. Useful for auditing coverage gaps.
php artisan spectator:coverage --spec=Api.v1.yml
php artisan spectator:coverage --spec=Api.v1.yml --format=json
Text output:
Operations in Api.v1.yml:
────── ───────────────
GET /users
POST /users
GET /users/{id}
────── ───────────────
3 operations
JSON output (--format=json):
{
"spec": "Api.v1.yml",
"operations": [
{ "method": "GET", "path": "/users" },
{ "method": "POST", "path": "/users" },
{ "method": "GET", "path": "/users/{id}" }
]
}
spectator:routesCross-references spec operations against registered Laravel routes. Surfaces which operations are matched, which are missing from the app, and which routes have no spec entry.
php artisan spectator:routes --spec=Api.v1.yml
php artisan spectator:routes --spec=Api.v1.yml --format=json
Text output:
Routes in Api.v1.yml:
──────── ──────── ───────────────────
Status Method Path
──────── ──────── ───────────────────
✔ GET /users
✔ POST /users
✗ DELETE /users/{id}
⚠ GET /internal
──────── ──────── ───────────────────
Matched: 2 | Unimplemented: 1 | Undocumented: 1
✔ matched — in spec and a Laravel route exists✗ unimplemented — in spec, no matching Laravel route⚠ undocumented — Laravel route exists, not in specIf your spec only documents a subset of the app's routes (e.g. the public /api/v2/* surface), every internal admin/web/webhook route otherwise shows up as undocumented and drowns the signal. Two flags narrow the Laravel side of the comparison:
--prefix=api/v2 — only consider routes whose URI starts with the given prefix. Leading/trailing slashes are normalised.--middleware=api — only consider routes that have the given middleware. Both group aliases (api, web) and fully-qualified class names work.Both can be combined (AND) and only affect the Laravel-routes side — spec operations are still listed as you wrote them. If neither is set, behavior is unchanged.
php artisan spectator:routes --spec=Api.v1.yml --prefix=api/v2
php artisan spectator:routes --spec=Api.v1.yml --middleware=api
php artisan spectator:routes --spec=Api.v1.yml --prefix=api/v2 --middleware=api
spectator:stubsGenerates skeleton test classes from a spec. Groups operations by tag (fallback: first path segment) and creates one class per group with one test_ method per operation. Each method body calls $this->markTestIncomplete(...) so the generated file is immediately runnable.
php artisan spectator:stubs --spec=Api.v1.yml
php artisan spectator:stubs --spec=Api.v1.yml --output=tests/Contract --namespace="Tests\\Contract"
php artisan spectator:stubs --spec=Api.v1.yml --force
| Option | Default | Description |
|---|---|---|
| --spec | — | Spec filename (required). |
| --output | tests/Contract | Directory to write generated classes to. |
| --namespace | Tests\Contract | PHP namespace for generated classes. |
| --base-class | Tests\TestCase | Parent class for generated test classes. |
| --force | false | Overwrite existing files. |
Example generated class:
namespace Tests\Contract;
use Spectator\Spectator;
use Tests\TestCase;
class UsersContractTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Spectator::using('Api.v1.yml');
}
public function test_get_users(): void
{
$this->markTestIncomplete('Implement: GET /users');
}
public function test_post_users(): void
{
$this->markTestIncomplete('Implement: POST /users');
}
}
Add spectator:validate as an early CI step to catch malformed specs before tests run:
# GitHub Actions example
name: Validate OpenAPI spec
run: php artisan spectator:validate --spec=Api.v1.yml --format=json
Set SPECTATOR_ERROR_FORMAT=json in your CI environment to make validation errors parseable by log aggregators and LLM agents:
SPECTATOR_ERROR_FORMAT=json
With this setting, a failed assertion produces a JSON error body instead of ANSI-coloured text:
{
"errors": [
"The data (null) must match the type: string"
]
}
The JSON error format is designed for toolchains that analyse test output programmatically. Parse {"errors": [...]} from test output and pass it directly to your LLM workflow for root-cause analysis or spec repair suggestions.
SpectatorExtension is a PHPUnit 11 extension that tracks which spec operations are exercised during a test run and prints a coverage summary when the suite finishes.
Enable it in phpunit.xml:
<extensions>
<bootstrap class="Spectator\Coverage\SpectatorExtension">
<!-- Fail the suite if coverage drops below 80% -->
<parameter name="min_coverage" value="80"/>
<!-- Optional: json | text (default: text) -->
<parameter name="format" value="text"/>
</bootstrap>
</extensions>
Example output at suite end:
Spectator Coverage
──────────────────────────────────────────
Spec Operations Covered %
──────────────────────────────────────────
Api.v1.yml 6 5 83%
──────────────────────────────────────────
When min_coverage is set and not met, the extension causes PHPUnit to exit with code 1, failing the CI job.
Please read UPGRADE.md for a full list of breaking changes between versions.
Spectator registers a middleware that intercepts every test request, matches it against the loaded spec's PathItem, and validates both the request and the response. Captured exceptions are stored on the RequestFactory singleton so assertions can read them after the response is returned.
cebe/php-openapi — parses OpenAPI 3.x specs into typed objectsopis/json-schema — validates request/response data against JSON SchemaA huge thanks to all our sponsors who help push Spectator development forward!
If you'd like to become a sponsor, please see here for more information. 💪
Made with contributors-img.
The MIT License (MIT). Please see License File for more information.