
September 07, 2026
14 min read
By Kokil Thapa | Last reviewed: September 2026
Your checkout hangs because a PDF runs in the request. Your nightly report never fires because nobody added the crontab line. Both failures trace back to the same decision: Laravel cron job vs queue worker when to use each. The scheduler runs work on a clock. Queue workers run work when something happens—a booking, a webhook, a payment callback. They are not interchangeable. This guide maps both paths with real config, deployment patterns from production apps, and a decision framework aligned with modern Laravel architecture.
What is the difference between Laravel cron jobs and queue workers?
Laravel has no separate "cron job" class. Developers mean the task scheduler: one system cron entry runs php artisan schedule:run every minute. Laravel then checks which tasks are due in routes/console.php on Laravel 12 and 13, or app/Console/Kernel.php on older releases.
Queue workers are different. They are long-running PHP processes—Supervisor, systemd, or Laravel Horizon keeps them alive. They pull jobs from Redis, a database table, or SQS and execute them as soon as work arrives.
The mental model is simple. Cron answers when something should run. Queues answer what should run outside the current request without making the user wait. On a legal-tech portal I built, booking confirmation emails go through a queue. A nightly command that archives expired document links runs on the scheduler. Same framework, different trigger.
Scheduler: one cron entry, many tasks
Your server needs exactly one crontab line. Laravel evaluates which scheduled tasks are due within that minute:
# /etc/cron.d/laravel-app (run as the deploy user)
* * * * * cd /var/www/myapp/current && /usr/bin/php artisan schedule:run >> /dev/null 2>&1 Tasks register in routes/console.php on Laravel 12 and 13:
use Illuminate\Support\Facades\Schedule;
Schedule::command('reports:daily-sales')
->dailyAt('01:00')
->timezone('Asia/Kathmandu')
->withoutOverlapping()
->onOneServer();
Schedule::command('cache:prune-stale-tags')->hourly(); The scheduler supports closures, queued jobs, shell commands, and output logging. Methods like withoutOverlapping() stop a slow report from stacking if yesterday's run is still going. I've seen that break on under-provisioned VPS hosts. For a full production checklist, see the guide on Laravel scheduled tasks in production.
Queue workers: persistent job processors
When a controller dispatches a job, Laravel serialises it to the configured queue connection. A worker dequeues and executes it:
// app/Jobs/SendBookingConfirmation.php
class SendBookingConfirmation implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 120;
public function __construct(public Booking $booking) {}
public function handle(MailService $mail): void
{
$mail->sendConfirmation($this->booking);
}
}
// In controller
SendBookingConfirmation::dispatch($booking); Workers must run continuously. On Ubuntu servers I maintain, Supervisor keeps them alive:
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=/usr/bin/php /var/www/myapp/current/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=deploy
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/myapp/shared/storage/logs/worker.log For Redis-backed queues at scale, Horizon replaces raw Supervisor configs with a dashboard, balancing, and metrics. Read the dedicated guide on scaling Laravel queues for high-traffic applications.
| Criteria | Laravel Scheduler (Cron) | Queue Workers |
|---|---|---|
| Trigger | Time (every minute, daily, weekly) | Event (user action, webhook, model observer) |
| Process model | Short-lived; runs and exits | Long-lived; polls for new jobs |
| Latency | Up to 60 seconds (cron granularity) | Milliseconds to seconds after dispatch |
| Concurrency | withoutOverlapping(), onOneServer() | Multiple workers, Horizon auto-scaling |
| Retries | Manual; re-run on next schedule | Built-in $tries, backoff, failed_jobs table |
| Best for | Reports, cleanup, renewals, sync windows | Email, SMS, PDFs, API calls, imports |
| User waiting? | No user involved | User triggered; response must be fast |
When should you use Laravel scheduled tasks instead of queues?
Reach for the scheduler when work is tied to a calendar, not a user click. If the task must run whether or not anyone visited the site today, it belongs on the schedule.
- Periodic maintenance — pruning old sessions, clearing expired password reset tokens, rotating log files.
- Scheduled reports — daily sales summaries emailed to admins at 6 AM Nepal time (
Asia/Kathmandu). - Subscription and billing cycles — charge recurring plans on the 1st of each month.
- Data sync windows — pull inventory from a supplier API every four hours during off-peak hours.
- Health checks — ping external services, verify SSL expiry, alert if disk usage exceeds 85%.
- Cache warming — pre-build sitemap or homepage fragments before morning traffic.
A pattern I use on eCommerce projects: the scheduler marks expired gift cards at midnight. The queue sends the purchase confirmation the moment checkout completes. Mixing these up means customers wait up to 60 seconds for an email. Or expired cards stay purchasable until the next cron tick. The broader trade-off is covered in background jobs vs cron jobs design.
Scheduled tasks that dispatch jobs
Laravel lets you schedule a job class directly. That helps when the same job is both user-triggered and periodically re-run:
use App\Jobs\SyncInventoryFromSupplier;
use Illuminate\Support\Facades\Schedule;
Schedule::job(new SyncInventoryFromSupplier)->everyFourHours(); The scheduler still decides when. The job still runs through queue infrastructure. You need workers running for this to work. The cron entry only enqueues—the worker executes.
When cron alone is enough
Skip the queue if the task is lightweight, idempotent, and finishes in seconds:
Schedule::command('model:prune', ['--model' => 'App\\Models\\AuditLog'])
->daily()
->runInBackground(); runInBackground() spawns a subprocess so schedule:run does not block other tasks. Do not use it for heavy work. Dispatch a queued job instead so retries and failure tracking apply.
When should you use Laravel queue workers instead of cron?
Any work triggered by a user, webhook, or domain event that would slow the HTTP response belongs on a queue. If removing the job makes the page feel instant, queue it.
- Notifications — transactional email, SMS, push, Slack alerts after order placement or form submission.
- Third-party API calls — payment verification, shipping label creation, CRM sync. Timeouts here kill checkout flows.
- File and media processing — image resizing, PDF generation, video transcoding. Spatie Media Library conversions are a classic queue candidate; see the Spatie Media Library guide.
- Webhook delivery — your app calling a client's endpoint with retry and backoff logic.
- Bulk imports — CSV uploads parsed row-by-row via chained or batched jobs. For large batches, read about Laravel job batching.
- Search index updates — Meilisearch or Elasticsearch indexing after model save.
On Adventure Third Pole Trek, booking confirmation, supplier notification, and payment callback handling all dispatch to Redis queues. The Livewire booking form returns quickly because nothing waits on SMTP or Khalti API latency.
Queue configuration essentials
Set your default connection in .env and match it in production:
QUEUE_CONNECTION=redis
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379 Redis 8.10 handles queue throughput far better than the database driver. That driver polls MySQL 9.7 and creates lock contention under load. Use it only on shared hosting where Redis is unavailable. The step-by-step Redis setup is in the Laravel queues with Redis production guide.
Define retry behaviour on the job class, not globally:
public int $tries = 5;
public array $backoff = [30, 60, 120, 300, 600];
public function failed(Throwable $exception): void
{
Log::error('Booking confirmation failed', [
'booking_id' => $this->booking->id,
'error' => $exception->getMessage(),
]);
} Failed jobs land in the failed_jobs table. Monitor it. A silent queue failure on a payment webhook is worse than a loud HTTP 500.
How do you configure Laravel cron and queue workers in production?
Production setup spans three layers: OS cron, process supervision, and deployment hooks. I've deployed this stack across sister sites sharing a Linux system administration pipeline with Deployer 7 and GitLab CI.
Step 1: Register the system cron entry
One line in the deploy user's crontab—or a file under /etc/cron.d/ on Ubuntu 22/24:
* * * * * deploy cd /var/www/myapp/current && php artisan schedule:run >> /var/www/myapp/shared/storage/logs/scheduler.log 2>&1 Verify it works:
php artisan schedule:list
php artisan schedule:test On Laravel 13 with PHP 8.3+, ensure cron uses the same PHP binary as PHP-FPM. A version mismatch between cron (8.2) and FPM (8.5) causes cryptic class-not-found errors after deploy. This is the most common scheduler failure I've debugged on client servers.
Step 2: Run queue workers under Supervisor
Install Supervisor, add the program config, reload:
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start laravel-worker:* After each Deployer release, workers must restart to pick up new code. Add to your deploy recipe:
task('artisan:queue:restart', function () {
run('{{bin/php}} {{release_path}}/artisan queue:restart');
}); queue:restart sets a cache flag. Workers finish their current job then exit. Supervisor respawns them against the new release symlink. Without this step, workers run stale opcached code until manually killed. That is a silent post-deploy bug. See zero-downtime Laravel deployment with Deployer for the full release flow.
Step 3: Use Horizon for Redis queues at scale
When you run more than two worker processes or need throughput visibility, install Horizon. It replaces manual Supervisor worker configs with a single horizon process. You get a dashboard at /horizon—protect it in production.
Horizon handles worker balancing, memory limits, and graceful termination. Pair it with the patterns in the Laravel Horizon monitoring guide.
Monitoring both paths
Scheduler health: log task output and alert on missed runs. Laravel 11+ supports ->emailOutputOnFailure('ops@example.com') per task. Queue health: watch queue depth, failed job count, and worker uptime.
A stalled queue on a payment-heavy app—like one using Khalti payment integration—means confirmed orders with no confirmation email. That becomes a support ticket avalanche.
For server-level cron concepts beyond Laravel, the Ubuntu cron jobs guide covers crontab syntax and timezone pitfalls that affect schedule:run.
How do Laravel cron jobs and queue workers work together?
Mature Laravel 12/13 applications use both. The scheduler orchestrates. The queue executes heavy or retryable units of work. Treat them as complementary, not competing.
Real-world combined pattern
On a production Laravel application with PostgreSQL 18 and Redis 8.10, a typical daily cycle looks like this:
- 00:05 — Scheduler runs
subscriptions:renew. For each due subscription, the command dispatchesProcessRenewalPaymentto thepaymentsqueue. - 01:00 — Scheduler runs
reports:daily, which queries aggregates and dispatches oneGenerateReportPdfjob per tenant. - Every hour —
Schedule::job(new PurgeExpiredSessions)keeps the session table lean. - On every booking — Controller dispatches
SendBookingConfirmation,NotifySupplier, andUpdateSearchIndexto separate queues for isolation.
Queue names (payments, notifications, default) let you assign dedicated workers. A slow PDF job must not block payment processing. Domain events can also dispatch jobs—see Laravel events and listeners use cases for wiring patterns.
Anti-patterns to avoid
Cron polling the database for work. A scheduled command that runs every minute, selects rows where status = pending, and processes them is a queue antipattern. You lose retry semantics. You create race conditions with multiple servers. You hammer the database. Dispatch a job when the row is created; let workers process it.
Running heavy work inside scheduled commands without a queue. A daily import that takes 20 minutes will overlap tomorrow's run unless withoutOverlapping() blocks it—and then you miss a day. Chunk the import into batched jobs.
Using sync queue driver in production. QUEUE_CONNECTION=sync executes jobs inline during the request. Fine for local development. Catastrophic for checkout flows in production.
Forgetting onOneServer() in multi-server setups. Without it, three app servers each run the same scheduled task. Three duplicate daily reports. Three renewal charges. Add onOneServer() and a shared cache driver (Redis) so Laravel acquires a mutex.
For database design that supports high-volume queued workloads, see the PostgreSQL for Laravel developers guide. Queue payloads and failed job records grow fast. Index and prune them.
Local development parity
Run the scheduler in dev with:
php artisan schedule:work Process queues locally with:
php artisan queue:listen --tries=1 Or use composer dev scripts that run server, queue listener, and Vite 8.x concurrently. Match production queue drivers in staging. Debugging a Redis-specific serialisation bug on the database driver locally wastes hours.
If you need help auditing an existing app's background job architecture, Laravel support and maintenance covers scheduler review, queue tuning, and Horizon setup. For greenfield apps, enterprise application development includes queue design from day one.
Key Takeaways
- Use the Laravel task scheduler (one OS cron entry) for time-based work: reports, cleanup, renewals, and sync windows—not for reacting to user clicks.
- Use queue workers for event-driven, retryable jobs: email, payments, webhooks, file processing, and anything that would slow an HTTP response.
- Most production Laravel 12/13 apps need both: the scheduler decides when; queues handle how heavy work executes with retries.
- Always restart workers after deploy (
queue:restart), match PHP versions between cron and PHP-FPM, and use Redis over the database queue driver when possible. - Add
withoutOverlapping()andonOneServer()to scheduled tasks; define$triesand$backoffon job classes. - Monitor failed jobs and scheduler logs—silent queue failures are harder to diagnose than loud HTTP errors.
People Also Ask
Can Laravel run queue workers without cron?
Yes. Queue workers are independent long-running processes started by Supervisor or Horizon. They do not require cron. However, if you schedule jobs with Schedule::job() or run any scheduled tasks, you still need the single schedule:run cron entry. User-dispatched jobs need only workers.
Should scheduled tasks use the database or Redis queue?
Prefer Redis for production queue throughput and lower latency. The database driver works on budget shared hosting but adds polling overhead to MySQL 9.7 or MariaDB 12.3. Scheduled tasks that dispatch jobs inherit whichever QUEUE_CONNECTION your app uses—set it in .env and keep staging aligned with production.
What happens if queue workers stop but cron keeps running?
Scheduled tasks that dispatch jobs will enqueue work, but nothing processes it. Jobs pile up in Redis or the jobs table. User-triggered dispatches also stall. Supervisor's autorestart=true handles crashes; monitor worker uptime and queue depth. Horizon sends notifications when wait times exceed thresholds.
Is Laravel Horizon required for queue workers?
No. Raw queue:work under Supervisor is sufficient for small and mid-size apps. Horizon adds a dashboard, auto-balancing, and per-queue worker configuration—worth it once you run four or more workers or need visibility into job throughput. Official docs cover both approaches at laravel.com/docs/queues and laravel.com/docs/scheduling.
Pick the right tool and ship reliable background work
The Laravel cron job vs queue worker when to use each question boils down to trigger type: clock versus event. Schedule periodic orchestration. Queue everything that users or webhooks initiate and that needs retries. Wire both into your deploy pipeline. Monitor failures. Test with production queue drivers before launch. Background job architecture is not an afterthought—it is what keeps checkout fast, emails delivered, and nightly reports running while you sleep.
Need a production audit of your scheduler and queue setup, or help scaling workers on Ubuntu? Contact us for a review. Explore more Laravel guides on the blog, validate JSON queue payloads with the JSON formatter, or read about Kokil Thapa's background building production systems since 2010. For new builds, see web development services and related work in the portfolio.
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.

