9 min read
Head of Engineering

We build and ship Laravel applications in production as an official Laravel Partner, and almost every one of them runs scheduled work, pruning expired invitations, syncing third-party integrations, rotating API keys and sending reminder batches. In our internal automation platform, the scheduler quietly rotates GitHub repository API keys every hour, and prunes expired Sanctum tokens every day, defined in a single `routes/console.php` file and triggered by exactly one cron line.
This guide shows you the modern, Laravel 12 way to do that: define your schedule in code, add one cron entry, and run it reliably. It also covers the parts most tutorials skip: what to do when `schedule:run` silently does nothing, how to stop a task running twice across multiple servers, and how to schedule work more often than once a minute.
A Laravel cron job is a task you define inside your application using Laravel's scheduler, triggered by a single system cron entry, instead of writing a separate crontab line for every task. With a traditional server cron, each job is its own crontab entry that lives outside version control and requires SSH access to view or change. Laravel inverts that: your entire schedule lives in routes/console.php, source control, reviewed like any other code, and the operating system's cron only ever calls one command - schedule:run - once a minute.
That's the whole value proposition: one cron entry, an unlimited schedule defined in PHP.
In Laravel 11 and 12, you define scheduled tasks in routes/console.php using the Schedule facade - the old app/Console/Kernel.php schedule() method was removed when Laravel 11 slimmed down its application skeleton. If a tutorial still tells you to editKernel.php, it's out of date.
Here's a closure scheduled to run daily at midnight:
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schedule;
Schedule::call(function () {
DB::table('recent_users')->delete();
})->daily();
You can schedule four kinds of work:
An Artisan command - the most common pattern in real apps:
use App\Console\Commands\SendEmailsCommand;
use Illuminate\Support\Facades\Schedule;
Schedule::command('emails:send Taylor --force')->daily();
// Or reference the class directly:
Schedule::command(SendEmailsCommand::class, ['Taylor', '--force'])->daily();
A queued job - dispatches onto your queue instead of running inline:
use App\Jobs\Heartbeat;
use Illuminate\Support\Facades\Schedule;
Schedule::job(new Heartbeat)->everyFiveMinutes();
A shell command - for anything outside PHP:
Schedule::exec('node /home/forge/script.js')->daily();
A closure - for quick, one-off logic, as shown above.
To see everything you've scheduled and when it next runs:
php artisan schedule:list
Add exactly one cron entry to your server that runs schedule:run every minute - that single line powers your entire Laravel schedule. Open your crontab (crontab -e) and add:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
This is the one line every Laravel app needs, and it never changes no matter how many tasks you add.
Production gotcha we hit repeatedly: on a real server, php on cron's bare PATH is often the wrong binary or missing entirely, and the crontab may belong to the wrong user. Use absolute paths and the right user. In practice we run it closer to this:
* * * * * cd /var/www/app && /usr/bin/php8.3 artisan schedule:run >> /dev/null 2>&1
If you're on Laravel Cloud or Laravel Forge, you don't touch crontab at all, the platform manages the scheduler for you.
If schedule:run works when you run it by hand but nothing fires from cron, the cause is almost always the cron entry itself - wrong PHP path, wrong user, or a timezone mismatch - not your scheduled code. Work through these in order.
root while the app runs as www-data. Run crontab -l as the app user.php with the full binary path (e.g. /usr/bin/php8.3) and use the project's absolute path after cd.schedule:run, not schedule run. A missing colon fails silently.config/app.php timezone disagree (see the next section).storage/logs/laravel.log and the system cron log (/var/log/syslog or grep CRON /var/log/syslog) for what cron actually attempted.php artisan schedule:run manually and php artisan schedule:list to confirm the tasks are registered and "due" when you expect.If it runs manually but never from cron, the problem is in the crontab line. If it doesn't run manually either, the problem is in your task definition.
Set a task's timezone with->timezone(), or set one globally with the schedule_timezone config option, but be aware that daylight-saving changes can make a task run twice or skip a day.
use Illuminate\Support\Facades\Schedule;
Schedule::command('report:generate')
->timezone('America/New_York')
->at('2:00');
To apply one timezone to every task, add it to your app config:
'timezone' => 'UTC',
'schedule_timezone' => 'America/Chicago',
Laravel's own docs recommend avoiding timezone scheduling where you can, precisely because of the DST double-run/skip risk. Keeping the server and app on UTC and scheduling in UTC is the safest default.
Use withoutOverlapping() to stop a slow task colliding with its next run, and onOneServer() to stop the same task firing on every server in a multi-server deployment. These two methods are what separate a hobby schedule from a production one.
// Won't start a new run while the previous one is still going:
Schedule::command('emails:send')->withoutOverlapping();
// Runs on only ONE server even if the scheduler is deployed to several:
Schedule::command('report:generate')
->fridays()
->at('17:00')
->onOneServer();
This is exactly the pattern we run in production. In one of our platforms, the daily and hourly maintenance commands are defined like this, background execution so they don't block each other, and single-server locking so a multi-instance deployment never double-processes:
Schedule::command(DeleteArchivedWorkspaces::class)
->daily()
->runInBackground()
->onOneServer();
Schedule::command(SyncSlackIntegrations::class, ['--mode' => 'hourly'])
->hourly()
->runInBackground()
->onOneServer();
runInBackground() lets simultaneously-scheduled tasks run in parallel instead of queuing up sequentially, and it only works with the command and exec methods.
Yes - Laravel supports sub-minute scheduling down toeverySecond(), even though system cron only fires once a minute. When you define a sub-minute task, schedule:run stays alive for the full minute and dispatches the task at the right sub-minute intervals.
Available intervals include everySecond(), everyTwoSeconds(), everyFiveSeconds(), everyTenSeconds(), everyFifteenSeconds(), everyThirtySeconds(), everyMinute(), everyFiveMinutes(), hourly(), daily(), weekly(), monthly(), quarterly(), and yearly(), plus fine-grained variants like dailyAt('13:00'), weeklyOn(1, '8:00'), and constraints like weekdays(), between('7:00', '22:00'), and environments(['production']).
Important caveat: because schedule:run holds the process open for the whole minute when sub-minute tasks exist, a redeploy can leave an old process running old code. Add php artisan schedule:interrupt to your deploy script to kill it cleanly. And for anything heavier than a trivial query, have your sub-minute task dispatch a queued job rather than doing the work inline.
There are many more frequency options available in Laravel’s task scheduler that could meet your specific requirements. The Laravel documentation on task scheduling provides more details and a list of available options.
Don't add a crontab entry on your dev machine; run php artisan schedule:work instead. It runs in the foreground and invokes the scheduler every minute (and handles sub-minute tasks) until you stop it.
php artisan schedule:work
Use sendOutputTo()/appendOutputTo() to log output to a file, emailOutputOnFailure() to be emailed only on failures, and the onSuccess()/onFailure() hooks to run your own code.
Schedule::command('report:generate')
->daily()
->appendOutputTo($filePath)
->emailOutputOnFailure('ops@example.com')
->onFailure(function () {
// Alert Slack, increment a metric, etc.
});
For external monitoring, pingBefore($url) and thenPing($url) let a service like a dead-man's-switch monitor confirm the task actually ran. This is how you find out a nightly job has been silently failing before your users do.
That’s all for now, folks - keep pushing forward! And if you ever need a hand with Laravel development, just know we’re here to help. Be sure to check out our dedicated page for more details.
In routes/console.php using the Schedule facade. The old app/Console/Kernel.php approach was removed in Laravel 11.
Exactly one: * * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
The scheduler decides when something runs; queues handle background processing of work. They're complementary; a scheduled task often dispatches a queued job.
Almost always a timezone mismatch between the server and config/app.php, or DST. Standardise on UTC.
Add ->onOneServer() (with a shared cache driver) so only the first server acquires the lock.
Nika is Engineering Director at Redberry, where he leads delivery across complex Laravel, Vue.js and React products for clients in fintech, wealth management, e-commerce, and SaaS. Over a decade of building large-scale Laravel applications, he has worked across multi-tenant architectures, real-time systems, third-party integrations, DevOps automation, and high-availability infrastructure.
He is the creator and maintainer of several open-source Laravel packages: Mailbox for Laravel, MCP Client for Laravel, and Laravel Packager - and co-organized Georgia's first official Laravel Meetup.
Last updated on by

We hosted the first official Laravel Meetup in Georgia, bringing together more than 100 attendees for an evening dedicated to Laravel, engineering, and community.
At our latest RDBR Meetup, we looked inside our two-year partnership with Tavistock Protect and the product we have been building together: PP Mobius.

We are a 200+ people agency and provide product design, software development, and creative growth marketing services to companies ranging from fresh startups to established enterprises. Our work has earned us 100+ international awards, partnerships with Laravel, Vue, Meta, and Google, and the title of Georgia’s agency of the year in 2019 and 2021.

