haddowg/json-api-symfony
A Symfony bundle for building JSON:API-compliant APIs, handling resource serialization, relationships, and spec-compliant responses with minimal configuration.
v1.0.0 is the first stable surface of this JSON:API server implementation. Note up front: this is a Symfony/Doctrine bundle, not a Laravel package — there is no service provider, no Eloquent adapter, and the validation bridge targets Symfony Validator. If you are evaluating it from a Laravel codebase, treat it as a reference implementation and a contract source (the JSON:API spec profiles it implements), and plan on writing a DataProviderInterface adapter over Eloquent before you get any value.
Minimal path to a first request:
readOnly shorthand — it wires up the read capability in a single declaration and is the cheapest way to see the machinery work end to end.DataProviderInterface, including the newer batch methods (relatedToOneMatches(), relatedToOneMatchesBatch()).GET /{type} and GET /{type}/{id}. Then diff the generated OpenAPI document against what you expected.Where to look first:
security booleans document secured vs. public operations (#74). Read it before you read the source.First real use case: expose an existing read-mostly aggregate as a JSON:API collection with sparse fieldsets, one included relationship, and cursor pagination. That exercises the query engine, the lazy linkage layer, and pagination-as-source-of-truth in one go.
Compose types from capabilities, not inheritance. The type declaration in 1.0 is assembled from independent optional capabilities (read, write, count, relationship-queries, custom actions). Prefer enabling capabilities per type over forking a base resource — the readOnly shorthand (#65) and write-only attributes exist precisely so a type can advertise asymmetry without a separate class.
Relationships are typed and windowed. The mandatory related-type relationship factory (#61) means every relationship names its related type explicitly; there is no implicit inference. Attributes use the flattened on() form (#50). Windowed includes are handled by the framework — you should not be hand-limiting included relations.
Linkage data is lazy by default. data() replaced the old linkage methods (#36) and does not resolve until the document is rendered (#39). Reach for eager resolution only when you genuinely need the linkage inside your own code; otherwise you are paying for includes nobody asked for.
Pagination is the single source of truth. v1.0.0 makes pagination opt-in and canonical (#40). Both offset and cursor/keyset pagination are supported — including on related-collection endpoints (#110) and on pivot-related and linkage endpoints (#111). Counting is not free: totals are emitted only when pagination counts (#41), and ?withCount is gated behind the Relationship Counts profile (#35). Declare countable relations explicitly rather than assuming counts appear.
to-many relationship endpoints are full query surfaces. GET /{type}/{id}/{rel} supports filter, sort, and pagination (#71) via the consolidated query engine. Pivot filters use the pivot.-prefixed declaration form; operators on pivot filters are supported. Do not build bespoke "filter a relationship" endpoints — this already covers it, and doing it yourself forfeits the OpenAPI projection of every sortable field (#62).
Security is request-aware and per-endpoint. Visibility, writability, and relationship-authorization predicates execute against the current request (#48). Collection reads are gated by a distinct securityList key (#75) — separate from item read. Critically, a resource's read-security expression now also gates its relationship and related-read endpoints (#47/#48 breaking note), and relationships can be authorized independently of their parent (#76). Model this as: item read, list read, relationship read, relationship write are four separate decisions.
Custom actions are first-class. Non-CRUD actions (#42) let you expose domain operations as JSON:API endpoints rather than smuggling them into PATCH. In 1.0 you must declare the action's meta-only output explicitly (#85) — the response shape is part of the contract, so it also lands in OpenAPI.
Writes can be async. The async write seam (#104) lets a create/update return 202 Accepted with a pointer to poll, or 303 See Other once complete. Use it for anything that triggers queues; it keeps the JSON:API document shape valid while the work finishes.
Atomic Operations (#55/#56) give you a single /operations endpoint with all-or-nothing semantics — the right tool for multi-resource client-side saves that must not half-apply.
Errors go through a mapping seam. Rather than catching and reshaping exceptions in every action, register exception→error-object mappings (#38). This keeps errors[] shapes consistent and lets domain exceptions stay domain exceptions.
Also available: cache and Deprecation headers plus strict query-parameter rejection; custom-encoded and policy-sourced resource IDs (#0cdcdb7); and a Validator bridge covering When and date constraints (#10).
data(), on() attributes flattened, the zero-config filter[position] pivot-equality filter removed in favour of pivot.-prefixed declarations, and the Custom bridge dropped in favour of a typed constraint-translator extension (#13). Budget accordingly.relatedToOneMatches() and relatedToOneMatchesBatch() are mandatory on DataProviderInterface. A partial implementation fails at runtime, not at compile time — implement the batch variant as a real batched query, or you have reintroduced N+1 behind a compliant-looking interface.DoctrineExtensionInterface::apply() now takes an ExtensionContext. Loose-argument extensions silently break. If you are porting the Doctrine extension pattern to an Eloquent equivalent, mirror the context object — it is what carries the request-scoped information the predicates need.TrustProxies equivalent, you will emit http://internal-host/... unless trusted hosts/proxies are configured. This is the most common "works locally, wrong in staging" bug in this class of package.meta.pivot (#80), symmetric with reads (#79). Client code that sent pivot attributes as top-level relationship members will silently no-op. Pivot meta is rendered on primary-resource document linkage — check your expected fixtures.?withCount requires the Relationship Counts profile. If totals are missing, the cause is almost always a profile that isn't enabled or a relation not declared countable — not a bug.data() inside an attribute computation defeats the lazy default. Keep attribute computation pure; let the serializer own linkage.LengthAwarePaginator. Cursor identity is opaque and must round-trip exactly. Likewise, validation is Symfony Validator constraint-based — do not assume Laravel FormRequest rules transfer; you will be re-declaring constraints, and the When/date coverage in the bridge is where the parity work lands.How can I help you explore Laravel packages today?