Repository navigation
Task Scheduling
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.
| 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 |
- PHP 8.4 or higher
- Machinjiri Framework (Container, Cache, Queue, Events)
-
dragonmantank/cron-expressionlibrary
composer require dragonmantank/cron-expressioncomposer require dragonmantank/cron-expressionRun 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
);use Mlangeni\Machinjiri\Core\Components\Task\Repositories\DatabaseScheduleRepository;
$app->bind('task.scheduler.repository', function ($app) {
return new DatabaseScheduleRepository($app);
});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',
];Add a cron job that runs the scheduler every minute:
* * * * * cd /path/to/your/app && php artisan scheduler:run >> /dev/null 2>&1Tip: 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.
php artisan scheduler:create-task SendDailyReport --register --cron "0 8 * * *"This creates app/Scheduler/Tasks/SendDailyReportTask.php and registers it.
<?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;
}
}# 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=1registerTask() ──► sync() ──► DB row created ──► next_run calculated
│
▼
┌────────────────┐
│ Cron triggers │
│ scheduler:run │
└────────────────┘
│
▼
getDueTasks() ──► processTask()
│
┌──────────────────────────┼──────────────────────────┐
▼ ▼ ▼
acquireLock() dispatch job updateNextRun()
│ │ │
▼ ▼ ▼
overlap? skip ExecuteScheduledTaskJob recordExecution()
│
▼
task->handle()
| 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 |
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',
];<?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
}
}# 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 * * *"| 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.
# 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 --forceuse 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();* * * * * 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.
| 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 |
# 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=90Tasks 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=reportsScheduler skips tasks during maintenance unless the task opts in:
protected bool $runInMaintenanceMode = true;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;For ad-hoc runs (e.g., from an admin panel):
$taskManager->runNow($taskId);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.
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 |
Warm the cache after deployments or task changes:
php artisan scheduler:cache-preloadProgrammatically:
$scheduler->preloadCache(); // Scheduler state
$taskManager->preloadCache(); // Task manager statephp artisan scheduler:cache-clearOr:
$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.
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());
});| Condition | Status |
|---|---|
| Repository fails | critical |
| > 100 due tasks | warning |
| > 10 active locks | degraded |
| Otherwise | healthy |
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));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']}");
});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',
];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()}"
);
}Failed executions throw TaskSchedulerException, wrapping the original error with task context (task_class, attempt).
use App\Scheduler\Tasks\CleanupTempFilesTask;
public function test_cleanup_removes_temp_files()
{
$task = new CleanupTempFilesTask($this->app);
$this->assertTrue($task->handle());
}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']);
}$this->app->bind(CacheManager::class, fn() => new InMemoryCacheManager());| 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
|
-
Enable debug logging — the component logs to
schedulerandtaskchannels. -
Check execution history:
php artisan scheduler:stats <id>
-
Inspect cache keys — list keys prefixed
scheduler:in your cache store. -
Verify locks — check
getStatus()['active_locks']. Stale locks eventually expire via TTL. - Test cron syntax — use crontab.guru.
| 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 |
| 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 |
| 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 |
| Method | Description |
|---|---|
check(): array |
Summary health check |
getDetailedReport(): array |
Per-task detailed report |
- Keep tasks idempotent — retries may re-execute a task.
- Keep them fast — delegate heavy work to queues or services.
- Set sensible timeouts — a stuck task holds a lock until TTL expires.
-
Use
withoutOverlappingfor cron-heavy tasks — prevents pile-ups. - Log meaningfully — include task name and run context in logs.
- Monitor success rates — anything below 90% deserves attention.
-
Clean history regularly — run
scheduler:cleanon a schedule. - Test your cron expressions — off-by-one errors cause silent misses.
-
Bind
task.scheduler.repositoryonce — in your service provider. - Use groups & priorities — reflect business importance, not just order.
- Prefer queue dispatch — the scheduler should be lightweight; workers do the heavy lifting.
-
Always override
failed()for critical tasks — alerts beat silent failures.
Machinjiri Docs
- Home
- Learn
- Build
-
Tutorials
- Routing
- Controllers and Middleware
- Views and Templates
- Database ORM
- Eloquent Database ORM
- Errors and Exceptions
- Forms and Validation
- Http
- Events and Logging
- Services and DI
- Security
- Authentication
- Caching
- File System
- Date and Time
- Debugging
- Queue and Jobs
- Task Scheduling
- Transport (Mail, SMS)
- LDAP
- Notifications
- Webhooks
- UUID Generations
- Terminal
- Helper Function
- Reference