
September 07, 2026
14 min read
By Kokil Thapa | Last reviewed: September 2026
A Laravel app that sends reminders, cleans temp files, or syncs inventory only works in production if the scheduler actually runs. Laravel scheduled tasks production setup is not about sprinkling php artisan schedule:run into random crontab lines—it is one reliable cron entry, a disciplined routes/console.php or app/Console/Kernel.php definition, queue workers for heavy jobs, and logging you can audit when something silently stops. I've maintained this pattern across multiple Laravel production deployments in Nepal and on shared EC2 infrastructure where a stale cron path after Deployer symlink swaps caused missed nightly backups. This guide walks through the full production stack for Laravel 12 and 13 on Ubuntu with PHP 8.3 or higher.
php artisan schedule:run every minute from your app's current release path, with tasks defined in Laravel's scheduler using withoutOverlapping(), proper logging, and separate queue workers for long-running jobs.How does Laravel task scheduling work in production?
Laravel's scheduler is a PHP-based cron manager. You register tasks in application code; the framework decides which ones are due each minute. The server runs one cron job that invokes the scheduler—Laravel handles the rest. That design replaces dozens of fragile per-task crontab entries with a single entry point you can version-control and test.
On a production Laravel application, three layers cooperate:
- System cron — fires
schedule:runevery minute as the web server user. - Laravel scheduler — evaluates due tasks, dispatches jobs or Artisan commands.
- Queue workers — execute long or asynchronous work dispatched from scheduled closures and commands.
This separation matters. A nightly report that takes four minutes should not block the next minute's scheduler tick. Dispatch it to a queue. A legal-tech portal I built runs document-expiry reminders and cleanup jobs on this model—scheduler triggers, queue processes, logs confirm delivery.
For background on replacing scattered crontab lines with Laravel's scheduler, see the companion article on replacing messy crontabs with Laravel task scheduling. If you are still defining schedules in the legacy Kernel class on Laravel 11 or 12, the concepts below apply—the file location changed in Laravel 11, but production behaviour is identical.
How do you configure the Laravel scheduler cron entry on Ubuntu?
The most common production failure I see is a cron entry pointing at the wrong path after a Deployer symlink swap, or running as root while the app expects the www-data user. Fix both before writing a single scheduled task.
Step 1: Identify the correct application path
On Deployer-managed servers, the live app lives at /var/www/example.com/current, not inside a dated release folder. Cron must target current so deploys do not break scheduling. For a flat deployment at /var/www/myapp, use that path directly. Document the path in your runbook alongside your Ubuntu server setup notes.
Step 2: Add the single cron entry
Edit the crontab for the user that owns application files—typically www-data on Apache/PHP-FPM setups:
sudo crontab -u www-data -e Add this line, adjusting paths and PHP binary:
* * * * * cd /var/www/example.com/current && /usr/bin/php artisan schedule:run >> /dev/null 2>&1 Important details:
- Use the full PHP path. Cron environments are minimal.
/usr/bin/phpor/usr/bin/php8.3avoids "command not found" when multiple PHP versions coexist—a pattern I use on servers running PHP 8.3 and 8.5 side by side. - Change directory first. The
cdensures.env,storage/, and autoload paths resolve correctly. - Run as the web user. Scheduled tasks that write to
storage/logsorstorage/frameworkneed matching ownership. - One entry only. Never add separate cron lines per Artisan command. Laravel's scheduler is the single gate.
Step 3: Verify cron is firing
Temporarily log scheduler output:
* * * * * cd /var/www/example.com/current && /usr/bin/php artisan schedule:run >> /var/www/example.com/shared/storage/logs/scheduler.log 2>&1 Wait two minutes, then inspect the log. You should see Laravel evaluating tasks even when none are due. Remove verbose logging once confirmed, or keep it on a managed Linux server where disk space is monitored.
Official reference: the Laravel scheduling documentation describes the cron entry and server configuration options.
How should you define scheduled tasks in Laravel 12 and 13?
In Laravel 11 and later, schedules live in routes/console.php. Laravel 12 and 13 follow the same pattern. Keep definitions readable, idempotent, and defensive against overlap.
Basic schedule definitions
<?php
use Illuminate\Support\Facades\Schedule;
use App\Jobs\SendDailyReport;
use App\Jobs\PurgeExpiredSessions;
Schedule::command('inspire')->hourly();
Schedule::job(new SendDailyReport)
->dailyAt('06:00')
->timezone('Asia/Kathmandu')
->withoutOverlapping(30)
->onOneServer()
->appendOutputTo(storage_path('logs/daily-report.log'));
Schedule::call(function () {
// Lightweight inline work only
})->weeklyOn(0, '03:00');
Schedule::command('model:prune')
->daily()
->runInBackground(); Each modifier serves a production purpose:
withoutOverlapping()— prevents a slow run from stacking. Pass lock expiry minutes so a crashed job does not block forever.onOneServer()— required when multiple app servers share one database and Redis. Uses atomic locks so only one node runs the task.timezone()— critical for Nepal-based apps usingAsia/Kathmandu. Server UTC vs app timezone mismatches cause tasks to fire at wrong local hours.runInBackground()— spawns subprocess for long Artisan commands soschedule:runreturns quickly.appendOutputTo()— writes command stdout to a dedicated log file for debugging.
Dispatch heavy work to queues
Scheduled closures that send hundreds of emails, regenerate sitemaps, or sync with third-party APIs belong in queued jobs. On a Laravel booking system with supplier CRM sync, nightly inventory pulls run as queued jobs triggered by the scheduler—not inline PHP that blocks the minute tick.
Schedule::job(new SyncSupplierInventory)
->dailyAt('02:00')
->withoutOverlapping(60)
->onOneServer(); Ensure queue workers run under Supervisor or systemd. The scheduler dispatches; workers execute. Missing workers mean silently growing queue depth—a problem I catch by monitoring failed jobs and queue size, not by assuming cron success means job completion.
Environment-aware scheduling
Schedule::command('analytics:aggregate')
->daily()
->environments(['production']);
Schedule::command('telescope:prune')
->daily()
->environments(['local', 'staging']); Never run destructive cleanup or test data seeders in production because someone copied a schedule block without environments() guards.
How do queue workers and Supervisor fit into scheduler production setup?
Scheduled tasks and queue workers are siblings, not substitutes. If your schedule dispatches jobs to Redis or database queues, production needs persistent workers.
Supervisor configuration for queue workers
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=/usr/bin/php /var/www/example.com/current/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/example.com/shared/storage/logs/worker.log
stopwaitsecs=3600 After each Deployer release, reload workers so they pick up new code:
php artisan queue:restart Add that to your deploy hook. Stale workers running old job classes cause class-not-found errors that look like application bugs. On sister sites sharing a GitLab CI + Deployer pipeline, this restart step is non-negotiable—same as PHP-FPM reload for opcache.
Redis as the production queue backend
Redis 8.10 is a solid default for Laravel queues: fast, supports onOneServer() locks, and handles scheduler mutexes. Database queues work for low-volume apps but add load to MySQL 9.7 or PostgreSQL 18 under burst conditions. Match your queue driver in .env:
QUEUE_CONNECTION=redis
REDIS_CLIENT=phpredis For architecture context, see modern Laravel architecture best practices and PostgreSQL for Laravel developers when your scheduled reports hit read replicas.
| Component | Role in production | Typical failure | Fix |
|---|---|---|---|
| System cron | Triggers schedule:run every minute | Wrong path after deploy | Point at current/ symlink |
| Laravel scheduler | Evaluates due tasks, acquires locks | Timezone mismatch | Set timezone() explicitly |
| Queue worker | Executes dispatched jobs | Worker not restarted post-deploy | queue:restart in deploy hook |
| Redis | Queue + mutex storage | Connection refused | Check REDIS_HOST, firewall |
| Supervisor | Keeps workers alive | Wrong user permissions | Run as www-data |
How do you monitor and debug Laravel scheduled tasks in production?
Silent failure is the scheduler's default mode. A missed cron entry or expired mutex lock can stop payment reconciliation or backup rotation for weeks before anyone notices. Build observability into your Laravel scheduled tasks production setup from day one.
Test schedules locally and on staging
php artisan schedule:list
php artisan schedule:test
php artisan schedule:run --verbose schedule:list shows every registered task, its cron expression, and next run time. schedule:test interactively fires a single task—useful before pushing a new nightly job. On staging, run the same cron entry as production for at least 24 hours before go-live.
Log and alert on failures
Schedule::job(new ProcessPendingPayments)
->everyFiveMinutes()
->withoutOverlapping(10)
->onFailure(function () {
Log::critical('Payment processing schedule failed');
}); Pipe critical logs to your monitoring stack. For eCommerce apps with Laravel payment integrations, a failed reconciliation job is a revenue issue, not a log noise issue. I've seen Khalti and Stripe callback retries depend on scheduled cleanup—if the scheduler stops, pending orders accumulate.
Health checks and external monitoring
Register a lightweight scheduled heartbeat that writes a timestamp to cache or database. An external uptime monitor—or a simple cron on another server—checks that timestamp is fresh. If the heartbeat is older than five minutes, cron or PHP is broken independent of application logic.
Schedule::call(function () {
Cache::put('scheduler:heartbeat', now(), 600);
})->everyMinute(); For JSON log inspection during debugging, a JSON formatter tool helps parse structured scheduler output if you ship logs to a central aggregator.
Mutex and overlap troubleshooting
When a task never runs despite appearing in schedule:list, check for stale mutex locks in Redis or cache. A killed job mid-run can leave a lock until expiry. Set reasonable withoutOverlapping($minutes) values—60 for hourly syncs, 10 for five-minute tasks. Use schedule:clear-cache during incident response to release stuck locks.
What are common Laravel scheduler mistakes in production?
These recur across client projects regardless of framework version. Avoid them during initial setup rather than debugging at 2 AM when backups stop.
Multiple cron entries for individual commands
Defeats Laravel's overlap protection and timezone handling. One schedule:run entry, all tasks in code. If you inherit a server with fifteen cron lines calling Artisan directly, consolidate during the next maintenance window with ongoing Laravel support.
Running scheduler as root
Root-owned log files and cache entries break web requests. Always use the PHP-FPM user. Permission errors after scheduled file cleanup often trace back to root cron.
Forgetting queue workers on containerised deploys
Docker Compose setups frequently run the web container but omit a worker service. If you containerise Laravel, define separate services for queue:work and optionally schedule:work (Laravel 11+). See Docker production multi-stage builds for Laravel for service topology.
No overlap protection on slow tasks
A report job that occasionally takes 90 seconds will stack three concurrent runs without withoutOverlapping(), hammering the database and API rate limits. Always protect tasks longer than your interval.
Hard-coded production paths in scheduled closures
Use Laravel helpers—storage_path(), base_path()—not absolute server paths. Deployer symlinks exist precisely so paths stay stable.
Skipping timezone configuration
Apps serving Nepal users with BS-calendar-aware reminders must set APP_TIMEZONE=Asia/Kathmandu in .env and explicit timezone() on critical schedules. A Nepali date converter helps verify local-time output against expected BS dates during QA.
The PHP manual's DateTime documentation covers timezone identifiers if you need to cross-check Asia/Kathmandu behaviour across PHP 8.3 and 8.5.
Key Takeaways
- Run exactly one cron entry per app:
* * * * * cd /path/to/current && php artisan schedule:runas the web server user with the full PHP binary path. - Define all tasks in
routes/console.phpwithwithoutOverlapping(),onOneServer()for multi-node setups, and explicittimezone('Asia/Kathmandu')where local timing matters. - Dispatch heavy or slow work to queued jobs; keep Supervisor-managed workers running and restart them on every deploy.
- Log per-task output with
appendOutputTo(), register a heartbeat, and alert when the scheduler stops firing. - After Deployer symlink swaps, confirm cron still targets
current/—stale release paths are the most common silent failure I've seen in production. - Test with
schedule:list,schedule:test, and 24 hours on staging before trusting a new nightly job in production.
People Also Ask
Does Laravel scheduler need cron running every minute?
Yes. Laravel's scheduler is designed around a single cron entry that executes schedule:run once per minute. The framework internally determines which tasks are due based on their cron expressions. Running cron less frequently means sub-hourly tasks will be missed entirely.
What is the difference between schedule:run and schedule:work?
schedule:run evaluates due tasks and exits—intended for system cron invocation every minute. schedule:work (available since Laravel 8) runs a long-lived process that invokes the scheduler every minute internally. It suits development or container environments where system cron is unavailable, but most Ubuntu production servers use cron + schedule:run because it survives process restarts cleanly under Supervisor if needed.
Can Laravel scheduler run without queue workers?
Yes, if every scheduled task is a fast inline Artisan command or closure completing within seconds. The moment you use Schedule::job() or dispatch to queues inside a scheduled task, workers must be running or jobs pile up unprocessed. Most production apps need both scheduler cron and queue workers.
How do you prevent duplicate scheduled task runs on multiple servers?
Add onOneServer() to scheduled tasks and configure a shared cache or Redis backend for mutex locks. Without it, each application server runs the same task independently—duplicate emails, double charges, or conflicting API writes follow. Redis 8.10 with the phpredis client is the standard production pairing for Laravel mutex support.
Ship reliable scheduled tasks on your next deploy
Production scheduling is boring infrastructure until it breaks—then missed invoices, stale caches, and failed backups become urgent. A correct Laravel scheduled tasks production setup boils down to one cron line, disciplined task definitions, queue workers that restart on deploy, and monitoring that catches silence before users do. On projects from Nepal Gift Card to legal-tech portals, this stack has kept nightly jobs dependable across Deployer releases and PHP upgrades.
If your scheduler works locally but stops after deployment, or you need queue workers and cron configured on a live Ubuntu server, contact us for Laravel production support. For broader application architecture—including API design and enterprise Laravel development—explore our services or read Laravel API best practices and building RESTful APIs with Laravel for related production patterns. You can also review shipped work on the portfolio page or learn more about my production DevOps experience.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

