jsonapi.rest
GitHub

{ json:api }

REST easy.

Get started ↓

The idea

Pick a backend.
One contract. Zero drift.

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.

every backend emits the same
The contract

One OpenAPI 3.1 document

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.

generates a client — optional from here up
The client · optional

A typed TypeScript SDK

@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 ↓
and optional sugar on top of the client
The bindings · optional

TanStack Query bindings

@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

JSON:API 1.1,
done properly.

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.

  • Verifiable spec compliance — built for 100% JSON:API 1.1 conformance, not “close enough”
  • PSR-7 & PSR-15 throughout — a middleware suite for the whole request lifecycle, whatever your framework
  • First-class profiles — JSON:API profiles built in, not bolted on
  • Atomic Operations extension — batch many writes into one request, applied all-or-nothing
PHP 8.3–8.5framework-agnosticMIT
ArticleResource.php
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

No controller.
No handler. No boilerplate.

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:

GET/albums
GET/albums/{id}
POST/albums
PATCH/albums/{id}
DEL/albums/{id}
  • Zero-handler CRUD — a DataProvider/DataPersister SPI with a reference Doctrine ORM implementation
  • Everything JSON:API — compound ?include, sparse fieldsets, cursor pagination, atomic operations, relationship endpoints
  • Validation → 422 — schema constraints become Symfony Validator rules and spec-compliant error documents
  • Live contract — OpenAPI 3.1 at /docs.json, Swagger UI or Redoc at /docs, JSON Schemas at /schemas.json
Symfony 6.4 – 8.xDoctrine ORMMIT
AlbumResource.php
#[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(),
        ];
    }
}
terminal
$ 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

Eloquent in.
JSON:API out.

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.

GET/api/albums
GET/api/albums/{id}
POST/api/albums
PATCH/api/albums/{id}
DEL/api/albums/{id}
  • Eloquent reference data layer — filters, sorting, page / offset / cursor pagination, batched ?include, and SQL window push-down for per-parent relationship paging (?withCount, relationship page[]) — or bring your own via the SPI
  • Self-registering routes — discovered from app/JsonApi, one route per type × operation, route:cache-safe
  • Always-on validation → 422 — core constraints become real Laravel rules with localisable messages and source.pointer
  • Policy authorisation — the model's Gate policy, per-operation ability overrides, or a dedicated API-policy class
  • Live OpenAPI + artisan exports — 3.1 at /docs.json, Swagger UI or Redoc at /docs, plus jsonapi:openapi:export
Laravel 12 – 13EloquentPHP 8.3–8.5MIT
app/JsonApi/AlbumResource.php
#[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.
terminal
$ 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

Speak fluent JSON:API.

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.

  • Compound documents — related resources ride along in one round trip
  • Strict query params — typos get a 400 with a pointer, not silent misbehaviour
  • Profiles & extensions — negotiated per-request, first-class on the server
request
GET /albums?include=artist&sort=-releasedAt&filter[status]=released
Accept: application/vnd.api+json
response · 200
{
  "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

Types that follow
the contract.

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-native — resources, relationships, sparse fieldsets and the query grammar are first-class types, not anonymous endpoint schemas
  • Type-safe compound documents — requested relations are hydrated in the result type; the rest stay identifiers
  • Framework-agnostic runtime — a tiny fetch-shaped transport seam; no React, no dependencies on your stack
  • Typed atomic operations — an all-or-nothing write batch where later operations reference a resource an earlier one just created, with typed positional results
  • Full surface — reads, mutations, custom actions, opt-in per-field validation
  • No runtime tie to codegen — generated output is committed to your repo, diffable and versioned
Node ≥ 20ESM + CJSMIT
terminal
$ 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
client.ts — generated from /docs.json
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)' })
atomic.ts — one request, all-or-nothing
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

Your cache,
normalised.

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.

  • Option factories — compose with your own useQuery / useMutation, any framework adapter
  • Query keys managed for you — derived from the generated descriptor, collision-free
  • Write-through normalisation — mutations patch every cached document that holds the resource
  • Strictly optional — the vanilla client works standalone; add this only if you want it
ReactVueSvelteSolid
queries.ts
import { 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

Same spec.
Different depth.

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.

Get started

Put your API to rest.

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.

haddowg/json-api

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-api

haddowg/json-api-symfony

The Symfony bundle. Zero-boilerplate CRUD with Doctrine, and your OpenAPI 3.1 contract served live at /docs.json.

composer require haddowg/json-api-symfony

haddowg/json-api-laravel

The Laravel package. Self-registering routes, an Eloquent data layer, and the OpenAPI 3.1 contract served live.

composer require haddowg/json-api-laravel

@haddowg/json-api-client

The JSON:API-native TypeScript client + codegen — typed resources, relationships and sparse fieldsets. Framework-agnostic, for any frontend.

npm install @haddowg/json-api-client

@haddowg/json-api-query

TanStack Query bindings with type:id cache normalisation — React, Vue, Svelte and Solid from one package.

npm install @haddowg/json-api-query