Drupal: Queue, Batch and Cron APIs
|
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. |
A request that has to talk to a slow remote service, rebuild thousands of records or send hundreds of emails cannot do the
work while a visitor waits: the web server times out, PHP runs out of memory, and a failure half way through leaves no trace
of what was done. Drupal offers three cooperating APIs for this. Cron (hook_cron) runs small recurring maintenance tasks
on a schedule. The Queue API stores units of work so that they can be processed later, at a controlled pace, by a worker
plugin that cron or Drush drives. The Batch API splits a long operation that an administrator starts from a form into many
short requests with a progress bar. This page explains how each one works inside core, when to choose which, how to write
them with the Drupal 11 conventions (PHP attributes, object-oriented hooks, constructor injection), and where the contributed
modules Advanced Queue and Ultimate Cron fit.
It was written against Drupal 11.4.x (11.4.0 was released on 1 July 2026), with Drush 13 or 14 and PHP 8.3 or later, and the facts were checked against the official documentation, change records, the API reference and the core source on 2026-10-30. Drupal 12.0.0 is scheduled for the week of 2026-12-07 and the end of life of Drupal 10 for 2026-12-09, so both are described as upcoming events. New sites start on Drupal 11.4; Drupal 10 behaviour is mentioned only where a feature arrived in a Drupal 10 minor.
The running example is the Meridian Museum, a fictitious regional science museum. Its module meridian_collection (see
Module anatomy and info.yml) keeps collection_object content entities (see
Custom entity types) in step with a fictitious collections-management system
through an API client service (see Services and dependency injection).
This page builds the synchronisation in three layers: a cron hook that plans the work, a queue worker that does it one object
at a time, and a batch that lets a curator re-synchronise everything from the admin UI. How cron is scheduled and monitored in
production (Automated Cron, system cron, Kubernetes, drush queue:list in a health check) is covered in
Cron, queues and maintenance; this page is about the code.
Who works with these APIs
-
A developer writes cron hooks, queue workers, batch operations and the services behind them.
-
A site builder sees the results only indirectly: a curator presses a button and watches a progress bar (Batch), or notices that a change in the collections system appears on the site within a quarter of an hour (Queue and cron).
-
An administrator decides how cron is triggered, which backend stores the queues, how long a worker may run, and watches for queues that do not drain.
Choosing between cron, queue and batch
The three APIs solve different problems, and choosing the wrong one is the most common design error.
| Question | Cron hook | Queue worker | Batch operation |
|---|---|---|---|
Who starts it? |
The scheduler (Automated Cron, system cron, Drush) |
Anyone who creates an item; cron, Drush or a contributed runner consumes it |
A person who submits a form, or code that calls |
What is it for? |
Short recurring tasks: plan work, expire records, rotate files |
Many independent units of work that may fail separately and be retried |
One long job where the person waits and wants progress feedback |
Where does it run? |
Inside the cron request or Drush process, as the anonymous user |
Inside the cron request, a Drush process, or the end of a request |
In a series of web requests (or a Drush child process), as the logged-in user |
On failure |
The exception is logged; other modules' cron hooks still run |
The item stays on the queue and is retried |
The batch stops and the finished callback reports the failure |
Typical Meridian use |
Decide which objects changed and queue them |
Synchronise one object from the collections API |
"Re-synchronise the whole collection" button for curators |
A good rule: cron decides, queues do, batches show. A cron hook should be fast and mostly create queue items; the queue
worker does the slow, retryable work; and a batch exists only because a person is waiting for the result. A long
hook_cron that does the work itself holds the cron lock for up to fifteen minutes and stops every other module’s
scheduled tasks behind it.
hook_cron
Cron is the scheduled entry point. When cron runs (see the triggers in
Cron, queues and maintenance), the core cron service
(Drupal\Core\Cron) does the following, in this order:
-
It ignores a client disconnect so that the run is not cut short, and switches the current user to the anonymous user so that cron behaves the same whichever way it was started.
-
It asks PHP for up to 240 seconds of execution time (
Environment::setTimeLimit(240)). The call only helps where the limit may be changed; a stricter web server or FPM limit still wins. Drush runs on the command line, where there is normally no limit. -
It takes the
cronlock for 900 seconds. If the lock is held, it logs the warning "Attempting to re-run cron while it is already running" and returns without doing anything, which is how overlapping triggers are made harmless. -
It invokes every
hook_cronimplementation, one module after another. An exception thrown by one implementation is caught and logged, and the next module still runs. -
It processes the queues that have a
crondefinition (next sections). -
It stores the completion time in the state key
system.cron_lastand releases the lock.
Since Drupal 11.1 a hook is a method with the #[Hook] attribute on an autowired class in src/Hook
(change record 3442349; see Hooks and OOP hooks for the whole mechanism and the
10.x-compatible #[LegacyHook] bridge). The museum’s cron hook does not synchronise anything itself. It checks the setting,
rate-limits itself with the State API, and asks a planner service to queue the objects that changed:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Hook;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\State\StateInterface;
use Drupal\meridian_collection\CollectionSyncPlanner;
/**
* Plans the collection synchronisation on cron.
*/
final class CollectionCronHooks {
/**
* Minimum seconds between two planning runs.
*/
private const PLAN_INTERVAL = 3600;
public function __construct(
private readonly CollectionSyncPlanner $planner,
private readonly ConfigFactoryInterface $configFactory,
private readonly StateInterface $state,
private readonly TimeInterface $time,
) {}
/**
* Implements hook_cron().
*/
#[Hook('cron')]
public function cron(): void {
if (!$this->configFactory->get('meridian_collection.settings')->get('sync_enabled')) {
return;
}
$now = $this->time->getRequestTime();
$last = (int) $this->state->get('meridian_collection.last_plan', 0);
if ($now - $last < self::PLAN_INTERVAL) {
return;
}
// Record the attempt first: a failing API must not be hammered by every
// cron run, and the queue worker has its own retry logic.
$this->state->set('meridian_collection.last_plan', $now);
$this->planner->enqueueChangedSince($last);
}
}
The class needs no registration: core finds attribute classes in src/Hook and autowires their constructor, and
TimeInterface, StateInterface and ConfigFactoryInterface resolve through the core service aliases. The older style, a
meridian_collection_cron() function in meridian_collection.module, still works and is what older books show.
Rules for a cron hook
-
Keep it short. Everything shares one lock and one time budget. Queue the work instead of doing it.
-
Never assume a user. Cron runs as the anonymous user, so entity access checks,
\Drupal::currentUser()and the language negotiated for a web request do not mean what they do in a page request. -
Make it idempotent. A cron run can be repeated after a crash, and two triggers can race at the edge of the lock timeout.
-
Do not rely on timing. Cron runs when it is scheduled, not when a person expects; if an action must happen at an exact minute, a contributed scheduler or a system-level job is the right tool, not
hook_cron. -
Record state in State, not configuration. Timestamps like the one above are environment data and must not appear in exported configuration (see Configuration API and schema).
-
Log with a channel. The
logger.channel.cronchannel records the "Cron run completed" message; the module’s own channel keeps its messages filterable in the log (see Logging, Status report and troubleshooting).
The system.cron configuration has a logging key. When it is enabled, cron logs the start and the duration of every
module’s hook_cron, which is the quickest way to find the module that makes cron slow.
The Queue API
A queue is a named list of work items. Each item carries a data payload and is stored by a queue backend. The API is
the same whatever the backend:
Method (QueueInterface) |
Purpose |
|---|---|
|
Prepares the storage. Idempotent; cron calls it before processing a queue. |
|
Adds an item. The payload is any serialisable value. Returns the item ID, or |
|
The number of items still in the queue (claimed items included). |
|
Takes the oldest available item and locks it for the lease time in seconds. Returns |
|
Removes a processed item. Needs the full object returned by |
|
Makes a claimed item available again immediately. |
|
Removes the queue and all its items. |
You obtain a queue from the queue service, Drupal\Core\Queue\QueueFactory, which is autowirable by its class name:
$queue = $this->queueFactory->get('meridian_collection_sync');
$queue->createItem(['accession_number' => 'MM-1987-0042']);
Source: Queue API
Several properties of the API drive correct designs:
-
At least once, not exactly once. If a consumer dies after doing the work but before
deleteItem(), the item reappears when its lease expires and is processed again. A worker must be idempotent: the Meridian worker below looks the object up by accession number and updates it, so processing the same item twice leaves the same result. -
Items are not unique.
createItem()never checks for duplicates. If planning runs twice before the worker runs, the same accession number is queued twice. Idempotent workers make that harmless; a cheap guard in the producer reduces the waste. -
Payloads are serialised. The database backend stores
datawith PHP serialisation. Queue identifiers and small scalar values, never entity objects or services: an entity may be deleted or changed by the time the worker runs, and a serialised object can fail to unserialize after a code change. The worker should load the fresh entity by ID. -
Order is best effort. The database queue returns the oldest item first (ordered by creation time and item ID), but a released or retried item goes back into that order, and non-reliable backends do not promise order at all.
Backends and settings
The default backend, queue.database, stores items in the queue table (columns item_id, name, data, expire
and created). It implements ReliableQueueInterface (order and at-least-once delivery are guaranteed) and
DelayableQueueInterface (an item can be delayed). Core also ships a memory backend and the special Batch and BatchMemory
queues used by the Batch API. Contributed backends trade guarantees for throughput: the Redis module provides queue.redis
(non-reliable) and queue.redis_reliable.
QueueFactory::get($name, $reliable = FALSE) chooses the backend from settings.php. The service for a queue is, in order of
precedence, the entry queue_reliable_service_NAME (only when a reliable queue is requested), the entry
queue_service_NAME, the global queue_default, and finally queue.database:
// sites/default/settings.php (excerpt)
// Use the database for every queue unless overridden below.
$settings['queue_default'] = 'queue.database';
// Move only the high-volume Meridian sync queue to a Redis queue that is
// still reliable (ordered, at least once).
$settings['queue_service_meridian_collection_sync'] = 'queue.redis_reliable';
Source: Redis module
The Redis module’s own documentation recommends a separate Redis instance for queue data, because items are not cache and must survive a restart. See settings.php and environments for how such overrides are kept per environment and Performance and caching for the Redis cache backend.
The lifecycle of a queue item
An item is never "failed" in the queue: it is either gone, waiting, or locked until a time. A permanently bad item (for example, an accession number the collections API no longer knows) therefore comes back for ever unless the worker removes it. The worker below handles that case on purpose.
QueueWorker plugins
A queue is only a list. The code that processes an item is a queue worker, a plugin of type QueueWorker (see
Plugin API and PHP attributes for plugin discovery). The plugin lives in
src/Plugin/QueueWorker, carries the QueueWorker attribute from Drupal\Core\Queue\Attribute, and implements
processItem($data), usually by extending QueueWorkerBase. The attribute has four arguments:
| Argument | Meaning |
|---|---|
|
The plugin ID. By convention it equals the machine name of the queue, so that cron can find the queue for a worker
( |
|
A |
|
Optional array with the single key |
|
Optional deriver class that generates several workers from one class (for example one per bundle). |
Plugins use PHP attributes since the 10.x line moved core’s plugin types away from annotations (10.2). The @QueueWorker
annotation (Drupal\Core\Annotation\QueueWorker) still works for now and appears in all tutorials written before 2024, but
new code uses the attribute.
The shared importer service
The worker, the Drush command and the batch all need the same operation, "make the site’s copy of this object match the collections system". That belongs in a service, not in the worker class. First the contract of the API client, which is the service the form in Form API already uses, and the exceptions it throws:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
/**
* Client for the museum's collections-management API.
*/
interface CollectionApiClientInterface {
/**
* Lists accession numbers changed since a Unix timestamp.
*
* @return string[]
* Accession numbers.
*
* @throws \Drupal\meridian_collection\Exception\CollectionApiUnavailableException
*/
public function listChangedSince(int $timestamp): array;
/**
* Fetches one object record.
*
* @return array<string, mixed>
* Keys: title, period, material, dimensions, on_display.
*
* @throws \Drupal\meridian_collection\Exception\CollectionApiUnavailableException
* When the API is down or timing out.
* @throws \Drupal\meridian_collection\Exception\CollectionApiRateLimitedException
* When the API answers HTTP 429.
* @throws \Drupal\meridian_collection\Exception\CollectionObjectNotFoundException
* When the object no longer exists upstream.
*/
public function fetchObject(string $accessionNumber): array;
}
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Exception;
/**
* The collections API did not answer.
*/
class CollectionApiUnavailableException extends \RuntimeException {}
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Exception;
/**
* The collections API asked the client to slow down.
*/
class CollectionApiRateLimitedException extends \RuntimeException {
public function __construct(
string $message,
private readonly int $retryAfter = 60,
?\Throwable $previous = NULL,
) {
parent::__construct($message, 0, $previous);
}
/**
* Seconds the API asked the client to wait.
*/
public function getRetryAfter(): int {
return $this->retryAfter;
}
}
(CollectionObjectNotFoundException is an empty \RuntimeException subclass in the same namespace.) The importer wraps
the client and the entity storage. It is the only class that knows how a record maps to fields:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
use Drupal\Core\Entity\EntityTypeManagerInterface;
/**
* Makes one collection_object entity match the collections API.
*/
final class CollectionObjectImporter {
public function __construct(
private readonly CollectionApiClientInterface $apiClient,
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}
/**
* Fetches an object and creates or updates its entity.
*
* @return bool
* TRUE if an entity was created or changed, FALSE if it was up to date.
*/
public function import(string $accessionNumber): bool {
$record = $this->apiClient->fetchObject($accessionNumber);
$storage = $this->entityTypeManager->getStorage('collection_object');
// loadByProperties() performs no access check, which is right here: a
// synchronisation is a system process, not an action of the current user.
$existing = $storage->loadByProperties(['accession_number' => $accessionNumber]);
$entity = $existing ? reset($existing) : $storage->create(['accession_number' => $accessionNumber]);
$values = [
'title' => $record['title'],
'period' => $record['period'] ?? NULL,
'material' => $record['material'] ?? NULL,
'dimensions' => $record['dimensions'] ?? NULL,
'on_display' => (bool) ($record['on_display'] ?? FALSE),
];
$changed = $entity->isNew();
foreach ($values as $field => $value) {
if ($entity->get($field)->value !== $value) {
$entity->set($field, $value);
$changed = TRUE;
}
}
if ($changed) {
$entity->save();
}
return $changed;
}
}
Source: Entity API
The services are declared with autowiring; _defaults applies to every service in the file. The CollectionApiClientInterface
alias was added in Services and dependency injection:
# meridian_collection.services.yml (excerpt)
services:
_defaults:
autowire: true
meridian_collection.object_importer:
class: Drupal\meridian_collection\CollectionObjectImporter
Drupal\meridian_collection\CollectionObjectImporter: '@meridian_collection.object_importer'
meridian_collection.sync_planner:
class: Drupal\meridian_collection\CollectionSyncPlanner
Drupal\meridian_collection\CollectionSyncPlanner: '@meridian_collection.sync_planner'
The worker
The worker translates the importer’s outcomes into the queue’s vocabulary. This translation, not the fetching, is the part that is specific to queues:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Plugin\QueueWorker;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Queue\Attribute\QueueWorker;
use Drupal\Core\Queue\DelayedRequeueException;
use Drupal\Core\Queue\QueueWorkerBase;
use Drupal\Core\Queue\SuspendQueueException;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\meridian_collection\CollectionObjectImporter;
use Drupal\meridian_collection\Exception\CollectionApiRateLimitedException;
use Drupal\meridian_collection\Exception\CollectionApiUnavailableException;
use Drupal\meridian_collection\Exception\CollectionObjectNotFoundException;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Synchronises one collection object from the collections-management API.
*/
#[QueueWorker(
id: 'meridian_collection_sync',
title: new TranslatableMarkup('Collection object sync'),
cron: ['time' => 60],
)]
final class CollectionObjectSyncWorker extends QueueWorkerBase implements ContainerFactoryPluginInterface {
public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
private readonly CollectionObjectImporter $importer,
private readonly LoggerInterface $logger,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
}
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('meridian_collection.object_importer'),
$container->get('logger.channel.meridian_collection'),
);
}
public function processItem($data): void {
$accession = is_array($data) ? ($data['accession_number'] ?? NULL) : NULL;
if (!is_string($accession) || $accession === '') {
// A malformed item can never succeed. Returning normally deletes it,
// which is exactly what a poison item needs.
$this->logger->error('Dropping a malformed sync item.');
return;
}
try {
$this->importer->import($accession);
}
catch (CollectionObjectNotFoundException) {
// Gone upstream: nothing to retry. Delete the item by returning.
$this->logger->warning('Object @accession no longer exists upstream.', ['@accession' => $accession]);
}
catch (CollectionApiRateLimitedException $e) {
// Keep this item locked for as long as the API asked, but let the rest
// of the queue go on.
throw new DelayedRequeueException($e->getRetryAfter(), $e->getMessage(), 0, $e);
}
catch (CollectionApiUnavailableException $e) {
// The next items would fail in the same way: stop this queue for this
// cron run. The item is released and tried again on the next run.
throw new SuspendQueueException($e->getMessage(), 0, $e);
}
}
}
Source: QueueWorker attribute
Dependency injection in a plugin goes through ContainerFactoryPluginInterface::create(), the plugin counterpart of the
service constructor; QueueWorkerBase itself extends PluginBase, whose constructor takes the three plugin arguments. Static
\Drupal:: calls inside the class are not needed.
What each outcome means
Drupal\Core\Cron (through QueueProcessTrait::processItem()) claims one item, calls processItem($item->data) and then
reacts to what the worker did:
| Worker behaviour | Effect on the item and the queue |
|---|---|
Returns normally |
|
Throws |
|
Throws |
The item stays locked. With a queue that implements |
Throws |
The item is released and the whole queue is skipped for this run. The exception may carry a delay in seconds (the fourth constructor argument) as a hint for when to resume. |
Throws any other |
The exception is logged through |
The behaviour of the SuspendQueueException delay is easy to misread. When a suspended exception has a delay below
suspendMaximumWait (30 seconds, the queue.config container parameter), cron puts the queue back at the end of its
processing stack with that start time and may come back to it in the same run, after the other queues. A delay of 30 seconds
or more, or no delay, means the queue is not revisited until the next cron run. The delay argument and its handling are
described in the documentation of the SuspendQueueException class and of Drupal\Core\Cron in the API reference.
How cron processes the queue
Three details of this loop matter in practice:
-
The
timevalue does two jobs.processQueue()keeps claiming items untiltimeseconds have passed, and the same value is passed toclaimItem()as the lease time. A queue declared withcron: ['time' => 60]is worked on for at most a minute per cron run, and each claimed item is locked for 60 seconds. An item that takes longer than that to process becomes available to another process and can be processed twice. Keep one item far below the value. -
The time is checked between items, not during one. A single slow item can overrun the budget, and the 240 seconds that cron asks PHP for are shared by all
hook_cronimplementations and all queues. In a web-triggered cron several queues with 60-second budgets can exceed it; a Drush-triggered cron has no such limit. -
Queues do not run in parallel. Cron processes one queue at a time inside one process and holds the
cronlock. A backlog that grows faster than cron drains it needs a dedicated, separately scheduleddrush queue:run(below), or a different runner.
A worker that depends on the current user, language or request must not. Cron runs as the anonymous user; if the sync needs particular permissions or content language, set them explicitly in the service.
Hooks that change the queue definitions
hook_queue_info_alter(&$queues) runs after the plugin definitions are collected and before cron uses them. It is the
supported place to change another module’s cron time or to remove a worker from cron without patching the module:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Hook;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Adjusts queue definitions.
*/
final class CollectionQueueHooks {
/**
* Implements hook_queue_info_alter().
*/
#[Hook('queue_info_alter')]
public function queueInfoAlter(array &$queues): void {
// On this site the Meridian sync is drained by a dedicated scheduled
// "drush queue:run", so cron must leave it alone.
unset($queues['meridian_collection_sync']['cron']);
}
}
Source: API reference: Queue operations
Removing the cron key is also the way to run a queue only from Drush: a worker without a cron definition is ignored by
the cron service but stays available to drush queue:run.
Planning the work: the enqueue side
The CollectionSyncPlanner service is the producer. It asks the API what changed and creates one item per distinct accession
number, so the API’s answer cannot queue the same object twice in one run:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
use Drupal\Core\Queue\QueueFactory;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Creates queue items for collection objects that changed upstream.
*/
final class CollectionSyncPlanner {
public const QUEUE = 'meridian_collection_sync';
public function __construct(
private readonly CollectionApiClientInterface $apiClient,
private readonly QueueFactory $queueFactory,
#[Autowire(service: 'logger.channel.meridian_collection')]
private readonly LoggerInterface $logger,
) {}
/**
* Queues every object changed since the given timestamp.
*
* @return int
* The number of items created.
*/
public function enqueueChangedSince(int $timestamp): int {
try {
$accessionNumbers = $this->apiClient->listChangedSince($timestamp);
}
catch (\RuntimeException $e) {
// The API is down: planning is skipped, and the next cron run plans
// again from the same timestamp because the caller keeps it.
$this->logger->warning('Cannot plan the sync: @message', ['@message' => $e->getMessage()]);
return 0;
}
$queue = $this->queueFactory->get(self::QUEUE);
$queue->createQueue();
$count = 0;
foreach (array_unique($accessionNumbers) as $accessionNumber) {
$queue->createItem(['accession_number' => $accessionNumber]);
$count++;
}
$this->logger->info('Queued @count objects for synchronisation.', ['@count' => $count]);
return $count;
}
}
Source: Queue API
Note that a planner that creates a very large number of items in one run, for instance a full re-import of 200,000 objects, makes that request itself slow. Create items in chunks (for example one item per hundred accession numbers, with the worker looping over its chunk), or put the work inside a batch or a Drush command.
The Drush command
meridian:sync plans the work from the command line, so that an operator can start a full synchronisation after an outage.
Drush 13 and 14 discover command classes ending in Commands in src/Drush/Commands of a module; the AutowireTrait from
Drush injects services by constructor type hint. (Drush 13.7 recommends writing new commands as Symfony Console commands and
treats attribute-annotated commands as the older style; both work in Drush 13 and 14. Commands are explained in
Drush.)
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Drush\Commands;
use Drupal\meridian_collection\CollectionSyncPlanner;
use Drush\Attributes as CLI;
use Drush\Commands\AutowireTrait;
/**
* Drush commands for the Meridian collection.
*/
final class MeridianSyncCommands {
use AutowireTrait;
public function __construct(
private readonly CollectionSyncPlanner $planner,
) {}
/**
* Queues collection objects for synchronisation.
*/
#[CLI\Command(name: 'meridian:sync', aliases: ['msync'])]
#[CLI\Option(name: 'since', description: 'Unix timestamp; objects changed after it are queued. 0 queues everything.')]
#[CLI\Usage(name: 'drush meridian:sync --since=0', description: 'Queue the whole collection, then run: drush queue:run meridian_collection_sync')]
public function sync(array $options = ['since' => 0]): string {
$count = $this->planner->enqueueChangedSince((int) $options['since']);
return sprintf('Queued %d objects. Process them with: drush queue:run meridian_collection_sync', $count);
}
}
Source: Drush: Dependency injection
Running a queue outside cron
The Drush commands for queues are queue:list (name, number of items and worker class of every queue), queue:run and
queue:delete:
# What is waiting?
ddev drush queue:list --fields=queue,items
# Queue the whole collection, then drain it with limits.
ddev drush meridian:sync --since=0
ddev drush queue:run meridian_collection_sync --time-limit=300 --items-limit=1000 --lease-time=120
# Throw away everything in a queue (this deletes work, not just retries it).
ddev drush queue:delete meridian_collection_sync
Source: Drush: queue:run
queue:run takes the queue name and the options --time-limit, --items-limit and --lease-time ("the maximum number of
seconds that an item remains claimed"); its alias is queue-run. Without limits it runs until the queue is empty. The
Drush process exits when it is done, so a scheduler (a systemd timer, a Kubernetes CronJob) can start it every few minutes.
For the scheduling recipes see Cron, queues and maintenance.
A Drush process that runs for a long time keeps services and entity static caches alive. A worker that loads thousands of
entities in one process should keep the memory bounded, for instance by using --items-limit and letting the scheduler start
a fresh process, instead of relying on a single run to drain a very large queue.
The instant queue runner (Drupal 11.5)
Cron is the default consumer, and a short cron interval is how editors' changes reach the queue’s output "soon". Core
change record 3617082 introduces a second consumer in Drupal 11.5.0, which is still in development on the 11.5.x branch at
the time of writing: the Drupal\Core\Queue\InstantQueueRunnerInterface service. A module that has just created queue
items calls registerQueue($queue_name), and at the end of the request, after the response has been sent (the kernel
destruct stage), the runner processes up to maximumItems items per queue (the container parameter instant_queue.config,
default 5). It does not guarantee that the processed items are the ones created in this request. The processing code was moved
into the shared QueueProcessTrait, which both Cron and the new runner use, so the exception semantics above are the same.
On 11.4 the feature is absent and the queue is drained only by cron, Drush or a contributed runner.
The Batch API
The Batch API serves a different situation: a person has pressed a button, the job takes minutes, and they should see a
progress bar and a result. The API spreads the job over many short HTTP requests (each one fits comfortably inside the PHP time
limit) and stores the state between requests. Core uses it for the database update screen (update.php), configuration import
and similar administrative operations. Its reference topic describes its main use as integrating with the Form API, but it
also works from page callbacks and scripts.
The batch definition
A batch is an array, normally built with Drupal\Core\Batch\BatchBuilder (added in Drupal 8.6) and registered with
batch_set(). The recognised keys, which are also the BatchBuilder setters, are:
| Key (builder method) | Meaning |
|---|---|
|
Required. A list of |
|
The title of the progress page. Defaults to "Processing". |
|
The message shown while the batch initialises. |
|
The progress text, with the placeholders |
|
The message shown when a request of the batch fails. |
|
The callback run when the batch ends, successfully or not. |
|
The file with the callbacks when they are not autoloaded or in a |
|
Libraries loaded on the progress page. |
|
Options for the redirect URLs. |
|
|
|
Overrides the queue that stores the operations: the |
The reference topic notes one security obligation: if the title, init_message, progress_message or error_message, or
anything an operation writes to $context['results'] or $context['message'], could contain user input, the code must
escape it first (Html::escape() or Xss::filter()). The museum’s object titles come from an external system and are
therefore escaped, not trusted.
An operation with a sandbox
An operation is called again and again until it reports that it is finished. It receives the arguments of its entry in
operations, followed by the $context array passed by reference:
$context key |
Meaning |
|---|---|
|
Free-form state that survives between the calls of the same operation. It is saved in the database between requests; this is where the cursor lives, instead of the session, which is unsafe if the user keeps browsing in another window. |
|
An array that collects data for the finished callback. It is shared by all operations of the batch set. |
|
The text shown under the progress bar. |
|
A number between 0 and 1: the completion level of this operation. The engine resets it to 1 before each call, so an operation sets it only when more work remains. When it is 1 (or unset), the engine moves to the next operation. |
The museum’s operation collects the list of accession numbers once, then imports ten objects per call. A batch callback is
static code that the batch engine calls by name, so it cannot receive injected services; this is one of the few places where
\Drupal::service() is the normal way to reach a service. Keep the callbacks thin and put the real work in injectable
services, as here:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Batch;
use Drupal\Component\Utility\Html;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Drupal\Core\Url;
use Drupal\meridian_collection\Exception\CollectionApiRateLimitedException;
use Drupal\meridian_collection\Exception\CollectionApiUnavailableException;
/**
* Batch callbacks for the full collection re-synchronisation.
*/
final class CollectionResyncBatch {
private const CHUNK = 10;
/**
* Batch operation: imports the next CHUNK objects.
*
* @param array<string, mixed> $context
* The batch context.
*/
public static function process(array &$context): void {
$sandbox = &$context['sandbox'];
if (!isset($sandbox['accession_numbers'])) {
$sandbox['accession_numbers'] = \Drupal::service('meridian_collection.api_client')->listChangedSince(0);
$sandbox['total'] = count($sandbox['accession_numbers']);
$sandbox['done'] = 0;
}
$context['results'] += ['imported' => 0, 'failed' => []];
$importer = \Drupal::service('meridian_collection.object_importer');
$slice = array_splice($sandbox['accession_numbers'], 0, self::CHUNK);
foreach ($slice as $accessionNumber) {
try {
$importer->import($accessionNumber);
$context['results']['imported']++;
}
catch (CollectionApiUnavailableException | CollectionApiRateLimitedException $e) {
// Remote trouble: fail the batch rather than recording thousands of
// identical errors. The finished callback reports it.
$context['results']['failed'][] = Html::escape($accessionNumber . ': ' . $e->getMessage());
$sandbox['accession_numbers'] = [];
$context['finished'] = 1;
return;
}
catch (\Throwable $e) {
// A bad object must not stop the others.
$context['results']['failed'][] = Html::escape($accessionNumber . ': ' . $e->getMessage());
}
$sandbox['done']++;
$context['message'] = (string) new TranslatableMarkup('Synchronised @accession (@done of @total)', [
'@accession' => $accessionNumber,
'@done' => $sandbox['done'],
'@total' => $sandbox['total'],
]);
}
$context['finished'] = $sandbox['total'] > 0 ? $sandbox['done'] / $sandbox['total'] : 1;
}
/**
* Batch finished callback.
*
* @param bool $success
* TRUE if no fatal PHP error interrupted the batch.
* @param array<string, mixed> $results
* The collected results.
* @param array<int, mixed> $operations
* The operations that were not processed.
* @param string $elapsed
* The elapsed time, formatted.
*/
public static function finished(bool $success, array $results, array $operations, string $elapsed): RedirectResponse {
$messenger = \Drupal::messenger();
if ($success && empty($results['failed'])) {
$messenger->addStatus(\Drupal::translation()->formatPlural(
$results['imported'] ?? 0,
'One object was synchronised in @elapsed.',
'@count objects were synchronised in @elapsed.',
['@elapsed' => $elapsed],
));
}
else {
$messenger->addError(new TranslatableMarkup('The synchronisation finished with errors (@count objects failed).', [
'@count' => count($results['failed'] ?? []),
]));
foreach (array_slice($results['failed'] ?? [], 0, 5) as $failure) {
$messenger->addWarning($failure);
}
}
return new RedirectResponse(Url::fromRoute('entity.collection_object.collection')->toString());
}
}
Source: API reference: Batch operations
A few points explain the code:
-
The operation is repeated until
finishedreaches 1. The first call has an empty sandbox and fills it; later calls continue from it. The progress bar uses the number the operation reports, so a wrong or missingfinishedvalue either ends the batch after one call or runs it for ever. -
Do a bounded amount of work per call. Ten objects at about a second each fit comfortably in a request; the engine also starts a new request when enough time has passed since the last one.
-
The finished callback may return a redirect. A
RedirectResponsereturned byfinishedis used by the standard batch pipeline and takes precedence over any other target. This is the supported way to choose where the user lands when the batch is started from a form. -
$successis not "no errors". It only means that no fatal PHP error interrupted the batch. Other failures are reported throughresults, as the code does. -
Messages are escaped. The failure strings pass through
Html::escape()because they contain data from the remote system.
Starting a batch from a form
A confirm form (see Form API) is the safe way to start a long, state-changing operation: the
route is protected by a permission, the confirmation step prevents an accidental start by a link, and the Form API supplies the
CSRF token. Inside submitForm() the builder registers the batch; the Form API then calls batch_process() itself:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Form;
use Drupal\Core\Batch\BatchBuilder;
use Drupal\Core\Form\ConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
use Drupal\meridian_collection\Batch\CollectionResyncBatch;
/**
* Confirms and starts a full re-synchronisation of the collection.
*/
final class CollectionResyncForm extends ConfirmFormBase {
public function getFormId(): string {
return 'meridian_collection_resync_confirm';
}
public function getQuestion(): TranslatableMarkup {
return $this->t('Re-synchronise the whole collection?');
}
public function getDescription(): TranslatableMarkup {
return $this->t('Every object is fetched again from the collections-management system. This can take several minutes.');
}
public function getCancelUrl(): Url {
return new Url('entity.collection_object.collection');
}
public function submitForm(array &$form, FormStateInterface $form_state): void {
$batch = (new BatchBuilder())
->setTitle($this->t('Synchronising the collection'))
->setInitMessage($this->t('Contacting the collections system.'))
->setProgressMessage($this->t('@current of @total steps done, about @estimate remaining.'))
->setErrorMessage($this->t('The synchronisation stopped because of an error.'))
->addOperation([CollectionResyncBatch::class, 'process'])
->setFinishCallback([CollectionResyncBatch::class, 'finished']);
batch_set($batch->toArray());
// No batch_process() call: the Form API processes batches set during
// form submission.
}
}
# meridian_collection.routing.yml (excerpt)
meridian_collection.resync:
path: '/admin/content/collection/resync'
defaults:
_form: '\Drupal\meridian_collection\Form\CollectionResyncForm'
_title: 'Re-synchronise the collection'
requirements:
_permission: 'administer meridian collection settings'
Source: Batch API overview
The progress page is generated by the batch engine at /batch, with the batch ID in the query string. Anyone who starts a
batch needs the permission of the form that starts it, and the batch pages themselves are tied to the session that created
the batch, so a batch ID copied into another browser does not give access to it.
Batches outside forms
When the batch is started from code that is not a form submit handler, for example a controller, batch_process() must be
called with the page to return to; a blank target causes an endless redirect loop in the example of the official overview. The
function returns a RedirectResponse for a progressive batch, which the controller returns:
public function resync(): ?RedirectResponse {
$batch = (new BatchBuilder())
->setTitle($this->t('Synchronising the collection'))
->addOperation([CollectionResyncBatch::class, 'process']);
batch_set($batch->toArray());
return batch_process(Url::fromRoute('entity.collection_object.collection'));
}
Source: batch_process()
A state-changing controller like this one needs a CSRF-protected route (_csrf_token: 'TRUE'), because a plain link to a
GET route would let any page on the web start a batch. The confirm form already does that, which is why it is the better
default.
Batch or queue?
A batch can store its operations in a queue and a queue can be drained in a batch, so the two are easily confused. Pick the Batch API when the person waiting needs a progress bar and a final report; pick the Queue API when the work is triggered by events or schedules, must survive restarts, and should retry on its own. The museum uses both on purpose: day-to-day changes flow through cron and the queue; the curator’s button runs the batch because the curator needs to know when it is over.
Batch operations are bound to a user’s browser session; closing the tab stops the progress, though the already processed operations stay done. That is why the operation above is idempotent and why a batch should not be the only way to perform a job that must be completed.
In update hooks the situation is different: hook_update_N() and hook_post_update_NAME() do not call batch_set(). They
receive a $sandbox argument and set $sandbox['#finished'], and the update runner provides the repetition (see
Update API and post-update hooks). Drush commands that need the Batch API
call batch_set() and then drush_backend_batch_process(), which runs the batch in child Drush processes; the batch code
itself is the same as above. For a Drush-only job, a loop with --items-limit and the queue is usually simpler than a batch.
Contributed alternatives
Core’s queue is deliberately small. Two contributed modules address its main limits; neither is needed for the Meridian module as written, and each one adds a dependency that must be maintained.
Advanced Queue (drupal/advancedqueue, release 8.x-1.7 of 7 September 2026, compatible with Drupal ^10.2 and ^11, covered
by the Drupal Security Team) replaces the bare list with queues as configuration entities and jobs as entities. Each job has
a type (a JobType plugin that processes it), a state (queued, processing, success, failure), a message and a processing
time. Retries can be configured per job type or per job, jobs can be scheduled for a later time, and job lists are available as
Views. Queues are processed by cron or by Drush commands such as advancedqueue:queue:process (check drush list on the
installed version for the exact names and options). Use it when the museum wants to see, search and retry individual failed
syncs from the admin UI, which the core queue cannot show.
Ultimate Cron (drupal/ultimate_cron, 8.x-2.0-beta2 released on 29 October 2026; the project page states that there is
currently no supported stable release, so check its status before relying on it) turns each cron hook into an individually
scheduled and logged job with its own rules, an overview page, a per-job log and Drush commands to list, start, enable and
disable jobs, and it supports Drupal queues. Use it when different hook_cron implementations need different schedules, for
instance a nightly cleanup and a five-minute sync, without writing separate system cron entries.
Both modules are optional and sit on top of what this page explains. The core queue, the exception semantics and the cron loop remain the model to understand first.
Testing queues and cron
Kernel tests exercise a worker through the real cron service without a browser. The test replaces the API client with a
PHPUnit mock before the importer is created, queues an item, runs cron and asserts that the queue is empty and the entity was
created. PHPUnit 11 attributes replace the old docblock annotations (see
Automated testing for the test types and phpunit.xml.dist):
<?php
declare(strict_types=1);
namespace Drupal\Tests\meridian_collection\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\meridian_collection\CollectionApiClientInterface;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
/**
* Tests that cron drains the collection sync queue.
*/
#[Group('meridian_collection')]
#[RunTestsInSeparateProcesses]
final class CollectionSyncCronTest extends KernelTestBase {
protected static $modules = ['system', 'user', 'field', 'text', 'options', 'meridian_collection'];
protected function setUp(): void {
parent::setUp();
$this->installEntitySchema('user');
$this->installEntitySchema('collection_object');
$this->installConfig(['system', 'meridian_collection']);
}
public function testCronProcessesQueuedObjects(): void {
$client = $this->createMock(CollectionApiClientInterface::class);
$client->method('fetchObject')->willReturn([
'title' => 'Foucault pendulum',
'period' => 'nineteenth_century',
'on_display' => TRUE,
]);
$this->container->set('meridian_collection.api_client', $client);
$queue = $this->container->get('queue')->get('meridian_collection_sync');
$queue->createQueue();
$queue->createItem(['accession_number' => 'MM-1987-0042']);
$this->container->get('cron')->run();
$this->assertSame(0, $queue->numberOfItems());
$objects = $this->container->get('entity_type.manager')
->getStorage('collection_object')
->loadByProperties(['accession_number' => 'MM-1987-0042']);
$this->assertCount(1, $objects);
}
}
Source: KernelTestBase
Test the failure paths the same way: a client that throws CollectionApiUnavailableException must leave the item on the queue
(numberOfItems() is still 1 after the cron run), and one that throws CollectionObjectNotFoundException must delete it.
The batch callbacks are plain static methods, so they can be called directly with a prepared $context array in a Kernel
test.
Security
-
A queue is an input channel. Items are created by code, but their payloads can derive from user input (a form field, an imported file, an API response). The worker validates what it reads, as the museum worker does with the accession number, and never trusts a serialised payload to be well-formed.
-
Cron has no user. Do not rely on access checks that assume a logged-in user; the code that must respect permissions loads the entity as a specific account, and code that deliberately ignores access (like the importer) says so in a comment. Entity queries in a worker call
accessCheck(FALSE)explicitly; they must callaccessCheck()either way. -
The cron URL is a trigger. Anyone who knows
/cron/KEYcan start cron. Keep the key out of public places and prefer Drush or a platform scheduler (see Security hardening). -
Batch and CSRF. Start batches from forms or CSRF-protected routes, and gate them with a permission. Escape any user or remote data placed in titles, messages and results.
-
Secrets stay out of items. Do not put API tokens in a queue payload or in
sandboxdata: both are stored in the database. The API client reads its key fromsettings.phpor the environment.
Common errors and pitfalls
-
The queue grows and never shrinks. The worker has no
crondefinition, so onlydrush queue:runwould drain it; or cron is not running at all; or every item throws and is retried. Checkdrush queue:listand the "Cron run completed" and worker error messages in the log. -
Items processed twice. The item took longer than the lease. Reduce the work per item, raise
timein the attribute, or make the worker idempotent (it should be anyway). -
Cron "hangs" or takes longer than the interval. A slow
hook_cronor queue holds the lock and the next trigger is rejected with "Attempting to re-run cron while it is already running". Enable cron timing logging, move the work to a queue and lower thetimebudgets. -
One bad item blocks progress. An exception other than the special ones leaves the item locked, and it is retried endlessly. Catch permanent errors in the worker and return normally, as the museum worker does for a malformed item and an unknown object.
-
SuspendQueueExceptionin a loop. An outage that throws it for every cron run produces a log entry per run. That is correct, but an alert ondrush queue:listis a better signal than reading logs. -
Wrong plugin namespace or ID. The worker must be in
Plugin\QueueWorker, and itsidmust match the queue name used byQueueFactory::get(); otherwise nothing runs it and no error appears. Rebuild caches after adding a worker so that plugin discovery sees it. -
Closures or objects in a batch. A batch operation must be a named function, a
Class::methodstring or a class and method array, because the definition is stored in the database. A closure fails to serialise. -
Batch state in the session. Use
$context['sandbox'], which is stored in the batch queue, not$_SESSION. -
$context['finished']mistakes. Forgetting to set it to a fraction below 1 while work remains ends the operation after one call; setting it to something that never reaches 1 loops for ever. -
Heavy work in
hook_cron. It runs as anonymous with the cron lock held, and a failure is only logged. Queue it. -
Using
\Drupal::calls in the worker. They work, but make the class untestable. Inject services throughcreate(); use static calls only in batch callbacks and procedural code.
Version notes and corrections of older material
-
Plugin attributes. Core’s plugin types accept PHP attributes since Drupal 10.2, and the
QueueWorkerattribute is inDrupal\Core\Queue\Attribute. Older books such as Drupal 10 Development Cookbook (2023) and most tutorials show the@QueueWorkerannotation in a docblock; it still works on 11.4, but new workers use the attribute and annotations are on a deprecation path; check the plugin API change records for the release that removes them. -
Object-oriented hooks. Those books implement cron as
module_cron()in the.modulefile. Since Drupal 11.1 the#[Hook('cron')]attribute on a service-like class is the recommended form, and the procedural function is only needed for Drupal 10.x compatibility (with#[LegacyHook]bridging both).hook_queue_info_alterfollows the same rule. -
Static service calls. Examples that fetch the queue with
\Drupal::queue('name')inside classes belong to the era before autowiring; classes now receiveQueueFactorythrough the constructor. The static call remains valid in procedural code and tests. -
Drush. Older material uses
drush queue-run; the current name isdrush queue:run(the old name remains an alias). Custom Drush commands use attributes and autowiring, not annotations anddrush.services.yml, which no longer exists since Drush 13. -
Batch tutorials. The official "Batch API overview" page is a Drupal 6-era example that is marked as needing review and uses Drupal 7 functions (
db_query_range(),node_load(),theme()). The batch definition and the$contextcontract it describes are still right, but the function calls in its example must not be copied. The finished callback also has a fourth parameter,$elapsed, in current core, which that page does not show.BatchBuilderis the current way to assemble a definition. -
Queue exceptions.
RequeueException(Drupal 8.1) andDelayedRequeueException(Drupal 9.1) are not in books that describe onlySuspendQueueException; a modern worker uses all three according to the failure. -
Instant queue runner. Arriving in Drupal 11.5, it removes the need for a one-minute cron just to flush a few notification items. It is not available on 11.4.
-
Drupal 12. Nothing on this page changes with Drupal 12.0.0 as far as the published change records show: the queue, cron and batch APIs stay, with attribute plugins and object-oriented hooks as the only supported style for new code. Query objects are no longer serialisable in Drupal 12, which reinforces the rule of queueing identifiers, not objects (see Preparing for Drupal 12). The wider list of outdated advice is collected in Where older books and tutorials are outdated.
Where the pieces fit in the Meridian module
| Piece | File | Role |
|---|---|---|
Cron hook |
|
Decides, at most once an hour, to plan |
Planner service |
|
Creates one queue item per changed object |
Queue worker |
|
Imports one object and translates failures into queue exceptions |
Importer service |
|
Shared by the worker and the batch |
Drush command |
|
|
Batch callbacks and confirm form |
|
The curator’s "Re-synchronise the collection" button |
Kernel test |
|
Proves that cron drains the queue |
Further reading inside this sub-section: Events and subscribers (reacting to entity changes by queueing work), Migrate API (large imports that run as a Drush operation) and Drush.