Scheduled jobs
SkillProductivityReference for OrangeHRM's task scheduling — the `orangehrm:run-schedule` command that operators run from system cron (every minute), the `SchedulerConfigurationInterface` that plugins implement to register tasks at framework boot, the `Schedule` / `Task` / `CommandInfo` value objects (Task extends `Crunz\Event`, so all Crunz fluent scheduling methods like `->cron()`, `->everyMinute()`, `->hourly()`, `->daily()`, `->weekdays()`, `->between()`, etc. are available), and the `TaskSchedulerLog` entity that records every task execution. Use whenever the user is adding a recurring background task, wiring up cron, debugging a job that should have run but didn't, or asking about setting up the host-level cron entry. Companion to `console-commands` (scheduled jobs are console commands underneath), `events` (the same plugin-config-implements-interface pattern), `mail` (a common scheduled task is draining queued emails on hosts where it can't happen via TERMINATE).
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Scheduled jobs skill
What this skill tells your AI
The instructions your AI receives, as published by orangehrm/orangehrm in .agents/skills/scheduled-jobs/SKILL.md and read by ahel’s review.
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.
Signals
- GitHub stars
- 1k
- Forks
- 749
- Last commit
- Jun 2026
Advanced
- Catalog kind
- skill
- Gateway key
scheduled-jobs-orangehrm- Source
- github.com/orangehrm/orangehrm