Creating a queue worker pluginfor Drupal 11 , 10 , 9 , and 8

Last updated :  

Items are sitting on the queue — now something has to actually take them off and do the work. That something is a QueueWorker plugin: a small, focused class whose only job is "given one item, process it." Drupal core's cron system automatically discovers every QueueWorker plugin in every enabled module and calls it to drain its queue on every cron run.

What you'll learn in this lesson

  • The @QueueWorker annotation and what it registers
  • The one method every worker must implement: processItem()
  • Dependency injection in a plugin via ContainerFactoryPluginInterface
  • Sharing logic between multiple related workers with a base class

A real QueueWorker plugin

This is the actual worker plugin from the Examples module's cron_example:

namespace Drupal\cron_example\Plugin\QueueWorker;

/**
 * A report worker.
 *
 * @QueueWorker(
 *   id = "cron_example_queue_1",
 *   title = @Translation("First worker in cron_example"),
 *   cron = {"time" = 1}
 * )
 */
class ReportWorkerOne extends ReportWorkerBase {

  /**
   * {@inheritdoc}
   */
  public function processItem($data) {
    $this->reportWork(1, $data);
  }

}

Three things register this worker with Drupal, all inside the annotation: id is the queue name this worker drains — it must exactly match whatever name you used in $queue_factory->get('cron_example_queue_1') when you enqueued items. title is a human-readable label shown on admin pages that list queues. cron tells Drupal's cron system how long, in seconds, to spend processing this specific queue on each cron run — here, just 1 second, intentionally small so a slow worker never dominates the whole cron run.

processItem() — the only method that matters

public function processItem($data) {
  $this->reportWork(1, $data);
}

This is the entire contract: processItem($data) receives exactly whatever you passed to createItem() back when the item was queued, fully unserialized and ready to use. If it returns normally, Drupal deletes the item from the queue — it's done. If it throws an exception (including a \Drupal\Core\Queue\SuspendQueueException to deliberately pause processing this queue for the rest of the current cron run), the item is not deleted, and a future cron run will hand it back to processItem() again.

Injecting services into a plugin

abstract class ReportWorkerBase extends QueueWorkerBase implements ContainerFactoryPluginInterface {

  public function __construct(array $configuration, $plugin_id, $plugin_definition, StateInterface $state, LoggerChannelFactoryInterface $logger) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
    $this->state = $state;
    $this->logger = $logger;
  }

  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    $form = new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('state'),
      $container->get('logger.factory')
    );
    return $form;
  }

}

Plugins get dependency injection through ContainerFactoryPluginInterface's create() method — a different signature from a form or controller's create(), since it also receives the plugin's own $configuration, $plugin_id, and $plugin_definition alongside the container. Every argument after those three is a service you're pulling in yourself — here, state and logger.factory.

Sharing logic across multiple workers with a base class

Notice ReportWorkerOne extends ReportWorkerBase, not QueueWorkerBase directly. This is a genuinely useful pattern once you have more than one related queue: put the shared constructor, injected services, and any common processing logic (reportWork() here) on an abstract base class, and let each concrete worker's processItem() stay a one-liner that just calls into the shared logic with its own identifying details.

Queue name must match exactly: the id in @QueueWorker and the name you pass to $queue_factory->get() are matched as plain strings — a typo in either place means your worker silently never runs, with no error, because Drupal simply never associates the two. If items are piling up and processItem() never seems to fire, check this first.

Quick check: your worker's processItem() throws an uncaught exception halfway through. What happens to that queue item on the next cron run? If you said "it gets handed to processItem() again" — correct. Drupal only removes an item from the queue after processItem() returns without throwing, so a failed item stays queued and gets retried, potentially forever if the same input keeps causing the same failure.

Key takeaways

  • @QueueWorker(id, title, cron) registers a plugin against a queue name — the id must match the name used at createItem() time exactly.
  • processItem($data) is the only method you must implement; returning normally deletes the item, throwing leaves it for retry.
  • Plugins inject services via ContainerFactoryPluginInterface::create(), whose signature includes the plugin's own configuration/ID/definition alongside the container.
  • An abstract base class is the right place to share logic and injected services across multiple related workers.

Coming up next

Your worker exists and can process one item — but nothing calls it yet outside of manually running cron. Next: how the cron key in the annotation actually controls when and how much of your queue gets processed on a real cron run.