Once your resource is discoverable, configured, and secured, the last thing to get right is the actual shape of what it returns. It's tempting to just dump an entity's field values straight into a ResourceResponse — but that couples your API's public contract to your internal data model, and any future change to a field name breaks every client depending on the old shape. This lesson is about returning JSON deliberately, not accidentally.
What you'll learn in this lesson
- Why a hand-shaped response array is usually better than serializing an entity directly
- How to build a consistent, predictable JSON structure across multiple endpoints
- How to return errors as JSON too, not as an HTML error page
- How
ResourceResponsecache metadata interacts with the JSON you return
Shaping your own response, instead of serializing raw entity data
Drupal's Serialization module can turn an entity object directly into JSON, and for quick internal tooling that's sometimes fine. But for anything a real client depends on, hand-picking exactly which fields appear — and under what key names — is worth the extra few lines:
public function get($id) {
$node = \Drupal::entityTypeManager()->getStorage('node')->load($id);
if (!$node) {
return new ResourceResponse(['error' => 'Resource not found.'], 404);
}
$data = [
'id' => (int) $node->id(),
'title' => $node->getTitle(),
'summary' => $node->hasField('field_summary') ? $node->get('field_summary')->value : NULL,
'published' => (bool) $node->isPublished(),
];
$response = new ResourceResponse($data);
$response->addCacheableDependency($node);
return $response;
}
This response has a stable contract: id, title, summary, published — always those four keys, always those types, regardless of what fields get added to the underlying content type later. A client written against this shape doesn't break when you add an unrelated field to the node.
Keeping the structure consistent across endpoints
If your module exposes more than one resource, agree on a shared convention early — the same key name always meaning the same thing (id is always an integer, dates are always ISO 8601 strings, a missing optional value is always null rather than sometimes omitted entirely). A small, consistent JSON shape across your whole API is worth far more to whoever consumes it than any individual endpoint being cleverly structured.
Returning errors as JSON, not as an HTML page
The 404 in the example above already returns JSON with an explicit status code — that matters. A REST client expects every response, success or failure, to be parseable JSON with a meaningful HTTP status. Throwing an uncaught PHP exception instead sends the client Drupal's themed HTML error page, which most JSON parsers will fail on:
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
public function get($id) {
$node = \Drupal::entityTypeManager()->getStorage('node')->load($id);
if (!$node) {
// Also valid — Drupal's REST module catches this and serializes
// it to JSON automatically, same effect as the array above.
throw new NotFoundHttpException('Resource not found.');
}
// ...
}
Either approach — an explicit ResourceResponse with an error array, or throwing one of Symfony's HTTP exception classes — keeps the client inside JSON the whole time. What you want to avoid is anything that can bubble up as an unhandled PHP error, since that's the one case guaranteed to break out of JSON entirely.
Quick check: your resource currently serializes the full node entity directly, and a client's code reads a field called
field_summary_valuestraight out of the response. Six months later, someone renames that field. What breaks, and whose fault is it? (Answer: the client breaks, and it's arguably the API's fault for exposing internal field machine names as its public contract in the first place — exactly the coupling a hand-shaped response array in this lesson avoids.)
Key takeaways
- Hand-shape your response array rather than serializing raw entity data — it decouples your API's public contract from your internal content model.
- Agree on and stick to consistent key names and types across every resource in your module.
- Return errors as JSON with an appropriate status code — either an explicit
ResourceResponsearray or a thrown Symfony HTTP exception, never an uncaught error that produces an HTML page. addCacheableDependency()belongs on every response branch, success and error alike, not just the happy path.
Coming up next
You've now built a complete custom REST API in Drupal: discoverable plugin, configured formats and authentication, layered security, and a clean, stable JSON contract. Next, we shift from exposing your own API to making your module more configurable for site builders — starting with building a proper configuration form so administrators can adjust your module's settings without touching code.