Scheduler
April 20, 2026 · View on GitHub
Cron job scheduler – with locks, parallelism and more
Content
- Why do you need it?
- Quick start
- Execution time
- Events
- Handling errors
- Logging potential problems
- Locks and job overlapping
- Parallelization and process isolation
- Job types
- Job info and result
- Run summary
- Run scheduler
- Run single job
- CLI commands
- Run tracking
- Maintenance mode
- Lazy loading
- Integrations and extensions
- Troubleshooting guide
Why do you need it?
Cron jobs configured via crontab (or a hosting-specific solution) must be registered in every environment (e.g. local, stage, production), and the application itself is generally not aware of them.
Manage all cron jobs in the application and set them up in crontab with a single line.
Why not any alternative library? There are tons of them.
Most alternatives do not cover everything needed. By not using crontab directly, a library must replicate several features:
- parallelism – jobs should run in parallel and start in time even if one or more run for a long time
- failure protection – if one job fails, the failure should be logged and the other jobs should still be executed
- cron expressions – library has to parse and properly evaluate cron expression to determine whether job should be run
Orisai Scheduler solves all of these problems.
On top of that:
- locking – each job runs only once at a time, without overlapping
- per-second scheduling – run jobs multiple times in a minute
- timezones – interpret job schedule within specified timezone
- events for accessing job status
- overview of all jobs, including estimated time of next run
- running jobs either once or periodically during development
- running just a single job, either ignoring or respecting due times
Quick start
Install with Composer:
composer require orisai/scheduler
Create a script with scheduler setup (e.g. bin/scheduler.php):
use Cron\CronExpression;
use Orisai\Scheduler\SimpleScheduler;
$scheduler = new SimpleScheduler();
// Add jobs
$scheduler->addJob(
new CallbackJob(fn() => exampleTask()),
new CronExpression('* * * * *'),
);
$scheduler->run();
Configure crontab to run the script each minute:
* * * * * cd path/to/project && php bin/scheduler.php >> /dev/null 2>&1
Good to go!
Execution time
Execution time is determined by cron expression, which allows scheduling jobs from once a year to once every minute, and seconds, allowing a job to run several times in a minute.
In an ideal situation, jobs execute just in time, but that may not always be the case. Crontab can execute jobs several seconds late, serial job execution may take over a minute, and long-running jobs may overlap. To prevent issues, the scheduler implements multiple measures:
- jobs repeated after seconds take in account crontab may run late and delay each execution accordingly to minimize unwanted gaps between executions (e.g. if crontab starts 10 seconds late, all jobs also run 10 seconds late)
- parallel execution can be used instead of the serial
- locks should be used to prevent overlapping of long-running jobs
Cron expression - minutes and above
Main job execution time is expressed via CronExpression, using crontab syntax:
use Cron\CronExpression;
$scheduler->addJob(
/* ... */,
new CronExpression('* * * * *'),
);
Use caution with cron syntax – refer to the example below. To validate a cron, use the explain command or the explainer directly.
* * * * *
- - - - -
| | | | |
| | | | |
| | | | +----- day of week (0-7) (Sunday = 0 or 7) (or SUN-SAT)
| | | +--------- month (1-12) (or JAN-DEC)
| | +------------- day of month (1-31)
| +----------------- hour (0-23)
+--------------------- minute (0-59)
Each part of expression can also use wildcard, lists, ranges and steps:
- wildcard – match always
* * * * *- At every minute.- day of week and day of month also support
?, an alias to*
- lists – match list of values, ranges and steps
- e.g.
15,30 * * * *- At minute 15 and 30.
- e.g.
- ranges – match values in range
- e.g.
1-9 * * * *- At every minute from 1 through 9.
- e.g.
- steps – match every nth value in range
- e.g.
*/5 * * * *- At every 5th minute. - e.g.
0-30/5 * * * *- At every 5th minute from 0 through 30.
- e.g.
- combinations
- e.g.
0-14,30-44 * * * *- At every minute from 0 through 14 and every minute from 30 through 44.
- e.g.
Use a macro instead of an expression:
@yearly,@annually- At 00:00 on 1st of January. (same as0 0 1 1 *)@monthly- At 00:00 on day-of-month 1. (same as0 0 1 * *)@weekly- At 00:00 on Sunday. (same as0 0 * * 0)@daily,@midnight- At 00:00. (same as0 0 * * *)@hourly- At minute 0. (same as0 * * * *)
Day of month extra features:
- nearest weekday – weekday (Monday-Friday) nearest to the given day
- e.g.
* * 15W * *- At every minute on a weekday nearest to the 15th. - If you were to specify
15Was the value, the meaning is: "the nearest weekday to the 15th of the month" So if the 15th is a Saturday, the trigger will fire on Friday the 14th. If the 15th is a Sunday, the trigger will fire on Monday the 16th. If the 15th is a Tuesday, then it will fire on Tuesday the 15th. - However, if you specify
1Was the value for day-of-month, and the 1st is a Saturday, the trigger will fire on Monday the 3rd, as it will not 'jump' over the boundary of a month's days.
- e.g.
- last day of the month
- e.g.
* * L * *- At every minute on a last day-of-month.
- e.g.
- last weekday of the month
- e.g.
* * LW * *- At every minute on a last weekday.
- e.g.
Day of week extra features:
- nth day:
- e.g.
* * * * 7#4- At every minute on 4th Sunday. - 1-5
- Every day of week repeats 4-5 times a month. To target the last one, use "last day" feature instead.
- e.g.
- last day
- e.g.
* * * * 7L- At every minute on the last Sunday.
- e.g.
Seconds
Run a job every n seconds within a minute:
use Cron\CronExpression;
$scheduler->addJob(
/* ... */,
new CronExpression('* * * * *'),
/* ... */,
1, // every second, 60 times a minute
);
$scheduler->addJob(
/* ... */,
new CronExpression('* * * * *'),
/* ... */,
30, // every 30 seconds, 2 times a minute
);
With the default synchronous job executor, all jobs scheduled for the current second are executed, and only after they finish do jobs for the next second start. With the parallel executor, all jobs execute as soon as it is their time. Therefore, it is strongly recommended to use locking to prevent overlapping.
Timezones
All jobs run within the timezone used by the application. Specify a different timezone for job execution time interpretation – e.g. every midnight in Europe/Prague:
use Cron\CronExpression;
use DateTimeZone;
$scheduler->addJob(
/* ... */,
new CronExpression('0 0 * * *'),
/* ... */,
/* ... */,
new DateTimeZone('Europe/Prague'),
);
Some timezones use daylight saving time. When daylight saving time changes occur, a scheduled job may run twice or even not run at all during that period. Run tasks often enough and ensure that running them more often produces expected results.
To run a job at a specific time (e.g. midnight) in each user's timezone, run it every 15 minutes and implement timezone-checking logic yourself. Several time zones have deviations of either 30 or 45 minutes. For instance, UTC-03:30 is the standard time in Newfoundland, while Nepal's standard time is UTC+05:45. Indian Standard Time is UTC+05:30, and Myanmar Standard Time is UTC+06:30.
Events
Run callbacks to collect statistics, etc.
Before job event
Executes before a job starts.
- has JobInfo available as a parameter
- does not execute if job is locked — check
$result->getState() === JobResultState::lock()in the after job event instead - does not execute if job is skipped due to maintenance mode
use Orisai\Scheduler\Status\JobInfo;
$scheduler->addBeforeJobCallback(
function(JobInfo $info): void {
// Executes before job start
},
);
After job event
Executes after a job reaches its final state — regardless of outcome.
- has JobInfo and JobResult available as a parameter
- executes for every job state:
done,fail,lock,maintenance - inspect
$result->getState()to distinguish between outcomes
use Orisai\Scheduler\Status\JobInfo;
use Orisai\Scheduler\Status\JobResult;
$scheduler->addAfterJobCallback(
function(JobInfo $info, JobResult $result): void {
// Executes after every job, whether it ran, failed, was locked or skipped
},
);
Before run event
Executes before every run (every minute), even if no jobs will be executed.
- has RunInfo available as a parameter
use Orisai\Scheduler\Status\RunInfo;
$scheduler->addBeforeRunCallback(
function(RunInfo $info): void {
$info->getStart(); // DateTimeImmutable
foreach ($info->getJobInfos() as $jobInfo) {
$jobInfo->getJobId(); // int|string
$jobInfo->getName(); // string
$jobInfo->getExpression(); // string, e.g. * * * * *
$jobInfo->getTimeZone(); // DateTimeZone|null
$jobInfo->getExtendedExpression(); // string, e.g. '* * * * * / 30 (Europe/Prague)'
$jobInfo->getRepeatAfterSeconds(); // int<0, 30>
$jobInfo->getRunsCountPerMinute(); // int<1, max>
$jobInfo->getEstimatedStartTimes(); // list<DateTimeImmutable>
}
},
);
After run event
Executes after every run (every minute), even if no jobs were executed.
- has RunSummary available as a parameter
use Orisai\Scheduler\Status\RunSummary;
$scheduler->addAfterRunCallback(
function(RunSummary $summary): void {
// Executes after every run (every minute), even if no jobs were executed
},
);
Tracking job executions
Use beforeJob and afterJob callbacks for real-time tracking of job executions. The beforeJob callback
fires as soon as a job starts, allowing you to record it immediately – before it finishes.
Pair the callbacks using $info->getExecutionId() – a unique identifier derived from the job ID, run second
and start time. The same JobInfo instance (with the same execution ID) is passed to both callbacks.
afterJob fires for every job, but beforeJob only fires for jobs that actually ran
(see Callback timing summary). Handle the "no pending entry" case in afterJob
by recording the final state directly – the job never reached the running phase.
use Example\Core\Scheduler\Db\JobRun;
use Orisai\Scheduler\Status\JobInfo;
use Orisai\Scheduler\Status\JobResult;
final class JobExecutionTracker
{
/** @var array<string, JobRun> */
private array $pendingRuns = [];
public function beforeJob(JobInfo $info): void
{
$jobRun = new JobRun(
jobId: $info->getJobId(),
name: $info->getName(),
startedAt: $info->getStart(),
);
$jobRun->status = 'running';
$this->entityManager->persist($jobRun);
$this->entityManager->flush();
$this->pendingRuns[$info->getExecutionId()] = $jobRun;
}
public function afterJob(JobInfo $info, JobResult $result): void
{
$jobRun = $this->pendingRuns[$info->getExecutionId()] ?? null;
unset($this->pendingRuns[$info->getExecutionId()]);
// lock or maintenance – job never ran, no prior beforeJob call.
if ($jobRun === null) {
$jobRun = new JobRun(
jobId: $info->getJobId(),
name: $info->getName(),
startedAt: $info->getStart(),
);
}
$jobRun->finishedAt = $result->getEnd();
$jobRun->status = $result->getState()->value;
$jobRun->lockExpired = $result->hasLockExpiredEarly();
$this->entityManager->persist($jobRun);
$this->entityManager->flush();
}
}
Register the callbacks:
$tracker = new JobExecutionTracker(/* ... */);
$scheduler->addBeforeJobCallback([$tracker, 'beforeJob']);
$scheduler->addAfterJobCallback([$tracker, 'afterJob']);
Handling errors
After all jobs finish, a RunFailure exception composing exceptions thrown by all jobs is thrown. This
exception reports which exceptions were thrown, including their messages and source. However, this still makes
exceptions hard to access by the application error handler and causes CLI commands to hard fail.
To overcome this limitation, add a minimal error handler to the scheduler. When an error handler is
set, RunFailure is not thrown.
Assuming a PSR-3 logger, e.g. Monolog, is installed:
use DateTimeInterface;
use Orisai\Scheduler\SimpleScheduler;
use Orisai\Scheduler\Status\JobInfo;
use Orisai\Scheduler\Status\JobResult;
use Throwable;
$errorHandler = function(Throwable $throwable, JobInfo $info, JobResult $result): void {
$id = $info->getJobId();
$name = $info->getName();
$this->logger->error("Job [$id] $name failed", [
'exception' => $throwable,
'id' => $id,
'name' => $name,
'expression' => $info->getExtendedExpression(),
'runSecond' => $info->getRunSecond(),
'start' => $info->getStart()->format(DateTimeInterface::ATOM),
'end' => $result->getEnd()->format(DateTimeInterface::ATOM),
'forcedRun' => $info->isManualRun(),
]);
},
$scheduler = new SimpleScheduler($errorHandler);
Logging potential problems
Using a PSR-3-compatible logger (like Monolog), log situations that do not fail the job but are most certainly unwanted:
- Lock was released before the job finished. The job has access to the lock and should extend the lock time to prevent this.
use Orisai\Scheduler\SimpleScheduler;
$scheduler = new SimpleScheduler(null, null, null, null, $logger);
With the process job executor, these situations are also logged:
- Subprocess running the job produced unexpected stdout output. A job should never echo or write directly to stdout.
- Subprocess running the job produced unexpected stderr output. This may happen due to deprecation notices but may also be caused by a more serious problem occurring in CLI.
use Orisai\Scheduler\SimpleScheduler;
use Orisai\Scheduler\Executor\ProcessJobExecutor;
$executor = new ProcessJobExecutor(null, $logger);
$scheduler = new SimpleScheduler(null, null, $executor, null, $logger);
Locks and job overlapping
Prevent time-based jobs from overlapping when they take too long and run simultaneously. Overlapping jobs accessing the same resources (files, databases) can lead to conflicts or data corruption.
The locking mechanism ensures that only one instance of a job runs at any given time:
use Orisai\Scheduler\SimpleScheduler;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\FlockStore;
$lockFactory = new LockFactory(new FlockStore());
$scheduler = new SimpleScheduler(null, $lockFactory);
Lock isolation across applications
If multiple applications share the same lock store (e.g. production and development on the same Redis server, or two apps using the same Symfony module with identical job IDs), their locks will collide. A job locked by one app will block the same job in the other.
Some stores support native isolation – use a unique path, table, or collection per app:
- FlockStore: unique
$lockPathdirectory per app - PdoStore / DoctrineDbalStore: unique
db_tableoption per app - MongoDbStore: unique
collectionper app - InMemoryStore: per-process, no collision possible
- PostgreSqlStore: per-connection, no collision possible
For stores without native isolation (RedisStore, MemcachedStore, SemaphoreStore), use
PrefixingLockFactory which prefixes all lock keys with an app-specific string:
use Orisai\Scheduler\Lock\PrefixingLockFactory;
use Orisai\Scheduler\SimpleScheduler;
use Symfony\Component\Lock\Store\RedisStore;
$store = new RedisStore($redis);
$lockFactory = new PrefixingLockFactory($store, 'MyApp/');
$scheduler = new SimpleScheduler(null, $lockFactory);
This turns lock keys like Orisai.Scheduler.Job/my-job into MyApp/Orisai.Scheduler.Job/my-job,
preventing collisions between applications.
To choose the right lock store, refer to symfony/lock documentation. There are several available stores with various levels of reliability, affecting when the lock is released.
Warning
Locks must work across processes — each scheduler:run invocation and each job run
with process job executor is a separate process.
The following stores are incompatible with the scheduler:
- InMemoryStore — per-process, locks are not shared between separate runs
- PostgreSqlStore / DoctrineDbalPostgreSqlStore — advisory locks are per-connection, each process opens its own connection
Use a cross-process store instead: FlockStore, RedisStore, PdoStore, MemcachedStore, SemaphoreStore, etc.
The lock is automatically acquired and released by the scheduler even if a (recoverable) error occurred during the job or its events. Handle lock expiration if jobs take more than 5 minutes and an expiring store is used.
For long-running jobs, call extendTo() periodically to prevent lock expiration:
use Orisai\Scheduler\Job\CallbackJob;
use Orisai\Scheduler\Job\JobLock;
new CallbackJob(function (JobLock $lock): void {
$items = $this->repository->findUnprocessed();
foreach ($items as $item) {
// Keep the lock alive while processing — expires 120 seconds from now
$lock->extendTo(120);
$this->process($item);
}
});
Available JobLock methods:
$lock->extendTo(120); // set lock expiration to 120 seconds from now
$lock->getRemainingLifetime(); // float|null - seconds until lock expires
$lock->isExpired(); // bool - whether the lock TTL has expired
If a lock expires before the job finishes, a warning is logged via the PSR-3 logger. Configure
a logger to be notified – this indicates the job takes longer
than the lock TTL and should use extendTo() to keep the lock alive.
Specify a constant ID for every added job to ensure locks work correctly during deployments – lock identifiers rely on it. Otherwise, the job ID changes when new jobs are added before it and the acquired lock is ignored:
$scheduler->addJob(
/* ... */,
/* ... */,
'job-id',
);
Multi-server protection
When running the scheduler on multiple servers, a job can be executed twice within the same minute: server A finishes the job and releases its lock, then server B starts slightly later, sees no lock, and runs the same job.
The scheduler prevents this using a minute lock – a lock keyed by {jobId}/{clockMinute} with a 60-second TTL,
acquired before the job runs. Unlike the job lock (which is released when the job finishes), the minute lock is never
explicitly released; it stays in the lock store until its TTL expires. Because the key includes the clock minute,
different minutes never share a lock — this is what makes the worker's immediate start at any second of a minute
safe (see Worker lifecycle), and the 60-second TTL keeps the lock in place for the entire
clock minute so servers arriving anywhere within that minute see the same lock and skip.
This requires a distributed lock store (Redis, database, etc.) – InMemoryStore is per-process and does not
provide multi-server protection.
For sub-minute jobs (repeatAfterSeconds > 0), each execution second gets its own minute lock key, so different
seconds within the same minute don't interfere with each other.
Manual job execution ($scheduler->runJob($id)) is not affected – manual runs bypass the minute lock.
Parallelization and process isolation
Execute scheduler tasks asynchronously in separate processes. This approach provides:
- Isolation – each task runs in its own process, so errors in one task do not affect others
- Resource management – multiple tasks execute simultaneously without causing resource conflicts
- Efficiency – concurrent execution reduces overall execution time
- Scalability – additional tasks can be added without increasing the load on any one process
- Flexibility – tasks run at different times and frequencies without interfering with each other
Set up the scheduler for parallelization and process isolation. This requires proc_* functions to be enabled. The run-job command is used in the background, so console must be set up as well:
use Orisai\Scheduler\Executor\ProcessJobExecutor;
use Orisai\Scheduler\SimpleScheduler;
$executor = new ProcessJobExecutor();
$scheduler = new SimpleScheduler(null, null, $executor);
If the executable script is not bin/console or multiple scheduler setups are used, specify the executable:
use Orisai\Scheduler\Executor\ProcessJobExecutor;
$executor = new ProcessJobExecutor();
$executor->setExecutable('bin/console', 'scheduler:run-job');
Job types
Callback job
Call a given Closure when the job runs:
use Closure;
use Orisai\Scheduler\Job\CallbackJob;
use Orisai\Scheduler\Job\JobLock;
$scheduler->addJob(
new CallbackJob(Closure::fromCallable([$object, 'method'])),
/* ... */,
);
$scheduler->addJob(
new CallbackJob(fn(JobLock $lock) => exampleTask()),
/* ... */,
);
Callback must be a function/method matching the following signature (unused parameters may be omitted). Functionality is
equal to the run() method of a custom job:
use Orisai\Scheduler\Job\JobLock;
public function example(JobLock $lock): void
{
// Do whatever you need to
}
Custom job
Create a custom job implementation.
- name should preferably be unique – it is used for logging, event metadata and listing jobs in commands
run()method must throw an exception to mark the job as failedrun()method may manipulate the locking mechanism
use Orisai\Scheduler\Job\Job;
use Orisai\Scheduler\Job\JobLock;
final class CustomJob implements Job
{
public function getName(): string
{
// Provide (preferably unique) name of the job
return static::class;
}
public function run(JobLock $lock): void
{
// Do whatever you need to
}
}
$scheduler->addJob(
new CustomJob(),
/* ... */,
);
Symfony console job
Run a symfony/console command as a job.
- if the job succeeds (returns zero code), command output is ignored
- if the job fails (returns non-zero code), an exception is thrown, including command return code, output and, if thrown by the command, the exception
use Orisai\Scheduler\Job\SymfonyConsoleJob;
$job = new SymfonyConsoleJob($command, $application);
$scheduler->addJob(
$job,
/* ... */,
);
Parametrize the command:
$job->setCommandParameters([
'argument' => 'value',
'--value-option' => 'value',
'--no-value-option' => null,
'--array-value-option' => ['value1', 'value2'],
]);
When running a command as a job, the lock cannot be refreshed as with other jobs. Instead, change the lock's default time to live to ensure the lock is not released before the job finishes:
$job->setLockTtl(600); // Time in seconds
Job info and result
Status information available via events and run summary.
Info:
$id = $info->getJobId(); // string|int
$name = $info->getName(); // string
$expression = $info->getExpression(); // string, e.g. '* * * * *'
$repeatAfterSeconds = $info->getRepeatAfterSeconds(); // int<0, 30>
$timeZone = $info->getTimeZone(); // DateTimeZone|null
$extendedExpression = $info->getExtendedExpression(); // string, e.g. '* * * * * / 30 (Europe/Prague)'
$runSecond = $info->getRunSecond(); // int
$start = $info->getStart(); // DateTimeImmutable
$manualRun = $info->isManualRun(); // bool, happens when running job via $scheduler->runJob() or scheduler:run-job command, ignoring the cron expression
Result:
$end = $result->getEnd(); // DateTimeImmutable
$state = $result->getState(); // JobResultState
$earlyExpiration = $result->hasLockExpiredEarly(); // bool
// Next runs are computed from time when job was finished
$nextRun = $info->getNextRunDate(); // DateTimeImmutable
$threeNextRuns = $info->getNextRunDates(3); // list<DateTimeImmutable>
Run summary
Scheduler run returns a summary for inspection:
$summary = $scheduler->run(); // RunSummary
$summary->getStart(); // DateTimeImmutable
$summary->getEnd(); // DateTimeImmutable
foreach ($summary->getJobSummaries() as $jobSummary) {
$jobSummary->getInfo(); // JobInfo
$jobSummary->getResult(); // JobResult
}
Check job info and result for available job status info.
Run scheduler
Run all due jobs once. Equivalent to invoking scheduler:run on the CLI.
$summary = $scheduler->run(); // RunSummary
// Or iterate summaries as each job finishes:
foreach ($scheduler->runPromise() as $jobSummary) {
// inspect $jobSummary incrementally
}
Scheduler run lifecycle
Each run: filter due jobs by cron + timezone, check maintenance mode, then run the jobs through the executor. beforeRun and afterRun fire once per run; afterJob fires once per due job.
flowchart TD
Start([Run scheduler]) --> FilterDue[Filter jobs due now]
FilterDue --> BeforeRun[🔔 beforeRun]
BeforeRun --> MaintCheck{In maintenance<br/>mode?}
MaintCheck -- yes --> MaintPath[Mark every due job<br/>with state = maintenance]
MaintCheck -- no --> ExecPath[Execute jobs]
MaintPath --> PerJob[🔔 afterJob for every job]
ExecPath --> PerJob
PerJob --> AfterRun[🔔 afterRun]
AfterRun --> End([Return RunSummary<br/>throw RunFailure<br/>if any job threw<br/>without errorHandler])
classDef event fill:#e6ffed,stroke:#28a745,color:#0d2818
classDef terminal fill:#fafbfc,stroke:#586069,color:#24292e
class Start,End terminal
class BeforeRun,PerJob,AfterRun,MaintPath,ExecPath event
Inside the executor
Two executors are available:
- Basic executor (default) runs every due job in the current process, one after another.
- Process executor runs each due job in its own subprocess, so jobs execute in parallel — see Parallelization and process isolation.
The callback contract is identical for both: beforeJob fires right before the job runs, afterJob fires once the job reaches a terminal state, and pairing via $info->getExecutionId() works transparently across the process boundary.
Callback timing summary
| Job state | beforeJob | afterJob |
|---|---|---|
| done | ✓ | ✓ |
| fail | ✓ | ✓ |
| lock | ✓ | |
| maintenance | ✓ |
Run single job
Run a single job for testing purposes.
Assign an ID to the job when adding it to the scheduler. An auto-assigned ID visible in list command also works, but is not recommended because it depends on the order in which jobs were added:
$scheduler->addJob($job, $expression, 'id');
$scheduler->runJob('id'); // JobSummary
To respect the job schedule and run it only if it is due, set the 2nd parameter to false:
$scheduler->runJob('id', false); // JobSummary|null
Runs respect maintenance mode regardless of the force parameter — when maintenance is active
the job is skipped and the returned JobSummary has state maintenance. To actually execute a job during maintenance,
disable maintenance first.
Handling errors is the same as for the run() method, except JobFailure is thrown instead
of RunFailure.
Single job lifecycle
Same flow whether you call $scheduler->runJob() directly in PHP or invoke scheduler:run-job on the CLI.
flowchart TD
Start([$scheduler->runJob id, force]) --> DueCheck{Not due and not forced?}
DueCheck -- yes --> RetNull([return null])
DueCheck -- no --> MaintCheck{In maintenance?}
MaintCheck -- yes --> RetMaint([return JobSummary<br/>state = maintenance])
MaintCheck -- no --> MinLock[🔒 Acquire minute lock<br/>skipped for forced runs]
MinLock --> MinLockOk{Minute lock<br/>acquired?}
MinLockOk -- no --> LockEvent[🔔 afterJob<br/>state = lock]
LockEvent --> RetLock([return JobSummary<br/>state = lock])
MinLockOk -- yes --> JobLock[🔒 Acquire job lock]
JobLock --> JobLockOk{Job lock<br/>acquired?}
JobLockOk -- no --> LockEvent
JobLockOk -- yes --> BeforeJob[🔔 beforeJob]
BeforeJob --> Run[Run the job]
Run --> AfterJob[🔔 afterJob<br/>state = done or fail]
AfterJob --> FailCheck{Job failed?}
FailCheck -- no --> RetOk([return JobSummary<br/>state = done])
FailCheck -- yes --> ErrHandler{errorHandler<br/>configured?}
ErrHandler -- yes --> HandleErr[errorHandler runs]
HandleErr --> RetFail([return JobSummary<br/>state = fail])
ErrHandler -- no --> ThrowJF([throw JobFailure])
classDef lock fill:#f5e8ff,stroke:#6f42c1,color:#2b0c4d
classDef event fill:#e6ffed,stroke:#28a745,color:#0d2818
classDef terminal fill:#fafbfc,stroke:#586069,color:#24292e
class Start,RetNull,RetMaint,RetLock,RetOk,RetFail,ThrowJF terminal
class MinLock,MinLockOk,JobLock,JobLockOk lock
class LockEvent,BeforeJob,Run,AfterJob,HandleErr event
| Outcome | beforeJob | afterJob | returns |
|---|---|---|---|
| done | ✓ | ✓ | JobSummary (state = done) |
| fail + errorHandler | ✓ | ✓ | JobSummary (state = fail) |
| fail − errorHandler | ✓ | ✓ | throws JobFailure |
| lock (minute or job) | ✓ | JobSummary (state = lock) | |
| maintenance | JobSummary (state = maintenance) | ||
| not due + !force | null |
CLI commands
Commands for symfony/console:
Note
Examples assume console is run via executable PHP script bin/console.
Without a DI library for handling services, register commands like this:
use Symfony\Component\Console\Application;
use Orisai\Scheduler\Command\ExplainCommand;
use Orisai\Scheduler\Command\ListCommand;
use Orisai\Scheduler\Command\RunCommand;
use Orisai\Scheduler\Command\RunJobCommand;
use Orisai\Scheduler\Command\WorkerCommand;
$app = new Application();
$app->addCommands([
new ExplainCommand($scheduler),
new ListCommand($scheduler),
new RunCommand($scheduler),
new RunJobCommand($scheduler),
new WorkerCommand(),
])
Run command
Run the scheduler once, executing jobs scheduled for the current minute. CLI wrapper around $scheduler->run() — see that section for the execution lifecycle.
bin/console scheduler:run
Options:
--json- output json with job info and result
Alternatively, change crontab settings to use the command:
* * * * * cd path/to/project && php bin/console scheduler:run >> /dev/null 2>&1
Run job command
Run a single job, ignoring scheduled time. CLI wrapper around $scheduler->runJob() — see that section for the execution lifecycle.
bin/console scheduler:run-job <id>
Options:
--no-force- respect due time and only run job if it is due--json- output json with job info and result
List command
List all scheduled jobs (in expression / second (timezone) [id] name... next-due format):
bin/console scheduler:list
bin/console scheduler:list --next=3
bin/console scheduler:list --timezone=Europe/Prague
bin/console scheduler:list --explain
bin/console scheduler:list --explain=en
Options:
--next- sort jobs by their next execution time--next=Nlists only N next jobs (e.g.--next=3prints maximally 3)
-v- display absolute times--timezone(or-tz) - display times in specified timezone instead of one used by application- e.g.
--tz=UTC
- e.g.
--explain[=<locale>]- explain whole expression, including seconds and timezones- Explain command with
--idparameter can be used to explain specific job - e.g.
--explain - e.g.
--explain=en(to choose locale)
- Explain command with
Worker command
Run the scheduler repeatedly, once every minute.
Note
This command should be used only for local development and requires interactive CLI. In production, use crontab with the run command.
bin/console scheduler:worker
- requires proc_* functions to be enabled
- if the executable script is not
bin/consoleor multiple scheduler setups are used, specify the executable:- via
your/console scheduler:worker -s=your/console -c=scheduler:run - or via setter
$workerCommand->setExecutable('your/console', 'scheduler:run')
- via
Options:
--script=<script>(or-s) - script executed by worker (defaults tobin/console)--command=<command>(or-c) - command executed by worker (defaults toscheduler:run)--force- force run when non-interactive CLI is detected (ensure the worker can be terminated!)
Worker lifecycle
The worker is a thin loop that spawns one scheduler:run subprocess per minute. On startup it spawns immediately so the worker doesn't idle until the next minute boundary — the minute lock prevents duplicate execution if another server is already handling the current minute. Subsequent runs fire at the start of each minute. The worker itself holds no locks and fires no events — everything interesting happens inside the subprocess (see Run scheduler).
sequenceDiagram actor Cron as Cron / supervisor box #96c3f5 Worker process (parent) participant W as scheduler:worker loop end box #f5b482 Subprocess (spawned every minute) participant R as scheduler:run end Cron->>W: start W->>R: spawn immediately (first tick) R-->>W: stdout / stderr streamed to output loop every 100ms W->>W: poll signal flag alt new minute boundary W->>R: spawn (Process::start) R-->>W: stdout / stderr streamed to output end end Cron->>W: SIGTERM / SIGINT W->>W: shouldStop = true Note over W: second signal forces exit(1) W->>W: wait for in-flight subprocess(es) W->>Cron: exit 0
Notes:
- First signal sets the
shouldStopflag; second signal force-exits. - The worker does not actively terminate the in-flight subprocess. If the signal comes from the terminal (Ctrl-C), it propagates to the subprocess too and triggers its own graceful shutdown. If the signal targets only the worker PID, the subprocess finishes its current minute naturally.
- All locks, events, and job orchestration live inside the
scheduler:runsubprocess.
Explain command
Explain cron expression syntax:
bin/console scheduler:explain
bin/console scheduler:explain --id="job id"
bin/console scheduler:explain --expression="0 22 * 12 *"
bin/console scheduler:explain --expression="* 8 * * *" --seconds=10 --timezone="Europe/Prague" --locale=en
bin/console scheduler:explain -e"* 8 * * *" -s10 -tz"Europe/Prague" -len
Options:
--id=<id>- explain specific job- List command with
--explainparameter can be used to explain all jobs
- List command with
--expression=<expression>(or-e) - explain expression--seconds=<seconds>(or-s) - repeat every n seconds--timezone=<timezone>(or-tz) - the timezone time should be displayed in--locale=<locale>(or-l) - explain in specified locale
Run tracking
RunRegistry tracks active scheduler:run processes, useful for monitoring and for deploy scripts
to check if it is safe to proceed. Run tracking works independently of maintenance mode with any executor.
Two implementations are provided:
FileRunRegistry (single-server):
use Orisai\Scheduler\RunRegistry\FileRunRegistry;
$registry = new FileRunRegistry(__DIR__ . '/var/scheduler-runs');
Stores a JSON file per active run with PID, process start time, and timestamp. Detects stale runs by:
- Checking if the process is alive (
posix_kill($pid, 0)) - Comparing process start time from
/procon Linux to detect PID reuse - Time-based fallback for systems without
/proc(e.g. macOS)
Dead and stale processes are cleaned up automatically.
LockPoolRunRegistry (multi-server):
use Orisai\Scheduler\RunRegistry\LockPoolRunRegistry;
$registry = new LockPoolRunRegistry($lockFactory, 10); // pool of 10 slots
Uses symfony/lock with a fixed pool of lock keys. Works with any lock store (Redis, database, etc.).
Locks are refreshed periodically to prevent TTL expiry during long runs.
If all pool slots are taken, throws LogicException – increase the pool size.
Pass the registry to the scheduler:
use Orisai\Scheduler\SimpleScheduler;
$scheduler = new SimpleScheduler(
null, // errorHandler
null, // lockFactory
null, // executor
null, // clock
null, // logger
null, // maintenanceManager
$registry,
);
Status command
Check active runs and maintenance state:
php bin/console scheduler:status
Output:
Maintenance: UNAVAILABLE
Active runs: 2
- 1712345678-a3f2b1 (PID 12345, started 15s ago)
- 1712345679-d4e5c2 (PID 12346, started 3s ago)
Ready for shutdown: NO
Also available programmatically:
$status = $scheduler->getStatus(); // ActivityStatus
$status->isMaintenanceEnabled(); // ?bool - null when maintenance not configured
$status->getActiveRuns(); // list<ActiveRun>
$status->isReadyForShutdown(); // true when maintenance active AND no active runs
Each ActiveRun provides getId(), getPid() and getStartTimestamp().
Maintenance mode
Stop running cron jobs during deployments to prevent interference. The scheduler supports a two-phase shutdown: first it waits for running jobs to finish naturally (graceful), then force-kills any remaining processes after a configurable grace period.
Both executors support maintenance mode:
- ProcessJobExecutor: force-kills child processes after the grace period expires
- BasicJobExecutor: stops after the current job finishes (cannot interrupt in-process execution)
Maintenance setup
Create a MaintenanceChecker implementation for the environment:
use Orisai\Scheduler\Maintenance\MaintenanceChecker;
final class AppMaintenanceChecker implements MaintenanceChecker
{
public function isMaintenance(): bool
{
// TODO - implement maintenance check
$path = __DIR__ . '/maintenance.running';
clearstatcache(true, $path); // Prevents stale results
return file_exists($path);
}
}
Set up the scheduler with MaintenanceManager and RunRegistry:
use Orisai\Scheduler\Command\RunCommand;
use Orisai\Scheduler\Maintenance\MaintenanceManager;
use Orisai\Scheduler\RunRegistry\FileRunRegistry;
use Orisai\Scheduler\SimpleScheduler;
$checker = new AppMaintenanceChecker();
$registry = new FileRunRegistry(__DIR__ . '/var/scheduler-runs');
$manager = new MaintenanceManager($checker);
$scheduler = new SimpleScheduler(
null, // errorHandler
null, // lockFactory
null, // executor
null, // clock
null, // logger
$manager,
$registry,
);
$runCommand = new RunCommand($scheduler, null, $manager);
The grace period (time before force-kill) defaults to 30 seconds. This matches the Kubernetes
terminationGracePeriodSeconds default – long enough for most jobs to finish DB transactions, API calls
and file operations, short enough to not block deploys excessively. Customize it:
$manager = new MaintenanceManager($checker, 60); // 60 second grace period
Signal handling
RunCommand and WorkerCommand handle SIGTERM and SIGINT signals:
- RunCommand: first signal triggers graceful shutdown (same as maintenance mode), second signal forces immediate exit
- WorkerCommand: first signal stops spawning new
scheduler:runprocesses and waits for current ones to finish, second signal forces immediate exit
Signal handling in RunCommand requires MaintenanceManager to be passed to the command constructor.
Signal handling requires the pcntl extension (available on Linux/macOS CLI, not on Windows).
When pcntl is not available, signals are silently skipped – the MaintenanceChecker polling approach still works.
Deploy integration
Typical deploy flow:
- Enable maintenance (create the maintenance flag)
- Poll
scheduler:statuswith--fail-when-not-ready-for-shutdownuntil ready (exits with0when ready,1when not):
while ! php bin/console scheduler:status --fail-when-not-ready-for-shutdown; do
sleep 1
done
- Deploy the application
- Disable maintenance (remove the maintenance flag)
Exit codes
The scheduler:run command returns:
0- all jobs completed successfully1- one or more jobs failed2- maintenance shutdown (run was stopped due to maintenance)
Lazy loading
Jobs execute only when their due time arrives. Lazy load jobs to prevent initializing potentially heavy dependencies when they are not needed. This is especially helpful when separate processes are used:
use Orisai\Scheduler\Job\Job;
$jobConstructor = function(): Job {
// Initialize job
};
$scheduler->addLazyJob($jobConstructor, $expression, /* ... */);
ManagedScheduler serves the same purpose as SimpleScheduler. It is functionally identical, except:
- it requires a
JobManagerimplementation as a first argument addJob()method is inJobManagerinstead of scheduler
Use the SimpleJobManager implementation to construct a job via a callback, or use it as an inspiration to create
a DI-specific version:
use Orisai\Scheduler\Job\CallbackJob;
use Orisai\Scheduler\Job\Job;
use Orisai\Scheduler\ManagedScheduler;
use Orisai\Scheduler\Manager\SimpleJobManager;
$manager = new SimpleJobManager();
$jobConstructor = function(): Job {
// Initialize job
};
$manager->addLazyJob($jobConstructor, $expression, /* ... */);
$scheduler = new ManagedScheduler($manager);
Integrations and extensions
- Nette integration – orisai/nette-scheduler
Troubleshooting guide
Common errors and solutions.
Running a job throws JobProcessFailure exception
A process can fail for various reasons. The most common (and known) ones are covered below.
Stdout is empty:
Stdout is used to return the job result as JSON. Being empty means that either the executed command is completely wrong
and does not run the job, or that the job was terminated prematurely. Premature termination may happen when the job or
one of its before/after events calls the exit() (or die()) function, or when the process is killed at system level.
Stdout contains different output than json with job result:
If the message says something like Could not open input file: bin/console, either the executable file does not exist (change the path to executable, as described here) or permissions are set up incorrectly.
For other stdout outputs, the command may be wrong or the command writes to stdout. While most output to
php://output (like print and echo) is caught and handled properly, this is not always possible.
Output may still be produced outside the PHP script, an output buffer with higher priority than the
one from job runner may be defined, or the job may have been terminated.
Stderr contains a suppressed error:
This means no error handler is set or the error handler throws an exception. Set an error handler and ensure it does not throw any exception.
Job starts too late
Set up parallel job executor. Otherwise, jobs execute one after the other and every preceding job delays execution of the next job.
Before-run/before-job events must finish before any jobs start. Optimize them well and never use functions
like sleep().
Job does not start at scheduled time
Cron expressions are complex and interpreting them is not always easy. Use the --explain parameter of
the list command or the explain command to explain the expression.
Check the next run date computed from the cron expression:
$scheduler->getJobSchedules()['job-id']->getExpression()->getNextRunDate();
Job executions overlap
Set up locking and ensure the lock store works across processes. InMemoryStore
and PostgreSqlStore / DoctrineDbalPostgreSqlStore do not work – see the
warning in the locks section.
Default lock timeout is 5 minutes. If a job takes longer, the lock expires and another instance can start.
Use $lock->extendTo() inside the job to keep the lock alive.
Job runs twice on multi-server
The scheduler uses a minute lock to prevent re-execution within the same minute. This requires a distributed lock store (Redis, database, etc.). If two servers share the same lock store with identical job IDs, also configure lock isolation.
Scheduler does not stop during deploy
Configure maintenance mode and use scheduler:status --fail-when-not-ready-for-shutdown to wait
for running jobs to finish before proceeding with the deploy.