roensby/symfony-drupal-jsonapi
A Symfony integration for Drupal's JSON:API, providing services and tools to query and consume Drupal content endpoints cleanly. Simplifies fetching, mapping, and working with JSON:API resources in Symfony applications.
Start by reading composer.json and the src/ directory before installing — with zero dependents and no stars, the source is the only reliable documentation. Check the package's Symfony version constraint and whether it registers a bundle (*Bundle class under src/), a service definition, or is purely a set of plain classes you wire yourself.
Minimal setup:
composer require roensby/symfony-drupal-jsonapiconfig/bundles.php; otherwise define the client service in config/services.yaml.https://example.drupal.site) so the client can build /jsonapi/{entity_type}/{bundle} paths.admin/people/permissions → JSON:API). If authentication is required, add a Authorization: Basic ... header (HTTP Basic Auth via the basic_auth Drupal module is the common path) or an OAuth2 bearer token.Your first use case: fetch a list of published articles.
// Pseudocode — verify method names against the package source.
$client = $container->get(JsonApiClient::class);
$result = $client->getCollection('node', 'article', [
'filter[published]' => 1,
'sort' => '-created',
'page[limit]' => 10,
]);
foreach ($result->getData() as $article) {
dump($article->getAttributes()['title']);
}
The single most important thing to understand before writing any code: Drupal's JSON:API returns JSON:API documents, not flat arrays. Expect data, included, relationships, links, and meta. The package's value-add is turning that verbose structure into something resembling Drupal entity objects — so lean on its entity/attribute helpers rather than manually digging through the response.
Wrap the client in a repository per entity type. Don't scatter getCollection('node', 'article', ...) calls across controllers. Create an ArticleRepository that injects the client, owns the field/filter names, and returns typed domain objects. This gives you exactly one place to change when the Drupal field names or JSON:API quirks bite you.
Resolve relationships deliberately. JSON:API returns relationships as {data: {type, id}} references. Use include= to pull related resources into the top-level included array in one request rather than N+1 round trips:
$client->getCollection('node', 'article', [
'include' => 'field_tags,uid',
]);
Then resolve references against included. The package may expose helpers for this; if not, build a small $includedIndex[strType][$id] map once per response.
Use typed wrappers for entities. If the package ships entity classes (Node, User, Term, etc.), subclass them or write thin decorators adding getTitle(), getPath(), getBody() accessors. Attribute keys are Drupal machine names (field_hero_image, body), and centralising that mapping makes future field renames trivial.
Pagination via links.next. Drupal's JSON:API paginates with page[offset] / page[limit] and returns a links.next href. Build a generator that follows next until absent, rather than guessing total counts:
$offset = 0;
$limit = 50;
do {
$page = $client->getCollection('node', 'article', [
'page[offset]' => $offset,
'page[limit]' => $limit,
]);
yield from $page->getData();
$offset += $limit;
} while ($page->hasNextPage());
Integration tips:
meta (or the X-Drupal-Cache-Tags header, if exposed by your proxy). Better yet: proxy through Drupal's own cache and don't double-cache.framework.http_client.scoped_clients and pass that to the package if it accepts a client — this avoids the package forcing its own transport config.This package has an extremely low signal footprint. Zero stars, zero dependents, a low opportunity score. Do not assume the README is accurate or that the API is stable. Before committing, read the source and add a composer.lock pin to a specific tag/commit. Vendor it or fork it if you rely on it long-term — expect no upstream support.
included is deduplicated and not guaranteed. When you include multiple relationships, a single entity may appear once in included but be referenced from many relationships. Always build an index by (type, id); never assume order.
Empty relationships look like data: null or data: []. A single-valued relationship (e.g. uid) returns data: {type: "user--user", id: "5"}; multi-valued (tags) returns an array; empty returns null or [] depending on cardinality. Guard both.
Filter operators are a string DSL. filter[title][operator]=CONTAINS and filter[title][value]=foo are different shapes — mistakes here silently return unfiltered or empty results. Verify by echoing the built URL before sending.
Attribute types don't round-trip cleanly. Drupal JSON:API returns dates as ISO-8601 strings, booleans as true/false, and some fields (text with format) as nested objects {value, format, processed}. If the package's entity classes cast lazily, confirm the cast for each field you actually consume.
403 vs 401 vs 404 are meaningful and distinct. A resource that exists but you can't read returns 403; a bundle with JSON:API disabled returns 404. Wrap calls so you can distinguish these in logs — "not found" in a client often means "permission denied" in Drupal.
links.self includes the full base URL as Drupal sees it. Behind a reverse proxy with a misconfigured trusted host, this can be an internal hostname. If the package follows links.next blindly, requests may break in production. Sanity-check the base URL config.
Extension points to look for in src/: a serializer/normalizer for entity hydration, an interface for custom entity classes, and a client/factory that accepts an injected HttpClientInterface. These are your realistic hooks for adapting Drupal-specific field handling without patching the package.
Debugging: log the fully constructed request URL and the raw response body at debug level. JSON:API errors come back as {errors: [{status, title, detail, source: {pointer}}]} — surface detail and source.pointer in exceptions; they usually name the exact invalid filter path or field.
If the package proves unmaintained, the fallback is short. Symfony HttpClient plus a small JSON:API normalizer is ~150 lines and removes a dependency risk. Given the package's maturity level, budget for that possibility from day one.
How can I help you explore Laravel packages today?