haddowg/json-api-symfony: spec-compliant JSON:API for Symfony¶
haddowg/json-api-symfony is a Symfony bundle that makes
haddowg/json-api idiomatic in a Symfony
application. You register a JSON:API type as a service; the bundle discovers it by
autoconfiguration, mounts the standard endpoint set through a route loader, runs
the request lifecycle from kernel listeners, renders every failure on a JSON:API
route as a spec-compliant error document, and — when Doctrine is installed — fetches
and persists your entities through a reference data layer. No controller, no
operation handler, no serializer wired by hand.
The bundle builds on core — you will read both doc sets¶
There are two libraries, with a clean split of responsibilities:
- The core library owns the JSON:API model.
AbstractResource, the field and relation DSL, the constraint vocabulary, the response value objects (VOs), the document model, operations, content negotiation, the exception catalogue — all of that lives in core and is framework- and storage-agnostic. Start with the core index, getting-started, and concepts — they are the shared mental model every page in these docs assumes. - This bundle owns the Symfony integration. Discovery and DI, the route loader,
the kernel-listener lifecycle, route-scoped error rendering, the Doctrine data
layer, the
DataProvider/DataPersisterservice-provider interface (SPI), the Symfony Validator bridge, configuration, and multi-server wiring.
So these docs never re-explain a core concept — they link it. When a bundle page
touches fields(), it links core
fields and documents
only the Symfony affordance around it. The same AbstractResource you would
hand-register on a core Server is here discovered by autoconfiguration; the same
operation handler you would hand-write in core is here a single generic handler
driven by listeners; the same constraint VOs core declares but never executes are
here translated to Symfony Validator rules and actually enforced.
Requirements¶
| Requirement | Version | Why |
|---|---|---|
| PHP | 8.3, 8.4, or 8.5 | The bundle uses typed class constants (public const string …), which are 8.3+. 8.3 is a hard floor. |
| Symfony | 6.4, 7.x, or 8.x | symfony/framework-bundle, config, dependency-injection, http-foundation, http-kernel, routing. |
nyholm/psr7 + symfony/psr-http-message-bridge |
latest | Hard runtime deps. The lifecycle listeners convert the Symfony Request to PSR-7 to drive core, then bridge the PSR-7 response back to HttpFoundation. |
Everything else — Doctrine, the validator, strict email, the opis linter — is
opt-in, declared as suggest. See the optional-dependency matrix on
configuration.
Install¶
Composer pulls the core library transitively. Registering the bundle and importing its routes lives in install.
A taste¶
A JSON:API type is a service. Extend AbstractResource, declare your fields(),
and map the backing entity with #[AsJsonApiResource]:
<?php
declare(strict_types=1);
namespace App\Resource;
use haddowg\JsonApi\Resource\AbstractResource;
use haddowg\JsonApi\Resource\Field\Id;
use haddowg\JsonApi\Resource\Field\Str;
use haddowg\JsonApiBundle\Attribute\AsJsonApiResource;
use App\Entity\Album;
#[AsJsonApiResource(entity: Album::class)]
final class AlbumResource extends AbstractResource
{
public static string $type = 'albums';
public function fields(): array
{
return [
Id::make(),
Str::make('title')->required()->sortable(),
// …
];
}
}
With autoconfigure: true, any class extending AbstractResource is auto-tagged —
no manual service tags. Set the base URI and import the route type:
# config/packages/json_api.yaml
json_api:
base_uri: 'https://music.example'
# config/routes/json_api.yaml — routes are NOT auto-mounted; this import is required
json_api:
resource: '.'
type: jsonapi
That is the whole integration. You now have GET /albums, GET /albums/{id},
POST /albums, PATCH /albums/{id}, and DELETE /albums/{id}, each rendering a
JSON:API 1.1 document — collections, sparse fieldsets, sorting, filtering,
pagination, relationships, and validated writes — over your Doctrine entity, with
no controller and no handler. The snippet above is illustrative; the
copy-paste-runnable version is built step by step in
getting-started, and every snippet in these docs is lifted
from the CI-tested example app — the real
AlbumResource
layers on a Map, a directional CompareField, relations, a default include, and
multi-server exposure.
Optional capabilities¶
The bundle does the minimum out of the box and lets you opt into the rest. Each of
these is a suggest dependency — install it to enable the capability, leave it out
and that path is simply absent:
| Install | Enables |
|---|---|
doctrine/orm |
The reference Doctrine data layer — zero-config fetch/persist for any #[AsJsonApiResource(entity: …)] type. |
symfony/validator |
The Symfony Validator bridge — core's declared constraints become enforced 422 validation. |
egulias/email-validator |
Strict (RFC 5322) email validation; without it the email rule degrades to HTML5 mode. |
opis/json-schema |
The optional structural document linter (json_api.schema_validation). |
The full matrix, with the degradation behaviour when each is missing, is on configuration.
Feature highlights¶
Beyond the convention CRUD endpoints, the bundle ships the capabilities a real JSON:API service needs. Each links the page that covers it in full:
- Relationships — declared relations with linkage and
self/relatedlinks, the related (GET /{type}/{id}/{rel}) and relationship (…/relationships/{rel}) read and mutation endpoints, compound?include, and?withCountfor countable relations. See relationships. - Pivot fields & filters — a
belongsToManyover an association entity renders join columns asmeta.pivot, writes them through linkagemeta, and exposes author-declared pivot?filter/?sortkeys. See relationships. - The Relationship Queries profile — order and narrow a relationship's linkage
from the primary request (
relatedQuery[rel][sort]/[filter]), opt-in by negotiating the profile. See relationships. - The Countable profile — ask for a countable relationship's size
from the primary request (
?withCount=rel1,rel2→meta.totalper relationship object), opt-in by negotiating the profile. See relationships. - Constrained-existence filtering —
WhereThroughdotted-path traversal (filter[author.name]=Smith) as a correlatedEXISTS, plus theWhereHas/WhereDoesntHaveexistence filters. See doctrine. - Lifecycle hooks — per-operation author seams (
beforeCreate,afterSave, …) as overridable resource methods or plain event subscribers. See lifecycle-hooks. - Declarative authorization — per-resource Symfony Security
security:expressions evaluated at the right hook, denying with a JSON:API403/401before any persistence. See authorization. - Custom & encoded resource ids — client-supplied natural keys, app-minted UUID/ULID, or an opaque encoded id over a store-assigned integer, with wire-format validation. See resources.
- Write-only attributes — a field accepted (and validated) on write but never
rendered, for a credential a client sets but never reads back (core's
writeOnly()field flag). See core fields. - Cursor (keyset) pagination — an opaque-cursor strategy for large, deep, or live collections, alongside the count-based paginators. See pagination.
- Strict query parameters — an unrecognized query-parameter family is a
400(on by default), so a client typo surfaces instead of a wrong-but-200. See configuration. - Declarative cache & deprecation headers —
Cache-Control/Varyand RFC 8594Deprecation/Sunsetheaders declared as attribute metadata. See configuration and resources. - Multi-server — expose several JSON:API servers (versions/audiences) from one app, each with its own route prefix and resource set. See multi-server and testing.
- A testing utility —
JsonApiBrowserwith JSON:API-aware assertions (assertFetchedOne,assertCreated,actingAs, …) for functional tests. See multi-server and testing.
Where to go next¶
These docs read in six arcs:
- Getting started — install, then build your first endpoint in getting-started.
- Wiring & discovery — resources (discovery and
#[AsJsonApiResource]), capability-composition (serializer / hydrator / relations as independent capabilities), and configuration. - The request lifecycle — routing, lifecycle, lifecycle-hooks (author seams around each operation), and route-scoped errors.
- The data layer — the data-layer SPI, the
doctrine reference adapter,
custom-data-providers,
pagination (the count-based strategies and cursor/keyset), and
async writes (
202 Accepted/303 See Other). - Validation — the validation bridge and the opis linter.
- Advanced & cross-cutting — relationships, custom
serializers and hydrators (and handler
decoration), multi-server and testing,
security and deployment, and declarative
authorization (per-resource
security:expressions).
If you are new to JSON:API itself, read core's getting-started and concepts first — this bundle assumes that vocabulary throughout.