REST and Serialization in Drupal

This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production.

This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages.

This page was checked against the official documentation and the Drupal core source on 2026-10-30 and is written for Drupal 11.4.x with PHP 8.3 or later. Drush examples work with Drush 13 and 14. The running example is the Meridian Museum site and its meridian_collection module, whose collection_object content entity (fields accession_number, period, material, dimensions and on_display) is exposed to a gallery kiosk and to a partner API. Drupal 10 reaches end of life on 2026-12-09, so everything below targets Drupal 11. Drupal 12.0.0 is scheduled for the week of 2026-12-07; nothing on this page depends on a module that the planned removals affect, but the HAL format discussed below is already outside core.

Core ships two ways to expose data over HTTP. The RESTful Web Services module (rest) is a configurable framework for custom endpoints and per-method control. The JSON:API module is an opinionated, zero-configuration interface to entities. Both rely on the Serialization module (serialization), which converts Drupal data to and from JSON and XML. This page explains how the REST pieces fit together, how to enable them for the museum’s entities, how to write a custom resource, how authentication and CSRF protection apply, and how to decide between REST and JSON:API.

The modules involved

Module Role

serialization

The serializer service, the JSON and XML encoders, and the normalizers that turn entities, fields and other data into arrays and back. Used by REST, JSON:API and the Views REST export display.

rest

The REST resource plugin type, the rest_resource configuration entities that switch a resource on, the route builder that turns each enabled method into a route, and the RequestHandler controller.

basic_auth

The basic_auth authentication provider (HTTP Basic). Optional.

user

The cookie authentication provider, which is always available because it belongs to the core user system.

hal (contrib)

The hal_json format. HAL was deprecated in 9.4, removed from core in 10.0 and moved to a contributed project.

The REST module is documented in the Drupal guide RESTful Web Services API overview and the older module guide RESTful Web Services module. Some of those pages were written for Drupal 8 and still describe the Drupal 8 rest.settings era or the http_basic provider name; the sections below follow the current 11.x source, which is what the site actually runs.

Install the modules with Composer-managed core and Drush:

ddev drush pm:install serialization rest basic_auth -y
ddev drush config:get rest.resource.entity.node

Nothing is exposed after installing the modules: every resource must be enabled explicitly, which is the opposite of JSON:API’s behaviour.

How a REST request is processed

When a resource method is enabled, the module builds a route for it. The request then follows the normal Drupal pipeline, with REST-specific route requirements checked on the way in and the serializer applied on the way out.

sequenceDiagram participant C as Client (kiosk) participant A as Authentication providers participant R as Route access checks participant H as RequestHandler participant P as Resource plugin participant S as Serializer C->>A: GET /api/meridian/on-display?_format=json A->>R: user identified (cookie or basic_auth) R->>R: format allowed, permission, CSRF for unsafe methods R->>H: route matched H->>P: get() P-->>H: ResourceResponse with cache metadata H->>S: serialize(data, json) S-->>C: 200 and JSON body

The steps worth remembering:

  • Format negotiation is by the _format query argument only. The Drupal 8 overview states that the HTTP Accept header is not used and that _format is always required. The default formats are json and xml.

  • Authentication providers are chosen per route from the resource configuration, not globally.

  • The RequestHandler calls the plugin method named after the HTTP method in lower case (get, post, patch, delete), serializes the returned response and, for read methods, caches it with the cache metadata attached to it.

  • PUT is not supported by the entity resource; use PATCH to update.

REST resource configuration entities

A resource is switched on by a rest_resource configuration entity named rest.resource.<id>. Without the entity the plugin exists but has no routes. The entity has four exported keys: id, plugin_id, granularity and configuration. The plugin_id for entity resources is entity:<entity_type_id>, and the config entity id replaces the colon with a dot.

The granularity key chooses how the configuration is shaped.

  • resource granularity applies one set of methods, formats and authentication providers to the whole resource. configuration has the keys methods, formats and authentication.

  • method granularity configures every HTTP method on its own. Each key of configuration is a method (GET, POST, …​) whose value has supported_formats and supported_auth.

A route is skipped, and a message is logged, when a method has no formats or no authentication providers. That is the usual reason for a resource that "does nothing" after a configuration import.

# config/sync/rest.resource.entity.collection_object.yml
langcode: en
status: true
dependencies:
  module:
    - meridian_collection
    - serialization
    - user
id: entity.collection_object
plugin_id: 'entity:collection_object'
granularity: method
configuration:
  GET:
    supported_formats:
      - json
    supported_auth:
      - cookie
  PATCH:
    supported_formats:
      - json
    supported_auth:
      - cookie

The same exports are managed with the ordinary configuration workflow described in Configuration Management and the Configuration API: enable the resource on a development site, export, review the diff and import it elsewhere with drush config:import or drush deploy. Because supported_auth is part of the exported configuration, a mistaken provider shows up in code review.

Enabling a resource from the user interface

The core RESTful Web Services module has no administration screen. Site builders who want a form use the contributed REST UI module, which edits the same rest_resource configuration entities. Editing the YAML directly, or through the Configuration Synchronization screen, is equivalent. Both approaches give identical results, so the choice is a matter of workflow.

Permissions

Every enabled method creates a permission named restful <method> <plugin id>, in lower case. The museum’s entity resource therefore adds restful get entity:collection_object and restful patch entity:collection_object. Route access has two layers: the REST permission decides whether the HTTP method is allowed for the role, and the entity access system decides whether the account may see or edit this entity and these fields. Both must pass.

For the entity resource, PATCH checks field access: a field the account may not edit but whose submitted value differs from the stored value makes the request fail with 403. When reading, fields the account may not view are returned as empty values rather than the request failing. See Access Control and Permissions in Code and Users, Roles and Permissions for the underlying system.

The permissions defined by REST do not apply to Views. A REST export display is protected by the view’s own access setting, covered below.

ddev drush role:perm:add curator 'restful get entity:collection_object'
ddev drush role:perm:add curator 'restful patch entity:collection_object'

Calling the entity resource

The next requests use the cookie provider, so the client logs in through the normal user login first. A safe request (GET, HEAD, OPTIONS, TRACE) needs only the session cookie. Any other method must also send the CSRF token in the X-CSRF-Token header, and a Content-Type header that matches the body format. The token is read from /session/token (older guides use /rest/session/token).

GET /collection_object/42?_format=json HTTP/1.1
Host: meridian.ddev.site
Cookie: SESS...=CHANGE_ME
# Log in, keep the session cookie, fetch a CSRF token, then PATCH one field.
curl -s -c cookies.txt -H 'Content-Type: application/json' \
  -d "{\"name\":\"curator\",\"pass\":\"${MERIDIAN_CURATOR_PASSWORD}\"}" \
  'https://meridian.ddev.site/user/login?_format=json'

TOKEN=$(curl -s -b cookies.txt https://meridian.ddev.site/session/token)

curl -s -b cookies.txt -X PATCH \
  -H 'Content-Type: application/json' -H "X-CSRF-Token: ${TOKEN}" \
  -d '{"on_display":[{"value":true}]}' \
  'https://meridian.ddev.site/collection_object/42?_format=json'

The entity route for an entity type that defines a canonical link template is that template with ?_format=... added, and the creation route is /entity/<entity_type> for POST. A successful POST returns 201 with a Location header and a successful DELETE returns 204 with no body. The X-CSRF-Token requirement is attached to the route unconditionally, so it also applies to authentication methods that are not session-based; the protection itself only matters for cookie-authenticated requests, because only cookies are sent automatically by a browser.

Formats and the Serialization module

The serializer service is Symfony’s serializer with Drupal-specific encoders and normalizers. Serialization happens in two steps: a normalizer turns an object (an entity, a field item list, a typed data value) into a nested array, and an encoder turns the array into a string in the chosen format. Deserialization reverses both steps for POST and PATCH bodies. The module also provides entity resolvers for references, either by UUID or by local ID.

  • json and xml ship with core. Both expose Drupal’s internal field structure, so a single-value text field appears as an array of objects with a value key.

  • hal_json is provided by the contributed HAL project. It adds _links and _embedded structures and was removed from core in 10.0. Older tutorials, and Drupal 8-era guides still on drupal.org, enable hal and basic authentication together; on current Drupal install the contrib project explicitly if HAL is really needed, and consider JSON:API instead.

  • A Views REST export can also output other formats added by contributed encoders, such as CSV.

A custom normalizer

When the museum wants the kiosk to receive a compact object, a normalizer can replace the default structure for one entity type. Normalizers are services tagged serializer.normalizer; a priority higher than the core normalizer makes it run first. This example is deliberately small. In a real module, check the normalizer interfaces and the getSupportedTypes() method of your Drupal version, because Symfony changed the normalizer interfaces between versions.

# meridian_collection.services.yml
services:
  meridian_collection.normalizer.collection_object:
    class: Drupal\meridian_collection\Normalizer\CollectionObjectNormalizer
    arguments: ['@entity_type.manager']
    tags:
      - { name: serializer.normalizer, priority: 20 }

Normalizers affect every consumer of the serializer, including REST export views and, for the entity types they support, anything else calling serializer.normalize(). Decide whether the change is wanted globally before adding one. JSON:API has its own normalization and ignores these normalizers, so a custom normalizer is a reason to prefer REST, not an add-on to JSON:API. See Services and Dependency Injection for the service container side.

Authentication providers

An authentication provider is a service tagged authentication_provider that identifies the user from a request. REST selects the providers for a route from the _auth route option, which is filled from the resource configuration. The providers core offers are:

Provider id Module Notes

cookie

user

The ordinary Drupal session. Best for a front end served from the same site, for example a progressively decoupled page. Unsafe methods need the CSRF token.

basic_auth

basic_auth

HTTP Basic: the credentials travel with every request. Acceptable for server-to-server calls over HTTPS and for local tests, and never over plain HTTP. Core’s page-cache request policy refuses to serve Basic-authenticated requests from the page cache, and the provider applies flood control to failed logins.

OAuth 2.0 is not in core. The contributed Simple OAuth module (6.x) adds an oauth2 provider that can be listed in supported_auth; see Authentication and OAuth for the grants Simple OAuth 6 still supports (its implicit and password grants were removed) and Authorization Code and PKCE for the protocol. The Drupal 8 guide Using other authentication protocols names the provider as http_basic in one place and basic_auth in another; use basic_auth, the id registered by the module’s service.

# Method granularity with one provider per audience.
configuration:
  GET:
    supported_formats: [json]
    supported_auth: [cookie, basic_auth]
  POST:
    supported_formats: [json]
    supported_auth: [cookie]

A browser-based client on another origin also needs CORS. Core supports it with the cors.config container parameter in a services file; see CORS for the headers and for why credentialed requests need an explicit origin, never *.

Writing a custom REST resource plugin

Use a custom plugin when the data is not an entity, when one request must run custom logic, or when the URL and payload should be shaped by hand. Resources are plugins in src/Plugin/rest/resource and, in current Drupal, are declared with the #[RestResource] PHP attribute. The attribute takes an id, a translatable label, an optional serialization_class to deserialize request bodies into, an optional deriver, and uri_paths, a map whose keys are link relation types (canonical, create) and whose values are URL templates. Annotation-based plugins (@RestResource) are still found by the plugin manager, which keeps the annotation class for compatibility; new code should use the attribute, as described in Plugin API and Attributes. The official page for the whole family is RESTful Web Services API.

Core’s DbLogResource in the dblog module is the smallest example in the source tree. The museum’s resource lists the objects on display, which is custom data assembled from a query rather than one entity.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Plugin\rest\resource;

use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\rest\Attribute\RestResource;
use Drupal\rest\Plugin\ResourceBase;
use Drupal\rest\ResourceResponse;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Lists collection objects that are currently on display.
 */
#[RestResource(
  id: 'meridian_on_display',
  label: new TranslatableMarkup('Meridian objects on display'),
  uri_paths: [
    'canonical' => '/api/meridian/on-display',
  ],
)]
final class OnDisplayResource extends ResourceBase {

  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    array $serializer_formats,
    LoggerInterface $logger,
    private readonly EntityTypeManagerInterface $entityTypeManager,
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition, $serializer_formats, $logger);
  }

  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->getParameter('serializer.formats'),
      $container->get('logger.factory')->get('rest'),
      $container->get('entity_type.manager'),
    );
  }

  /**
   * Responds to GET requests.
   */
  public function get(): ResourceResponse {
    $storage = $this->entityTypeManager->getStorage('collection_object');
    $ids = $storage->getQuery()
      ->accessCheck(TRUE)
      ->condition('on_display', 1)
      ->sort('accession_number')
      ->range(0, 100)
      ->execute();

    $items = [];
    foreach ($storage->loadMultiple($ids) as $object) {
      $items[] = [
        'accession_number' => $object->get('accession_number')->value,
        'title' => $object->label(),
      ];
    }

    $response = new ResourceResponse($items);
    // The list changes when any collection object changes.
    $response->addCacheableDependency(
      (new CacheableMetadata())
        ->addCacheTags(['collection_object_list'])
        ->addCacheContexts(['user.permissions'])
    );
    return $response;
  }

}

Source: Creating modules: Defining a route (routes and access in general) and the core class \Drupal\dblog\Plugin\rest\resource\DbLogResource at api.drupal.org

The plugin is switched on exactly like an entity resource, with a configuration entity of the same shape.

# config/sync/rest.resource.meridian_on_display.yml
langcode: en
status: true
dependencies:
  module:
    - meridian_collection
    - serialization
id: meridian_on_display
plugin_id: meridian_on_display
granularity: method
configuration:
  GET:
    supported_formats:
      - json
    supported_auth:
      - cookie
      - basic_auth

Points that matter in a custom resource:

  • Permissions. The plugin’s permissions() method, inherited from ResourceBase, creates one permission per implemented method. For this resource it is restful get meridian_on_display. If it is not granted to the anonymous role, anonymous visitors get 403.

  • Cache metadata. A ResourceResponse is cacheable and is cached in the cache.rest bin together with its metadata. A response without correct cache tags and contexts keeps serving stale data, so attach the tags of everything the payload depends on and the contexts it varies by. Use ModifiedResourceResponse for responses that must never be cached, such as POST results. The ideas are explained in Render API and Cache Metadata and Performance and Caching.

  • Exceptions as status codes. Throw BadRequestHttpException, NotFoundHttpException or AccessDeniedHttpException rather than returning an error array; the handler converts them to the right status.

  • Entity queries. Call accessCheck() explicitly, as the example does. See Database API and Entity Queries.

  • Dependency injection. Inject services through the constructor, as above; do not call \Drupal:: in the class. The pattern is described in Services and Dependency Injection.

  • Unsafe methods. A post(), patch() or delete() method needs a serialization_class when the body should be turned into an object, and always inherits the CSRF requirement.

REST export views

A view with a REST export display returns a list in any serializer format, which is how the museum publishes the exhibition list. Enable rest and serialization, add a REST export display, give it a path, and in the style settings choose the Serializer style and the formats (JSON, XML). The _format argument is still required, for example /api/meridian/exhibitions?_format=json.

Three limits matter. First, REST permissions do not apply to Views, so set the display’s access to a role or permission; a view left on "Unrestricted" is public. Second, only the mini and full pagers produce several pages, and the default output has no total count or paging information; the contributed Pager Serializer module adds it. Third, filters need an exposed filter for every field and operator a client will use, which JSON:API provides for free. The page GET on Views-generated lists lists the steps. See Views for building the view itself.

REST or JSON:API?

The Drupal documentation page JSON:API vs. core’s REST module (last updated 2 October 2021) puts the decision plainly: choose REST for non-entity data, and in all other cases choose JSON:API. Its reasoning:

Concern Core REST JSON:API

Setup

Each resource, method, format and authentication provider is configured.

No configuration; every entity type is exposed, and all installed authentication providers are accepted.

Non-entity data

Supported through custom resource plugins.

Not supported.

Lists

A view with a REST export display.

Collections with native filtering, sorting, pagination and includes.

Response shape

Drupal’s internal structure, or HAL when the contrib format is used.

Drupal-agnostic JSON:API documents without needless wrapping of simple fields.

Custom formats or request logic

Any format and any logic, which makes it powerful but brittle.

Fixed to the JSON:API specification.

In practice for the Meridian site: the Next.js kiosk reads exhibitions and collection objects through JSON:API (see Decoupled Drupal with Next.js), while a small REST resource serves the partner API that returns a fixed, non-entity summary, and REST is also used for the one webhook-style endpoint that accepts a POST from the collections-management system. Mixing the two is normal; nothing stops both modules from being enabled. GraphQL is a third option and is covered in GraphQL. The JSON:API module itself is described in JSON:API.

Rules of thumb:

  • Use JSON:API when a client needs entities, relationships, filters, sorting or pagination.

  • Use REST when the response is not an entity, when the payload must be hand-shaped, when an XML or custom format is required, or when a method must run bespoke logic.

  • Do not use HAL for a new project.

Security

  • Expose the minimum. Enable only the methods and formats that a client needs. Anything enabled is a public attack surface, and the user entity resource in particular exposes accounts.

  • Never put credentials over HTTP. Basic authentication has no protection of its own. Force HTTPS at the web server and see Security Hardening.

  • Respect CSRF. Keep the X-CSRF-Token requirement. Do not copy tutorials that disable it for convenience.

  • Check access twice. Grant the REST permission to the narrowest role and rely on entity and field access for the rest. Never build a custom resource that returns entities without accessCheck() or equivalent access checks.

  • Protect secrets. Credentials, tokens and OAuth secrets come from environment variables or a secrets manager, never from exported configuration.

  • Do not expose internals. Custom payloads should list the fields intended for the public, not whole entities.

  • Rate and cache. Cacheable responses reduce load; authenticated or Basic-authenticated traffic bypasses the page cache, so put a limit in front of the API when it faces the internet.

Common errors and pitfalls

  • 404 on the request. No rest_resource entity exists, the method has no formats or providers, or _format is missing. Check with drush config:get rest.resource.<id> and the Recent log messages.

  • An error about the format. The format in _format or the Content-Type header is not in supported_formats, or the module providing it (for example HAL) is not installed.

  • 403 with the right credentials. The role lacks the restful ... permission, or the account lacks entity access.

  • 403 on POST or PATCH with a session. The X-CSRF-Token header is missing or stale; fetch /session/token again.

  • Stale results. The resource’s response has no, or incomplete, cache tags and contexts.

  • PUT returns 405. The entity resource does not offer it; use PATCH.

  • Configuration deleted by an import. If a module is uninstalled on production but its REST configuration is still in config/sync, the import fails on the missing plugin; the dependencies block exists to prevent that.

Version notes

  • The RESTful Web Services API is a Drupal 8 introduction and is stable in 11.4.

  • HAL was deprecated in 9.4 and removed from core in 10.0.

  • Plugin attributes replace annotations for resources in current core; the Drupal\rest\Attribute\RestResource class is the one used by core’s own resources. Annotation discovery has not been removed from the plugin manager, and the removal version of annotations for plugins in general should be checked against the release notes before the upgrade to Drupal 12.

  • Older books such as Drupal 10 Development Cookbook (2023) show annotation-based plugins and \Drupal:: calls inside classes; use attributes and constructor injection as in the example above.