Configuration reference (json_api:) and optional dependencies¶
The bundle's configuration tree is intentionally tiny. Most of what makes a
JSON:API type work — discovery, routing, the data layer, validation — is wired by
service tags and autoconfiguration, not configuration keys (see
capability composition and the data layer).
What json_api: configures is the small set of values that can't fall out of
discovery: the API's base_uri and version, the server default paginator's
page-size cap, the ?include nesting-depth cap, an optional structural linter toggle,
and — for the multi-API case — additional named servers.
There is no Configuration.php and no Extension class. The tree is declared
inline in the bundle's configure() (an AbstractBundle), and the extension alias
json_api is auto-derived from the bundle name. The whole tree lives in
JsonApiBundle::configure().
The config tree¶
Ten top-level keys, all optional:
# config/packages/json_api.yaml
json_api:
base_uri: 'https://music.example'
version: '1.1'
max_include_depth: 3
strict_query_parameters: true
pagination:
max_per_page: 50
doctrine:
window_functions: true
atomic_operations:
enabled: false
defaults:
cache_headers:
max_age: 60
public: true
servers:
admin:
base_uri: 'https://admin.music.example'
— config/packages/json_api.yaml
(the example app's real config; the admin server is the multi-server
witness).
| Key | Type | Default | What it does |
|---|---|---|---|
base_uri |
scalar | '' |
The base prepended to every generated link. Empty (the default): links are absolute, built from the request's scheme + host — each tenant/host gets correct self-links with no hardcoded host. Set a value to pin a fixed canonical base regardless of the request. Trailing slashes are tolerated. |
version |
scalar | '1.1' |
The JSON:API version the implicit default server advertises. Defaults to '1.1', so set it only to pin a non-default version — most apps omit it (the example app's '1.1' above is just the explicit-version witness). |
max_include_depth |
int | 3 |
The cap on ?include nesting depth (relationship hops from the primary resource). 0 is unlimited. A resource's own maxIncludeDepth() overrides it. |
strict_query_parameters |
bool | true |
Reject an unrecognized top-level query-parameter family — and an unknown fields[type] sparse-fieldset member — with a 400. false restores the old silent-ignore behaviour. |
pagination.max_per_page |
int | 100 |
The page-size cap the built-in server default paginator clamps page[size]/page[limit] to. 0 installs no built-in default (those collections render unpaginated). |
doctrine.window_functions |
bool | true |
Use SQL window functions (ROW_NUMBER/COUNT OVER) for the bounded windowed-include batch. Requires MySQL ≥ 8, MariaDB ≥ 10.2, SQLite ≥ 3.25, or any PostgreSQL. On an older engine the default true throws a 500 at the first windowed include — set false for the per-parent bounded fallback. |
atomic_operations.enabled |
bool | false |
Emit the Atomic Operations endpoint (POST {path}) per server. |
atomic_operations.path |
scalar | /operations |
The path the Atomic Operations endpoint is served at. Must not equal a resource's collection path (the loader fails fast if it does). |
schema_validation |
bool | false |
Registers the optional opis structural linter. Enabling it without opis/json-schema fails the build. |
defaults.cache_headers |
map | {} |
Fleet-wide default HTTP cache directives for GET reads. A resource's own cacheHeaders overrides these. |
defaults.deprecation / sunset / sunset_link |
scalar | null |
Fleet-wide default deprecation/sunset headers. A resource's own deprecation/sunset overrides these. |
servers |
map | [] |
Additional named servers, keyed by name. |
base_uri and version configure the implicit default server — link core
server.md for what
a core Server does with them. A single-API app sets just base_uri (and usually
nothing else) and never touches servers:.
Empty
base_uri(the default) emits request-host-absolute links — a request tohttps://albums.example/albums/1renders"self": "https://albums.example/albums/1", scheme and host taken from the incoming request's origin. That is the multi-tenant / multi-host recipe: one deployment served under several hostnames emits correct absolute self-links per host with no per-request configuration. Behind a TLS-terminating proxy this works as long as the request is proxy-aware — configure Symfony's trusted proxies so the public scheme/host (X-Forwarded-Proto/X-Forwarded-Host) flow into the request the bundle reads. Set a non-emptybase_urionly when every link must carry a single fixed canonical host regardless of the request; it is a compile-time constant applied to every request and is not request-host aware. Trailing slashes on a configured base are tolerated (https://api.example/andhttps://api.exampleare equivalent).
Container parameters¶
Two of the keys surface as container parameters you can read elsewhere:
| Parameter | Source | Value |
|---|---|---|
haddowg_json_api.base_uri |
base_uri |
the configured (or empty) base URI |
haddowg_json_api.version |
version |
the configured (or '1.1') version |
haddowg_json_api.max_include_depth |
max_include_depth |
the resolved include-depth cap (default 3, 0 = unlimited) |
haddowg_json_api.pagination.max_per_page |
pagination.max_per_page |
the resolved page-size cap (default 100, 0 = no built-in default) |
haddowg_json_api.doctrine.window_functions |
doctrine.window_functions |
whether the Doctrine provider runs the native windowed-include batch (default true) or the per-parent bounded fallback |
haddowg_json_api.servers |
derived | the list of all server names, e.g. ['default', 'admin'] |
haddowg_json_api.servers is the resolved name list — always including the implicit
default — that the compiler pass reads to validate resource-to-server assignments
and bucket each type onto the right server.
pagination.max_per_page¶
The bundle gives every server a default paginator — by default a
page-number/page-size strategy
(core PagePaginator) —
and pagination.max_per_page is the cap it clamps a client's page[size] to.
A client controls the page size, so without a ceiling ?page[size]=1000000 would
force your store to fetch a million rows — a page-size denial-of-service vector.
The cap closes it by clamping the resolved size down to the maximum (the same
clamp-don't-400 stance core takes for every garbage page[…] value), so an
over-large request returns the capped number of items with a 200 and
meta.page.perPage reports the cap.
The cap is on by default at 100 — every collection resolving to the server
default is protected without any configuration. Set 0 to install no built-in
default paginator: collections that resolve to the server default then render
unpaginated (the whole list, no page links). A resource (or relation) that
declares its own
pagination()
paginator overrides the server default entirely and sets its own cap with
withMaxPerPage().
The cap concept, the wither and the disable-with-0 semantics are all owned by
core — see core
pagination.md → Capping the page size.
max_include_depth¶
A compound document can grow without bound in two ways: a deeply nested
?include=a.b.c.d.e walks the relationship graph as far as the client asks, and a
default-included relation pointing back at its own type (or a mutual pair — A
default-includes B, B default-includes A) recurses the renderer forever. Both are
denial-of-service vectors, and the second is a latent infinite loop. max_include_depth
closes both: it caps the nesting depth of an ?include, where depth is the number
of relationship hops from the primary resource — ?include=a is depth 1,
?include=a.b.c is depth 3.
The cap is on by default at 3 — a request for a path deeper than the cap is a
400 (INCLUSION_DEPTH_EXCEEDED), and a default-include cascade silently stops at the
cap (so a mutual default-include cycle terminates rather than looping). Set 0 for
unlimited (core's unopinionated default). A resource overrides the server default
for requests where it is the primary/root type by implementing core's
IncludeControlsInterface::maxIncludeDepth():
final class CommentResource extends AbstractResource
{
public function maxIncludeDepth(): ?int
{
return 1; // this type only ever compounds one hop deep
}
}
The depth cap is one of three composing include safeguards. The
other two are author-declared, not config: a per-relation cannotBeIncluded() opt-out
and a root-scoped allowed-include-paths whitelist
(IncludeControlsInterface::getAllowedIncludePaths()). See
relationships → controlling what can be included
for all three. The depth-cap concept and the IncludeControlsInterface seam are owned by
core; the bundle supplies only the opinionated default of 3 and threads it to each
server.
Customising the server default paginator¶
The built-in default is PagePaginator. To make a server default to a different
strategy — a CursorPaginator,
an OffsetPaginator, or your own — register a PaginatorInterface service under a
conventional id. The ServerFactory reads it via nullOnInvalid(), so
registering the service is the entire wiring (no tag, no compiler pass). Two ids,
resolved by-server-first then generic:
| Service id | Applies to |
|---|---|
haddowg.json_api.default_paginator |
every server (the generic default) |
haddowg.json_api.default_paginator.<name> |
one server only (overrides the generic for that server) |
# config/services.yaml
services:
# Cursor pagination for every server's default…
haddowg.json_api.default_paginator:
class: haddowg\JsonApi\Pagination\CursorPaginator
# …but keep the page-number default on the `admin` server only.
haddowg.json_api.default_paginator.admin:
class: haddowg\JsonApi\Pagination\PagePaginator
factory: [haddowg\JsonApi\Pagination\PagePaginator, make]
A custom paginator owns its own page-size ceiling, so pagination.max_per_page
applies only to the built-in PagePaginator fallback — set a cap on your own
paginator with its own withMaxPerPage() where the strategy supports one. The full
resolution order a server walks is: this-server service → generic service →
built-in capped PagePaginator (when max_per_page > 0) → none (max_per_page: 0).
As always, a resource/relation pagination() still wins over whatever the server
resolves.
schema_validation¶
schema_validation is an optional dev/CI structural linter, distinct from the
always-relevant semantic validation. When true, the bundle wires core's
DocumentValidator + VendoredSchemaProvider so write bodies are checked against
the JSON:API JSON Schema before they reach the handler (a 400 on a malformed
document) — link core
schema-validation.md
for what the linter checks. It is off by default and is not a substitute for the
Symfony Validator bridge, which validates values against your
declared constraints (a 422).
opis/json-schema is a suggest dependency, so enabling schema_validation
without it is a wiring mistake the bundle catches at container-build time:
json_api.schema_validation is enabled but opis/json-schema is not installed;
require opis/json-schema (dev/CI) to use the structural document linter.
You can toggle it per-environment by layering a partial override — the example app's
SchemaValidationKernel
does exactly this, merging ['schema_validation' => true] over the shipped config so
base_uri/servers stay unchanged.
strict_query_parameters¶
By default the bundle rejects an unrecognized top-level query-parameter family
with a 400. Before this, a misspelled or unsupported
family was silently dropped — so ?filtr[title]=x (a typo for filter) returned a
wrong-but-200 collection instead of an error, and a client could not tell its
query was ignored. Strict mode turns that silent failure into a loud one.
A family is recognized when its base name is:
- a reserved JSON:API family —
include,fields,filter,sort,page(their internal key validation is unchanged: an unknownfilter[…]/sortkey or a badpagestill400s on its own); - a key the resolved primary resource declares;
- a negotiated profile's keyword — the Relationship Queries
profile's
relatedQuery/rQand the Countable profile'swithCountare recognized only when the client negotiated the relevant profile, so addressing one without negotiation now400s rather than being ignored; - any param an app registers via
Server::withCustomQueryParameter().
Anything else is a 400 (QUERY_PARAM_UNRECOGNIZED, source.parameter = the
offending base name). This aligns with the spec, which mandates a 400 for a
query param that follows neither the reserved-family rules nor the
custom-param naming rules and that the server does not recognize, and permits a
400 for a well-named custom param the server does not support — strict mode takes
the 400.
Set it to false to opt out (e.g. while migrating a client that sends stray
params). Note that core's always-on spec baseline still rejects an all-lowercase
custom param (one with no non-a-z character, like ?bogus) regardless of this
toggle — the spec requires it. The toggle governs the strict superset: a
well-named unsupported custom param (one carrying an uppercase letter or other
non-a-z character) is 400'd when strict and ignored when relaxed.
Unknown sparse-fieldset members¶
The same gate also rejects an unknown fields[type] member. A sparse fieldset
such as ?fields[articles]=title,bogus previously dropped bogus silently and
returned a wrong-but-200 document; with strict_query_parameters on (the default)
a member that names no declared field of a known resource type is now a 400
(FIELDSET_MEMBER_UNRECOGNIZED, source.parameter = fields, the message naming
the offending member) — mirroring how an unknown ?include path already 400s. The
check covers every named fields[type], including the types of ?included
related resources, so a typo in an included type's fieldset is caught too.
The recognized member set is a resource's full declared field namespace — every
attribute and relationship name it declares, including id, hidden, write-only and
non-sparse fields. So a member is "unknown" only when it names no
declared field at all (a real typo): naming a hidden field is tolerated (a hidden
name and a bogus name behave identically, so there is no information leak), as is id
or a non-sparse field. A fields[type] for a type the server cannot resolve (an
unregistered type) is left alone — only unknown members of known types are
rejected. Setting strict_query_parameters: false stands this member check down too,
restoring the silent-drop behaviour.
Response headers (caching and deprecation)¶
Two cross-cutting HTTP-response concerns are declarative: an
RFC-7234 HTTP cache policy for a type's reads, and a deprecation/sunset signal (the
IETF Deprecation header field plus the RFC-8594 Sunset header). Both are emitted
by one route-scoped kernel.response listener that reads
the resource attribute (falling back to the global json_api.defaults) — you never
mutate the response in an after-hook for these.
Cache headers (G7)¶
Declare cache directives on #[AsJsonApiResource(cacheHeaders: …)]. The map keys
are max_age, s_maxage (the shared/CDN lifetime), public/private,
no_cache, must_revalidate, and vary (a list of response-header names added to
Vary). An optional nested operations key overrides them per read shape —
collection, read, related, relationship:
#[AsJsonApiResource(cacheHeaders: [
'max_age' => 60,
'public' => true,
'vary' => ['Accept'],
'operations' => [
'collection' => ['max_age' => 30], // the collection caches for less
],
])]
final class TrackResource extends AbstractResource { /* … */ }
They map onto Cache-Control + Vary and are applied only to a successful
GET — never a write (POST/PATCH/DELETE) and never an error document
(caching either is wrong). A resource that declares no cacheHeaders (and has no
json_api.defaults.cache_headers) gets no Cache-Control at all — unchanged
behaviour. The global default applies to a resource that declares none; a
resource-level value merges over the default directive-by-directive (your
max_age wins, an unset directive inherits the default), and a per-operation
override merges over the resource-level value. An app that set Cache-Control
itself (e.g. in an after-hook) is never clobbered.
Deprecation + Sunset (G16)¶
Declare a deprecation on #[AsJsonApiResource(deprecation: …, sunset: …,
sunsetLink: …)]. deprecation is true (emit a bare Deprecation: true) or a
date string (Deprecation: <date>); sunset is an HTTP-date (Sunset: <date>);
when sunsetLink is set, a companion Link: <uri>; rel="sunset" is emitted too:
#[AsJsonApiResource(
deprecation: true,
sunset: 'Sat, 31 Dec 2050 23:59:59 GMT',
sunsetLink: 'https://music.example/deprecations/tracks',
)]
final class LegacyTrackResource extends AbstractResource { /* … */ }
The Deprecation header is the IETF Deprecation header field
(draft-ietf-httpapi-deprecation-header); Sunset and the sunset link relation
are RFC 8594. The bundle passes both date values through verbatim, so format
them for whichever draft revision your consumers expect — the latest Deprecation
draft wants a structured-field date such as @1688169599.
Unlike caching, deprecation/sunset ride every response for the type — reads
and writes — because a deprecated endpoint is deprecated regardless of method.
The same json_api.defaults.deprecation / sunset / sunset_link keys supply a
fleet-wide default a resource overrides. An explicit Deprecation/Sunset header
your app already set is never clobbered.
Named servers (json_api.servers)¶
The architecture is N-server-capable but single-server optimized. The
top-level base_uri/version define the implicit default server, so most apps
need no servers: block at all. When you genuinely run more than one API surface —
say a public catalog and an internal admin API with a different base URI — declare the
extra surfaces under servers::
Each named server inherits the top-level value when its own is omitted — the
admin server above declares only base_uri and inherits version: '1.1'. Every
declared server (including default) gets one ServerFactory, registered under the
id haddowg.json_api.server_factory.<name>.
Reserved name. A named server may not be literally default — that name
belongs to the implicit top-level server. Declaring it is a build-time
LogicException:
The JSON:API server name "default" is reserved for the implicit server defined by
the top-level base_uri/version; declare other servers under different names.
Configuration only declares servers. The rest of the multi-server story lives on three other pages, cross-linked so they don't drift:
- Assignment — which types join which server — is the
server:argument on the resource attribute (#[AsJsonApiResource(server: 'admin')], or a list for a shared type). See resources. - Route mounting — one per-server import per surface (
resource: admin, under aprefix:) — lives in yourroutes/json_api.yaml. See routing. - End-to-end resolution — how a request reaches its own server's
ServerFactoryand renders that server'sbase_uriin links — is on multi-server and testing.
Optional dependencies¶
The bundle's hard runtime dependencies are minimal (see installation).
Everything beyond the read/write core is opt-in via a suggest dependency. Each one
degrades gracefully when absent — but the degradation differs, and two of them
are silent, so read this table before assuming a capability is active.
| Package | Enables | When absent |
|---|---|---|
doctrine/orm |
The reference Doctrine provider/persister (doctrine) | No Doctrine path; #[AsJsonApiResource(entity:)] mappings are inert. Provide your own data provider. |
symfony/validator |
The constraint bridge (validation) | Writes run unvalidated, silently — declared constraints are not enforced. |
symfony/event-dispatcher |
The lifecycle-hook seam (lifecycle hooks) | The per-operation events and resource hook methods are inert — the dispatcher is injected ->nullOnInvalid() and the seam is simply off. |
symfony/security-core + symfony/expression-language |
Declarative resource authorization (authorization) | The security:/securityCreate:/… expressions on #[AsJsonApiResource] are inert — both packages must be present for the security subscriber to register. (Enforcement also needs a configured firewall; absent one the subscriber is a no-op.) |
symfony/doctrine-bridge |
The UniqueEntity entity-level rule |
UniqueEntity cannot be translated. Usually present transitively via doctrine/doctrine-bundle. |
egulias/email-validator |
Strict (RFC 5322) email validation (EmailFormat(strict)) |
Strict silently degrades to Symfony's HTML5 email mode. |
opis/json-schema |
The structural document linter (schema_validation) |
Enabling schema_validation: true without it fails the build. |
symfony/intl |
Sourcing a reference-data type (e.g. countries) from the ICU dataset |
The example app's countries type — a standalone CountrySerializer + CountryProvider, no entity — has no data source. |
The two silent degradations are the ones to internalise: without
symfony/validator, CrudOperationHandler's validator resolves to null and writes
are accepted without checking your constraints; and EmailFormat(strict) quietly
falls back to HTML5 validation without egulias/email-validator. Neither raises an
error — see validation for the full bridge behaviour.
symfony/doctrine-bridge is the subtle one: it ships Symfony's UniqueEntity
constraint and validator that the bundle's UniqueEntity VO translates to, and it is
typically only present transitively through doctrine/doctrine-bundle. The bundle
lists it in its own composer.json suggest so the requirement
is discoverable; the example app depends on it directly. See the UniqueEntity
section of validation.
These suggest entries are declared in the bundle's
composer.json and mirrored in the
example app's composer.json.
Why this page is short¶
Almost nothing in the bundle is configured through json_api:. A resource is
discovered because its service is tagged by autoconfiguration; a custom data provider
shadows Doctrine because it's tagged at a higher priority; a constraint translator
registers because it implements an autoconfigured interface. The tag/priority model —
not config — is how you compose and override behaviour:
- The discovery and capability tags (
RESOURCE_TAG,SERIALIZER_TAG, …) → capability composition. - The data-layer tags (
DATA_PROVIDER_TAG,DATA_PERSISTER_TAG,DOCTRINE_EXTENSION_TAG) and their priority/first-match resolution → the data layer and custom data providers. - The validation tag (
CONSTRAINT_TRANSLATOR_TAG) → validation.
If you find yourself looking for a config key to turn something on, it's almost certainly a tag instead.
Next / See also¶
- Resources and
#[AsJsonApiResource]— including theserver:assignment argument. - Routing — the per-server route import and the operation allow-list.
- Multi-server and testing — end-to-end server resolution.
- Validation — what
symfony/validator(and the optional linter) buys you. - Core server.md and schema-validation.md.