enm/json-api-client
Abstract client-side PHP implementation of the JSON:API specification. Build requests with JsonApiClient using any PSR-18 HTTP client, execute them, and deserialize responses into documents and included resources via the bundled serializer and deserializer.
enm/json-api-client is the transport layer of the ENM JSON:API stack: it builds JSON:API request objects (includes, sparse fieldsets, sort, pagination, filters), sends them over any PSR-18 HTTP client, and deserializes the response into typed Document / Resource objects from enm/json-api-common. It is deliberately abstract — it knows the JSON:API format, not your domain. The mapping to Eloquent models, DTOs, auth headers, retries and pagination loops are all yours to write.
Note before you commit: the repo is archived (last release 2019-08-29) and has no dependents. Treat adoption as "vendor with intent to fork" rather than "install and forget" — see Gotchas.
1. Install the client plus a PSR-18/PSR-17 stack.
composer require enm/json-api-client
# Laravel already ships Guzzle 7, which is PSR-18, and GuzzleHttp\Psr7\HttpFactory
# implements all three PSR-17 factories — no extra deps needed.
The README suggests kriswallsmith/buzz + nyholm/psr7; inside Laravel you can reuse Guzzle instead and avoid a second HTTP stack.
2. Read the enm/json-api-common docs first. Everything you will actually touch — ResourceInterface, RelationshipInterface, DocumentInterface, ErrorInterface, LinksInterface — is defined there, not in this package. The client README is short because the model lives upstream.
3. Wire it once as a container singleton. The constructor takes the base URI, an HTTP client, three factories and a serializer/deserializer pair — too much ceremony to build per call site:
// app/Providers/JsonApiServiceProvider.php
$this->app->singleton(JsonApiClient::class, function () {
$baseUrl = rtrim(config('services.jsonapi.url'), '/');
$http = new \GuzzleHttp\Client(['timeout' => 10]);
$factory = new \GuzzleHttp\Psr7\HttpFactory();
return new JsonApiClient(
$baseUrl,
$http, // Psr\Http\Client\ClientInterface
$factory, // UriFactoryInterface
$factory, // RequestFactoryInterface
$factory, // StreamFactoryInterface
new Serializer(), // Enm serializer, see package README
new Deserializer()
);
});
4. First use case — read one resource. Pass a relative URI; the client prefixes the base URI (README example: http://example.com/api + /myResources/1).
use Nyholm\Psr7\Uri;
$request = $client->createGetRequest(new Uri('/articles/1'));
$request->requestInclude('author', 'comments');
/** @var \Enm\JsonApi\Model\Document\ResourceDocumentInterface $document */
$document = $client->execute($request);
$article = $document->data(); // ResourceInterface
$article->id(); // string
$article->attributes()->get('title');
$article->relationships()->get('author')->resource()->attributes()->get('name');
If that round-trip works, everything else is composition.
Repository wrapper per resource type. Never let JsonApiClient leak into controllers. One thin class per upstream resource keeps request-building in one place and gives you a seam for caching and tests:
final class ArticleRepository
{
public function __construct(private JsonApiClient $client) {}
public function find(string $id): ?ResourceInterface
{
$request = $this->client->createGetRequest(new Uri("/articles/{$id}"));
$request->requestInclude('author');
$document = $this->client->execute($request);
return $document->data();
}
}
Query building is fluent per request. requestInclude(), requestFieldset($type, [...]), requestSort([...]), requestPagination([...]), requestFilter([...]) are all setters on the request object, so compose them conditionally from your own query DTO:
$request = $client->createGetRequest(new Uri('/articles'));
$request->requestSort(['-publishedAt']);
$request->requestFieldset('articles', ['title', 'publishedAt']); // sparse fieldset
$request->requestPagination(['number' => $page, 'size' => 25]);
$request->requestFilter(['status' => 'published']);
The client only serializes these as query params — whether the server honours them is the server's contract. Verify against the API before relying on filters for correctness.
Write path: build a Resource, attach it to the request. POST and PATCH both take a resource body; PATCH should carry only the id plus the changed attributes (JSON:API merge semantics):
$resource = new Resource('articles');
$resource->attributes()->set('title', $data['title']);
$resource->relationships()->set('author', new Relationship(new Resource('people', $authorId)));
$request = $client->createPostRequest(new Uri('/articles'));
$request->addResource($resource);
$created = $client->execute($request)->data(); // server-assigned id
DELETE returns an empty document (often 204) — do not read data() without a null/empty guard.
Hydrate to domain objects in one place. The deserializer gives you generic Resource instances with string ids. Put the mapping in a dedicated hydrator (attribute('publishedAt') → CarbonImmutable, id() → int) rather than sprinkling casts across repositories; JSON:API ids are always strings and will silently break find() calls if you forget.
Pagination by following document links. The client does not auto-page. Read the document's links() collection, pull the next/prev href, turn it into a Uri, and loop — or expose links() to your front end so the UI can drive paging.
Map errors into Laravel-land. JSON:API failures arrive as an ErrorDocument (or a RequestException for non-2xx, depending on the path taken in execute()). Handle both, and translate errors()->… entries into ValidationException when source.pointer points at an attribute — that gets you free 422s in Laravel.
Test by faking the PSR-18 client, not the network. A stub ClientInterface returning a canned JSON:API payload exercises the serializer, deserializer and your hydrator in one shot — far more valuable than asserting on a JSON string. Http::fake() does not apply here; keep the fake at the PSR-18 boundary.
composer require from a VCS repo) if you hit anything.new Uri('/articles/1') becomes {baseUri}/articles/1. Watch trailing vs leading slashes; a base URL of /api/ plus /articles can produce /api//articles. Normalize config('services.jsonapi.url') once.execute(), so dd($request) shows you query params but not the wire format. Wrap the HTTP client and log method/URI/headers/body plus the raw response — that catches wrong Accept headers and malformed bodies immediately.included is only populated for requested includes. $document->included() is empty unless you called requestInclude(); relationships degrade to linkage-only identifier objects. Request all needed includes in the first call to avoid N+1 at the API boundary.id(), attribute(), meta() values may be null; data() may be null for 200 responses with data: null. Guard before hydrating.Deserializer if you need custom Resource subclasses in your domain; extend JsonApiClient if you want per-call defaults (e.g. always request a fieldset) rather than repeating them in every repository.requestFieldset silently changes what exists. Missing attributes after adding a sparse fieldset are a serialization win, not a bug — but code that assumed attributes()->get('body') will start returning null. Keep fieldsets in the repository, not in callers.How can I help you explore Laravel packages today?