Skip to content

Task Scheduling

Precious Lyson edited this page Nov 13, 2026 · 2 revisions

The Task Scheduler component provides a robust, cron-based job scheduling system with:

  • Cron expressions for flexible scheduling (* * * * * style)
  • Distributed locking to prevent overlapping executions
  • Multiple retry strategies (exponential, linear, fixed)
  • Multi-layer caching for high-performance reads
  • Health monitoring and detailed execution statistics
  • Event-driven architecture for observability
  • Queue integration for async task execution
  • CLI tooling for task management

Whether you're building a small app that needs a daily cleanup job or an enterprise system orchestrating hundreds of tasks, this component scales with you.


Features

Category Features
Scheduling Cron expressions, priorities, task groups, maintenance mode awareness
Execution Sync or async (queue), overlapping prevention, timeouts, retries
Reliability Cache-based locks with file-system fallback, failure callbacks
Observability Execution history, success rates, duration & memory metrics, events
Performance 3-tier caching, per-task caching, batch operations
Management Full CLI: list, create, enable/disable, run, stats, clean, cache preload
Extensibility Custom repositories, custom generators, event hooks

Requirements

  • PHP 8.4 or higher
  • Machinjiri Framework (Container, Cache, Queue, Events)
  • dragonmantank/cron-expression library
composer require dragonmantank/cron-expression

Installation

1. Install the cron library

composer require dragonmantank/cron-expression

2. Create the database tables

Run the migrations or create the tables manually:

CREATE TABLE scheduled_tasks (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    cron_expression VARCHAR(100) NOT NULL,
    handler_class VARCHAR(255) NOT NULL,
    options JSON NULL,
    priority INT DEFAULT 0,
    task_group VARCHAR(100) DEFAULT 'default',
    queue VARCHAR(100) DEFAULT 'scheduler',
    enabled TINYINT(1) DEFAULT 1,
    next_run INT NULL,
    created_at INT NULL,
    updated_at INT NULL,
    INDEX idx_due (enabled, next_run)
);

CREATE TABLE task_executions (
    id INT AUTO_INCREMENT PRIMARY KEY,
    task_id INT NOT NULL,
    status VARCHAR(50) NOT NULL,
    started_at INT NULL,
    completed_at INT NULL,
    duration INT NULL,
    memory_usage INT NULL,
    peak_memory INT NULL,
    error TEXT NULL,
    error_code INT NULL,
    error_file VARCHAR(255) NULL,
    error_line INT NULL,
    created_at INT NULL,
    INDEX idx_task (task_id, started_at),
    FOREIGN KEY (task_id) REFERENCES scheduled_tasks(id) ON DELETE CASCADE
);

3. Bind the repository in your container

use Mlangeni\Machinjiri\Core\Components\Task\Repositories\DatabaseScheduleRepository;

$app->bind('task.scheduler.repository', function ($app) {
    return new DatabaseScheduleRepository($app);
});

4. Create the config file

Create config/components/tasks.php:

<?php

return [
    'default' => 'database',

    'drivers' => [
        'database' => [
            'table' => 'scheduled_tasks',
            'executions_table' => 'task_executions',
        ],
    ],

    'registered_tasks' => [
        // App\Scheduler\Tasks\YourTask::class,
    ],

    'defaults' => [
        'max_attempts' => 3,
        'timeout' => 60,
        'retry_delay' => 60,
        'queue' => 'scheduler',
        'retry_strategy' => 'exponential',
        'without_overlapping' => true,
    ],

    'lock_dir' => sys_get_temp_dir() . '/scheduler_locks',
];

5. Schedule the runner

Add a cron job that runs the scheduler every minute:

* * * * * cd /path/to/your/app && php artisan scheduler:run >> /dev/null 2>&1

Tip: If your scheduler is invoked by cron every minute, make sure the process finishes in under 60 seconds, or use a worker loop / queue dispatch strategy.


🏁 Quick Start

Step 1: Create a task

php artisan scheduler:create-task SendDailyReport --register --cron "0 8 * * *"

This creates app/Scheduler/Tasks/SendDailyReportTask.php and registers it.

Step 2: Implement the logic

<?php

namespace App\Scheduler\Tasks;

use Mlangeni\Machinjiri\Core\Container;
use Mlangeni\Machinjiri\Core\Components\Task\ScheduledTask;

class SendDailyReportTask extends ScheduledTask
{
    protected string $name = 'SendDailyReport';
    protected string $cronExpression = '0 8 * * *';

    public function __construct(Container $app)
    {
        parent::__construct($app);
    }

    public function handle(): mixed
    {
        $reportService = $this->app->resolve(ReportService::class);
        $reportService->sendDailyDigest();

        return true;
    }
}

Step 3: Run it

# List tasks
php artisan scheduler:list

# Run everything that's due now
php artisan scheduler:run

# Run a specific task by ID
php artisan scheduler:run --task=1

🧠 Core Concepts

The Task Lifecycle

registerTask() ──► sync() ──► DB row created ──► next_run calculated
                                                     │
                                                     ▼
                                            ┌────────────────┐
                                            │  Cron triggers │
                                            │ scheduler:run  │
                                            └────────────────┘
                                                     │
                                                     ▼
                                    getDueTasks() ──► processTask()
                                                     │
                          ┌──────────────────────────┼──────────────────────────┐
                          ▼                          ▼                          ▼
                  acquireLock()             dispatch job                updateNextRun()
                          │                          │                          │
                          ▼                          ▼                          ▼
                  overlap? skip            ExecuteScheduledTaskJob     recordExecution()
                                                     │
                                                     ▼
                                              task->handle()

Three main classes

Class Responsibility
TaskManager Registry & lifecycle: register, sync, list, enable/disable, stats
TaskScheduler Runtime: find due tasks, dispatch jobs, locks, next-run calc
ExecuteScheduledTaskJob Execution: instantiate task, run handle(), record metrics

⚙️ Configuration

return [
    // Repository driver (database by default)
    'default' => 'database',

    'drivers' => [
        'database' => [
            'table' => 'scheduled_tasks',
            'executions_table' => 'task_executions',
        ],
    ],

    // Task classes to auto-register & sync
    'registered_tasks' => [
        App\Scheduler\Tasks\SendDailyReportTask::class,
        App\Scheduler\Tasks\CleanupTempFilesTask::class,
    ],

    // Defaults applied to every task unless overridden
    'defaults' => [
        'max_attempts' => 3,
        'timeout' => 60,
        'retry_delay' => 60,
        'queue' => 'scheduler',
        'retry_strategy' => 'exponential',
        'without_overlapping' => true,
    ],

    // Directory for fallback file locks
    'lock_dir' => '/tmp/scheduler_locks',
];

🛠 Creating Tasks

Manually

<?php

namespace App\Scheduler\Tasks;

use Mlangeni\Machinjiri\Core\Components\Task\ScheduledTask;

class CleanupTempFilesTask extends ScheduledTask
{
    // Human-readable name
    protected string $name = 'CleanupTempFiles';

    // Standard 5-field cron expression
    protected string $cronExpression = '0 3 * * *'; // 3:00 AM daily

    // Lower numbers = higher priority
    protected int $priority = 5;

    // Logical grouping for organization
    protected string $group = 'maintenance';

    // Should this task run while the app is in maintenance mode?
    protected bool $runInMaintenanceMode = false;

    // Prevent concurrent runs
    protected bool $withoutOverlapping = true;

    // Per-task options
    protected array $options = [
        'maxAttempts' => 3,
        'timeout' => 300,
        'retryDelay' => 120,
        'retryStrategy' => 'exponential',
        'queue' => 'maintenance',
    ];

    public function handle(): mixed
    {
        $dir = sys_get_temp_dir();
        foreach (glob("{$dir}/app_*") as $file) {
            @unlink($file);
        }
        return true;
    }

    /**
     * Optional: called when the task ultimately fails (after retries).
     */
    public function failed(\Throwable $e): void
    {
        // Alert the ops team
    }
}

With the generator

# Standard (with options, failure handler)
php artisan scheduler:create-task SendNewsletter --register --cron "0 9 * * 1"

# Simple (minimal boilerplate)
php artisan scheduler:create-task PingHealthcheck --type=simple --cron "*/5 * * * *"

# Advanced (logger + complex config)
php artisan scheduler:create-task SyncInventory --type=advanced --cron "0 */2 * * *"

Cron expression examples

Expression Meaning
* * * * * Every minute
*/5 * * * * Every 5 minutes
0 * * * * Every hour, on the hour
0 0 * * * Daily at midnight
0 8 * * 1-5 Weekdays at 8 AM
0 0 1 * * First of every month
0 0 * * 0 Every Sunday at midnight

Use crontab.guru to craft your expressions.


▶️ Running the Scheduler

Via CLI

# Run all due tasks
php artisan scheduler:run

# Run one specific task by ID (ignores cron timing)
php artisan scheduler:run --task=3

# Force-run a disabled task
php artisan scheduler:run --task=3 --force

Programmatically

use Mlangeni\Machinjiri\Core\Components\Task\TaskScheduler;

$scheduler = $app->resolve(TaskScheduler::class);

// Run everything due
$scheduler->run();

// Run one task
$scheduler->runTask($taskId);

// Get status
$status = $scheduler->getStatus();

Via cron (production)

* * * * * cd /var/www/myapp && php artisan scheduler:run >> /var/log/scheduler.log 2>&1

⚠️ Important: Only one scheduler process should run at a time. Use the built-in locks or a dedicated worker (e.g., supervisor) for high-throughput scenarios.


💻 CLI Commands

Command Description
scheduler:list List all tasks (--enabled, --group)
scheduler:create-task <name> Generate a new task (--register, --cron, --type)
scheduler:run Run due tasks (--task=ID, --force)
scheduler:status Show scheduler state & memory
scheduler:enable <id> Enable a task
scheduler:disable <id> Disable a task
scheduler:stats [id] Show execution statistics
scheduler:clean Clean old history (--days=30)
scheduler:cache-preload Warm cache with task data
scheduler:cache-clear Invalidate all scheduler caches

Examples

# Show only enabled tasks in the "maintenance" group
php artisan scheduler:list --enabled --group=maintenance

# Show stats for a single task
php artisan scheduler:stats 5

# Keep 90 days of execution history
php artisan scheduler:clean --days=90

🎓 Advanced Usage

Priority & Groups

Tasks are ordered by priority (ascending) when due. Groups let you organize and query tasks:

protected int $priority = 1;      // runs before priority 5
protected string $group = 'reports';

Then filter:

php artisan scheduler:list --group=reports

Maintenance Mode

Scheduler skips tasks during maintenance unless the task opts in:

protected bool $runInMaintenanceMode = true;

Overlap Prevention

By default, a task won't start if its previous invocation is still running (via cache lock with file fallback). Disable per task:

protected bool $withoutOverlapping = false;

Manual Task Execution

For ad-hoc runs (e.g., from an admin panel):

$taskManager->runNow($taskId);

Async Execution via Queue

All tasks are dispatched as ExecuteScheduledTaskJob through your queue. This means execution happens on a queue worker, not the cron process — providing natural parallelism and resilience.

// The scheduler dispatches; the worker executes
$this->dispatcher->dispatch(new ExecuteScheduledTaskJob($app, $taskClass, $options, $jobOptions));

If queue.dispatcher isn't bound, runNow() falls back to synchronous execution.


⚡ Caching & Performance

The component uses three cache keys for different access patterns:

Cache Key TTL Purpose
scheduler:manager:tasks 1 hr Full task list
scheduler:manager:task:{id} 5 min Individual task
scheduler:manager:stats 5 min Aggregated statistics
scheduler:tasks:due 1 min Due tasks snapshot
scheduler:status 1–5 min Scheduler health
scheduler:locks:task:{id} 5 min Overlap locks

Preloading

Warm the cache after deployments or task changes:

php artisan scheduler:cache-preload

Programmatically:

$scheduler->preloadCache();   // Scheduler state
$taskManager->preloadCache(); // Task manager state

Clearing

php artisan scheduler:cache-clear

Or:

$scheduler->clearCache();
$taskManager->clearCache();

Note: Writes (enable/disable, sync(), updateNextRun()) automatically invalidate relevant caches, so you don't have to manually clear after every change.


❤️ Health Monitoring

SchedulerHealthCheck provides both summary and detailed reports.

use Mlangeni\Machinjiri\Core\Components\Task\SchedulerHealthCheck;

$health = $app->resolve(SchedulerHealthCheck::class);

// Summary
$check = $health->check();
// [
//   'status' => 'healthy' | 'warning' | 'degraded' | 'critical',
//   'timestamp' => 1712345678,
//   'metrics' => [...],
//   'checks' => ['repository' => 'ok', 'locks' => 0, 'due_tasks' => 3, 'queue' => 'ok'],
// ]

// Detailed report with per-task stats
$report = $health->getDetailedReport();

Use this in a /health endpoint or monitoring dashboard:

Route::get('/health/scheduler', function () use ($app) {
    return json_encode($app->resolve(SchedulerHealthCheck::class)->check());
});

Status thresholds

Condition Status
Repository fails critical
> 100 due tasks warning
> 10 active locks degraded
Otherwise healthy

🔌 Custom Repositories

Implement ScheduleRepository to swap out the storage backend (e.g., Redis, MongoDB):

use Mlangeni\Machinjiri\Core\Components\Task\ScheduleRepository;

class RedisScheduleRepository implements ScheduleRepository
{
    public function getDueTasks(): array { /* ... */ }
    public function updateNextRun(int $taskId, int $nextRun): void { /* ... */ }
    public function recordExecution(int $taskId, array $data): void { /* ... */ }
    public function getTaskById(int $id): ?array { /* ... */ }
    public function listTasks(bool $onlyEnabled = false): array { /* ... */ }
    public function saveTask(array $taskData): int { /* ... */ }
    public function setEnabled(int $taskId, bool $enabled): void { /* ... */ }
    public function getExecutionHistory(int $taskId, int $limit = 10): array { /* ... */ }
    public function cleanExecutionHistory(int $daysToKeep = 30): void { /* ... */ }
    public function getTaskStats(int $taskId): array { /* ... */ }
}

Then bind it:

$app->bind('task.scheduler.repository', fn($app) => new RedisScheduleRepository($app));

📡 Events

The scheduler emits events you can listen to for observability:

Event Payload When
scheduler.completed {duration, tasks_processed} End of run()
scheduler.failed {error} run() threw
task.dispatched {task_id, task_name, queue} Task sent to queue
task.executed {task_class, status, duration} Task succeeded
task.failed {task_class, error, attempt} Task failed
task.error {task_id, task_name, error} Scheduler-level error
$events->listen('task.failed', function ($payload) {
    Slack::notify("#alerts", "Task {$payload['task_class']} failed: {$payload['error']}");
});

🔁 Error Handling & Retries

Retry strategies

Set per task via $options['retryStrategy']:

Strategy Behavior
exponential Backoff grows with each attempt
linear Fixed increments
fixed Same delay every retry
protected array $options = [
    'maxAttempts' => 5,
    'retryDelay' => 30,
    'retryStrategy' => 'exponential',
];

Failure handler

Override failed() on your task for terminal failure behavior:

public function failed(\Throwable $e): void
{
    $this->app->resolve(AlertService::class)->pagerDuty(
        "Critical: {$this->getName()} failed — {$e->getMessage()}"
    );
}

Exception hierarchy

Failed executions throw TaskSchedulerException, wrapping the original error with task context (task_class, attempt).


🧪 Testing

Unit testing a task

use App\Scheduler\Tasks\CleanupTempFilesTask;

public function test_cleanup_removes_temp_files()
{
    $task = new CleanupTempFilesTask($this->app);
    $this->assertTrue($task->handle());
}

Integration testing the scheduler

public function test_scheduler_runs_due_tasks()
{
    $taskId = $this->repository->saveTask([
        'name' => 'test_task',
        'cron_expression' => '* * * * *',
        'handler_class' => TestTask::class,
        'options' => json_encode([]),
        'enabled' => 1,
        'next_run' => time() - 60, // due
    ]);

    $scheduler = $this->app->resolve(TaskScheduler::class);
    $scheduler->run();

    $stats = $this->repository->getTaskStats($taskId);
    $this->assertGreaterThan(0, $stats['total_executions']);
}

Mocking the cache

$this->app->bind(CacheManager::class, fn() => new InMemoryCacheManager());

🩺 Troubleshooting

Symptom Likely Cause Fix
Tasks never run Cron not set up Add * * * * * php artisan scheduler:run
task.scheduler.repository not bound Missing container binding Bind ScheduleRepository in your service provider
CronExpression library not installed Missing dependency composer require dragonmantank/cron-expression
Tasks run but next_run never updates Invalid cron expression Validate your cron_expression
Duplicate executions Multiple schedulers running Use a single cron entry or distributed lock
Task is disabled error Task enabled = 0 php artisan scheduler:enable <id>
High memory in status Long-running scheduler Ensure shutdown() runs; use cron-per-minute model
Cache stale after deploy Old cache retained php artisan scheduler:cache-clear then cache-preload

Debugging tips

  1. Enable debug logging — the component logs to scheduler and task channels.
  2. Check execution history:
    php artisan scheduler:stats <id>
  3. Inspect cache keys — list keys prefixed scheduler: in your cache store.
  4. Verify locks — check getStatus()['active_locks']. Stale locks eventually expire via TTL.
  5. Test cron syntax — use crontab.guru.

📚 API Reference

TaskManager

Method Description
registerTask(string $class): void Register a task class
registerTasks(array $classes): void Register multiple
sync(): void Persist registered tasks to DB
runNow(int $taskId): void Dispatch a task immediately
listTasks(bool $onlyEnabled = false): array List tasks
getTask(int $id): ?array Fetch one task
setTaskEnabled(int $id, bool $enabled): void Toggle
getTaskHistory(int $id, int $limit = 10): array Execution history
getTaskStats(int $id): array Stats for one task
getAllStats(): array Stats for all tasks
preloadCache(): void Warm cache
clearCache(): void Invalidate cache
getCache(): CacheManager Access cache

TaskScheduler

Method Description
run(): void Execute all due tasks
runTask(int $id): void Execute one task now
getStatus(): array Runtime status
getCachedStatus(): ?array Cached status
preloadCache(): void Warm cache
clearCache(): void Invalidate cache
cleanHistory(int $days = 30): void Purge old executions
getCache(): CacheManager Access cache

ScheduledTask (abstract)

Member Description
$name Human-readable task name
$cronExpression Cron schedule
$priority Lower = higher priority
$group Logical grouping
$runInMaintenanceMode Run during maintenance
$withoutOverlapping Prevent concurrent runs
$options Retry/timeout/queue config
initialize() Custom setup hook
handle(): mixed (abstract) Task logic
failed(\Throwable $e) Terminal failure hook
getName(), getCronExpression(), getOptions(), getPriority(), getGroup(), shouldRunInMaintenanceMode(), shouldPreventOverlapping(), getDescription() Getters

SchedulerHealthCheck

Method Description
check(): array Summary health check
getDetailedReport(): array Per-task detailed report

✅ Best Practices

  1. Keep tasks idempotent — retries may re-execute a task.
  2. Keep them fast — delegate heavy work to queues or services.
  3. Set sensible timeouts — a stuck task holds a lock until TTL expires.
  4. Use withoutOverlapping for cron-heavy tasks — prevents pile-ups.
  5. Log meaningfully — include task name and run context in logs.
  6. Monitor success rates — anything below 90% deserves attention.
  7. Clean history regularly — run scheduler:clean on a schedule.
  8. Test your cron expressions — off-by-one errors cause silent misses.
  9. Bind task.scheduler.repository once — in your service provider.
  10. Use groups & priorities — reflect business importance, not just order.
  11. Prefer queue dispatch — the scheduler should be lightweight; workers do the heavy lifting.
  12. Always override failed() for critical tasks — alerts beat silent failures.

Clone this wiki locally