Scheduled jobs
OrangeHRM has its own task scheduler built on top of Crunz. The flow:
System cron (host-level)
→ runs `php bin/console orangehrm:run-schedule` every minute
orangehrm:run-schedule (RunScheduleCommand)
→ iterates every plugin
→ if plugin implements SchedulerConfigurationInterface, calls plugin->schedule($schedule)
→ plugin adds Task objects to the Schedule with cron expressions
→ command computes which tasks are "due" right now
→ for each due task: run it, log to ohrm_task_scheduler_log table
The framework abstracts Crunz behind the Schedule, Task, CommandInfo value objects. Plugin code defines what to schedule; the framework handles which tasks are due now.
This skill covers the full pattern. For the underlying console command mechanics, see console-commands.
The host-level cron entry
The operator sets up one system cron entry, called every minute:
* * * * * www-data php /var/www/html/bin/console orangehrm:run-schedule >> /dev/null 2>&1
That's it. The framework decides per minute which tasks are due. Operators don't add new cron lines when developers add new tasks — they configure it once and it picks up everything plugins register.
For Docker setups, the dev environment's compose file may include a separate cron container or a */1 * * * * task running in the PHP container. In dev, you can invoke it manually:
docker exec -it os_dev_php83 php /var/www/<ohrm-checkout>/bin/console orangehrm:run-schedule --verbose
The --verbose flag flips it from NullOutput to actual output so you can see what's running.
SchedulerConfigurationInterface — the plugin contract
Same pattern as ConsoleConfigurationInterface and PluginConfigurationInterface. The plugin's <Plugin>PluginConfiguration class implements it in addition to whatever else:
namespace OrangeHRM\Framework\Console\Scheduling;
interface SchedulerConfigurationInterface
{
public function schedule(Schedule $schedule): void;
}
RunScheduleCommand::execute() iterates all plugin configurations and calls schedule() on any that implement this interface:
foreach ($pluginConfigs as $pluginConfig) {
$configClass = new $pluginConfig['classname']();
if ($configClass instanceof SchedulerConfigurationInterface) {
$configClass->schedule($schedule); // ← plugin registers tasks
}
}
$dueTasks = $schedule->getDueTasks(new DateTimeZone('UTC'));
foreach ($dueTasks as $task) {
$task->start(); // ← run each due task
// … log to ohrm_task_scheduler_log
}
The real example — LDAP sync
LDAPAuthenticationPluginConfiguration implements all three interfaces (PluginConfigurationInterface, ConsoleConfigurationInterface, SchedulerConfigurationInterface):
class LDAPAuthenticationPluginConfiguration implements
PluginConfigurationInterface,
ConsoleConfigurationInterface,
SchedulerConfigurationInterface
{
public function initialize(Request $request): void { /* … */ }
public function registerCommands(Console $console): void
{
$console->add(new LDAPSyncUserCommand()); // see console-commands skill
}
public function schedule(Schedule $schedule): void
{
$ldapSettings = $this->getConfigService()->getLDAPSetting();
if ($ldapSettings instanceof LDAPSetting && $ldapSettings->isEnable()) {
$interval = 1;
if ($ldapSettings->getSyncInterval() <= 23 && $ldapSettings->getSyncInterval() >= 1) {
$interval = $ldapSettings->getSyncInterval();
}
$schedule->add(new CommandInfo('orangehrm:ldap-sync-user'))
->cron("0 */$interval * * *"); // every N hours, at minute 0
}
}
}
Three things to notice:
registerCommandsadds the command (sobin/console orangehrm:ldap-sync-userruns it manually).scheduleregisters the SAME command on a cron schedule — the scheduler doesn't need a separate "scheduled task" class. It schedules an existing command.- The schedule is conditional — only registered if LDAP is enabled in config. Disabled features don't waste cron evaluations.
The two parts (registerCommands + schedule) are independent. You can have a command that's only ever scheduled (no manual invocation needed), or a command that's only manual (never scheduled). The most useful pattern is "scheduled but also manually invokable" — like LDAP sync.
The fluent scheduling API — Task extends Crunz\Event
Task extends Crunz\Event, so every method on Crunz's Event is available when defining a schedule:
$schedule->add(new CommandInfo('widget:cleanup'))
->daily() // run once per day at 00:00 UTC
->at('02:30'); // change time to 02:30 UTC
Common scheduling methods:
| Method | Schedule |
|---|---|
->cron('* * * * *') |
Raw cron expression (5-field) |
->everyMinute() |
Every minute |
->everyFiveMinutes() / ->everyTenMinutes() / ->everyThirtyMinutes() |
At fixed intervals |
->hourly() |
Every hour at minute 0 |
->daily() |
Every day at 00:00 |
->dailyAt('14:30') |
Every day at 14:30 |
->weekly() |
Every Sunday at 00:00 |
->monthly() |
First of the month at 00:00 |
->yearly() |
January 1st at 00:00 |
->weekdays() |
Mon-Fri (combine with one of the above) |
->weekends() |
Sat-Sun |
->mondays(), ->tuesdays(), etc. |
Specific days |
->between('09:00', '17:00') |
Only between these times of day |
->timezone('Asia/Colombo') |
Interpret the schedule in this timezone |
Combine to build the schedule:
$schedule->add(new CommandInfo('widget:report'))
->weekdays()
->dailyAt('09:00')
->timezone('Asia/Colombo');
For the full Crunz vocabulary, see vendor/crunzphp/crunz — it's a well-documented library.
Passing arguments to a scheduled command
Use CommandInfo's second argument:
use OrangeHRM\Framework\Console\ArrayInput;
use OrangeHRM\Framework\Console\Scheduling\CommandInfo;
$schedule->add(new CommandInfo('widget:cleanup', new ArrayInput(['--days' => '30'])))
->daily();
The ArrayInput is OHRM's thin wrapper that preserves the raw parameters for logging in ohrm_task_scheduler_log.
Time zone — UTC matters
RunScheduleCommand::execute() calls getDueTasks(new DateTimeZone('UTC')) — scheduling is evaluated in UTC by default. A ->dailyAt('09:00') runs at 09:00 UTC, not 09:00 local time.
To run a task at a specific local time, set the timezone on the task:
$schedule->add(new CommandInfo('widget:cleanup'))
->dailyAt('09:00')
->timezone('Asia/Colombo'); // 09:00 in Colombo = 03:30 UTC
This matters because the cron runs RunScheduleCommand every minute, but each task's "due" check uses its own timezone. The scheduler is timezone-aware per task.
Logging — ohrm_task_scheduler_log
Every task execution is logged in ohrm_task_scheduler_log:
SELECT id, command, input, started_at, finished_at, status FROM ohrm_task_scheduler_log
WHERE command = 'orangehrm:ldap-sync-user' ORDER BY id DESC LIMIT 5;
Columns:
command— the command name that raninput— raw parameters (theArrayInput::getRawParameters()output)started_at,finished_at— UTC timestampsstatus— the command's exit code (0 = SUCCESS, non-zero = FAILURE)
Use this for audit and debugging. "Did the email queue drain last night?" → query the log.
Rows are kept indefinitely — no automatic cleanup. If your instance accumulates a lot of scheduled runs, consider a cleanup task that itself runs daily and prunes old log rows (meta!).
What happens on errors
If a scheduled task throws:
try {
return $this->consoleCommand->run($input, $this->commandOutput);
} catch (Throwable $e) {
$logger = LoggerFactory::getLogger('scheduler');
$logger->error($e->getMessage());
$logger->error($e->getTraceAsString());
return Command::FAILURE;
}
The exception is caught, logged to src/log/scheduler.log, and the task is recorded as FAILURE in ohrm_task_scheduler_log. Other tasks in the same run-schedule invocation still run. One failing task doesn't block the rest.
The plugin's schedule() method is also wrapped:
try {
$configClass->schedule($schedule);
} catch (Throwable $e) {
$logger->error($e->getMessage());
$logger->error($e->getTraceAsString());
}
A plugin throwing during schedule() (e.g. its config service threw) doesn't break scheduling for other plugins.
Common scheduled tasks in the codebase
| Plugin | Command | Schedule | Use |
|---|---|---|---|
| LDAP | orangehrm:ldap-sync-user |
Hourly (interval configurable) | Sync users from the LDAP server |
| Various | (others added as features need) |
Most current-codebase scheduling is LDAP-focused. The pattern is reusable for anything: nightly reports, attendance roll-ups, queue draining, data exports, cleanup jobs.
Recipes
Recipe 1 — Schedule an existing command to run nightly
You already have a widget:cleanup command (see console-commands skill). To schedule it nightly at 02:00 UTC:
class XPluginConfiguration implements
PluginConfigurationInterface,
ConsoleConfigurationInterface,
SchedulerConfigurationInterface
{
use ConfigServiceTrait;
public function initialize(Request $request): void { /* … */ }
public function registerCommands(Console $console): void
{
$console->add(new CleanupWidgetsCommand());
}
public function schedule(Schedule $schedule): void
{
$schedule->add(new CommandInfo('widget:cleanup'))
->dailyAt('02:00');
}
}
That's the entire recipe — add SchedulerConfigurationInterface to the implements list, add the schedule() method, and add a Task with a Crunz fluent schedule.
Recipe 2 — Schedule conditional on config
When a task should only run if a feature is enabled:
public function schedule(Schedule $schedule): void
{
if (!$this->getConfigService()->isWidgetCleanupEnabled()) {
return;
}
$schedule->add(new CommandInfo('widget:cleanup'))
->dailyAt('02:00')
->timezone('UTC');
}
This is the LDAP pattern — schedule() checks config and only registers the task if it's enabled. No task = no cron evaluation = no log row when disabled.
Recipe 3 — Schedule with arguments
public function schedule(Schedule $schedule): void
{
$schedule->add(new CommandInfo(
'widget:cleanup',
new ArrayInput(['--days' => '90', '--force' => true]),
))->weekly();
}
The ArrayInput is logged as the command's input field in ohrm_task_scheduler_log, so you can trace exactly what each scheduled invocation called with.
Recipe 4 — Multiple tasks from one plugin
public function schedule(Schedule $schedule): void
{
$schedule->add(new CommandInfo('widget:cleanup'))
->dailyAt('02:00');
$schedule->add(new CommandInfo('widget:report-weekly'))
->mondays()
->dailyAt('06:00');
$schedule->add(new CommandInfo('widget:sync-external'))
->everyTenMinutes();
}
Each $schedule->add(...) produces a separate Task with its own due-time calculation. They run independently.
Recipe 5 — Drain the email queue on hosts where TERMINATE doesn't fire
The mail skill mentions MailerSubscriber drains on KernelEvents::TERMINATE. CLI invocations don't go through the kernel — so emails queued by a console command never drain via the subscriber.
A scheduled task can flush the queue periodically:
public function schedule(Schedule $schedule): void
{
$schedule->add(new CommandInfo('orangehrm:flush-email-queue'))
->everyFiveMinutes();
}
Pair with a custom FlushEmailQueueCommand that calls EmailService::sendQueuedEmails() directly. Not currently shipped, but the pattern is straightforward.
Checklists
Schedule an existing command
- Plugin's config class adds
SchedulerConfigurationInterfaceto itsimplementslist - Add
public function schedule(Schedule $schedule): voidmethod - Inside:
$schedule->add(new CommandInfo('command:name'))->dailyAt(...)(or other Crunz fluent method) - If conditional, wrap with a Config check
- Test locally:
docker exec ... php bin/console orangehrm:run-schedule --verbose— your task should appear if it's due now, or you can temporarily change the schedule to->everyMinute()to force a run - Confirm a row appears in
ohrm_task_scheduler_logafter running
Add a new schedulable task (command + schedule)
- Write the
Commandclass (seeconsole-commandsskill) - In
<Plugin>PluginConfiguration::registerCommands(), add the command so it's manually invokable - In
<Plugin>PluginConfiguration::schedule(), add the task with its schedule - Run
bin/console <command:name>to verify manual invocation works - Run
bin/console orangehrm:run-schedule --verboseto verify it's picked up by the scheduler
Set up the host cron entry (one-time, per deployment)
- Add the line:
* * * * * www-data php /path/to/orangehrm/bin/console orangehrm:run-schedule >> /dev/null 2>&1 - Adjust user (
www-dataon Apache,nginxon Nginx setups) to whoever owns the application files - Adjust path to the actual deployment location
- Verify cron is running (
systemctl status cronor equivalent) - Wait 1-2 minutes, check
ohrm_task_scheduler_logfor the first auto-run
Debug "my scheduled task didn't run"
- Was the host cron line running? Check system mail /
/var/log/cronfor the cron entry firing - Did
orangehrm:run-scheduleexecute? Run it manually with--verboseand see if it reports "Event count: 0" — that means no tasks were due, not that yours wasn't registered - Is the plugin config registered? Verify the plugin's
PluginConfigurationclass implementsSchedulerConfigurationInterface— only then doesRunScheduleCommandcall itsschedule()method - Is the schedule conditional? If wrapped in
if ($enabled), verify the config value - Time zone issue? A
->dailyAt('09:00')with no->timezone()runs at 09:00 UTC. If you wanted local time, add->timezone('Region/City') - Plugin error during
schedule()? Checksrc/log/scheduler.log— exceptions during plugin scheduling are caught and logged but don't show in the command's stdout - Task error during run? Check
ohrm_task_scheduler_logfor the row —statuscolumn has the exit code; non-zero means failure. Thensrc/log/scheduler.logfor the trace
Things that bite
orangehrm:run-scheduleitself is filtered out —Schedule::add()throws if you try to schedule it. Don't try to self-schedule the runner.- All scheduling is evaluated in UTC by default. A
->dailyAt('09:00')is 09:00 UTC. Set->timezone()per task for local times. - CLI invocations don't trigger
KernelEvents::TERMINATE— the mail queue drain viaMailerSubscriberwon't run from a scheduled task. Use a dedicated drain command if you need email-from-scheduled-tasks. - The host cron entry is operator responsibility — devs adding a scheduled task don't change anything on the host. If the cron isn't set up, no scheduled task runs. Check this first when "scheduled" features mysteriously don't fire.
schedule()runs once perorangehrm:run-scheduleinvocation (i.e., every minute). Don't do expensive work in it — only register tasks. Heavy logic belongs in the command itself.- Multiple
orangehrm:run-scheduleruns in parallel (if the cron entry is mis-configured to overlap) can cause duplicate task executions. Crunz has overlap-prevention but it's per-process and host-level — the safest bet is a single cron entry, every minute, no&background. - Plugin
schedule()exceptions are caught and logged, but they're silent in the command's output unless--verbose. A typo'd config service call won't crash anything visible — just won't schedule the task. Checksrc/log/scheduler.log. ohrm_task_scheduler_loggrows indefinitely. No built-in pruning. If your scheduler runs frequently, expect this table to bloat over months/years. Consider adding a scheduled task that prunes old log rows.