Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Laravel Scheduled Tasks Production Setup

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.

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:run every 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.

Laravel Scheduler Production FlowSystem CronEvery minuteschedule:runArtisan commandSchedulerEvaluates due tasksQueue WorkerHeavy async jobsLogs + MonitorAudit trailInline CommandsFast sync tasks
Laravel scheduled tasks production setup: one cron entry drives the scheduler, which routes work to inline commands or queue workers.

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:

  1. Use the full PHP path. Cron environments are minimal. /usr/bin/php or /usr/bin/php8.3 avoids "command not found" when multiple PHP versions coexist—a pattern I use on servers running PHP 8.3 and 8.5 side by side.
  2. Change directory first. The cd ensures .env, storage/, and autoload paths resolve correctly.
  3. Run as the web user. Scheduled tasks that write to storage/logs or storage/framework need matching ownership.
  4. 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.

Cron Setup Checklist1. Find PHPwhich php8.32. Set Path/current symlink3. Edit Crontabwww-data user4. Test RunCheck scheduler.log5. Deploy SafePath survives swapCommon failure: cron still points at old release folderAlways use Deployer current/ symlink path
Production cron configuration for Laravel schedule:run with Deployer-safe paths and www-data ownership.

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 using Asia/Kathmandu. Server UTC vs app timezone mismatches cause tasks to fire at wrong local hours.
  • runInBackground() — spawns subprocess for long Artisan commands so schedule:run returns 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.

Inline vs Queued Scheduled TasksInline CommandUnder 30 secondsCache clears, pruningSimple DB updatesRuns inside schedule:runQueued JobOver 30 secondsEmail batches, API syncFile generation, reportsNeeds Supervisor workerRisk: blocks cronRisk: no workerRule: scheduler triggers, queue executes heavy work
Choosing between inline Artisan commands and queued jobs in Laravel scheduled tasks production setup.

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.

ComponentRole in productionTypical failureFix
System cronTriggers schedule:run every minuteWrong path after deployPoint at current/ symlink
Laravel schedulerEvaluates due tasks, acquires locksTimezone mismatchSet timezone() explicitly
Queue workerExecutes dispatched jobsWorker not restarted post-deployqueue:restart in deploy hook
RedisQueue + mutex storageConnection refusedCheck REDIS_HOST, firewall
SupervisorKeeps workers aliveWrong user permissionsRun 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.

Scheduler Monitoring StackHeartbeatEvery minute cacheTask LogsappendOutputToFailed Jobsqueue:failed tableExternal Uptime CheckAlert if heartbeat staleIncident: check cron, PHP path, mutex locksRun schedule:run --verbose manually
Monitoring layers for Laravel scheduled tasks production setup: heartbeat, per-task logs, failed job tracking, and external alerts.

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:run as the web server user with the full PHP binary path.
  • Define all tasks in routes/console.php with withoutOverlapping(), onOneServer() for multi-node setups, and explicit timezone('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

One system cron entry runs php artisan schedule:run every minute from your app's current release path, with tasks defined in Laravel code, queue workers for heavy jobs, and logging you can audit.

Edit crontab for the web server user, typically www-data: sudo crontab -u www-data -e. Add one line: cd /var/www/example.com/current && /usr/bin/php artisan schedule:run >> /dev/null 2>&1. Use the full PHP binary path because cron environments are minimal. On Deployer-managed servers, point at the current symlink, not a dated release folder. Run as www-data so scheduled tasks writing to storage/logs match PHP-FPM ownership. Verify by temporarily redirecting output to storage/logs/scheduler.log and confirming entries appear within two minutes.

Exactly one per application. Laravel's scheduler evaluates all registered tasks each minute from that single schedule:run invocation.

In routes/console.php for Laravel 11 and later, including Laravel 12 and 13. Register tasks using Schedule::command(), Schedule::job(), or Schedule::call(). Apply production modifiers: withoutOverlapping() to prevent stacked runs, onOneServer() when multiple app servers share one database and Redis, timezone('Asia/Kathmandu') for Nepal-based timing, appendOutputTo() for per-task logs, and runInBackground() for long Artisan commands. Use environments(['production']) to guard destructive tasks. On Laravel 11 or 12 projects still using app/Console/Kernel.php, the production behaviour is identical even though the file location differs.

A nightly report taking four minutes must not block the next minute's schedule:run tick. The scheduler should trigger; queue workers execute. Scheduled closures sending hundreds of emails, regenerating sitemaps, or syncing third-party APIs belong in queued jobs like Schedule::job(new SyncSupplierInventory)->dailyAt('02:00')->withoutOverlapping(60)->onOneServer(). Without running Supervisor-managed workers, jobs accumulate silently in Redis or the database queue. I've seen this on booking systems where inventory sync appeared scheduled but never completed because workers were missing post-deploy.

Scheduled tasks and queue workers are siblings, not substitutes. When schedules dispatch jobs to Redis, production needs persistent workers under Supervisor. A typical config runs php artisan queue:work redis --sleep=3 --tries=3 --max-time=3600 as www-data with numprocs=2, logging to shared/storage/logs/worker.log. After every Deployer release, run php artisan queue:restart in your deploy hook so workers load new job classes. Stale workers cause class-not-found errors that look like application bugs. On sister sites sharing GitLab CI and Deployer pipelines, this restart step is as non-negotiable as PHP-FPM reload for opcache.

Redis 8.10 is the solid default: fast, supports onOneServer() atomic locks, and handles scheduler mutexes. Set QUEUE_CONNECTION=redis and REDIS_CLIENT=phpredis in .env. Database queues work for low-volume apps but add load to MySQL 9.7 or PostgreSQL 18 under burst conditions. Match your queue driver to your infrastructure. If scheduled tasks use onOneServer() across multiple app nodes, Redis is effectively required for reliable mutex storage alongside job execution.

It prevents a slow run from stacking concurrent executions. Pass lock expiry minutes so a crashed job does not block forever—60 for hourly syncs, 10 for five-minute tasks. Without it, a report occasionally taking 90 seconds will stack three concurrent runs, hammering the database and API rate limits. If a task never runs despite appearing in schedule:list, check for stale mutex locks in Redis. A killed job mid-run can leave a lock until expiry. Use php artisan schedule:clear-cache during incident response to release stuck locks.

Required when multiple app servers share one database and Redis. It uses atomic locks so only one node runs the task, preventing duplicate nightly backups, payment reconciliation, or inventory syncs. Pair it with Redis as your queue and cache backend. Single-server deployments can omit it, but I add it proactively on any horizontally scaled Laravel 12 or 13 setup because the modifier is harmless on one node and saves a production incident when you add a second server later.

Set APP_TIMEZONE=Asia/Kathmandu in .env and add explicit timezone('Asia/Kathmandu') on critical schedules. Server UTC versus app timezone mismatches cause tasks to fire at wrong local hours—a real problem for BS-calendar-aware reminders and document-expiry notifications on legal-tech portals. Use php artisan schedule:list to verify next run times match expected local hours. Cross-check output against expected Bikram Sambat dates during QA, especially across PHP 8.3 and 8.5 side-by-side servers where DateTime behaviour should remain consistent.

Silent failure is the scheduler's default mode. Use php artisan schedule:list to see registered tasks and next run times, schedule:test to fire one interactively, and schedule:run --verbose for detailed evaluation output. Register a heartbeat: Schedule::call(fn () => Cache::put('scheduler:heartbeat', now(), 600))->everyMinute(), then alert if the timestamp is older than five minutes. Add onFailure callbacks on critical tasks to Log::critical. Use appendOutputTo() for per-task stdout logs. Monitor failed jobs and queue depth separately—cron success does not mean queued work completed. On staging, run the same cron entry as production for at least 24 hours before go-live.

Multiple cron entries for individual Artisan commands defeats overlap protection—consolidate to one schedule:run line. Running as root breaks storage ownership when the web user needs those files. Forgetting queue workers on containerised deploys means jobs never execute. Skipping withoutOverlapping() on slow tasks causes concurrent runs hammering databases. Hard-coded server paths in closures break after Deployer symlink swaps—use storage_path() and base_path() instead. Missing environments() guards can run destructive cleanup in production. No timezone configuration shifts Nepal-facing tasks to wrong hours. Each of these has caused missed backups or payment reconciliation gaps on client projects I've inherited.

Deployer uses symlinked releases where the live app lives at /var/www/example.com/current, not inside a dated release folder. Cron entries pointing at an old release path stop invoking schedule:run after deploys—a pattern I've hit on shared EC2 infrastructure where stale cron paths caused missed nightly backups. Fix by targeting current in your crontab and documenting the path in your runbook. Use Laravel path helpers in scheduled closures, never absolute release directories. After each deploy, also run php artisan queue:restart and reload PHP-FPM so workers and opcache pick up new code alongside the updated cron target.

PHP 8.3 or higher on Ubuntu, per Laravel 12 and 13 requirements. Use the full binary path in cron.

Temporarily change the cron line to redirect 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. Confirm the entry runs as www-data with the correct PHP binary—/usr/bin/php or /usr/bin/php8.3 on servers running multiple PHP versions side by side. Once confirmed, revert to /dev/null or keep verbose logging on servers where disk space is monitored. Cross-check with php artisan schedule:list on the same release path the cron job uses.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: