Scheduled jobs

SkillProductivity

Helps your agent set up and debug recurring background tasks in OrangeHRM using its cron-based scheduler.

Use Scheduled jobs in Claude, ChatGPT or Ahel Desktop

Free. Sign in, add Scheduled jobs and connect your AI. About a minute.

Also: Claude Code · Cursor · Codex

Then ask your AI: use the Scheduled jobs skill

Details

Instructions available. Your AI can read the instructions. Execution depends on the setup they require.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Scheduled jobsStart free
About this skill

Reference 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 `

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:

  1. registerCommands adds the command (so bin/console orangehrm:ldap-sync-user runs it manually).
  2. schedule registers the SAME command on a cron schedule — the scheduler doesn't need a separate "scheduled task" class. It schedules an existing command.
  3. 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:

MethodSchedule
->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 ran
  • input — raw parameters (the ArrayInput::getRawParameters() output)
  • started_at, finished_at — UTC timestamps
  • status — 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

PluginCommandScheduleUse
LDAPorangehrm:ldap-sync-userHourly (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 SchedulerConfigurationInterface to its implements list
  • Add public function schedule(Schedule $schedule): void method
  • 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_log after running

Add a new schedulable task (command + schedule)

  • Write the Command class (see console-commands skill)
  • 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 --verbose to 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-data on Apache, nginx on Nginx setups) to whoever owns the application files
  • Adjust path to the actual deployment location
  • Verify cron is running (systemctl status cron or equivalent)
  • Wait 1-2 minutes, check ohrm_task_scheduler_log for the first auto-run

Debug "my scheduled task didn't run"

  • Was the host cron line running? Check system mail / /var/log/cron for the cron entry firing
  • Did orangehrm:run-schedule execute? Run it manually with --verbose and 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 PluginConfiguration class implements SchedulerConfigurationInterface — only then does RunScheduleCommand call its schedule() 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()? Check src/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_log for the row — status column has the exit code; non-zero means failure. Then src/log/scheduler.log for the trace

Things that bite

  • orangehrm:run-schedule itself 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 via MailerSubscriber won'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 per orangehrm:run-schedule invocation (i.e., every minute). Don't do expensive work in it — only register tasks. Heavy logic belongs in the command itself.
  • Multiple orangehrm:run-schedule runs 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. Check src/log/scheduler.log.
  • ohrm_task_scheduler_log grows 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
755
Last commit
Jun 2026
Advanced
Item type
skill
Key
scheduled-jobs
Source
github.com/orangehrm/orangehrm