07 · Background Job Processing¶
Working with Queues built the
transport — pushing raw job data onto a queue and popping it off in a
worker. This module covers the layer on top: modeling jobs as classes
with their own handle() logic (instead of raw arrays a worker has to
switch on), dispatching them from application code, and running
scheduled (cron-style) tasks — the other common shape background work
takes.
Jobs as classes, not raw payloads¶
A queue that stores ['type' => 'send_email', 'to' => '...'] and a worker
switch-ing on type works, but grows unwieldy fast — every new job type
adds a case and the actual logic ends up scattered wherever the switch
lives. Modeling each job as a class implementing a common interface keeps
each job's logic (and its data) together, and adding a new job type never
means touching existing ones.
<?php
// Job.php
declare(strict_types=1);
interface Job
{
public function handle(): void;
public function payload(): array; // for serializing onto a real queue transport
}
<?php
// SendWelcomeEmailJob.php, GenerateReportJob.php
declare(strict_types=1);
final class SendWelcomeEmailJob implements Job
{
public function __construct(private string $email) {}
public function handle(): void
{
echo "Sending welcome email to {$this->email}\n";
}
public function payload(): array
{
return ['type' => self::class, 'email' => $this->email];
}
}
final class GenerateReportJob implements Job
{
public function __construct(private int $reportId) {}
public function handle(): void
{
echo "Generating report #{$this->reportId}\n";
}
public function payload(): array
{
return ['type' => self::class, 'reportId' => $this->reportId];
}
}
A dispatcher: the application-facing API¶
Application code shouldn't know or care whether a job runs immediately or
gets queued for a worker — JobDispatcher::dispatch() is the one place
that decision lives.
<?php
// JobDispatcher.php
declare(strict_types=1);
final class JobDispatcher
{
/** @var Job[] */
private array $queue = [];
public function dispatch(Job $job): void
{
$this->queue[] = $job;
echo "Dispatched: " . get_class($job) . "\n";
}
/** Bypass the queue entirely -- useful for CLI commands and tests. */
public function runSync(Job $job): void
{
$job->handle();
}
/** What a worker process calls in a loop. */
public function processQueue(): int
{
$count = 0;
while ($job = array_shift($this->queue)) {
$job->handle();
$count++;
}
return $count;
}
}
<?php
// demo.php
declare(strict_types=1);
require __DIR__ . '/Job.php';
require __DIR__ . '/SendWelcomeEmailJob.php';
require __DIR__ . '/GenerateReportJob.php';
require __DIR__ . '/JobDispatcher.php';
$dispatcher = new JobDispatcher();
$dispatcher->dispatch(new SendWelcomeEmailJob('ada@example.com'));
$dispatcher->dispatch(new GenerateReportJob(42));
echo "Queue built, now processing on a worker...\n";
$processed = $dispatcher->processQueue();
echo "Processed $processed jobs\n";
Dispatched: SendWelcomeEmailJob
Dispatched: GenerateReportJob
Queue built, now processing on a worker...
Sending welcome email to ada@example.com
Generating report #42
Processed 2 jobs
In this in-memory demo, dispatch() and processQueue() run in the same
process for clarity. In a real system, dispatch() would call
payload() and hand the result to a real transport — the FileQueue from
Working with Queues, or Redis/RabbitMQ
in production — and a separate, long-running worker process would pop
payloads, look up the class by payload['type'], reconstruct the job, and
call handle(). The dispatcher's interface stays identical either way;
only what's behind dispatch() changes.
Scheduled (cron-style) tasks¶
Not all background work is triggered by an event (a user signing up). Some runs on a fixed interval — clearing expired sessions every hour, sending a daily digest. The underlying mechanism is a cron entry invoking a PHP script on a schedule; inside that script, a simple "is this due yet" check lets multiple scheduled tasks share one process.
<?php
// ScheduledTask.php
declare(strict_types=1);
final class ScheduledTask
{
public function __construct(
private string $name,
private int $intervalSeconds,
private int $lastRun = 0,
) {}
public function isDue(int $now): bool
{
return ($now - $this->lastRun) >= $this->intervalSeconds;
}
public function run(int $now): void
{
echo "Running scheduled task '{$this->name}' at " . date('H:i:s', $now) . "\n";
$this->lastRun = $now;
}
}
<?php
// demo.php (continued)
declare(strict_types=1);
$task = new ScheduledTask('cleanup-temp-files', 5); // every 5 "seconds" for this demo
$t0 = time();
var_dump($task->isDue($t0)); // never run yet -> due immediately
$task->run($t0);
var_dump($task->isDue($t0 + 2)); // only 2s since last run -> not due
var_dump($task->isDue($t0 + 6)); // 6s since last run -> due again
The real-world equivalent is a single cron entry —
* * * * * php /path/to/artisan schedule:run (Laravel's approach) or a
bare * * * * * php /path/to/run-scheduler.php — firing every minute, with
each ScheduledTask's own isDue() (or a full cron-expression parser,
for more complex schedules) deciding whether that particular task
actually executes on this tick. One cron line ends up driving arbitrarily
many independently-scheduled tasks.
PHP traps¶
Reconstructing a job from payload() needs the class to still exist and
match its constructor. If SendWelcomeEmailJob's constructor changes
(a new required parameter) after jobs are already queued with the old
payload() shape, a worker deploying the new code fails trying to
reconstruct old, in-flight jobs. Real systems version job payloads or drain
the queue before deploying breaking job changes — the same discipline as
API versioning, applied to background work
instead of HTTP responses.
A scheduled task with $lastRun stored only in memory resets every time
the process restarts. ScheduledTask above is fine for a single
long-running scheduler process, but a script invoked fresh by cron every
minute needs to persist $lastRun somewhere durable (a database row, a
file) — otherwise every invocation thinks nothing has ever run and fires
immediately.
runSync() silently hiding failures a real queue would retry. Running
a job synchronously inside a web request (rather than dispatching it)
means any exception the job throws propagates straight into the HTTP
response — useful for tests and CLI tools, dangerous as a default, since it
reintroduces exactly the "slow/failing background work blocks the user"
problem queues exist to solve.
Background jobs cheat sheet¶
| Concept | Purpose |
|---|---|
Job interface |
Each background task is a class: data + handle() logic together |
dispatch() |
Application-facing entry point; hides sync vs. queued behind one call |
runSync() |
Bypass the queue — useful for tests/CLI, risky as a request-path default |
payload() |
How a job serializes itself onto a real transport (Redis, FileQueue) |
ScheduledTask::isDue() |
Interval check driving cron-style recurring work |
Single cron entry + per-task isDue() |
One * * * * * line can drive many independent schedules |
How It Actually Works¶
Modeling jobs as classes rather than raw payloads means each job is a real PHP object with a handle() method — when the dispatcher needs to hand it to a worker, it must first serialize that object (via serialize(), storing the class name plus its property values) into a string that can be written to a queue backend, and the worker process later unserialize()s it back into a live object before calling handle(); this round-trip is exactly why job classes must avoid holding un-serializable state (open database handles, closures over resources) as properties — those can't survive the serialize/unserialize boundary between the dispatching process and the (often entirely separate) worker process. The dispatcher's application-facing API (dispatch($job)) typically just pushes that serialized payload onto a queue and returns immediately — a deliberately non-blocking design that decouples the web request's process lifetime (which must stay short) from the job's actual execution time, which might happen seconds or minutes later in a completely different, long-running worker process. Scheduled cron-style tasks work through an OS-level mechanism entirely outside PHP: cron (or a container-level scheduler) starts a fresh php process at the configured interval, and because each invocation is once again a full compile-and-run cycle in an independent process, a "recurring task" in PHP is really just "the OS repeatedly launching a short-lived process," not a persistent in-process timer the way setInterval works in a long-running Node.js server.
Exercise¶
Add a RetryableJob interface extending Job with a
maxAttempts(): int method, and a FlakyReportJob implements RetryableJob
whose handle() throws a RuntimeException on its first two calls (track
attempts in a property) and succeeds on the third, with maxAttempts()
returning 3. Extend JobDispatcher::processQueue() to catch exceptions from
jobs implementing RetryableJob, re-queue them (up to maxAttempts()),
and give up (print "Giving up on " . get_class($job)) once exhausted.
Dispatch one FlakyReportJob and confirm it eventually succeeds, printing
each attempt.