fr3d/swagger-assertions
Laravel testing package adding PHPUnit assertions that validate API responses against your Swagger/OpenAPI definitions. Use it in feature tests to check status codes, schemas, and response structures, keeping documentation and endpoints in sync and catching contract drift early.
1. Install the package (dev only — it's a test dependency):
composer require --dev fr3d/swagger-assertions:^1.0
Pin to ^1.0. The v1.0.0 release is the first one in years and is explicitly a break from the pre-1.0 line (dependency removal + new supported PHP/PHPUnit range) — don't let Composer float you across the boundary in a repo that's already on 0.x.
2. Find your Swagger document. This library validates Swagger 2.0 (swagger: "2.0"), not OpenAPI 3.x. If you're generating docs with darkaonline/l5-swagger, check which spec you're emitting — if it's OAS3, this package will not parse it. You need a swagger.json/swagger.yaml on disk that describes your API paths.
3. Register the schema once, in a base test case. The schema is registered statically, so do it in setUpBeforeClass() of an abstract ApiTestCase that other tests extend:
namespace Tests;
use PHPUnit\Framework\TestCase;
use SwaggerAssertions\PhpUnit\AssertsTrait;
use SwaggerAssertions\SwaggerAssertions;
abstract class ApiContractTestCase extends TestCase
{
use AssertsTrait;
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
SwaggerAssertions::registerSchemaFromFile(base_path('docs/swagger.json'));
}
}
The entry point is registerSchemaFromFile() in the current line; the trait file is small, so skim it once on upgrade to confirm nothing renamed.
4. First use case — validate one endpoint: write a single test that hits a real endpoint and asserts the response matches the documented operation:
public function test_index_users_matches_contract(): void
{
$laravel = $this->get('/api/users');
$psr7 = $this->toPsr7($laravel);
static::assertResponseMatches($psr7, '/users', 'get');
}
The second argument is the templated path from your swagger file (/users/{id}), not the concrete URL you requested. That's the single most common source of confusion — see Gotchas.
Bridge TestResponse → PSR-7. The assertions accept Psr\Http\Message\ResponseInterface / ServerRequestInterface, which Laravel's test response is not. Add a small helper to your base test case:
use GuzzleHttp\Psr7\Response as Psr7Response;
protected function toPsr7(TestResponse $response): Psr7Response
{
return new Psr7Response(
$response->getStatusCode(),
$response->headers->all(), // array<string, string[]>
$response->getContent()
);
}
Build the PSR-7 object yourself from Laravel primitives. v1.0.0 dropped the laminas/laminas-http dependency, so don't assume any HTTP-message types come in transitively — declare guzzlehttp/psr7 explicitly in require-dev, and if your helper happened to construct Laminas\Http\Response objects, that path is gone.
If you need request assertions (query params, body schema on inbound requests), build a GuzzleHttp\Psr7\ServerRequest explicitly from the payload you sent rather than trying to reverse-engineer it out of the Laravel test response — it's clearer and keeps the test intent obvious.
Assert on both the happy path and the error contract. A big chunk of the value here is confirming your error responses are documented and shaped correctly:
$response = $this->toPsr7($this->getJson('/api/users/999999'));
static::assertResponseMatches($response, '/users/{id}', 'get');
This will fail loudly if 404 isn't in the responses block, or if your error envelope drifts from the documented Error schema.
Data-provider sweep for coverage. Rather than hand-writing a test per endpoint, drive it from the swagger document itself — but do it as a smoke layer, not a replacement for behavior tests:
/**
* @dataProvider endpointProvider
*/
public function test_every_documented_operation_responds(string $path, string $method): void
{
$response = $this->toPsr7($this->json($method, $this->sampleUrl($path)));
static::assertResponseMatches($response, $path, $method);
}
Keep the provider list explicit ([['/users','get'], ['/users/{id}','get']]) rather than auto-parsing the schema — an explicit list documents which endpoints you actually cover and fails visibly when you add a new one.
Use it as a drift detector in CI, not as your primary test suite. Wire it into a phpunit --testsuite=contract group so you can run it on every PR without bloating the fast unit suite. Treat a failure here as "either the implementation or the swagger file changed without the other" — that's the actual signal.
Pair it with schema generation. The pattern that works long-term is: generate swagger.json from annotations (l5-swagger) → validate responses against the generated file. That closes the loop, because a stale schema is the only failure mode that hurts. Hand-maintained swagger files rot fast and turn this library into a nuisance.
Request-side assertions for write endpoints. For POST/PUT, assert the request body and query parameters against the documented parameters before sending, so you catch a test that's exercising an undocumented code path:
static::assertRequestMatches($psr7Request, '/users', 'post');
oneOf/anyOf/nullable (OAS3 semantics), no components. The v1.0.0 release modernised tooling, not the spec support. If your team has migrated to OpenAPI 3, this library is a dead end — look at league/openapi-psr7-validator instead, which handles both and is actively maintained.laminas/laminas-http was removed. If anything in your test helpers leaned on that package being installed transitively, Composer will now fail or your helper will fatal — add an explicit require-dev entry or refactor the helper. Expect to spend one commit on the upgrade, not zero.Content-Type matching after upgrading. Header handling used to be partly delegated to the Laminas HTTP layer; it's now in-package. Before you trust the suite again, run one endpoint whose spec produces is application/json while Laravel returns application/json; charset=utf-8. If your version no longer normalises the parameter, set the header explicitly in the response or drop the charset from the spec.assertResponseMatches($r, '/users/42', 'get') fails; you must pass /users/{id}. Build a small templatize() helper or keep a map of route → swagger path.responses.{status} in the operation. An undocumented 422 or 500 throws. This is the library doing its job, but it surprises people on the first run against a real API.setUp() with different files, leads to order-dependent failures. Register exactly once in a base class; use @runInSeparateProcess only as a last resort.additionalProperties defaults are strict. A response body containing a field not in the schema fails. This catches real drift (good) but also catches debug payloads leaking into responses (also good) and Laravel's pagination meta/links wrappers if you haven't modeled them (annoying — model them once, in a shared definition)."id": "5") will fail against type: integer. Eloquent's $casts usually saves you, but check models with string primary keys.#/definitions/User/properties/email). Read it literally — it tells you whether the response or the schema is wrong, and that distinction is the whole diagnosis.^1.0 and should stay on the 0.x line (and accept the old friction). Run the contract suite once on your lowest and highest CI job before rolling it out repo-wide.NOASSERTION on the metadata may not have changed with this release. It's still a small codebase, so forking remains the pragmatic plan if you commit to it.How can I help you explore Laravel packages today?