Configuring Task Scheduler (Cron-like scheduler) for Background Processes
System cron is a standard tool for periodic tasks, but has limitations: difficulty managing, no execution history, no error handling, doesn't work in containers without extra setup. Framework schedulers solve these problems by adding code-based management.
Laravel Task Scheduler
How it works: one cron entry calls the scheduler every minute, and it decides which tasks to run:
# /etc/cron.d/laravel
* * * * * www-data php /var/www/artisan schedule:run >> /dev/null 2>&1
All schedules are defined in routes/console.php (Laravel 9+) or app/Console/Kernel.php:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
// Artisan commands
Schedule::command('reports:daily')->dailyAt('02:00');
Schedule::command('sitemap:generate')->hourly();
Schedule::command('cache:clear-expired')->everyFifteenMinutes();
// Dispatch Queue Job
Schedule::job(new CleanupOldUploadsJob())->weekly()->sundays()->at('03:00');
Schedule::job(new SyncExchangeRatesJob(), 'high')->everyThirtyMinutes();
// Arbitrary code
Schedule::call(function () {
DB::table('sessions')->where('last_activity', '<', now()->subDays(30))->delete();
})->daily()->name('cleanup-sessions')->withoutOverlapping();
// Shell command
Schedule::exec('node scripts/process-queue.js')->everyFiveMinutes();
Important Modifiers
withoutOverlapping() — don't run task if previous execution hasn't completed. Critical for long-running tasks:
Schedule::command('import:products')
->hourly()
->withoutOverlapping(10); // lock for 10 minutes
runInBackground() — don't wait for command completion before next. Scheduler continues while task executes in separate process:
Schedule::command('reports:generate')->daily()->runInBackground();
onOneServer() — with multiple servers, execute task on only one. Requires cache driver with atomic lock support (Redis, Memcached):
Schedule::command('newsletter:send')
->dailyAt('09:00')
->onOneServer()
->withoutOverlapping();
between() — limit time range:
Schedule::command('process:orders')
->everyMinute()
->between('08:00', '22:00'); // only during business hours
when() / skip() — conditional execution:
Schedule::command('sync:users')
->hourly()
->skip(fn() => app()->isDownForMaintenance());
Storing Execution History
By default, Laravel doesn't store task history. Add via onSuccess/onFailure hooks:
Schedule::command('reports:daily')
->dailyAt('02:00')
->before(function () {
ScheduleLog::create([
'command' => 'reports:daily',
'status' => 'started',
'started_at'=> now(),
]);
})
->onSuccess(function (\Illuminate\Foundation\Bus\PendingDispatch $pending) {
ScheduleLog::where('command', 'reports:daily')
->latest()
->first()
?->update(['status' => 'success', 'finished_at' => now()]);
})
->onFailure(function () {
ScheduleLog::where('command', 'reports:daily')
->latest()
->first()
?->update(['status' => 'failed', 'finished_at' => now()]);
Http::post(config('services.slack.webhooks.alerts'), [
'text' => ":x: Scheduled task `reports:daily` failed",
]);
});
Or use spatie/laravel-schedule-monitor package, which does this automatically for all tasks and integrates with Oh Dear for external monitoring.
Monitoring via Healthcheck URL
Heartbeat pattern: on successful execution, task pings external service (Healthchecks.io, Better Uptime, Dead Man's Snitch). If ping doesn't arrive — service sends alert:
Schedule::command('backup:run')
->daily()
->onSuccess(function () {
Http::get('https://hc-ping.com/' . config('services.healthchecks.backup_uuid'));
})
->onFailure(function () {
Http::get('https://hc-ping.com/' . config('services.healthchecks.backup_uuid') . '/fail');
});
Dynamic Schedules from Database
Config schedules are static. If you need to manage schedules via interface (e.g., each client has their own report send time):
// routes/console.php
use App\Models\ScheduledTask;
ScheduledTask::where('is_active', true)->each(function (ScheduledTask $task) {
$event = Schedule::call(function () use ($task) {
dispatch(new DynamicScheduledJob($task->id));
})
->cron($task->cron_expression)
->name("dynamic-task-{$task->id}")
->withoutOverlapping();
if ($task->only_on_weekdays) {
$event->weekdays();
}
});
Table scheduled_tasks:
CREATE TABLE scheduled_tasks (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
cron_expression VARCHAR(100) NOT NULL, -- '0 9 * * 1-5'
job_class VARCHAR(500) NOT NULL,
payload JSONB,
is_active BOOLEAN DEFAULT true,
last_run_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
Node.js: node-cron / agenda
For Node.js services — node-cron (simple tasks) or agenda (with persistence in MongoDB):
// node-cron
import cron from 'node-cron';
cron.schedule('0 */2 * * *', async () => {
console.log('Running every 2 hours');
await syncExchangeRates();
}, {
scheduled: true,
timezone: 'Europe/Kiev',
});
// agenda with MongoDB — execution history out of the box
import Agenda from 'agenda';
const agenda = new Agenda({ db: { address: process.env.MONGODB_URI } });
agenda.define('send daily digest', async (job) => {
await sendDailyDigest(job.attrs.data.userId);
});
await agenda.start();
await agenda.every('24 hours', 'send daily digest', { userId: 123 });
Supervisor for Scheduler
In containerized environments (Docker), system cron may be unavailable or undesirable. Alternative — run schedule:work (available since Laravel 8):
php artisan schedule:work
This is a process that monitors the schedule itself without system cron. In Dockerfile:
CMD ["php", "artisan", "schedule:work"]
Or in Supervisor next to queue worker:
[program:scheduler]
command=php /var/www/artisan schedule:work
autostart=true
autorestart=true
user=www-data
stdout_logfile=/var/log/scheduler.log
Timeline
Converting existing cron tasks to Laravel Scheduler, basic modifiers — 2–3 hours. History storage, alerting, healthcheck integration — another 3–4 hours. Dynamic schedules from database — separately, 5–7 hours.







