The idea
Everything starts with a backend. It describes itself as an OpenAPI 3.1 document, conformance-tested against the responses it actually serves — and the typed client and its query bindings are generated from that document, so backend and frontend can never disagree. Every implementation is held to the same contract in CI, so everything above it behaves the same whichever you run.
Choose your backend pick one
haddowg/json-api-symfony
The core wired into Symfony — attribute-declared resources, Doctrine persistence, validation and auth, no boilerplate.
Explore ↓ Laravelhaddowg/json-api-laravel
The core wired into Laravel — attribute-declared resources, Eloquent persistence, always-on validation and policy auth, no boilerplate.
Explore ↓ No framework · or your ownhaddowg/json-api
The framework-agnostic toolkit both integrations are built on — JSON:API 1.1 semantics, PSR-7 / PSR-15 middleware, data-layer seams and spec generation. Fewer batteries included: you wire the routing and persistence, the same way the Symfony and Laravel layers do.
Explore ↓Generated and hosted by your backend, detailed, exact and customisable — the single source of truth for everything above it. And “conformance-tested” is a mechanism, not a slogan: in CI, every implementation fetches real responses from a full example API and validates them against the document’s own schemas. The same assertion ships in each testing kit, so your app can hold itself to its contract too.
@haddowg/json-api-client
Generated from the contract, and JSON:API-native — typed resources with hydrated relationships and sparse fieldsets, not anonymous endpoint schemas. Framework-agnostic, for any frontend, with zero drift.
Explore ↓@haddowg/json-api-query
Option factories and type:id cache normalisation for React, Vue, Svelte and Solid — opt-in convenience over the client.
Explore ↓The server core · haddowg/json-api
A modern, framework-agnostic PHP library for serialising resources, parsing and validating requests, and shaping spec-compliant responses. No framework required — it plugs into any PSR-15 stack. Declare a resource type’s fields, filters, sorts and validation in one fluent schema — the library handles the rest.
final class ArticleResource extends AbstractResource
{
public static string $type = 'articles';
public function fields(): array
{
return [
Id::make(),
Str::make('title')->required()->minLength(3)->maxLength(255)->sortable(),
Str::make('body')->required(),
Str::make('slug')->slug()->requiredOnCreate(),
Boolean::make('featured'),
Integer::make('readingMinutes')->min(1),
DateTime::make('createdAt')->readOnly()->sortable(),
];
}
}
$server = Server::make()
->withBaseUri('/api/v1')
->register(ArticleResource::class)
->withHandler($articleHandler);
$response = $server->handle($request); // spec-compliant PSR-7
The Symfony bundle · haddowg/json-api-symfony
Register a resource as a service and get the full JSON:API endpoint set — discovery, routing, validation, Doctrine persistence, error documents, authorisation — with nothing wired by hand. One attribute gives you:
DataProvider/DataPersister SPI with a reference Doctrine ORM implementation?include, sparse fieldsets, cursor pagination, atomic operations, relationship endpoints/docs.json, Swagger UI or Redoc at /docs, JSON Schemas at /schemas.json#[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(),
];
}
}
$ composer require haddowg/json-api-symfony
# the contract, exported for codegen
$ php bin/console json-api:openapi:export --output build/openapi.json
# or clone the repo for the full twelve-type demo
$ git clone https://github.com/haddowg/json-api-symfony
$ cd json-api-symfony/examples/music-catalog-symfony && docker compose up # → localhost:8080/albums · /docs
The Laravel package · haddowg/json-api-laravel
Declare a resource and the package does the rest — routes register themselves, an Eloquent data layer answers the queries, validation and policy authorisation are always on, and the API documents itself. Idiomatic Laravel end to end, from Gate policies to artisan exports.
?include, and SQL window push-down for per-parent relationship paging (?withCount, relationship page[]) — or bring your own via the SPIapp/JsonApi, one route per type × operation, route:cache-safesource.pointer/docs.json, Swagger UI or Redoc at /docs, plus jsonapi:openapi:export#[AsJsonApiResource]
final class AlbumResource extends AbstractResource
{
public static string $type = 'albums';
public function fields(): array
{
return [
Id::make(),
Str::make('title')->required()->maxLength(200)->sortable(),
BelongsTo::make('artist', 'artists'),
HasMany::make('tracks', 'tracks')->countable(),
];
}
}
// no controller, no route file — the Eloquent Album model is
// mapped by convention, and the routes register themselves.
$ composer require haddowg/json-api-laravel
# the contract, exported for codegen
$ php artisan jsonapi:openapi:export --output build/openapi.json
# or clone the repo for the full twelve-type demo
$ git clone https://github.com/haddowg/json-api-laravel
$ cd json-api-laravel && docker compose up # → localhost:8080/api/albums · /docs
On the wire
Filtering, sorting, sparse fieldsets, compound documents — the whole query grammar of the JSON:API 1.1 specification, parsed, validated and answered for you. Unknown parameters are rejected, includes are typed, and every error is a proper JSON:API error document.
GET /albums?include=artist&sort=-releasedAt&filter[status]=released
Accept: application/vnd.api+json
{
"data": [{
"type": "albums",
"id": "1",
"attributes": { "title": "OK Computer", "status": "released" },
"relationships": {
"artist": { "data": { "type": "artists", "id": "9" } }
}
}],
"included": [{
"type": "artists",
"id": "9",
"attributes": { "name": "Radiohead" }
}]
}
The client · @haddowg/json-api-client
Point the codegen at your server’s /docs.json and get a committed, reviewable, fully typed client for any frontend — not generic OpenAPI codegen, but a client that speaks JSON:API natively. ?include widens the result type. Sparse fieldsets narrow it. The wire envelope disappears into clean, flat objects.
$ json-api-codegen --input https://music.example/docs.json \
--output src/api/music.gen.ts
# or from a committed spec file — no server needed in CI
$ json-api-codegen --input build/openapi.json --output src/api/music.gen.ts
import { createClient } from './api/music.gen'
const client = createClient({ baseUrl: 'https://music.example' })
const albums = await client.albums.list({
filter: { status: 'released' },
sort: '-releasedAt',
include: ['artist'],
})
albums[0].artist?.name // hydrated & typed — not an identifier
await client.albums.create({ title: 'Kid A', status: 'released' })
await client.albums.id('1').update({ title: 'OK Computer (Remaster)' })
const [artist, album] = await client.atomic((tx) => {
const a = tx.create({ type: 'artists', name: 'Boards of Canada' })
// `a` is a typed ref — usable before the server assigns an id
const b = tx.create({ type: 'albums', title: 'Geogaddi', artist: a })
return [a, b] as const
})
artist.data.id // the server-assigned id, now real
album.data.title // 'Geogaddi' — typed, in the position you asked for
The bindings · @haddowg/json-api-query
An opt-in layer over the client for TanStack Query. Option factories — not pre-bound hooks — so one package covers React, Vue, Svelte and Solid over @tanstack/query-core. And because every resource has a type:id identity, the cache can be normalised: edit a resource once and every cached query holding it updates.
useQuery / useMutation, any framework adapterimport { createQueryApi, createMutationApi, installNormalization }
from '@haddowg/json-api-query'
import { createClient, resourceMap } from './api/music.gen'
installNormalization(queryClient, resourceMap) // type:id write-through
const reads = createQueryApi(client)
const writes = createMutationApi(queryClient, client, resourceMap)
// in a component — plain TanStack, any framework
const { data: albums } = useQuery(
reads.albums.list({ include: ['artist'] })
)
// edit once — every cached query holding albums:1 updates
const rename = useMutation(writes.albums.update())
Side by side
Best-in-class JSON:API, whichever framework you build in. Against the library you’d shortlist instead — API Platform on Symfony, LaravelJsonApi or json-api-server on Laravel — this stack matches or exceeds them on the JSON:API surface itself, while feeling every bit as native in your framework as the incumbent does. Highlights below, scored honestly; the full comparisons are one click away.
| jsonapi.rest | API Platformcore v4.3.16 | LaravelJsonApiv5.2.1 | json-api-serverv1.0.0-rc.1 | |
|---|---|---|---|---|
| Full JSON:API 1.1 spec coverage | ✓Every spec row test-proven — ext, profile and lid included |
◐1.1 mechanisms unimplemented — request open since 2021 | ✗JSON:API 1.0 only | ◐Claims conformance; target spec version unstated |
| Atomic operations, transactional | ✓All-or-nothing /operations batch in a real DB transaction |
✗Not implemented — no issue or PR even mentions it | ✗Requested since 2021, still unshipped | ◐Extension ships — but you wrap the transaction yourself |
| JSON:API profile support | ✓Full 1.1 profile machinery; the published cursor-pagination profile ships and auto-advertises | ✗No profile mechanism — a 1.1 concept it hasn’t implemented | ✗No profile mechanism — JSON:API 1.0 only | ✓Profile mechanism + the cursor profile, in the 1.0 release candidate |
| Relationship counts on demand | ✓?withCount returns per-relationship totals, opt-in per relation — with semantics clients can discover via a published profile |
✗None documented | ✓Countable to-many relations, counts on request | ◐Opt-in collection totals; no per-relationship counts documented |
| Filter & sort a relationship in the primary request | ✓relatedQuery[rel][…] narrows any relationship’s linkage inline — published as a profile clients can discover |
✗None documented | ✗An open request in its tracker, unresolved | ✗Related endpoints are queryable, but only as separate requests |
| OpenAPI 3.1 from resource metadata | ✓Generated, then conformance-tested against real responses in CI | ◐Mature generator, but JSON:API schemas are built apart from the serializer — a maintainer-acknowledged drift risk | ✗None first-party — a long-open request; third-party generators only | ◐OAS 3.1 generator; atomic operations absent from output, no CLI export |
| Typed TypeScript client + query cache | ✓Generated typed client + TanStack Query bindings, from the same contract | ✗App scaffolding or plain interfaces — no typed JSON:API client, no query cache | ✗None | ✗None |
| Pagination strategies | ✓Page, offset, fixed-page & true keyset cursor — and the server can offer clients a menu of them | ◐Page & partial modes; its cursor mode still runs OFFSET underneath — acknowledged by the project founder | ✓Page-number & cursor (via a separate package) | ◐Offset & cursor; the strategy is fixed per resource |
| Efficient loading & pagination of included relationships | ✓Batched, windowed queries — includes, linkage endpoints, even pivots stay paginated with no N+1 | ✗Force-joins readable relations; no per-relation pagination found | ✗Included relations can’t be paginated — an open feature request | ✗Docs warn against includable to-many relations — no pagination at all |
| Native Symfony / Doctrine | ✓Zero-handler Doctrine CRUD, kernel-listener lifecycle, dual-provider conformance suite | ✓Its home turf — mature, with years of production hardening | ✗Laravel only | ✗Laravel only |
| Native Laravel / Eloquent | ✓Zero-config discovery, self-registering routes, Gate policies, artisan tooling | ◐Bridge exists, but JSON:API filters and pagination are hand-wired per model | ✓Its home turf — deeply idiomatic | ◐First-party Eloquent support; routing and the PSR-7 bridge wired by hand |
✓shipped · ◐partial · ✗not available — verified against the latest releases at the time of writing: api-platform/core v4.3.16, laravel-json-api/laravel v5.2.1, tobyz/json-api-server v1.0.0-rc.1.
Full comparison: API Platform →LaravelJsonApi →json-api-server →
Get started
Every layer ships a live, runnable music-catalogue example that doubles as the single source of truth for its docs — including a backend-free, Spotify-style React app for the client. docker compose up and go.
The framework-agnostic JSON:API 1.1 server core for PHP 8.3+. PSR-7/15, fluent schemas, first-class profiles.
composer require haddowg/json-apiThe Symfony bundle. Zero-boilerplate CRUD with Doctrine, and your OpenAPI 3.1 contract served live at /docs.json.
composer require haddowg/json-api-symfonyThe Laravel package. Self-registering routes, an Eloquent data layer, and the OpenAPI 3.1 contract served live.
composer require haddowg/json-api-laravelThe JSON:API-native TypeScript client + codegen — typed resources, relationships and sparse fieldsets. Framework-agnostic, for any frontend.
npm install @haddowg/json-api-client