Contents
Some tasks in a business happen on a clock. Invoices go out on the first of the month. A report hits the owner's inbox every Monday morning. Trial accounts that expired three days ago get a final reminder. Stale sessions get cleaned up overnight.
None of these should require a human to remember them. The Laravel scheduler exists to handle exactly this — recurring business logic that runs on its own, reliably, without a cron job graveyard you're afraid to touch.
How It Works
Laravel's scheduler lives in routes/console.php (or app/Console/Kernel.php in older apps). You define what runs and when. A single cron entry on your server runs php artisan schedule:run every minute, and Laravel figures out which commands are due.
That's the whole architecture. One cron entry. Everything else is in code.
Schedule::command('invoices:generate-monthly')
->monthlyOn(1, '8:00')
->withoutOverlapping()
->onFailure(function () {
Notification::route('mail', '[email protected]')
->notify(new ScheduledTaskFailed('invoices:generate-monthly'));
});
Schedule::command('reports:weekly-summary')
->weeklyOn(1, '7:00'); // Monday at 7am
Schedule::command('trials:send-expiry-reminders')
->dailyAt('9:00');
Plain English: the first block generates invoices on the 1st of every month at 8am, prevents it from running twice if a previous run is still going, and emails you if it fails. The second sends a weekly report every Monday morning. The third checks for expiring trials every day at 9am. All of it is defined in one file, under version control, readable by anyone on the team.
Why This Beats Raw Cron
The old way was a crontab full of entries like 0 8 1 * * /usr/bin/php /var/www/html/some-script.php. Nobody remembers what they do. They're not in version control. When the server gets rebuilt, half of them disappear and nobody notices for two weeks.
Laravel's scheduler puts all of that logic back inside your application:
- It's in version control, so changes get reviewed like any other code
- Failed tasks can trigger notifications, not silent data corruption
withoutOverlapping()prevents the same task from stacking up if it runs longrunInBackground()lets multiple tasks run in parallel without blocking each other- You can test commands locally with
php artisan schedule:test
Building the Commands
Each scheduled task usually maps to an Artisan command — a dedicated PHP class with a clear job. Here's a simplified version of what a monthly invoice generator looks like:
class GenerateMonthlyInvoices extends Command
{
protected $signature = 'invoices:generate-monthly';
protected $description = 'Generate invoices for all active subscriptions';
public function handle(InvoiceService $invoiceService): int
{
$count = $invoiceService->generateForActiveSubscriptions();
$this->info("Generated {$count} invoices.");
return Command::SUCCESS;
}
}
The command delegates the real work to a service class. The command is just the entry point — it could be triggered by the scheduler, by a developer running it manually, or even by a UI button down the road. That separation is what makes it maintainable.
The Business Case for Automation
Every task a human does on a schedule is a task that sometimes gets forgotten, done late, or done slightly differently each time. Automated tasks don't forget. They don't skip Friday because of a holiday. They run at 8am whether your team is in the office or not.
For clients I work with, the scheduler usually comes up in the context of three things: billing (generate and send invoices automatically), reporting (weekly summaries for stakeholders), and hygiene (archive old records, clear expired tokens, sync data with external services). In each case, the conversation is the same — you describe what should happen and when, and we turn that into code that runs itself.
That's time back in your team's day. Every month. Indefinitely.
If your business has recurring tasks that still depend on someone remembering to do them, let's talk. Automation is usually faster to set up than you think.