enm/json-api-server
Abstract server-side PHP implementation of the JSON:API specification, built on enm/json-api-common. Provides endpoints, request handling, and exception handling for building JSON:API-compliant APIs in Laravel or any PHP application.
Install with Composer and immediately check what you actually got:
composer require enm/json-api-server
composer show enm/json-api-server enm/json-api-common
The last release is from October 2018, so before wiring anything into an existing app, try the install in a scratch Laravel project on your target PHP version. If Composer blocks it on PHP 8.x, stop there and evaluate a fork or a maintained Laravel-native JSON:API package rather than forcing it into production.
Where to look first
enm/json-api-common docs before the server docs. The server package builds on the common structures: Document, Resource, Relationship, Error, Link, and pagination. If you do not understand those, the server docs will be confusing.docs/json-api-server/index.md — Endpoints and Usagedocs/request-handler/index.md — Concept, Interface, Usagedocs/exception-handling/index.mdFirst use case: one read-only resource
Start with GET /api/articles/{id}?include=author, not with full CRUD.
articles type. The endpoint owns the type name and implements the package’s fetch hooks for a single resource and its relationships.Article to a package Resource. Cast the primary key to a string, map attributes explicitly, and build the author relationship with type/id linkage plus links.// routes/api.php — put this last, or scope it under a dedicated prefix.
Route::any('/{path}', JsonApiController::class)->where('path', '.*');
// app/Http/Controllers/JsonApiController.php
public function __invoke(Request $request, JsonApiServer $server)
{
$psrRequest = $this->toPackageRequest($request); // PSR-7 bridge if required
$psrResponse = $server->handleRequest($psrRequest);
return response($psrResponse->getBody()->getContents(), $psrResponse->getStatusCode())
->withHeaders($this->filterHeaders($psrResponse->getHeaders()))
->header('Content-Type', 'application/vnd.api+json');
}
Test it with the correct media type:
curl -H 'Accept: application/vnd.api+json' \
'http://localhost/api/articles/1?include=author'
If that works, add collection fetch, then relationship endpoints, then writes.
One endpoint per type, dispatch by type. The request handler should resolve the endpoint from the JSON:API resource type. Keep Laravel routing thin: one catch-all controller, all JSON:API routing inside the package. This is the opposite of Laravel’s “route per controller action” habit.
Separate transport, endpoint logic, and persistence. A workable layering:
include, fields, sort, filter, and page, authorizes the action, validates input, calls a repository/query service, and returns package Resource/Document objects.This keeps package classes out of your domain code and makes a future replacement feasible.
Read patterns
Resource, attach requested relationships./articles/1/relationships/author — resource linkage only (data with type/id)./articles/1/author — the related resource document.Write patterns
data.attributes with Laravel’s validator or a FormRequest before constructing resources. Convert validation failures into JSON:API Error objects with source.pointer such as /data/attributes/title.id, enforce data.type, wrap persistence in a transaction, and return 201 with a Location header.204 with no body.Query building
filter[...] must be an allowlist mapped to columns/operators. Never pass raw query input into where.sort is comma-separated; -created means descending. Validate against allowed fields and return 400 for unsupported sorts.page[number] and page[size] map to forPage. Cap page size. Generate pagination links with the current query string preserved.include and eager load with with() before mapping. Enforce an allowlist and a maximum include depth.Links and URL generation. The package cannot know your Laravel host, proxy configuration, or route names. Generate absolute URLs with url()/route() inside endpoints, or inject a URL generator. Set self, related, and relationship links deliberately. Behind a load balancer, configure Laravel Trusted Proxies first.
Error handling. Centralize it. Throw domain exceptions from endpoints/repositories, then map them to JSON:API Error documents with status, title, detail, code, and source. Add a branch in Laravel’s exception handler for API routes so 401, 403, 404, 422, and 500 responses are also JSON:API-shaped. Middleware auth failures are a common leak: they return Laravel’s default JSON unless you intercept them.
Container and testing. Bind the server/handler as a singleton and resolve endpoints from the container so constructor injection works. Write feature tests through the catch-all route and assert:
Content-Type is application/vnd.api+json.data.type and data.id exist and id is a string.included contains no duplicate type/id pairs.errors with the correct source.pointer.204 delete responses have no body.Add a JSON:API schema validator to CI if the API is public.
Caching. Cache keys must include the full query string (include, fields, sort, page, filter), the Accept header, and the authenticated user context. Send Vary: Accept.
symfony/psr-http-message-bridge with nyholm/psr7 or guzzlehttp/psr7. Preserve the raw request body with $request->getContent() for writes. When converting back, copy status, headers, and body; force application/vnd.api+json and do not double-encode an already serialized body.id values must be strings. (string) $model->getKey() everywhere. Integer IDs will fail schema validation and confuse clients.data => null. Empty to-many: data => []. Do not omit the key when linkage is requested. included must be unique by type + id; do not nest duplicate resources.fields[articles] applies to the primary resource, while fields[people] applies to included authors. If you emit attributes after mapping, filter them by the requested fieldset for both primary and included resources.Accept: application/vnd.api+json. Writes with the wrong Content-Type should get 415; unacceptable Accept should get 406. Laravel will not do this for you. Be pragmatic about lenient reads, but be strict on writes.201 + Location for create, 200 for update, 204 with no body for delete, 409 for conflicts, 422 for validation with pointers, 401/403 for auth. A JSON:API client will not treat a 200 delete with a body as correct.include. Eager load requested relationships, allowlist include paths, and cap depth and count. Relationship endpoints can otherwise trigger expensive traversals.JsonResource produces a different envelope and will fight this package. Pick one serialization approach.url()->current() rather than hardcoding hosts. Preserve existing query parameters in self/first/last/next/prev links.404/405, dump the parsed package request (type, id, relationship name, includes) and verify endpoint registration and type matching. If a 500 lacks detail, temporarily bypass Laravel’s exception handler and inspect the package exception; log the package request object.Resource, Document, and Error classes. Since upstream is inactive, prefer composition and a local fork over waiting for upstream changes.How can I help you explore Laravel packages today?