Sorry, you need to enable JavaScript to visit this website.
Skip to main content
Welcome to our website! Explore our services and portfolio.

Creating a Custom Controller for JSON Responses in Drupal

Submitted by admin on

In Drupal, creating a Controller for a JSON response involves using the JsonResponse class from Symfony. To make it cacheable, you need to leverage Drupal's Cache API, specifically cache tags, cache contexts, and max-age.

Here is the implementation.


1. Define the Route

Create or update my_module.routing.yml:

YAML

my_module.json_api:
  path: '/api/v1/content/{node}'
  defaults:
    _controller: '\Drupal\my_module\Controller\JsonDataController::render'
  requirements:
    _permission: 'access content'

2. The Controller Implementation

This controller retrieves a node, extracts data, and attaches Cacheable Metadata.

src/Controller/JsonDataController.php

PHP

namespace Drupal\my_module\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Cache\CacheableJsonResponse;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\node\NodeInterface;

class JsonDataController extends ControllerBase {

  public function render(NodeInterface $node) {
    // 1. Prepare your data array
    $data = [
      'title' => $node->label(),
      'created' => $node->getCreatedTime(),
      'author' => $node->getOwner()->getDisplayName(),
    ];

    // 2. Create the Cacheable Response
    $response = new CacheableJsonResponse($data);

    // 3. Add Cacheability Metadata
    $cache_metadata = new CacheableMetadata();
    
    // Cache Tags: Invalidate this JSON when the node is updated
    $cache_metadata->addCacheTags($node->getCacheTags());
    
    // Cache Contexts: Vary the response by user permissions or URL query args
    $cache_metadata->addCacheContexts(['user.permissions']);
    
    // Max Age: Set how long the edge/browser should cache it (seconds)
    $cache_metadata->setCacheMaxAge(3600);

    $response->addCacheableDependency($cache_metadata);

    return $response;
  }
}

3. Understanding the Cache Layers

When you use CacheableJsonResponse, Drupal ensures the response is stored in the Dynamic Page Cache (and the Internal Page Cache for anonymous users).

Feature

Function

Cache Tags

The "Invalidation" logic. If node 5 is updated, node:5 tag is cleared, and this JSON is purged.

Cache Contexts

The "Variation" logic. Tells Drupal to store different versions of the JSON for different users, languages, or timezones.

Max-Age

The "Expiraton" logic. Tells the browser or Varnish how long to keep the file before asking Drupal for a new one.


4. Testing the Cache

You can verify the cache is working by checking the HTTP headers of your API response:

  • X-Drupal-Cache: Should say HIT (for anonymous).

  • X-Drupal-Dynamic-Cache: Should say HIT (for authenticated).

  • Cache-Control: Should reflect your max-age.

A common pitfall

If you are fetching data that doesn't come from a specific entity (like a random quote or external API), you must still provide a cache context or a max-age of 0, otherwise Drupal might cache the first result forever for all users.

Next we will look at how to include referenced entities (like an image URL or taxonomy term name) in this JSON output.

 

To include referenced entities like images or taxonomy terms, you need to load the referenced objects and extract their data. Crucially, you must also merge their cache tags into your response so that if the image or term changes, the JSON updates automatically.

Here is how you can update your controller to include an image URL and a list of taxonomy terms.


Updated Controller Implementation

PHP

namespace Drupal\my_module\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Cache\CacheableJsonResponse;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\node\NodeInterface;
use Drupal\file\Entity\File;

class JsonDataController extends ControllerBase {

  public function render(NodeInterface $node) {
    // Initialize cache metadata with the node itself.
    $cache_metadata = CacheableMetadata::createFromObject($node);

    // 1. Extract Basic Data
    $data = [
      'id' => $node->id(),
      'title' => $node->label(),
      'tags' => [],
    ];

    // 2. Handle Taxonomy Term References (field_tags)
    foreach ($node->get('field_tags')->referencedEntities() as $term) {
      $data['tags'][] = [
        'id' => $term->id(),
        'name' => $term->label(),
      ];
      // Merge term cache tags so JSON clears if a tag name changes
      $cache_metadata->addCacheableDependency($term);
    }

    // 3. Handle Image Field (field_image)
    if (!$node->get('field_image')->isEmpty()) {
      $file = $node->field_image->entity;
      if ($file instanceof File) {
        $data['image_url'] = \Drupal::service('file_url_generator')->generateAbsoluteString($file->getFileUri());
        // Merge file cache tags
        $cache_metadata->addCacheableDependency($file);
      }
    }

    // 4. Create and Return the Response
    $response = new CacheableJsonResponse($data);
    $response->addCacheableDependency($cache_metadata);

    return $response;
  }
}

Why addCacheableDependency() is essential

When building an API response from multiple sources, your JSON becomes a "composite" object. If you don't merge dependencies, you end up with "Stale Data."

  • The Node Tag: node:5 — Ensures JSON updates if the title changes.

  • The Term Tag: taxonomy_term:12 — Ensures JSON updates if a category name is edited.

  • The File Tag: file:101 — Ensures JSON updates if the image alt text or file is replaced.


Pro-Tip: Using Image Styles

Usually, you don't want to output the raw original image. You can use an Image Style to output a cropped or resized version:

PHP

use Drupal\image\Entity\ImageStyle;

$style = ImageStyle::load('thumbnail');
$data['image_url'] = $style->buildUrl($file->getFileUri());

Handling "Empty" States

Notice the check !$node->get('field_image')->isEmpty(). Accessing ->entity on an empty field will return NULL and trigger a white screen (WSOD). Always validate that the reference exists before attempting to call methods on it.

Next we will look at how to handle pagination for this JSON response if you were listing multiple nodes instead of just one.


 

To handle pagination in a JSON response, you typically transition from loading a single entity to using a Entity Query. To keep it cacheable, you must also consider Cache Contexts for the URL query parameters (like ?page=0).

Here is how you can implement a paginated list of nodes.


1. Update the Route

We’ll add a route that doesn't require a specific node ID.

YAML

my_module.json_list:
  path: '/api/v1/articles'
  defaults:
    _controller: '\Drupal\my_module\Controller\JsonDataController::list'
  requirements:
    _permission: 'access content'

2. Implement the Paginated List

This method uses the entity.manager (or entity_type.manager) and the pager service.

PHP

public function list() {
  $storage = $this->entityTypeManager()->getStorage('node');
  
  // 1. Initialize Query
  $query = $storage->getQuery()
    ->condition('status', 1)
    ->condition('type', 'article')
    ->sort('created', 'DESC')
    ->pager(10); // Limit to 10 items per page

  $nids = $query->execute();
  $nodes = $storage->loadMultiple($nids);

  $data = ['items' => []];
  $cache_metadata = new CacheableMetadata();
  
  // 2. Build Data and Collect Cache Tags
  foreach ($nodes as $node) {
    $data['items'][] = [
      'id' => $node->id(),
      'title' => $node->label(),
    ];
    $cache_metadata->addCacheableDependency($node);
  }

  // 3. Add List Cache Tags
  // This ensures the cache clears if ANY article is added, deleted, or updated.
  $cache_metadata->addCacheTags(['node_list:article']);

  // 4. Add Pager Cache Context
  // This tells Drupal to cache different results for ?page=0, ?page=1, etc.
  $cache_metadata->addCacheContexts(['url.query_args:page']);

  $response = new CacheableJsonResponse($data);
  $response->addCacheableDependency($cache_metadata);

  return $response;
}

3. Essential Concepts for Lists

Cache Tags for Collections

When listing items, you don't just care about the individual nodes. You care if the list itself changes (e.g., a new article is published).

  • node_list: Clears when any node is modified.

  • node_list:article: (Standard in modern Drupal) Clears only when nodes of type 'article' are modified.

Cache Contexts for Pagination

If you forget url.query_args:page, Drupal might show the results of page 1 to someone who requested page 2, because it treats the URL path as the same cache key.


4. Including Pagination Metadata

Front-end developers usually need to know how many pages exist. You can extract this from the pager service:

PHP

$pager_manager = \Drupal::service('pager.manager');
$pager = $pager_manager->getPager();

$data['pagination'] = [
  'current_page' => $pager ? $pager->getCurrentPage() : 0,
  'total_pages' => $pager ? $pager->getTotalPages() : 1,
  'total_items' => $pager ? $pager->getTotalItems() : count($nids),
];

Next we will see how to restrict this API access using an API Key or a specific header instead of just a standard Drupal permission.


 

To restrict your custom JSON API using an API key or a custom header, the most "Drupal" way is to implement a Custom Access Checker. This decouples your security logic from your data logic.


1. Define the Route with a Requirement

In your my_module.routing.yml, add a custom requirement key (e.g., _api_key_check).

YAML

my_module.json_list:
  path: '/api/v1/articles'
  defaults:
    _controller: '\Drupal\my_module\Controller\JsonDataController::list'
  requirements:
    _api_key_check: 'TRUE' # This triggers our custom service

2. Register the Access Checker Service

Create or update my_module.services.yml. You must tag the service with access_check and link it to the requirement key used above.

YAML

services:
  my_module.api_key_check:
    class: Drupal\my_module\Access\ApiKeyAccessCheck
    tags:
      - { name: access_check, applies_to: _api_key_check }

3. Create the Access Checker Class

This class intercepts the request, looks for your custom header, and validates it.

src/Access/ApiKeyAccessCheck.php

PHP

namespace Drupal\my_module\Access;

use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Access\AccessResult;
use Symfony\Component\HttpFoundation\Request;

class ApiKeyAccessCheck implements AccessInterface {

  /**
   * The actual check method.
   */
  public function access(Request $request, AccountInterface $account) {
    // 1. Define your secret key (ideally stored in protected config or .env)
    $secure_key = 'your-very-secure-random-token';

    // 2. Check for the custom header (e.g., X-API-KEY)
    $header_key = $request->headers->get('X-API-KEY');

    if ($header_key && $header_key === $secure_key) {
      // Access allowed. We mark it as neutral on the user account 
      // but "allowed" because the key matched.
      return AccessResult::allowed()->setCacheMaxAge(0);
    }

    // Access denied if the key is missing or wrong.
    return AccessResult::forbidden('Invalid or missing API Key.');
  }
}

Important Security & Caching Notes

1. Caching the Access Result

If your API key is static for all users, AccessResult::allowed() is fine. However, if the key is checked against a database or varies per request, you should use ->setCacheMaxAge(0) inside the access checker to prevent Drupal from caching an "Allowed" result and showing it to someone who later provides an invalid key.

2. Header Naming

By convention, custom headers should start with X- (like X-API-KEY) or use a vendor prefix. Ensure your web server (Nginx/Apache) is configured to pass these headers through to PHP, as some configurations strip unknown headers for security.

3. Middleware vs. Access Check

  • Access Check (shown here): Best for specific routes. It's clean and integrates with Drupal's routing system.

  • Middleware: Better if you want to enforce a key across the entire site or a massive prefix (like /api/*) without editing every route.


Pro-Tip: Storing the Key Securely

Instead of hardcoding the string in your PHP file, use the State API or Settings:

PHP

// In your access check
$secure_key = \Drupal::state()->get('my_module.api_key');
// OR
$secure_key = \Drupal\Core\Site\Settings::get('my_api_key');

Next we will see how to implement this using Middleware so it covers all routes starting with /api/v1/ automatically.







 

Using Middleware is a powerful approach because it sits between the incoming request and the Drupal routing system. It allows you to enforce security rules across entire path patterns (like /api/v1/*) without having to touch every single route definition.


1. Register the Middleware

Create or update my_module.services.yml. You must tag the service with http_middleware and specify a priority (higher numbers run earlier).

YAML

services:
  my_module.api_middleware:
    class: Drupal\my_module\Middleware\ApiAuthMiddleware
    tags:
      - { name: http_middleware, priority: 200 }

2. Create the Middleware Class

The middleware implements HttpKernelInterface. It checks the path; if it starts with /api/v1/, it validates the key. If the key is invalid, it returns a 403 Forbidden response immediately, stopping Drupal from doing any further processing (which saves server resources).

src/Middleware/ApiAuthMiddleware.php

PHP

namespace Drupal\my_module\Middleware;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\HttpKernelInterface;

class ApiAuthMiddleware implements HttpKernelInterface {

  protected $app;

  public function __construct(HttpKernelInterface $app) {
    $this->app = $app;
  }

  public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = true) {
    $path = $request->getPathInfo();

    // Only intercept paths starting with /api/v1/
    if (str_starts_with($path, '/api/v1/')) {
      $api_key = $request->headers->get('X-API-KEY');
      $valid_key = 'your-very-secure-random-token';

      if (!$api_key || $api_key !== $valid_key) {
        return new JsonResponse([
          'error' => 'Unauthorized',
          'message' => 'A valid X-API-KEY header is required.'
        ], 403);
      }
    }

    return $this->app->handle($request, $type, $catch);
  }
}

Comparison: Access Checker vs. Middleware

Feature

Access Checker

Middleware

Scope

Route-specific (defined in routing.yml).

Global or Path-based pattern.

Performance

Runs after Drupal routing is resolved.

Runs very early, before routing or full bootstrapping.

Response

Standard Drupal 403 page/response.

Full control over the raw Symfony Response.

Complexity

Simple, integrated with Permissions.

Advanced, handles the request before Drupal "knows" what it is.


3. Best Practices for Middleware

  • Priority: Setting priority to 200 ensures it runs before most other Drupal logic, but after basic request initialization.

  • Non-Blocking: Always ensure you return $this->app->handle($request...) for paths that don't match your criteria, or you will break the entire site.

  • Environment Variables: In a production environment, use getenv('MY_API_KEY') or Drupal's Settings::get() rather than hardcoding the token in the PHP class.

Pro-Tip: Debugging Headers

If you find your middleware isn't "seeing" the header, it's often because of a reverse proxy (like Varnish or Nginx). Ensure your Nginx config includes:

underscores_in_headers on; (if your header has underscores) or simply ensure the proxy is configured to pass custom headers to the backend.