Multi-server & functional testing¶
Two cross-cutting topics live on this page. The first is the bundle's
config-declared multi-server feature: how you expose one Symfony app
as several JSON:API servers — for API versioning, an admin surface, or a
public/internal split — and how a request resolves to the right one. The second is
the functional-testing harness the example app uses and an integrating app
copies: a KernelTestCase-based base that drives JSON:API requests through a real
kernel.
If you run a single API you can skip the multi-server half entirely: the top-level
base_uri/version define an implicit default server, so one server is the
zero-config baseline. Multi-server is purely additive on top.
Core owns the Server value object each server wraps —
core server.md and
core architecture.md
describe the multi-Server concept this bundle config-drives. Core's runtime
document-assertion helpers —
core testing.md —
are usable inside any bundle KernelTestCase.
The one-server baseline¶
A single-API app needs no servers: block. The top-level keys define the implicit
default server, and a bare route import mounts it:
That is the whole multi-server story for most apps. Everything below adds named servers on top of this baseline.
The four moving parts¶
Multi-server is four small, independent steps. The example app exercises all four
with a single named admin server; the witness is
MultiServerTest.
1. Declare the extra server (config)¶
Add named servers under json_api.servers. Each carries its own base_uri /
version and inherits the top-level value when omitted:
# config/packages/json_api.yaml — examples/music-catalog-symfony
json_api:
base_uri: 'https://music.example'
version: '1.1'
servers:
admin:
base_uri: 'https://admin.music.example'
# version omitted → inherits the top-level '1.1'
The full config tree, the surfaced container parameters
(haddowg_json_api.servers), and the reserved-name guard live on
configuration. The one rule worth repeating here: a named
server may not be literally default (that name is the implicit top-level
server) — declaring it throws a LogicException at container build.
2. Assign types to servers (the resource attribute)¶
A type joins a server through the server: argument on its
#[AsJsonApiResource] — a single name, a list of names, or unset for the implicit
default. The same argument is available on the standalone capability attributes
(#[AsJsonApiSerializer] / #[AsJsonApiHydrator] / #[AsJsonApiRelations]). See
resources for the attribute as a whole.
The example uses all three assignment shapes:
// src/Resource/AlbumResource.php — shared across both servers
#[AsJsonApiResource(entity: Album::class, server: ['default', 'admin'])]
final class AlbumResource extends AbstractResource { /* … */ }
// src/Resource/UserResource.php — admin-only
#[AsJsonApiResource(entity: User::class, server: 'admin')]
final class UserResource extends AbstractResource { /* … */ }
// src/Resource/ArtistResource.php — default-only (no server: argument)
#[AsJsonApiResource(entity: Artist::class)]
final class ArtistResource extends AbstractResource { /* … */ }
A type assigned to a server that isn't declared in json_api.servers is a
build-time LogicException naming the offending type and listing the declared
servers — the assignment is validated against the config at compile time, never at
request time.
3. Mount each server's routes (per-server import)¶
A type: jsonapi import's resource: value is not a path or glob — it names
the server whose routes to emit. The bare . (or empty / default) import emits
the default server; a non-empty, non-. string emits that named server. Prefix
and host stay where Symfony users expect them — the import's prefix()/host()
apply to the emitted paths:
# config/routes/json_api.yaml — examples/music-catalog-symfony
json_api_default:
resource: '.'
type: jsonapi
json_api_admin:
resource: admin
type: jsonapi
prefix: /admin
The route loader namespaces names per server so a type exposed on two servers never
collides: the default server keeps the existing unprefixed
jsonapi.{type}.{action}, a named server uses jsonapi.{server}.{type}.{action}
(e.g. jsonapi.admin.albums.show). The full route-name scheme and the import
mechanics are owned by routing.
4. Resolution is automatic (_jsonapi_server)¶
Each emitted route carries a _jsonapi_server route default — default for the
bare import, the named server otherwise. The request lifecycle reads it and resolves
the matching Server through ServerProvider::get($name) — ServerProvider is the
name → ServerFactory locator (detailed in the next section):
// src/EventListener/RequestListener.php
$serverName = $request->attributes->get('_jsonapi_server');
$server = $this->servers->get(\is_string($serverName) ? $serverName : null);
// src/Server/ServerProvider.php
public function get(?string $name = null): Server
{
$name ??= self::DEFAULT_SERVER;
if (!$this->factories->has($name)) {
throw new \LogicException(\sprintf('No JSON:API server is configured under the name "%s".', $name));
}
$factory = $this->factories->get($name);
\assert($factory instanceof ServerFactory);
return $factory->create();
}
An unknown name is a LogicException — a wiring fault, never a request-time 404.
The kernel listeners are otherwise unchanged by multi-server: it falls out of
the existing _jsonapi_server seam with no lifecycle change (see
lifecycle).
What ServerProvider and ServerFactory build¶
ServerProvider holds a name → factory service locator and returns the right
Server by name. There is one ServerFactory per declared server, registered at
the service id haddowg.json_api.server_factory.<name> (use
JsonApiBundle::serverFactoryId($name)).
Each factory builds the immutable, memoized core Server for its surface from:
- that server's
base_uri/version; - the PSR-17 response / stream factories;
- only the resources and standalone pairs assigned to that server (the
compiler pass buckets each type per server, so the
adminfactory holds only the admin-assigned types); - the bundle's single
CrudOperationHandlerviawithHandler().
It deliberately does not install core's PSR-15 Middleware\* chain — the bundle
drives the lifecycle from kernel listeners over Server::dispatch(), so the only
core wiring it needs is withHandler() (core's dispatch() throws without a
target). A Doctrine RelationshipLoadStateInterface predicate is threaded in when
present, and null otherwise (core then treats every relation as loaded) — see
doctrine for the load-state seam.
Because each server's Server carries its own base_uri, a type held by two
servers renders different self-links per server. The example proves this on the
wire — the shared albums type resolves the default base_uri on /albums/1 and
the admin base_uri on /admin/albums/1:
// tests/MultiServerTest.php
$fromDefault = $this->handle('/albums/1');
$fromAdmin = $this->handle('/admin/albums/1');
self::assertSame(
'https://music.example/albums/1/relationships/artist',
$this->relationshipSelf($fromDefault, 'artist'),
);
self::assertSame(
'https://admin.music.example/albums/1/relationships/artist',
$this->relationshipSelf($fromAdmin, 'artist'),
);
Where each multi-server concern is documented¶
| Concern | Owning page |
|---|---|
The json_api.servers config map + inheritance + reserved-name guard |
configuration |
Per-type server: assignment (the attribute argument) |
resources |
| Per-server route import + the per-server route-name scheme | routing |
_jsonapi_server resolution in the lifecycle |
lifecycle |
ServerProvider / ServerFactory (end-to-end, this page) |
here |
Functional testing¶
The bundle ships a KernelTestCase-based harness the example app extends and an
integrating app copies for its own functional tests. The base lives in the bundle's
own test suite at
haddowg\JsonApiBundle\Tests\Functional\JsonApiFunctionalTestCase —
tests/Functional/JsonApiFunctionalTestCase.php.
That Tests\ namespace is autoload-dev only (the bundle's shipped autoload is
src/), so it is not importable from a consuming app — copy
tests/Functional/JsonApiFunctionalTestCase.php into your own tests/ directory,
reparent its namespace, and extend your copy. The example app extends the bundle
class directly only because it lives inside the bundle repo (where tests/ is on
the dev autoload); a standalone app cannot.
JsonApiFunctionalTestCase¶
It boots the kernel a subclass names, issues JSON:API requests through it, and decodes documents — and it keeps the global error/exception-handler stack balanced so PHPUnit's strict mode stays happy (booting and handling install Symfony's handlers). The methods you use:
| Member | Purpose |
|---|---|
getKernelClass(): string |
the kernel to boot (override in a subclass) |
afterBoot(): void |
hook for data-layer setup once the container is booted (Doctrine schema + seed) |
handle(string $path, string $method = 'GET', ?array $body = null): Response |
sets the application/vnd.api+json Accept (and Content-Type for a body), issues the request with catch: true, returns the HttpFoundation Response |
decode(Response $response): array |
the response body JSON-decoded to an array |
browser(): JsonApiBrowser |
a lazily-built fluent client over the booted kernel — the successor to handle()/decode() (see JsonApiBrowser below) |
Two details matter for fidelity to production:
handle()callskernel->handle($request, MAIN_REQUEST, catch: true)— the production path — so errors route throughkernel.exceptionwhere the bundle'sExceptionListenerrenders JSON:API error documents (see errors). A test asserting a400/404/422body relies on this.setUp()boots with['debug' => false], so debug-only error meta is redacted exactly as in production (see security for the gating).
A body is passed as a PHP array and JSON-encoded for you; null sends no body
(GET/DELETE).
JsonApiBrowser — the fluent client¶
handle()/decode() give you a Response + an array; you then hand-assert the
status, the Location header, and $data['type']. The bundle ships a fluent
alternative that asserts those as a unit: JsonApiBrowser
(haddowg\JsonApiBundle\Testing\JsonApiBrowser) — a public, supported test utility
that extends Symfony's KernelBrowser, knows the JSON:API media type, and bridges
the HttpFoundation response to core's fluent assertion families. Unlike the test
case it is on the shipped src/ autoload, so an integrating app imports it
directly.
JsonApiFunctionalTestCase exposes it lazily via browser(): JsonApiBrowser
(built once per test, kernel reuse intact). Construct one standalone with new
JsonApiBrowser($kernel).
// A GET fluent chain: status + content type + body asserted together.
$this->browser()
->get('/articles/1')
->assertFetchedOne() // 200 + application/vnd.api+json
->assertHasType('articles')
->assertHasId('1')
->assertHasAttribute('title', 'JSON:API in PHP');
// The ?sort order witness — order matters, and is now first-class.
$this->browser()
->get('/articles?sort=title')
->assertFetchedManyInOrder(['5', '3', '1', '2', '4'], 'articles');
// A POST: the body is JSON-encoded with the write Content-Type for you.
$this->browser()
->post('/articles', [
'data' => ['type' => 'articles', 'attributes' => ['title' => 'New', 'category' => 'news']],
])
->assertCreated(); // 201 + Location + content type
// Exact-match catches a leaked/extra field; the expected object is derived
// from the entity's own serializer, so you never hand-write the shape.
$article = $repository->find(1);
$this->browser()
->get('/articles/1')
->assertFetchedOneExact($this->browser()->expectResource($article));
// Stateless Bearer auth (the firewall under test) over chained requests.
$this->browser()
->actingAs('admin') // Authorization: Bearer admin
->delete('/articles/1')
->assertNoContent(); // 204 + empty body
actingAs(UserInterface|string $user) authenticates statelessly as a seeded
user — the most common API auth scenario. It resolves the user identifier (from a
UserInterface or the raw string) and sets Authorization: Bearer <token> on every
subsequent request; the firewall's access_token authenticator resolves that token
back to the user (in the test apps the token is the identifier via a tiny
AccessTokenHandler; a real app maps an opaque token to a user). There is no session
and no loginUser(). A consumer whose stateless scheme differs overrides one
protected seam — authenticateAs(string $identifier) (the header) or
tokenFor(string $identifier) (the token).
Three browser behaviours mirror handle()'s fidelity guarantees, and one is the
headline trap:
disableReboot()is called in the constructor. A vanillaKernelBrowserreboots the kernel between requests, which would wipe an in-memory SQLite seed bound to the kernel's connection. The browser keeps the one booted kernel across requests — so a write-then-read in a single test sees the write, exactly as the oldhandle()reusingstatic::$kerneldid.- The
kernel.exceptionpath is preserved. Requests route throughkernel->handle(catch: true), so a400/404/422comes back as a rendered JSON:API error document, asserted viagetErrors():
$this->browser()->get('/articles/999')->getErrors()
->assertStatus(404)
->assertContentType()
->assertHasError(status: '404');
- The PHPUnit strict handler stack stays balanced — the browser snapshots and restores Symfony's error/exception handlers around each request.
getDocument(): JsonApiDocument / getErrors(): JsonApiErrors expose the underlying
core wrappers (status + headers carried as a ResponseMeta), so the full core
assertion vocabulary — assertFetchedManyExact, assertIncludedExactly,
assertExactMeta, assertNoData/assertNoLink, assertErrorsExact, … — is
available beyond the browser-level shorthands.
Extending the browser¶
The class is non-final and exposes its behaviour as protected, overridable
seams, so a consumer subclasses and customises without copy-paste:
| Seam | Default | Override to … |
|---|---|---|
authenticateAs(string $identifier) |
sets Authorization: Bearer <token> |
authenticate over a different stateless scheme (a custom header, X-Api-Key) |
tokenFor(string $identifier) |
returns the identifier (the token is the user) | mint the opaque token your app's token handler expects |
defaultRequestServer(bool $hasBody) |
the Accept (+ Content-Type on a body) negotiation |
negotiate a different media-type profile or add standing headers |
documentFor(Response) / errorsFor(Response) |
decode $response->getContent() + a ResponseMeta |
wrap a response that needs decoding before the assertions |
Using it from a WebTestCase¶
A standard Symfony WebTestCase gets a JsonApiBrowser from the normal
client-creation flow via the shipped InteractsWithJsonApi trait
(haddowg\JsonApiBundle\Testing\InteractsWithJsonApi) — static::createClient() (or
the jsonApiClient() accessor) returns a JsonApiBrowser:
use haddowg\JsonApiBundle\Testing\InteractsWithJsonApi;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class PlaylistTest extends WebTestCase
{
use InteractsWithJsonApi;
public function test_owner_reads_their_playlists(): void
{
$client = static::createClient(); // a JsonApiBrowser
$client->actingAs('ada@example.com') // Authorization: Bearer …
->get('/playlists')
->assertFetchedMany();
}
}
The idiomatic swap would be to redefine the test.client service's class to
JsonApiBrowser (its constructor mirrors KernelBrowser's, so it is a drop-in). The
trait instead overrides createClient() to build the browser straight from the
booted kernel — the bundle's harness boots imperative MicroKernelTrait test kernels
with no shared config/packages/test/ to carry a service override, which makes a
per-kernel service edit fragile. The standard static::createClient() ergonomics are
preserved; the trait also snapshots/restores the error/exception handlers (PHPUnit
strict) and boots debug => false (production-fidelity error meta, no stdout debug
logs).
The example app's base case¶
The example app's suites extend one thin base that names the kernel and seeds the
database in afterBoot():
// examples/music-catalog-symfony/tests/MusicCatalogKernelTestCase.php
abstract class MusicCatalogKernelTestCase extends JsonApiFunctionalTestCase
{
protected static function getKernelClass(): string
{
return MusicCatalogKernel::class;
}
protected function afterBoot(): void
{
$entityManager = static::getContainer()->get('doctrine.orm.entity_manager');
\assert($entityManager instanceof EntityManagerInterface);
$schemaTool = new SchemaTool($entityManager);
$schemaTool->createSchema($entityManager->getMetadataFactory()->getAllMetadata());
Seed::into($entityManager);
}
}
The in-memory SQLite database lives and dies with the kernel's connection, so the schema and seed are recreated per test — every suite boots against a fully populated database.
The kernel an integrating app copies¶
The example's
MusicCatalogKernel
is a real Symfony app kernel (MicroKernelTrait), not an imperative test kernel: it
loads config/bundles.php, config/packages/*, and config/routes/* from the
project dir. That is the model an integrating app copies for its own tests:
- register FrameworkBundle + JsonApiBundle (+ DoctrineBundle for the Doctrine path)
in
config/bundles.php; - configure
json_api/doctrineinconfig/packages; - import the route loader (
$routes->import('.', 'jsonapi')) inconfig/routes; - register
src/as autowired + autoconfigured services — anyAbstractResourceis then auto-discovered with no hand-written service definition.
The example's config/services.yaml does only that, plus binding the two override
services' scalar constructor args. With autoconfiguration doing the discovery, your
test kernel needs almost no wiring.
The dual-provider conformance discipline¶
The bundle's own suite (and the discipline an integrator can adopt) splits tests by whether a behaviour touches storage:
- Storage-touching behaviour (create/update/delete, filter/sort/pagination,
relationship mutation) is asserted against both providers. An abstract
*ConformanceTestCaseholds the assertions once; two thin subclasses differ only in the kernel they name — an in-memory kernel and a Doctrine-sqlite kernel. SoWriteConformanceTestCaseis run byInMemoryWriteTestandDoctrineWriteTest, and a failure on one provider but not the other localizes to that persister's execution. - Storage-orthogonal concerns (routing, registration, rendering, multi-server route emission) are witnessed on a single in-memory kernel — there is nothing for a second provider to disagree about.
The heuristic: if a different DataProvider/DataPersister could plausibly produce
a different answer, run it against both; otherwise once is enough. Tests are
spec-grouped with #[Group('spec:…')] so a CI run can slice by JSON:API spec
chapter.
Next / see also¶
- routing — the per-server route import and the per-server route-name scheme.
- resources — the
#[AsJsonApiResource(server: …)]assignment argument. - configuration — the
json_api.serversconfig map and the reserved-name guard. - lifecycle — how
_jsonapi_serverflows through the request listeners. - core
testing.md— the runtimeJsonApiDocument/JsonApiErrorsassertion helpers usable inside a bundleKernelTestCase.