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 |
|---|---|
|
The |
|
The REST resource plugin type, the |
|
The |
|
The |
|
The |
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
Source: Drush: pm:install
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.
The steps worth remembering:
-
Format negotiation is by the
_formatquery argument only. The Drupal 8 overview states that the HTTPAcceptheader is not used and that_formatis always required. The default formats arejsonandxml. -
Authentication providers are chosen per route from the resource configuration, not globally.
-
The
RequestHandlercalls 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.
-
resourcegranularity applies one set of methods, formats and authentication providers to the whole resource.configurationhas the keysmethods,formatsandauthentication. -
methodgranularity configures every HTTP method on its own. Each key ofconfigurationis a method (GET,POST, …) whose value hassupported_formatsandsupported_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'
Source: Drush: role:perm:add
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
Source: GET for reading content entities
# 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.
-
jsonandxmlship with core. Both expose Drupal’s internal field structure, so a single-value text field appears as an array of objects with avaluekey. -
hal_jsonis provided by the contributed HAL project. It adds_linksand_embeddedstructures and was removed from core in 10.0. Older tutorials, and Drupal 8-era guides still on drupal.org, enablehaland 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.
The serialization API is documented in Serialization API overview, Adding new formats and Changing the way Serializer handles entities.
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 |
|---|---|---|
|
|
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. |
|
|
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 fromResourceBase, creates one permission per implemented method. For this resource it isrestful get meridian_on_display. If it is not granted to the anonymous role, anonymous visitors get 403. -
Cache metadata. A
ResourceResponseis cacheable and is cached in thecache.restbin 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. UseModifiedResourceResponsefor 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,NotFoundHttpExceptionorAccessDeniedHttpExceptionrather 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()ordelete()method needs aserialization_classwhen 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
userentity 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-Tokenrequirement. 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_resourceentity exists, the method has no formats or providers, or_formatis missing. Check withdrush config:get rest.resource.<id>and the Recent log messages. -
An error about the format. The format in
_formator theContent-Typeheader is not insupported_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-Tokenheader is missing or stale; fetch/session/tokenagain. -
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; thedependenciesblock 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\RestResourceclass 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.