The php cron vs laravel scheduler question trips up almost everyone moving from plain PHP to Laravel, and the confusion comes from a false premise: that you have to pick one. You don't. The Laravel scheduler does not replace cron — it rides on top of a single cron entry. You register one crontab line that runs every minute, and from then on you define every recurring task in version-controlled PHP instead of editing crontab on the server. So the real decision is not 'cron or scheduler', it's 'which tasks belong in the scheduler and which still belong in raw crontab'. That's what I'll settle here.
What does the one crontab line actually do?
Every Laravel project that uses the scheduler needs exactly one entry in the system crontab. This is the only thing the operating system knows about. It fires every minute, hands control to Laravel, and Laravel decides what — if anything — is due to run right now.
* * * * * cd /var/www/your-app && php artisan schedule:run >> /dev/null 2>&1Read it left to right: the five asterisks mean 'every minute of every hour of every day'. Each minute the OS changes into your project directory and runs schedule:run. That command evaluates your defined schedule, runs only the tasks due at that minute, and exits. If nothing is due, it does nothing and returns instantly. The redirect at the end discards stdout and stderr so the box doesn't try to mail you cron output. That single line is the entire footprint of the scheduler on your server — you set it up once and never touch crontab again.
Where do I define the schedule in modern Laravel?
In Laravel 11 and 12 the schedule lives in routes/console.php (the old app/Console/Kernel.php schedule() method is gone in the slim skeleton). You import the Schedule facade and chain your frequency and guards onto each command or closure. This is the whole point of the scheduler: the schedule is code, it sits in your repo, it goes through code review, and it deploys with everything else. No more SSHing into a box to discover someone hand-edited a crontab entry six months ago.
<?php
use Illuminate\Support\Facades\Schedule;
// A console command, every night at 02:00
Schedule::command('reports:generate')
->dailyAt('02:00')
->withoutOverlapping()
->onOneServer();
// A closure, every five minutes, only in production
Schedule::call(function () {
// sync something here
})->everyFiveMinutes()
->environments('production');
// A shell command, weekdays during business hours
Schedule::exec('php /var/www/app/scripts/sync.php')
->weekdays()
->hourly()
->between('9:00', '17:00');Two methods on that first task are the ones I reach for in production every time. withoutOverlapping() stops a slow task from stacking on top of itself — if the 02:00 report is still running at 02:01, the scheduler skips the second invocation instead of running two copies that fight over the same rows. onOneServer() solves the multi-server problem: if you deploy the same code to three app servers, all three crontabs fire schedule:run, and without this guard the report generates three times. onOneServer() uses an atomic cache lock so exactly one server wins. It needs a shared lock store — Redis, Memcached, DynamoDB, or a database cache driver, not the per-box file or array driver — which is the gotcha that bites people who copy this blind.
How do I test and run the schedule without waiting a minute?
This is where the scheduler pulls decisively ahead of raw cron. Locally you don't want to install a system crontab at all. Instead run a foreground process that mimics what cron does:
# Run the scheduler in the foreground (Ctrl+C to stop) — local dev
php artisan schedule:work
# See exactly what is registered, with next run times
php artisan schedule:list
# Force a single evaluation pass right now (CI / debugging)
php artisan schedule:run
# Run one scheduled task interactively, ignoring its frequency
php artisan schedule:testschedule:work invokes schedule:run every minute exactly like cron would, so your local behaviour matches production. schedule:list is the command I run the moment a task 'didn't fire' — nine times out of ten the answer is a typo in the frequency or an environment guard that excluded the current environment. Try debugging that with a raw crontab and you're reading mail spools. Because every task is a console command or closure, you can also unit-test the logic directly; I keep that logic in service classes so it's testable in isolation, which I wrote about in building testable service classes in Laravel.
So when does raw cron still make sense?
The scheduler is not a universal replacement. Reach for a plain crontab entry when the task does not depend on a healthy Laravel boot:
- Non-PHP system tasks — log rotation with logrotate, certbot renewals, rsync backups, a Bash maintenance script. There's no reason to route these through artisan.
- Jobs that must run even if the app is broken. If a bad deploy or a fatal in routes/console.php means php artisan won't even boot, your scheduler is dead and so is everything riding on it. Critical database backups belong in their own independent crontab so they survive an app outage.
- OS-level or root-owned work that runs as a different user than your web/app user, where pulling it into the application context just adds coupling for no benefit.
- A task whose timing the dev team should not be able to change without infra review — keeping it in crontab puts it behind a server-access boundary on purpose.
The mental model I use: if the task is part of the application's domain (generating invoices, sending reminders, pruning records, calling an API the app owns), it goes in the scheduler so it's versioned and testable. If the task is part of keeping the box alive (backups, certs, the schedule:run line itself), it stays in raw cron so it's decoupled from the app's health. A good example of domain work that belongs in the scheduler is recurring financial automation, like the approach in scheduling automatic loan repayments in Laravel.
The Laravel scheduler doesn't replace cron — it replaces hand-editing crontab. You still need one cron line; everything else moves into your repo.
One thing the scheduler is not: a queue worker
The most expensive mistake I see is treating the scheduler like a job-processing system. It isn't. The scheduler answers 'what should start running at this time?' — it is a clock. A queue worker answers 'I have a backlog of jobs; process them as fast as I can'. If you put heavy work directly inside a scheduled closure, every long task blocks the next minute's evaluation and you'll hit the overlapping problem fast. The correct pattern is for the scheduled task to dispatch a job onto the queue and return immediately, letting a supervised worker do the heavy lifting. I cover that distinction and the Supervisor config in depth in running Laravel queue workers under Supervisor in production.
So stop framing it as php cron vs laravel scheduler. You always have cron — exactly one line of it — and you let the scheduler own everything that belongs to your application's domain because that code is reviewable, testable, and deploys with the rest of your work. Keep raw crontab for the handful of system-level jobs that must survive even when the app can't boot, dispatch the heavy work to queue workers instead of running it inside the scheduler, and make sure your lock store is shared before you trust onOneServer(). Get those three boundaries right and scheduled work stops being the fragile, undocumented corner of your infrastructure and becomes just another part of the codebase.

