Application · Adapter
Scheduler
Register due work in application code, execute commands through ProcessRunner, and make locking, failure notification, output, and time-zone policy explicit.
Scheduler coordinates recurring command and callable work. New consumers should construct it with
Scheduler::withProcessRunner() so command execution stays behind the Application-owned
ProcessRunner port and framework selection remains in the composition root.
Ownership. Scheduler and its exceptions belong to the Application layer; Timezone is a
Domain value. Process execution, logging, and mail delivery arrive through Application contracts.
The consuming application owns the entry point, schedule definitions, shared lock storage, worker
invocation cadence, output retention, alert recipients, and recovery procedure.
Dependencies. Cron schedules use dragonmantank/cron-expression. Command jobs need a selected
ProcessRunner adapter; the supplied runner uses Symfony Process. The maxRuntime guard requires
ext-posix; PSR-3 logging and MailService failure notifications are optional.
Install.
A cron-style job scheduler for PHP CLI processes. Jobs are registered with a name, a
schedule, and a command string or PHP callable. On each run() call the scheduler checks
which jobs are due and executes them with file-based exclusive locking to prevent
overlapping runs.
Application\Scheduler
├── Scheduler — Job registry and runner
└── Exception\
├── SchedulerException — extends SystemException
└── LockException — extends SchedulerException
Domain\Value\DateTime
└── Timezone — Immutable timezone value object
Table of Contents¶
- Portable ProcessRunner Composition
- Legacy 1.x Construction Compatibility
- Legacy Command Compatibility Bridge
- Schedule Formats
- Locking
- Output Modes
- Error Handling and Notification
- Max Runtime Guard
- Timezone
- Symfony Configuration
- Usage Examples
Portable ProcessRunner Composition¶
Fight\Common\Application\Scheduler\Scheduler
use Fight\Common\Application\Scheduler\Scheduler;
use Fight\Common\Application\Process\ProcessRunner;
use Fight\Common\Domain\Value\DateTime\Timezone;
$scheduler = Scheduler::withProcessRunner(
timezone: new Timezone('America/New_York'),
tempDirectory: '/var/run/scheduler',
processRunner: $processRunner, // ProcessRunner (required on this named path)
logger: $logger, // ?LoggerInterface (default null)
mailService: $mailService, // ?MailService (default null)
fromEmail: 'cron@example.com'
);
Command jobs are described with ProcessBuilder::shellCommand() and executed through the
Application-owned ProcessRunner port. This named construction path is the recommended composition for new
consumers. It keeps framework selection in the composition root and does not change any argument of the
published constructor.
Legacy 1.x Construction Compatibility¶
The published 1.1.0 constructor remains available unchanged through 1.x:
$scheduler = new Scheduler(
timezone: new Timezone('America/New_York'),
tempDirectory: '/var/run/scheduler',
logger: $logger, // ?LoggerInterface (default null)
mailService: $mailService, // ?MailService (default null)
fromEmail: 'cron@example.com',
processFactory: $processFactory // ?Closure (default null)
);
The exact positional order is timezone, tempDirectory, logger, mailService, fromEmail, then
processFactory. Two-argument, positional-optional, and named-argument construction remain supported. The
constructor and its optional processFactory command seam are deprecated compatibility APIs through 1.x and
will be removed in 2.0.0. They emit no runtime deprecation warning.
Legacy Command Compatibility Bridge¶
Schedulers created through the legacy constructor execute command jobs through the supplied processFactory.
When no factory is supplied and symfony/process is available, Scheduler conditionally constructs
Symfony\Component\Process\Process without making it a production dependency. When neither facility is
available, the command failure is reported through the Scheduler's normal logging and notification behavior.
This conditional Symfony bridge is deprecated through 1.x, receives no new capabilities, and emits no runtime
deprecation warning. New composition should use withProcessRunner(...) with an Application ProcessRunner
implementation selected by the consumer.
Registering Jobs¶
Callable job — runs a PHP closure or callable:
$scheduler->addJob(
name: 'send-digest',
schedule: '0 8 * * *', // daily at 08:00
job: fn() => $this->digestService->send(),
enabled: true,
output: false,
maxRuntime: 120,
notify: ['ops@example.com'],
environment: 'production'
);
Shell command job — runs an accepted command string through the configured ProcessRunner:
$scheduler->addCommand(
name: 'cache-warm',
schedule: '*/15 * * * *', // every 15 minutes
command: 'bin/console cache:warmup --env=prod',
output: '/var/log/cache-warm.log'
);
Running¶
// Typically called from a cron entry that runs every minute:
// * * * * * php bin/scheduler.php
$scheduler->run();
run() iterates all registered jobs, skips disabled ones and those not currently due,
and executes the rest with exclusive locking.
Callable Return Values¶
A callable job is considered successful if it returns 0, true, or null. Any other
return value throws SchedulerException. Exceptions thrown inside the callable are caught,
logged, and (if configured) emailed — they do not propagate to the caller.
Schedule Formats¶
Three formats are accepted for the $schedule parameter:
| Format | Example | Description |
|---|---|---|
| Cron expression | '0 8 * * *' |
Standard 5-field cron, powered by dragonmantank/cron-expression |
| Datetime string | '2026-12-31 23:59:00' |
Runs once at that exact minute |
| Callable | fn() => $myCondition |
Returns true when the job should run |
The callable form is useful for event-driven or condition-based scheduling:
Locking¶
Each job acquires an exclusive file lock before running, preventing the same job from
running concurrently across multiple processes. Lock files are written to $tempDirectory
with names derived from the job name (lowercased, special characters stripped):
My Nightly Job!→my_nightly_job.lock
If a lock cannot be acquired (another process holds it), the job is silently skipped and
a DEBUG entry is written to the logger. If the scheduler itself holds the lock (e.g.,
via a recursive call), a RuntimeException is caught, logged as an error, and the job
is skipped.
Output Modes¶
The $output parameter controls where job output is written:
| Value | Behavior |
|---|---|
false (default) |
Output is suppressed |
true |
Output is echoed to stdout |
'/path/to/file.log' |
Output is appended to the specified file |
Error Handling and Notification¶
When a job fails the scheduler:
- Logs the error via
LoggerInterface::error()(if a logger is configured) - Sends a failure email (if a
MailServiceand$notifyaddresses are configured)
The notification email includes the environment, error message, code, file, line, and
full stack trace. Treat recipients and mail transport as privileged operational configuration.
The $notify parameter accepts an array of addresses or a comma-separated string:
$scheduler->addJob(
name: 'import',
schedule: '0 2 * * *',
job: $importCallable,
notify: 'alice@example.com, bob@example.com',
environment: 'production'
);
Job execution failures are caught, logged, and handed to notification when configured; they do not
normally propagate from run(). A failure from the mail transport itself can throw MailException,
so the scheduler entry point should still have a top-level failure policy.
Max Runtime Guard¶
The $maxRuntime parameter (in seconds) is checked at the start of each run. This optional guard
requires ext-posix because it probes the lock-owning PID with posix_kill(). If the job's lock
file exists and the owning PID has been alive for longer than $maxRuntime, a SchedulerException
is thrown, logged, and (if configured) emailed. Leave maxRuntime unset on hosts without
ext-posix.
Timezone¶
Fight\Common\Domain\Value\DateTime\Timezone
An immutable value object that wraps a DateTimeZone name with construction-time
validation. Passed to Scheduler to anchor cron and datetime schedule comparisons.
use Fight\Common\Domain\Value\DateTime\Timezone;
$tz = new Timezone('America/Chicago');
$tz->value(); // 'America/Chicago'
(string) $tz; // 'America/Chicago'
Timezone::fromString('Europe/London'); // factory method
new Timezone('Not/Real'); // throws DomainException
Cron expressions and exact datetime strings are evaluated in this configured time zone. Daylight saving transitions can skip or repeat local wall-clock times; choose UTC when a stable cadence is more important than local-time alignment, and make jobs idempotent when duplicate invocation would be consequential.
Symfony Configuration¶
# config/packages/common_scheduler.yaml
services:
_defaults:
autowire: true
autoconfigure: true
Fight\Common\Domain\Value\DateTime\Timezone:
arguments:
- '%env(APP_TIMEZONE)%'
Fight\Common\Application\Scheduler\Scheduler:
factory: ['Fight\Common\Application\Scheduler\Scheduler', 'withProcessRunner']
arguments:
$timezone: '@Fight\Common\Domain\Value\DateTime\Timezone'
$tempDirectory: '%kernel.cache_dir%/scheduler'
$processRunner: '@Fight\Common\Application\Process\ProcessRunner'
$logger: '@logger'
$mailService: '@Fight\Common\Application\Mail\MailService'
$fromEmail: '%env(SCHEDULER_FROM_EMAIL)%'
Fight\Common\Adapter\Process\Symfony\SymfonyProcessRunner: ~
Fight\Common\Application\Process\ProcessRunner:
alias: Fight\Common\Adapter\Process\Symfony\SymfonyProcessRunner
Then add the entry point to the project (e.g. bin/scheduler.php):
#!/usr/bin/env php
<?php
require __DIR__.'/../vendor/autoload.php';
$scheduler = $container->get(Scheduler::class);
$scheduler->addCommand(
'cache-warm',
'*/15 * * * *',
'bin/console cache:warmup --env=prod',
output: '/var/log/scheduler/cache-warm.log',
notify: 'ops@example.com'
);
$scheduler->addJob('report', '0 6 * * 1', fn() => $container->get(WeeklyReporter::class)->run());
$scheduler->run();
And the crontab entry that runs every minute:
Usage Examples¶
Basic Cron Job¶
$scheduler->addJob(
name: 'send-reminders',
schedule: '0 9 * * *', // 09:00 every day
job: fn() => $reminderService->sendAll()
);
$scheduler->run();
Logging Output to a File¶
$scheduler->addCommand(
name: 'database-backup',
schedule: '0 0 * * *',
command: 'bin/backup.sh',
output: '/var/log/backup.log',
notify: 'dba@example.com'
);
Conditional Schedule¶
$scheduler->addJob(
name: 'maintenance-cleanup',
schedule: fn() => $this->featureFlags->isMaintenanceWindow(),
job: fn() => $this->cleanupService->run()
);
One-Time Scheduled Run¶
$scheduler->addJob(
name: 'data-migration',
schedule: '2026-12-01 02:00:00',
job: fn() => $migrationService->run()
);
Disabled Job¶
$scheduler->addJob(
name: 'experimental-sync',
schedule: '*/5 * * * *',
job: $syncCallable,
enabled: false // won't run until re-enabled
);