---
title: 'Scheduled Tasks'
source: 'https://academia.sh/en/courses/web-application-framework/scheduled-tasks'
course: 'The Web Application Framework'
language: en
updated: '2026-10-21T05:03:39+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Defines a recurring task with a frequency word and registers it with the scheduler'
    - 'Measures that a task registered in a request runs on the scheduler tick and not within the request'
    - 'Shows that a task whose time has not come is skipped on the tick and that the tick interval limits the task frequency'
---

# Scheduled Tasks

The recurring task definition, the frequency words and the scheduler tick; a task registered in a request running on the tick, a task whose time has not come being skipped on the tick, and the limit the tick interval puts on the task frequency.

In the File Storage lesson the uploaded file was written to a disk, and this work finished inside the request, before the response came. Not every job is like this. Cleaning old files, producing a summary every night, or resetting a counter every minute are jobs that are not tied to any user request; they have to run at certain intervals even if no request comes. This lesson asks: **inside which request does a repeated job count as having run?**

A **scheduled task** is a recurring job defined by a **frequency**: every minute, every hour, every day. What runs the task is not a request but a **scheduler** that works at regular intervals; each of its runs is called a **tick**. On every tick the scheduler looks at the registered tasks and runs the ones whose time has come. A request can only register the task; the moment the task runs is outside the request, on a later tick. The job in the Queues and Jobs lesson also ran outside the request, but that job was born from a request and ran once. Here the job is defined once and repeats itself.

**AS8.** The clock is a variable: a tick takes an integer as the current moment (seconds), and neither `sleep` nor the real clock is called. The scheduler itself is not a loop, a process or a system tool; the `tick()` method is called by hand by the driver. A task runs at the end of an interval that starts at the moment it was registered or at the previous run moment; the first run is one interval after registration, not at the moment of registration.

**AS9.** Three frequency words are defined: `minutely` is 60 seconds, `hourly` is 3600 seconds, `daily` is 86400 seconds. Any other word throws `InvalidArgumentException`. Calendar time, time zones, month and day names, clustering, and two schedulers working at the same time running a task twice are not modeled. There is also no mechanism that makes up for a missed tick; the consequence of this assumption is measured in the Bounding Measurement section.

## The Measurement Core

The same measurement is used in every lesson of the course. `ran.php` records whether an expected part ran in this request; the file is given verbatim in every lesson, and it has no output block, since it is only saved to disk. `Ledger` keeps three lists: the parts placed on the request's path, the parts called within the request, and the parts left outside the request. `classify()` checks them in order. **The decision order is `classify()` itself:** ran, deferred, skipped, not wired. The developer names the part; what is measured are the parts the developer expects to run in this request. In this lesson the "request" occurs in two places: the real request that registers the task, and each tick of the scheduler. `defer()` is called at registration, and `onPath()` and `ran()` are called on the tick.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

## The Task Definition and the Scheduler

`scheduler.php` defines three classes. `Counter` counts how many times a job ran; what is measured is not the body of the job but whether the job ran. `Scheduler::register()` takes a task with a name, a frequency word and a function, converts the frequency to seconds, computes the first run moment as `now + interval`, and writes the task to the list of parts left outside the request. `Scheduler::tick()` opens a new measurement round, places each task on the path, and if the current moment has not reached the task's `next` value, moves on to the next task without touching it. If it has, it updates the next run moment to `now + interval`, marks the task as ran and calls the function. `ReportController` registers the `report` task in a request so that it runs every minute, and returns 202.

The 202 response is meaningful: the request accepted something but did not do it. The controller does not run the task and does not wait for it to run; the response has returned before the task has run. This has the same structure as the response in the Queues and Jobs lesson, the only difference being that the run moment is decided not by a queue worker but by a clock value.

```php
<?php
// scheduler.php — the task definition, the frequency words and the scheduler tick
declare(strict_types=1);
require_once 'ran.php';

final class Counter {
    public array $counts = [];
    public function increment(string $name): void { $this->counts[$name] = ($this->counts[$name] ?? 0) + 1; }
    public function read(string $name): int { return $this->counts[$name] ?? 0; }
}

final class Scheduler {
    private const INTERVAL = ['minutely' => 60, 'hourly' => 3600, 'daily' => 86400];
    private array $tasks = [];

    public function register(string $name, string $frequency, callable $job, int $now): void {
        if (!isset(self::INTERVAL[$frequency])) {
            throw new InvalidArgumentException("unknown frequency: $frequency");
        }
        $interval = self::INTERVAL[$frequency];
        $this->tasks[$name] = ['interval' => $interval, 'next' => $now + $interval, 'job' => $job];
        Ledger::defer($name);
    }

    public function tick(int $now): void {
        Ledger::newRequest();
        foreach (array_keys($this->tasks) as $name) {
            Ledger::onPath($name);
            if ($now < $this->tasks[$name]['next']) {
                continue;
            }
            $this->tasks[$name]['next'] = $now + $this->tasks[$name]['interval'];
            Ledger::ran($name);
            ($this->tasks[$name]['job'])();
        }
    }
}

final class ReportController {
    public function __construct(private Scheduler $scheduler, private Counter $counter) {}
    public function register(int $now): array {
        Ledger::newRequest();
        $this->scheduler->register('report', 'minutely', fn() => $this->counter->increment('report'), $now);
        return [202, 'registered'];
    }
}
```

## The Task Registered in a Request Ran on the Tick

The driver makes the request at time 0, then ticks the scheduler at time 60. Right after the request the outcome and the counter are read, and they are read again after the tick. The only difference between the two readings is that the scheduler has worked.

```php
<?php
// deferral.php — a task registered in a request runs on the tick
require_once 'scheduler.php';

$counter = new Counter();
$scheduler = new Scheduler();
$controller = new ReportController($scheduler, $counter);

$response = $controller->register(0);
printf("request (time 0)  -> %d %s\n", $response[0], $response[1]);
printf("  report %s, job run count: %d\n", classify('report'), $counter->read('report'));

$scheduler->tick(60);
printf("tick (time 60)\n");
printf("  report %s, job run count: %d\n", classify('report'), $counter->read('report'));
```

```
request (time 0)  -> 202 registered
  report deferred, job run count: 0
tick (time 60)
  report ran, job run count: 1
```

Right after the request, `report` was **deferred**: the task was registered but not called in this request; the counter is 0. After the tick the same part **ran**: the task entered the path, its time had come and the function was called; the counter is 1. For this reason the outcome of the lesson reads as **deferred**: from the request's point of view the job was not done, it was only recorded that it would be done. The time between "deferred" and "ran" cannot be smaller than the task's frequency; the first run is one interval later. The measurement does not say whether the job finished; it says only in which round it ran.

## The Contrast: A Task Whose Time Had Not Come Was Skipped on the Tick

Not every tick of the scheduler runs the task. After the same registration, the driver ticks the scheduler at times 30, 59, 60 and 61. After each tick the outcome of `report` and the total run count are printed.

```php
<?php
// skipping.php — a task whose time has not come is skipped on the tick
require_once 'scheduler.php';

$counter = new Counter();
$scheduler = new Scheduler();
(new ReportController($scheduler, $counter))->register(0);

foreach ([30, 59, 60, 61] as $time) {
    $scheduler->tick($time);
    printf("tick (time %2d)  report %-10s total: %d\n", $time, classify('report'), $counter->read('report'));
}
```

```
tick (time 30)  report skipped    total: 0
tick (time 59)  report skipped    total: 0
tick (time 60)  report ran        total: 1
tick (time 61)  report skipped    total: 1
```

At times 30 and 59 the task entered the path but was not called because it had not reached its `next` value: **skipped**. At time 60 its time came and it **ran**. At time 61 the task entered the path again, but its `next` value is now 120, and on this tick too it was **skipped**. The total stayed at 1. The first three lines are the contrast of the lesson's observation: the same code, the same registration, but a different outcome because the time of the tick is different.

The difference between "skipped" here and "not wired" in the File Storage lesson is the existence of the path. Here the task enters the path on every tick, because the scheduler evaluates it every time. Being skipped does not mean it was not evaluated; it means it was evaluated and its time had not come. This distinction matters: if a task has not entered the path on a tick, it has no registration or the scheduler never ran.

## The Bounding Measurement

What happens if the scheduler misses a tick? `frequency.php` runs three tasks, with three frequency words, over a two-hour stream of ticks; it builds the stream twice with two different tick intervals. In the first stream the scheduler ticks every 60 seconds, in the second every 300 seconds. The tasks start from registration moment 0.

```php
<?php
// frequency.php — three frequency words, a two-hour tick stream, two different tick intervals
require_once 'scheduler.php';

foreach ([60, 300] as $tickInterval) {
    $counter = new Counter();
    $scheduler = new Scheduler();
    foreach (['minutely' => 'minutely', 'hourly' => 'hourly', 'daily' => 'daily'] as $frequency => $name) {
        $scheduler->register($name, $frequency, fn() => $counter->increment($name), 0);
    }
    for ($time = $tickInterval; $time <= 7200; $time += $tickInterval) {
        $scheduler->tick($time);
    }
    printf("tick interval %3d s: minutely %3d, hourly %d, daily %d\n", $tickInterval,
        $counter->read('minutely'), $counter->read('hourly'), $counter->read('daily'));
}
```

```
tick interval  60 s: minutely 120, hourly 2, daily 0
tick interval 300 s: minutely  24, hourly 2, daily 0
```

With a scheduler that ticks every 60 seconds, the `minutely` task ran 120 times, the `hourly` task 2 times and the `daily` task never: in two hours there are 120 minutes, 2 hours, and a long way to go before a day is complete. With a scheduler that ticks every 300 seconds, the `minutely` task ran only 24 times; the reason is that a task can run at most once per tick, not once per minute. There are 24 ticks in two hours, and on each tick the task runs at most once. The 96 missed runs were not made up: in this lesson there is no mechanism that takes back a missed tick. The `hourly` task ran 2 times in both streams, because 300 seconds divides 3600 and the ticks coincide with the start of the hours.

The result of this measurement is as follows: the frequency word decides how often the task can run at most; the tick interval of the scheduler limits how often it actually runs. The measurement gives only the result in this model. Another scheduler may make up for missed runs in order, or may run the same task more than once in a tick; that behavior is not measured here, and the driver belongs only to this lesson's model.

The other end of the tick interval can also be measured: what happens if two ticks come at the same moment? After the same registration, `double.php` ticks the scheduler twice at time 60. On the first tick the task runs and its `next` value is moved to 120; on the second tick the current moment is still 60, and 60 is less than 120.

```php
<?php
// double.php — two ticks at the same moment: the second tick does not run the task again
require_once 'scheduler.php';

$counter = new Counter();
$scheduler = new Scheduler();
(new ReportController($scheduler, $counter))->register(0);

foreach ([1, 2] as $round) {
    $scheduler->tick(60);
    printf("tick %d (time 60)  report %-10s total: %d\n", $round, classify('report'), $counter->read('report'));
}
```

```
tick 1 (time 60)  report ran        total: 1
tick 2 (time 60)  report skipped    total: 1
```

On the second tick the task entered the path but was not called: **skipped**, and the total stayed at 1. A double tick at the same moment does not run the task twice, because the run moment is held in the task's own `next` value, not in the number of ticks. This protection holds only for a single scheduler object: the `next` value lives inside that object, and two separate scheduler processes can run the same task at the same moment. AS9 left this situation out of scope, and the measurement in this section does not cover it either.

## Summary

- This lesson's outcome is **deferred**: the `report` task registered in the request was not called within the request, the counter stayed at 0, and the task ran on the tick at time 60.
- A scheduled task is defined with a frequency word (`minutely`, `hourly`, `daily`), and its first run is one interval after registration.
- Its contrast: a task whose time had not come entered the path on the ticks at times 30, 59 and 61 but was not called, and was **skipped**; it **ran** at time 60.
- In the bounding measurement a tick stream of 60 seconds ran the `minutely` task 120 times and a stream of 300 seconds ran it 24 times; the missed runs were not made up.
- A double tick at the same moment did not run the task twice; on the second tick the task was **skipped**.
- The frequency of a task is a target, not a guarantee; the tick interval of the scheduler limits how often it actually runs.

## Next Step

Scheduled tasks tie work to time that the user does not wait for. But time is not the only variable that shapes an application's response. If the task in this lesson were sending a notification to a user, the language in which the notification is written would also be a choice. And what happens if there is no text in the requested language: an error, an empty response, or a text from another language? The Localization lesson builds text from language files, placeholders and plural forms, and measures how a key that is missing in the requested language is wired to the response.
