art4/json-api-client
A PHP library for parsing and validating JSON:API documents. It turns responses into typed resources, relationships, links, errors, and meta objects, helping you build reliable JSON:API clients without manual array handling.
art4/json-api-client is a parser/validator only — it does not make HTTP calls. You bring your own PSR-7/PSR-18 client (Guzzle, Laravel's Http, Symfony HttpClient) and hand the raw body to the parser.
Minimal steps
composer require art4/json-api-client (check the PHP constraint in composer.json; 4.x targets PHP 7.2+/8.x).use Art4\JsonApiClient\Helper\Parser;
$body = Http::withHeaders(['Accept' => 'application/vnd.api+json'])
->get('https://api.example.com/articles/1')
->body();
$document = Parser::parse($body); // returns DocumentInterface
if ($document->has('data')) {
$article = $document->get('data'); // ResourceObject
$type = $article->get('type'); // "articles"
$id = $article->get('id'); // "1" (string per spec)
$title = $article->get('attributes')->get('title');
}
Where to look first
docs/ folder / README usage blocks — the element hierarchy (Document, ResourceObject, ResourceIdentifier, Relationship, Error, Link) is the mental model you need.tests/ — the fastest way to see how each member (links, meta, included, errors) is represented.Helper\Parser.First real use case: a "show" endpoint (GET /articles/1?include=author). You'll parse the document, read data.attributes, then resolve data.relationships.author.data.id against included to hydrate the author.
1. Wrap it in a dedicated client/service. Don't scatter Parser::parse() through controllers. Create App\Services\JsonApiClient that owns the HTTP layer, headers, base URL, auth, and returns typed results. This also isolates you from the package's low bus factor (138 stars, GPL license).
final class JsonApiClient
{
public function get(string $path, array $query = []): DocumentInterface
{
$response = Http::withHeaders([
'Accept' => 'application/vnd.api+json',
])->get(config('services.jsonapi.base_url').$path, $query);
$response->throw(); // transport/5xx first
return Parser::parse($response->body()); // then document validation
}
}
2. Map resource objects to DTOs. JSON:API's nesting is noisy; don't leak ResourceObject into your domain.
$attrs = $document->get('data')->get('attributes');
$article = new ArticleDto(
id: $document->get('data')->get('id'),
title: $attrs->get('title'),
body: $attrs->get('body'),
);
3. Resolve included resources yourself. Relationships give you identifiers, not the object. Build a lookup keyed by "type:id" and join in the mapper.
$included = [];
if ($document->has('included')) {
foreach ($document->get('included') as $resource) {
$included[$resource->get('type').':'.$resource->get('id')] = $resource;
}
}
$rel = $article->get('relationships')->get('author');
if ($rel->has('data')) {
$identifier = $rel->get('data'); // may be null or a collection
$author = $included[$identifier->get('type').':'.$identifier->get('id')] ?? null;
}
4. Pagination from links + meta. Cursor/page numbers aren't standardized in the body — they live in links.next, links.prev, and meta. Read them defensively:
$next = $document->has('links') && $document->get('links')->has('next')
? $document->get('links')->get('next')
: null;
$total = $document->has('meta') ? $document->get('meta')->get('total') : null;
5. Error mapping to Laravel exceptions / validation messages. Distinguish three layers: HTTP failure, JSON:API errors payload, and parse/validation failure. For the second:
if ($document->has('errors')) {
foreach ($document->get('errors') as $error) {
report(new JsonApiException(
title: $error->get('title'),
detail: $error->get('detail'),
status: $error->get('status'),
source: $error->has('source') ? $error->get('source') : null,
));
}
}
422 responses from a compliant server are data, not transport errors — don't let ->throw() swallow them before you parse. Check $response->status() and parse the body for both success and 4xx where a JSON:API document is expected.
6. Writes (POST/PATCH/DELETE). This library is read-oriented; you'll typically assemble the JSON:API request body as a PHP array and json_encode it, sending Content-Type: application/vnd.api+json. Confirm in the docs whether your version ships serializer helpers before hand-rolling one — keep that serialization in your JsonApiClient wrapper either way.
7. Testing. Use Http::fake() with recorded JSON:API fixtures (success, sparse fieldsets, included, error documents, 204 No Content) and assert your DTOs. Parsing is pure, so mapper tests need no HTTP at all.
swisnl/json-api-client and keep your adapter thin so swapping is a one-file change.Accept/Content-Type headers, timeouts) are yours. The library only validates and exposes document structure.data has three shapes: null, a single resource object, or an array (collections). Always branch on the endpoint or inspect the type before calling get('id'); a null data (e.g., 404-style "missing" documents, or to-one relationships with no link) will otherwise fatal.relationships entries may lack data. A relationship can carry only links and/or meta (the "related link only" pattern). Use $relationship->has('data') before get('data').(int) them silently if you key caches or database lookups on them.204 No Content and 304 responses have no document; Parser::parse('') will fail. Guard on trim($body) !== ''.has() everywhere. Missing-member access throws an access exception rather than returning null, which is easy to trip over in optional attributes.included can be large. There's no streaming; a heavily-included document is fully materialized. Watch memory on list endpoints, and prefer fields[...] sparse fieldsets and targeted include parameters.Accept: application/vnd.api+json; a server returning application/json may still parse structurally, masking a contract violation.AccessExceptions into a five-second diagnosis.Parser::parse() protects you during upgrades.How can I help you explore Laravel packages today?