Task Scheduling in Laravel
How I use Laravel's task scheduler to manage multiple scheduled jobs from a single cron entry, keeping all scheduling logic readable and version-controlled inside the codebase.
Routine jobs are unavoidable in any application: sending daily report emails, clearing expired sessions, polling an API at regular intervals. Managing these through the server’s crontab is technically possible, but every time you add a new job you have to SSH in, edit the crontab, and hope nothing breaks — it’s tedious, error-prone, and hard to track. Laravel’s task scheduler moves all of that into the codebase.
How it works
The idea is simple: you add exactly one cron entry to the server. That entry fires every minute and kicks off Laravel’s scheduler. Which jobs run and when is determined entirely in code, inside the schedule method of app/Console/Kernel.php.
The single cron line you add to the server:
* * * * * cd /path/to/your/project && php artisan schedule:run >> /dev/null 2>&1
That’s it. All scheduling logic now lives inside the application, tracked by version control, and readable by anyone on the team.
Filling in the schedule method
The schedule method in app/Console/Kernel.php is where you define your jobs:
<?php
protected function schedule(Schedule $schedule)
{
// Send the daily report every morning at 08:00
$schedule->command('reports:send-daily')
->dailyAt('08:00');
// Clear expired tokens every hour
$schedule->command('auth:clear-expired-tokens')
->hourly();
// Calculate database statistics every Monday at 02:00
$schedule->command('stats:calculate')
->weekly()
->mondays()
->at('02:00');
}
Each line is self-explanatory. You can see exactly what runs and when without touching the server or memorising cron syntax.
Creating an Artisan command
Scheduled jobs are usually wired up to Artisan commands. To generate one:
php artisan make:command SendDailyReport
Then add the business logic to the generated app/Console/Commands/SendDailyReport.php:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class SendDailyReport extends Command
{
protected $signature = 'reports:send-daily';
protected $description = 'Sends the daily summary report to administrators';
public function handle()
{
$report = app(ReportService::class)->buildDaily();
app(ReportMailer::class)->send($report);
$this->info('Daily report sent.');
}
}
You also need to register the command in the commands array inside app/Console/Kernel.php:
<?php
protected $commands = [
Commands\SendDailyReport::class,
];
Quick job definitions with closures
If you don’t want to create a separate command class, you can define simple jobs as closures directly inside schedule:
<?php
$schedule->call(function () {
DB::table('temp_sessions')->where('created_at', '<', now()->subDay())->delete();
})->daily();
For small cleanup tasks this is perfectly fine — no need to open a new file. But for anything with more complex logic, an Artisan command stays cleaner.
One caveat: closure-based jobs don’t show up well in php artisan schedule:list — they have no signature, so they just appear as “Closure”. For that reason I keep closure jobs to a minimum on multi-developer projects; I want anyone glancing at the list to immediately understand what each job does.
Frequency helpers
Laravel’s frequency helpers cover a wide range of scheduling needs. The ones I reach for most often:
->everyMinute()— every minute->everyFiveMinutes()— every five minutes->hourly()— at the top of every hour->daily()— every day at midnight->dailyAt('13:00')— every day at a specific time->weekly()— every Sunday at midnight->monthly()— on the first day of every month->cron('0 8 * * 1-5')— raw cron expression
Raw cron expression support is there when the standard helpers don’t cover your use case — ->cron() gives you full control.
Preventing overlapping runs
If a job hasn’t finished by the time its next scheduled run arrives, two instances can end up running simultaneously. When that’s undesirable, withoutOverlapping() is the answer:
<?php
$schedule->command('import:process-queue')
->everyFiveMinutes()
->withoutOverlapping();
With this in place, Laravel won’t start a new run if the previous one is still going.
Worth noting: withoutOverlapping() uses Laravel’s cache to manage the lock. If your cache driver isn’t configured correctly, or if your application runs across multiple servers without a shared cache, this mechanism won’t behave as expected. In multi-server environments you need a shared cache driver — Redis being the obvious choice.
Logging job output
When a job fails or you just want to confirm it ran, redirecting its output to a file is the quickest way to check:
<?php
$schedule->command('reports:send-daily')
->dailyAt('08:00')
->appendOutputTo(storage_path('logs/scheduler.log'));
Wrapping up
When I first set up task scheduling I found myself thinking, “why would I change my crontab setup?” A few jobs later the difference became obvious: all scheduling logic is in the codebase, committed to Git, and any developer can see exactly what’s running without ever needing server access. There’s one line on the server; everything else lives in Kernel.php.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.