Multi-server & testing¶
Two cross-cutting capabilities: exposing several JSON:API servers from one app, and testing JSON:API endpoints with a Laravel-native kit.
Multi-server¶
Each entry in jsonapi.servers is an independent API surface — its own URI prefix, route
middleware, and optional domain. A single-API app needs only default; add a named server
for a versioned or audience-scoped surface. The example adds admin:
'servers' => [
'default' => ['prefix' => 'api', 'middleware' => [], 'domain' => null],
'admin' => ['prefix' => 'admin', 'middleware' => [], 'domain' => null],
],
A resource joins servers on its attribute:
#[AsJsonApiResource(server: ['default', 'admin'])] // exposed on both
final class AlbumResource extends AbstractResource { /* … */ }
#[AsJsonApiResource(server: 'admin')] // admin-only — /users 404s, /admin/users resolves
final class UserResource extends AbstractResource { /* … */ }
A shared type drags its related endpoints' targets along: GET /admin/albums/1/artist
returns an artists resource object, so artists has to be registered on admin too. The
workbench's artists, tracks, playlists, libraries and public-profiles are all
shared for that reason, and jsonapi:optimize fails the deploy when one is missing — see
relationships.
Links per server¶
Each server links under its own prefix. With an empty base_uri that happens on its own: a
request to /api/albums/1 links http://host/api/albums/1 and one to /admin/albums/1
links http://host/admin/albums/1. A single global base_uri can only carry one prefix, so
give each server its own base_uri when you pin a canonical host. The music-catalog
workbench does:
'base_uri' => 'https://music.example/api',
'servers' => [
'default' => ['prefix' => 'api', 'middleware' => [], 'domain' => null],
'admin' => ['prefix' => 'admin', 'middleware' => [], 'domain' => null, 'base_uri' => 'https://admin.music.example/admin'],
],
A server without its own base_uri inherits the global one. See
configuration.
Route names carry the server: jsonapi.admin.albums.show. OpenAPI projects one document per
server by default (/docs.json, /admin/docs.json) — see openapi and
routing. The same type may render differently per server (an admin server can
expose more fields via a distinct resource) — see
capability-composition.
Output timezone per server¶
Each server can render DateTime attributes in its own zone. A server without an
output_timezone inherits the global one, and '' turns conversion off for that server
only:
'output_timezone' => 'UTC',
'servers' => [
'default' => ['prefix' => 'api'], // UTC
'partners' => ['prefix' => 'partners', 'output_timezone' => 'America/New_York'],
'internal' => ['prefix' => 'internal', 'output_timezone' => ''], // as stored
],
A resource shared by several servers renders in the zone of the server whose route matched. Each server holds its own instance of the resource, even when you bind the resource as a singleton: the first server gets the container's instance and every other server a clone of it, so one server's zone or action links never leak into another's. See configuration.
Testing¶
The kit reshapes the bundle's JsonApiBrowser into Laravel testing grammar (PLAN decision 12):
an InteractsWithJsonApi trait for building requests, and TestResponse macros for asserting
responses. actingAs() is native Laravel.
The request builder¶
use InteractsWithJsonApi; gives your test case a jsonApi() builder with a typed query DSL
and body sugar — no hand-encoded query strings or JSON:API envelopes:
use haddowg\JsonApiLaravel\Testing\InteractsWithJsonApi;
final class AlbumTest extends TestCase
{
use InteractsWithJsonApi;
public function test_it_lists_and_filters_albums(): void
{
$this->jsonApi()
->withInclude('artist')
->withFilter('title', 'comp')
->withSort('-releasedAt')
->withPage(['number' => 1, 'size' => 10])
->get('/api/albums')
->assertFetchedMany();
}
public function test_it_creates_an_album(): void
{
$this->actingAs($user) // native Laravel auth
->jsonApi()
->withResource('albums', attributes: ['title' => 'Kid A'])
->post('/api/albums')
->assertJsonApiDocument(fn ($doc) => $doc->assertResource('albums'));
}
}
Builder highlights: withResource() / withData() / withDocument() (body), withInclude(),
withFields(), withSort(), withFilter(), withPage(), withProfile(), withHeader(s),
then get() / post() / patch() / delete().
The assertion macros¶
Registered on Laravel's TestResponse, so they compose with the native assertions:
| Macro | Asserts |
|---|---|
assertJsonApiDocument(?Closure) |
a well-formed JSON:API success document (+ optional callback over it) |
assertJsonApiError(?int $status, ?Closure) |
a JSON:API error document (+ optional status/callback) |
assertFetchedOne(?Closure) |
a 200 single-resource document |
assertFetchedMany(?Closure) |
a 200 collection document |
assertJsonApiSpecCompliant() |
the response validates against the JSON:API 1.1 JSON Schema (opis-gated) |
jsonApiDocument() / jsonApiErrors() |
extract the parsed document / errors for custom assertions |
$response->assertJsonApiError(404, fn ($errors) => $errors->assertHasError(/* … */));
$id = $response->jsonApiDocument()->id(); // chain create → fetch
Schema conformance¶
With opis/json-schema installed, SchemaConformanceTrait validates real responses against
the generated OpenAPI component schemas, and assertJsonApiSpecCompliant() becomes active. A
test fails as soon as a response drifts from the document you publish. Without opis both are
skipped rather than failed.
use haddowg\JsonApiLaravel\Testing\InteractsWithJsonApi;
use haddowg\JsonApiLaravel\Testing\SchemaConformanceTrait;
use haddowg\JsonApiLaravel\Testing\SchemaDocumentKind;
final class AlbumSchemaTest extends TestCase
{
use InteractsWithJsonApi;
use SchemaConformanceTrait;
public function test_albums_match_the_published_schema(): void
{
$response = $this->jsonApi()->get('/api/albums/1')->assertOk();
$this->assertResponseMatchesGeneratedSchema($response, 'albums', SchemaDocumentKind::Single);
}
}
SchemaDocumentKind picks the envelope: Single, Collection, Relationship and Related
(the last two take the relation name as the fourth argument). Pass server: to validate
against a named server's document. assertBodyMatchesGeneratedComponent($body, 'AlbumsResource')
validates a raw body against any component by name, and bodyMatchesGeneratedComponent()
returns the result as a bool for tests that expect a body to fail.
Included resources¶
The envelope schemas type included loosely, as objects with a type and an id. So once the
envelope passes, the helpers validate each included resource against its own type's
<Type>Resource component, e.g. ArtistsResource for an included artist. An included resource
that is missing an attribute, or carries one of the wrong type, fails the assertion with a
message naming its index, such as /included/0 does not validate against "ArtistsResource". A
type the document has no <Type>Resource component for is skipped.
Sparse fieldsets¶
The generated read schemas list every attribute as required, since a full response carries
them all and generated clients rely on that. A ?fields[albums]=title response leaves the
other attributes out, so the helpers relax required to match. For every type the request
names in fields[…], only the members in that type's fieldset stay required, wherever that
type's resource schema applies: primary data, included resources, and primary data of
another type, such as the artists returned by /api/albums/1/artist. An empty fieldset
(fields[artists]=) requires none of that type's members. Types the request doesn't name keep
their full required list, so ?include=artist&fields[albums]=title,artist still checks every
attribute of the included artist. A member that is in the fieldset but missing from the body
still fails.
The helpers read the fieldsets from the request behind the TestResponse, so a sparse request
needs no extra argument:
$response = $this->jsonApi()
->withInclude('artist')
->withFields('albums', 'title', 'artist')
->withFields('artists', 'name')
->get('/api/albums/1');
$this->assertResponseMatchesGeneratedSchema($response, 'albums', SchemaDocumentKind::Single);
When you validate a raw body string, there's no request to read, so pass fieldsets: yourself.
Each value is the wire form or a list of member names. An explicit fieldsets: also replaces
whatever a TestResponse's request carried, and fieldsets: [] turns the relaxation off.
$this->assertBodyMatchesGeneratedComponent($body, 'AlbumsDocument', fieldsets: ['albums' => 'title,artist']);
$this->bodyMatchesGeneratedComponent($body, 'AlbumsDocument', fieldsets: ['albums' => ['title']]);
Testbench¶
The package's own suite (and the workbench) run on orchestra/testbench. Your
application tests use the standard Laravel TestCase — the trait and macros work in both.