An invoice reminder that should go out at 8 a.m. never arrives, and nobody notices until a customer complains. Or the daily report lands twice because two runs overlapped. These problems almost always come from scheduled work that is neither organized nor monitored.
Laravel's task scheduler moves every schedule into your codebase and needs just one cron entry on the server. This guide covers the scheduler in Laravel 11: how it works, writing scheduled commands, time zones, overlap protection, logging, server setup and debugging schedules that do not run.
1. How the Scheduler Works
Cron calls php artisan schedule:run every minute. That command reads your schedule definitions, works out which are due this minute and runs them.
| Aspect | Many manual cron entries | Laravel scheduler |
|---|---|---|
| Where schedules live | Each server's crontab | routes/console.php in Git |
| Preventing double runs | Write your own locks | withoutOverlapping() |
| Per-task time zone | Server time only | timezone() per task |
| Logs and failure alerts | Manual output redirects | appendOutputTo(), onFailure() |
| Listing schedules | crontab -l | php artisan schedule:list |
2. A Command for Recurring Work
Closures can be scheduled, but artisan commands are easier to test and run by hand.
php artisan make:command SendInvoiceReminders
class SendInvoiceReminders extends Command
{
protected $signature = 'invoices:remind {--days=3 : Days before the due date}';
protected $description = 'Send reminders for invoices that are about to fall due';
public function handle(): int
{
$target = today()->addDays((int) $this->option('days'));
$count = 0;
Invoice::where('status', 'unpaid')
->whereDate('due_date', $target)
->whereNull('reminded_at')
->with('customer')
->chunkById(200, function ($invoices) use (&$count) {
foreach ($invoices as $invoice) {
$invoice->customer->notify(new InvoiceDueSoon($invoice));
$invoice->update(['reminded_at' => now()]);
$count++;
}
});
$this->info("Reminders sent: {$count}");
return self::SUCCESS;
}
}
The reminded_at column makes the command idempotent: if it accidentally runs twice, nobody is emailed twice. Any scheduled task that sends messages or touches money should work this way.
3. Registering Schedules
New Laravel 11 projects have no console kernel. Schedules go in routes/console.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('invoices:remind --days=3')
->dailyAt('08:00')
->timezone('America/New_York')
->withoutOverlapping()
->appendOutputTo(storage_path('logs/schedule-invoices.log'));
Schedule::command('reports:daily')
->weekdays()
->at('07:00')
->onFailure(fn () => logger()->error('reports:daily failed'));
Schedule::command('model:prune')->daily();
Schedule::command('queue:prune-failed --hours=168')->weekly();
Handy Frequencies
->everyFiveMinutes();
->hourlyAt(15);
->weekdays()->at('09:00');
->weeklyOn(1, '06:00'); // Monday 06:00
->monthlyOn(1, '00:30');
->cron('0 */2 * * *');
->environments(['production']);
4. Time Zones
Servers usually run on UTC. A task written as dailyAt('08:00') in a UTC app runs at 8 a.m. UTC, which may be the middle of the night for your customers. Either set APP_TIMEZONE for the whole app, or keep the app on UTC and add ->timezone() per task. The second option is safer when you serve several regions.
5. Installing the Cron Entry
- VPS: run
crontab -eas the user that owns the project and add the line below. - Control panels such as cPanel or Plesk: add a cron job set to every minute with the full PHP path.
- Make sure the CLI PHP version matches the web PHP version.
* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1
Locally, skip cron and run php artisan schedule:work.
6. Overlaps and Slow Tasks
withoutOverlapping()skips a run if the previous one is still going. The lock expires after 24 hours by default; pass minutes, such aswithoutOverlapping(30), to shorten it.runInBackground()stops a slow task from delaying others due the same minute.onOneServer()runs a task once across several servers, using a shared cache store.- Queue heavy work: let the command dispatch jobs and let workers do the lifting.
On hosts that do not allow a permanent worker process, the scheduler can drain the queue:
Schedule::command('queue:work --stop-when-empty --max-time=50')
->everyMinute()
->withoutOverlapping();
7. Debugging a Schedule That Does Not Run
php artisan schedule:listshows each task and its next run. Missing task means a problem inroutes/console.php.php artisan schedule:testruns one schedule on demand.- Run the command directly to see its error output.
- Temporarily send cron output to a log file instead of
/dev/nullto confirm cron calls Laravel at all. - Clear stuck overlap locks with
php artisan schedule:clear-cache.
8. Common Mistakes
- Tasks run at the wrong hour: server and app time zones differ.
- "Could not open input file: artisan": cron is not in the project folder. Add
cd /path &&. - Log files cannot be written: cron runs as a different user from the web server.
- Duplicate emails: the command is not idempotent.
Production Checklist
- One cron entry per server, using the correct PHP binary.
- Every task has an explicit time zone.
- Tasks that send messages or change data are idempotent.
- Long tasks use
withoutOverlapping()or a queue. - Output is logged and failures trigger an alert.
schedule:listis checked after each deploy.
For long-running work, read Laravel queues and jobs, and for setting up cron on a budget host, see deploying Laravel to shared hosting.