Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 028 / 100

Laravel task scheduling: a practical PHP & Laravel guide

One cron entry for all scheduled tasks.

Expona Admin7 min read

Laravel task scheduling: a practical PHP & Laravel guide

Managing background jobs and maintenance tasks in production applications can quickly become unwieldy when you're juggling multiple cron entries across different servers. Laravel task scheduling centralizes all of this complexity into a single, elegant system—giving you one cron entry to rule them all while keeping your scheduled logic clean and maintainable. In this guide, we'll walk through how to set up robust task scheduling in Laravel, prevent common pitfalls like duplicate runs, and ensure your tasks work reliably across multi-server environments.

Why centralized task scheduling matters

Most teams start by adding individual cron entries directly to their server's crontab. This approach works initially, but it creates several problems: configuration lives outside your codebase, developers can't easily understand what tasks run and when, and deploying new tasks requires manual server access. Laravel's scheduler moves all of this into your application code, making it version-controlled, reviewable, and deployable alongside your regular releases.

The magic happens with a single cron entry that runs every minute and delegates to Laravel's scheduler. This entry simply executes php artisan schedule:run, which then examines your defined schedules and runs whatever is due at that moment. We've seen teams go from maintaining 15+ separate cron lines to maintaining exactly one.

Defining schedules in routes/console.php

Your scheduled tasks live in routes/console.php (in modern Laravel) or app/Console/Kernel.php (in older versions). This is where you declare what should run and when. Let's look at a real example from an e-commerce application:

php
// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('orders:process-pending')
    ->everyMinute()
    ->name('process-pending-orders');

Schedule::command('inventory:sync-warehouse')
    ->hourly()
    ->name('sync-warehouse-inventory');

Schedule::call(function () {
    User::where('inactive_days', '>=', 90)
        ->where('status', 'active')
        ->update(['status' => 'dormant']);
})
    ->daily()
    ->at('02:00')
    ->name('mark-dormant-users');

Schedule::command('reports:generate')
    ->dailyAt('03:30')
    ->name('generate-daily-reports');

Schedule::job(new ProcessCRM)
    ->everyFiveMinutes()
    ->name('process-crm-queue');

Notice we're mixing command calls, closure-based tasks, and queued jobs. The scheduler is flexible—you can run Artisan commands, execute PHP code inline, or dispatch queued jobs. Each schedule has a human-readable name, which becomes invaluable when debugging. The frequency methods are self-explanatory: everyMinute(), hourly(), daily(), dailyAt('HH:MM'), and many others.

To implement this in your server's crontab, you need exactly one entry:

php
* * * * * cd /path/to/app && php artisan schedule:run >> /dev/null 2>&1

That's it. Every minute, Laravel checks which tasks are due and runs them. The rest is handled by your application code.

Preventing double runs with withoutOverlapping

Here's a scenario we've encountered on real production systems: a task takes 70 seconds to complete, but the scheduler checks every minute. Without protection, Laravel will start a second instance before the first one finishes, leading to duplicate work, race conditions, and data corruption.

The withoutOverlapping() method prevents this by using a lock mechanism. When a task starts, Laravel creates a lock file (or cache entry) with the task's name. If the scheduler tries to run that task again before the lock expires, it skips the execution.

php
Schedule::command('orders:process-pending')
    ->everyMinute()
    ->withoutOverlapping()
    ->name('process-pending-orders');

// In an e-commerce system, we might have:
Schedule::command('payments:verify-transactions')
    ->everyFiveMinutes()
    ->withoutOverlapping(timeout: 300) // 5 minute timeout
    ->name('verify-transactions');

Schedule::command('reports:generate')
    ->dailyAt('03:30')
    ->withoutOverlapping(expiresAt: 3600) // Lock expires after 1 hour
    ->name('generate-daily-reports');

The timeout parameter sets how long the lock persists before automatically releasing (preventing zombie locks if a task crashes). The expiresAt parameter lets you set a maximum duration explicitly. Without these parameters, Laravel defaults to 24 hours—which is usually safe but overly conservative.

In a CRM application handling real-time customer data, we use withoutOverlapping() on every single scheduled task. The performance cost is negligible, but the peace of mind is immense. We've prevented countless race conditions by using this single method consistently.

Multi-server setups with onOneServer

When you scale to multiple application servers (common in ride-hailing, travel, and SaaS platforms), you face a new problem: which server runs your scheduled tasks? If all servers run them independently, you duplicate work. If no server runs them, work never happens.

The onOneServer() method solves this by ensuring a task runs on only one server in your cluster, regardless of how many servers are active. It works by having each server attempt to acquire a distributed lock (typically via your cache driver) before running the task.

php
// Using with Redis cache (required for multi-server)
Schedule::command('data:cache-refresh')
    ->everyHour()
    ->onOneServer()
    ->name('cache-refresh');

Schedule::command('analytics:process-events')
    ->everyTenMinutes()
    ->onOneServer()
    ->withoutOverlapping(timeout: 600)
    ->name('process-events');

// Combine for maximum safety
Schedule::command('database:maintenance')
    ->dailyAt('04:00')
    ->onOneServer()
    ->withoutOverlapping(timeout: 3600)
    ->name('database-maintenance');

Important: onOneServer() requires a cache driver that supports atomic operations, typically Redis or Memcached. Using the file-based cache driver won't work reliably in a multi-server environment. We've learned this the hard way in travel systems with 8+ application servers.

When combining onOneServer() with withoutOverlapping(), you get defense in depth. First, only one server acquires permission to run the task. Second, even if somehow two servers both acquired permission, the overlapping lock prevents simultaneous execution. In practice, just using onOneServer() is usually sufficient, but the combination costs nothing and provides absolute certainty.

Logging output for debugging and monitoring

Silent task failures are debugging nightmares. You need visibility into what your scheduled tasks are doing, especially in production. Laravel provides several logging options.

The sendOutputTo() method writes task output to a file:

php
Schedule::command('reports:generate')
    ->dailyAt('03:30')
    ->sendOutputTo(storage_path('logs/reports.log'))
    ->name('generate-daily-reports');

The appendOutputTo() method appends instead of overwriting, which is usually better for ongoing monitoring:

php
Schedule::command('orders:process-pending')
    ->everyMinute()
    ->withoutOverlapping()
    ->appendOutputTo(storage_path('logs/orders.log'))
    ->name('process-pending-orders');

For more sophisticated monitoring, use the onSuccess() and onFailure() callbacks:

php
Schedule::command('payments:reconcile')
    ->dailyAt('02:00')
    ->withoutOverlapping(timeout: 1800)
    ->onSuccess(function () {
        Log::info('Payment reconciliation completed successfully');
        // Send Slack notification, update monitoring dashboard, etc.
    })
    ->onFailure(function () {
        Log::error('Payment reconciliation failed');
        // Alert the team immediately
        Slack::send('Critical: Payment reconciliation task failed');
    })
    ->name('reconcile-payments');

In real production systems, we combine all three approaches. We log output to files for historical inspection, use callbacks to alert the team on failures, and monitor exit codes through external tools like New Relic or Datadog. For critical tasks like payment processing or inventory synchronization, we've also added database logging:

php
Schedule::command('payments:verify-transactions')
    ->everyFiveMinutes()
    ->withoutOverlapping(timeout: 300)
    ->onSuccess(function () {
        ScheduledTaskLog::create([
            'task_name' => 'verify-transactions',
            'status' => 'success',
            'executed_at' => now(),
        ]);
    })
    ->onFailure(function () {
        ScheduledTaskLog::create([
            'task_name' => 'verify-transactions',
            'status' => 'failed',
            'executed_at' => now(),
        ]);
    })
    ->name('verify-transactions');

This gives us queryable records of every scheduled task execution, crucial for compliance audits in regulated industries like trading and fintech.

Common mistakes

  • Forgetting the cron entry entirely. We've seen deployments where teams set up perfect schedules in console.php but forgot to add the one-line cron entry to their server. The tasks never run. Always verify the cron entry exists with crontab -l.
  • Using file-based cache with onOneServer. File locks aren't distributed across servers. Use Redis or Memcached when you have multiple app servers. Testing locally with file cache won't reveal the problem until production.
  • Neglecting timeout parameters on withoutOverlapping. A task that hangs leaves its lock forever, blocking all future runs. Always set reasonable timeout values based on typical task duration.
  • Running database-heavy tasks during peak hours. Scheduled tasks compete for database connections. Process reports, data cleanup, and heavy analytics during off-peak times. Use between() and skipWhen() methods to avoid peak traffic:
php
Schedule::command('reports:generate')
    ->dailyAt('03:30')
    ->between('03:00', '04:00') // Only within this window
    ->skipWhen(function () {
        // Don't run if database is under heavy load
        return Cache::get('database_overloaded') === true;
    })
    ->name('generate-daily-reports');
  • Not testing schedule expressions locally. Use php artisan schedule:list to see what's actually scheduled and when it will run next. Test with php artisan schedule:test orders:process-pending to manually trigger a task.
  • Key takeaways

    • Use routes/console.php to define all scheduled tasks in code, replacing dozens of manual cron entries with one line in crontab.
    • Always apply withoutOverlapping() to prevent duplicate task execution when tasks take longer than the schedule interval.
    • In multi-server environments, combine onOneServer() with a distributed cache driver (Redis) to ensure tasks run exactly once across your cluster.
    • Implement comprehensive logging via appendOutputTo(), onSuccess(), and onFailure() callbacks to catch failures early and maintain audit trails.
    • Test your schedules locally with php artisan schedule:list and php artisan schedule:test before deploying to production.
    • Set reasonable timeout values on overlapping locks and avoid scheduling heavy work during peak application hours to prevent resource contention.
    ShareXLinkedInReddit

    Found this useful? Support the writing.

    Buy me a coffee
    Written by
    Expona Admin
    Site administrator

    Comments

    Loading comments…

    Leave a comment

    Comments are reviewed before they appear. Be kind and stay on topic.

    Related articles