Skip to content

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 { /* … */ }

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.

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 generated OpenAPI schemas (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 strong regression net that a response never drifts from its own contract. Without opis both degrade gracefully (the conformance assertions are skipped).

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.