Drupal Cron Tasks: Implementing hook_cron() the Right Wayfor Drupal 11 , and 10

Last updated :  

You've now met two ways of deferring work: queues (someone processes items whenever they get around to it) and batches (a person watches a progress bar while it happens right now). This lesson covers the third piece of the puzzle — the thing that actually triggers most background work in the first place, on a recurring schedule, with nobody watching at all: cron.

What you'll learn in this lesson

  • What cron is in Drupal, and the different ways it can be triggered
  • How to implement hook_cron() to run your own code on a schedule
  • Why you almost always need to throttle a cron hook, and the State API pattern for doing it correctly
  • How to log cron activity so you can actually confirm it's running

What "cron" means in Drupal

If you've used Linux, "cron" probably already means something to you: a scheduler that runs commands at set intervals. Drupal's cron is the same idea adapted to a web application — it's a signal that says "some time has passed, now would be a good moment to do your periodic housekeeping." Exactly what counts as "periodic housekeeping" is entirely up to whichever modules implement hook_cron(): expiring old sessions, checking for module updates, processing queued items, cleaning up temporary files, or — as in this lesson's example — just logging a message on a schedule.

Drupal cron can be triggered several different ways: manually from /admin/config/system/cron, from the command line with drush cron, automatically by the core Automated Cron module (which runs it opportunistically at the end of a page request once enough time has passed), or by an external system cron job hitting a special URL. Whichever triggers it, every enabled module's hook_cron() implementation runs.

The source file

Path (relative to the Examples module's root): modules/cron_example/cron_example.module

<?php

/**
 * @file
 * Demonstrates use of the Cron API in Drupal - hook_cron().
 */

/**
 * @defgroup cron_example Example: Cron
 * @ingroup examples
 * @{
 * Example using Cron API, including hook_cron() and @QueueWorker plugins
 *
 * This example is part of the Examples for Developers Project
 * which you can download and experiment with at
 * http://drupal.org/project/examples
 */

/**
 * Implements hook_cron().
 *
 * We implement hook_cron() to do "background" processing. It gets called every
 * time the Drupal cron runs. We then decide what has to happen in response.
 *
 * In this example, we log a message after the time given in the state value
 * 'cron_example.next_execution'. Then we update that variable to a time in the
 * future.
 */
function cron_example_cron() {
  // We access our configuration.
  $cron_config = \Drupal::config('cron_example.settings');
  // Default to an hourly interval. Of course, cron has to be running at least
  // hourly for this to work.
  $interval = $cron_config->get('interval');
  $interval = !empty($interval) ? $interval : 3600;

  // We usually don't want to act every time cron runs (which could be every
  // minute) so keep a time for the next run in the site state.
  $next_execution = \Drupal::state()->get('cron_example.next_execution', 0);
  $request_time = \Drupal::time()->getRequestTime();
  if ($request_time >= $next_execution) {
    // This is a silly example of a cron job.
    // It just makes it obvious that the job has run without
    // making any changes to your database.
    \Drupal::logger('cron_example')->notice('cron_example ran');
    if (\Drupal::state()->get('cron_example_show_status_message')) {
      \Drupal::messenger()->addMessage(t('cron_example executed at %time', ['%time' => date('c')]));
      \Drupal::state()->set('cron_example_show_status_message', FALSE);
    }
    \Drupal::state()->set('cron_example.next_execution', $request_time + $interval);
  }
}

/**
 * @} End of "defgroup cron_example".
 */

How it works

The hook_cron() naming convention

Just like every other hook in this course, the function name follows the pattern {module_name}_cron() — here, cron_example_cron(). There's nothing else to register or configure; Drupal discovers this function purely by its name and calls it every time cron runs. It has no return value and no required arguments — any work it does happens entirely as a side effect inside the function body.

Reading a configurable interval

$cron_config = \Drupal::config('cron_example.settings');
$interval = $cron_config->get('interval');
$interval = !empty($interval) ? $interval : 3600;

This module's cron job shouldn't necessarily run every single time cron is triggered — that could be every minute, depending on site configuration. Instead it reads an interval value from its own config (shipped with a default of 300 seconds via config/install/cron_example.settings.yml), falling back to a safe 3600 seconds (one hour) if that value is missing. Reading this from configuration — rather than hardcoding a number — is what lets a site administrator tune "how often" through a settings form, without ever touching code.

Throttling with the State API

$next_execution = \Drupal::state()->get('cron_example.next_execution', 0);
$request_time = \Drupal::time()->getRequestTime();
if ($request_time >= $next_execution) {
    // ... the actual work ...
    \Drupal::state()->set('cron_example.next_execution', $request_time + $interval);
}

This is the single most important pattern in the whole lesson, and one you'll reuse in nearly every hook_cron() you ever write. The problem: Drupal cron might fire every minute, but your task only needs to run every hour. The solution: remember, in the State API (\Drupal::state()), the timestamp of the next time this task is actually allowed to run. Every time cron fires, check whether that time has arrived yet; if not, do nothing at all and return instantly.

The State API stores small, environment-specific key/value pairs in the database — the kind of thing that's true right now on this specific site, but shouldn't be exported to configuration and deployed elsewhere (like "when did this last run"). If a value is something an administrator should be able to tune and export, it belongs in Config instead — which is exactly why this example uses both: Config for the interval, State for the next-run timestamp.

Notice \Drupal::time()->getRequestTime() rather than PHP's plain time(). It returns the same timestamp consistently for the whole request (useful when a request touches this logic more than once) and, just as importantly, it's mockable in automated tests — you can simulate "three hours later" in a test without waiting three hours.

Logging so you can actually tell it ran

\Drupal::logger('cron_example')->notice('cron_example ran');

\Drupal::logger('cron_example') returns a logger scoped to the cron_example channel. Calling ->notice() writes an entry to Drupal's database log, visible to an administrator at /admin/reports/dblog, filterable by that channel name. Cron runs invisibly in the background by design — logging is how you and any future site administrator can confirm a scheduled task actually fired, and start diagnosing if it didn't.

The Recent log messages admin page filtered to the cron_example type, showing five separate 'cron_example ran' entries with real timestamps

Filter /admin/reports/dblog to the cron_example type and this is what you'll find: a permanent trail of every past run, each with its own timestamp — completely independent of whether anyone had the "show status message" checkbox ticked at the time. This is the record that actually matters in production, since nobody's watching the browser when cron fires from a real server-level schedule.

A one-time visible confirmation

if (\Drupal::state()->get('cron_example_show_status_message')) {
    \Drupal::messenger()->addMessage(t('cron_example executed at %time', ['%time' => date('c')]));
    \Drupal::state()->set('cron_example_show_status_message', FALSE);
}

Normally cron runs with no browser watching, so a status message would go nowhere. This block exists purely for the demo: a checkbox elsewhere in the module's settings form sets a state flag before cron runs, requesting a one-time visible confirmation. When cron finds that flag set, it shows the message and immediately flips the flag back off, so it only ever appears once — the next cron run stays silent again, exactly like it would in production.

See it for yourself

Visit /examples/cron-example on your DDEV site, tick the box to run regardless of the interval, and click "Run cron now."

A Drupal status message reading cron_example executed at a timestamp, cron ran successfully

That confirmation message — "cron_example executed at [timestamp]. Cron ran successfully." — is exactly the messenger call from cron_example_cron() firing for real. Behind the scenes, the function also just wrote a NOTICE-level log entry (check /admin/reports/dblog if you want to see it) and pushed cron_example.next_execution forward, so if you click "Run cron now" again immediately, nothing will happen this time — the throttle you just read about is doing its job.

Quick check: if you click "Run cron now" twice in a row, why does the second click produce no visible message? Because next_execution was pushed into the future by the first run, and $request_time >= $next_execution is now false — the throttle is silently skipping the work, exactly as designed.

Key takeaways

  • hook_cron() is implemented as {module_name}_cron() in your .module file — no separate registration is needed, Drupal finds it by name.
  • Almost always throttle cron work with the State API: store a "next allowed run" timestamp and check it before doing anything, since cron can fire far more often than your task needs to run.
  • Read tunable settings (like an interval) from Config, and store runtime/environment facts (like "when did this last run") in State — keep the two separate.
  • Use \Drupal::time()->getRequestTime() instead of time() for consistency within a request and mockability in tests.
  • Log cron activity with \Drupal::logger('your_module')->notice() so you can actually verify a scheduled task ran, via /admin/reports/dblog.

Coming up next

You've now seen how data can flow through a Drupal site in the background — queued, batched, or scheduled. Next up: the Render API — how Drupal actually turns all of that data, however it got there, into the HTML that ends up in a browser.