Laravel Overlapping Scheduled Tasks in Production | Mohamed Said        [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://msaied.com) [ Home ](https://msaied.com) [ Projects ](https://msaied.com/projects) [ Articles  ](https://msaied.com/articles) [ Certificates ](https://msaied.com/certificates) [ Contact ](https://msaied.com#contact-section) 

       [  ](https://github.com/EG-Mohamed)       

 [ Home ](https://msaied.com) [ Projects ](https://msaied.com/projects) [ Articles ](https://msaied.com/articles) [ Certificates ](https://msaied.com/certificates) [ Contact ](https://msaied.com#contact-section) 

  [ home ](https://msaied.com)    [ articles ](https://msaied.com/articles)    Laravel Overlapping Scheduled Tasks: The Production Problem Nobody Talks About        On this page       1. [  What Is an Overlapping Scheduled Task? ](#what-is-an-overlapping-scheduled-task)
2. [  Why Overlapping Happens in Production ](#why-overlapping-happens-in-production)
3. [  1. The Database Grows ](#1-the-database-grows)
4. [  2. External APIs Become Slow ](#2-external-apis-become-slow)
5. [  3. The Task Processes Too Much Work ](#3-the-task-processes-too-much-work)
6. [  4. The Server Is Under Load ](#4-the-server-is-under-load)
7. [  5. The App Runs on Multiple Servers ](#5-the-app-runs-on-multiple-servers)
8. [  Why This Problem Is Dangerous ](#why-this-problem-is-dangerous)
9. [  Duplicate Emails ](#duplicate-emails)
10. [  Duplicate Payments ](#duplicate-payments)
11. [  Duplicate API Calls ](#duplicate-api-calls)
12. [  Race Conditions ](#race-conditions)
13. [  Database Locks and Deadlocks ](#database-locks-and-deadlocks)
14. [  Wrong Reports ](#wrong-reports)
15. [  Confusing Logs ](#confusing-logs)
16. [  Why Local Development Does Not Reveal This ](#why-local-development-does-not-reveal-this)
17. [  The Basic Fix: Use withoutOverlapping() ](#the-basic-fix-use-codewithoutoverlappingcode)
18. [  Set a Reasonable Lock Expiration ](#set-a-reasonable-lock-expiration)
19. [  Clear Stuck Scheduler Locks ](#clear-stuck-scheduler-locks)
20. [  Multi-Server Deployments: Use onOneServer() ](#multi-server-deployments-use-codeononeservercode)
21. [  The Cache Driver Matters ](#the-cache-driver-matters)
22. [  The Problem with schedule:list ](#the-problem-with-codeschedulelistcode)
23. [  Log Real Execution, Not Just Exceptions ](#log-real-execution-not-just-exceptions)
24. [  Use Scheduler Hooks for Visibility ](#use-scheduler-hooks-for-visibility)
25. [  Capture Command Output When Needed ](#capture-command-output-when-needed)
26. [  Send Alerts for Critical Failures ](#send-alerts-for-critical-failures)
27. [  Make Scheduled Work Idempotent ](#make-scheduled-work-idempotent)
28. [  Use Transactions Carefully ](#use-transactions-carefully)
29. [  Process Work in Batches ](#process-work-in-batches)
30. [  Prefer Queue Jobs for Heavy Work ](#prefer-queue-jobs-for-heavy-work)
31. [  Protect Queue Jobs Too ](#protect-queue-jobs-too)
32. [  Add Job Uniqueness When Needed ](#add-job-uniqueness-when-needed)
33. [  Always Use Timeouts for External Calls ](#always-use-timeouts-for-external-calls)
34. [  Be Careful with runInBackground() ](#be-careful-with-coderuninbackgroundcode)
35. [  Sub-Minute Tasks Need Extra Care ](#sub-minute-tasks-need-extra-care)
36. [  Safe Record Claiming Pattern ](#safe-record-claiming-pattern)
37. [  Add Recovery for Stuck Records ](#add-recovery-for-stuck-records)
38. [  Use Database Constraints Where Possible ](#use-database-constraints-where-possible)
39. [  Example: Unsafe Invoice Sending ](#example-unsafe-invoice-sending)
40. [  Example: Safer API Sync ](#example-safer-api-sync)
41. [  Think About Deployment Behavior ](#think-about-deployment-behavior)
42. [  Do Not Schedule the Same Work Twice ](#do-not-schedule-the-same-work-twice)
43. [  Do Not Run the Scheduler on Every Container by Accident ](#do-not-run-the-scheduler-on-every-container-by-accident)
44. [  Monitor Scheduler Health ](#monitor-scheduler-health)
45. [  Watch for Skipped Runs ](#watch-for-skipped-runs)
46. [  Common Production Mistakes ](#common-production-mistakes)
47. [  Mistake 1: Scheduling Critical Tasks Every Minute Without Protection ](#mistake-1-scheduling-critical-tasks-every-minute-without-protection)
48. [  Mistake 2: Assuming schedule:list Means Everything Is Healthy ](#mistake-2-assuming-codeschedulelistcode-means-everything-is-healthy)
49. [  Mistake 3: Using Local Cache in Multi-Server Deployments ](#mistake-3-using-local-cache-in-multi-server-deployments)
50. [  Mistake 4: Processing All Records at Once ](#mistake-4-processing-all-records-at-once)
51. [  Mistake 5: No HTTP Timeout ](#mistake-5-no-http-timeout)
52. [  Mistake 6: No Idempotency ](#mistake-6-no-idempotency)
53. [  Mistake 7: No Failure Alerts ](#mistake-7-no-failure-alerts)
54. [  Mistake 8: Lock Expiration Is Too Long ](#mistake-8-lock-expiration-is-too-long)
55. [  Recommended Production Pattern ](#recommended-production-pattern)
56. [  When You Should Not Use withoutOverlapping() ](#when-you-should-not-use-codewithoutoverlappingcode)
57. [  Production Checklist for Laravel Scheduled Tasks ](#production-checklist-for-laravel-scheduled-tasks)
58. [  Final Thoughts ](#final-thoughts)

  ![Laravel Overlapping Scheduled Tasks: The Production Problem Nobody Talks About](https://cdn.msaied.com/93/01KTTJBMWPGG4V0TG5B5B6GF9P.png)

 [  Laravel ](https://msaied.com/articles?category=laravel) [  Tips &amp; Tricks ](https://msaied.com/articles?category=tips-tricks) 

 Laravel Overlapping Scheduled Tasks: The Production Problem Nobody Talks About 
================================================================================

     11 Jun 2026      18 min read    ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said  

       Table of contents

  58 sections  

1. [  01   What Is an Overlapping Scheduled Task?  ](#what-is-an-overlapping-scheduled-task)
2. [  02   Why Overlapping Happens in Production  ](#why-overlapping-happens-in-production)
3. [  03   1. The Database Grows  ](#1-the-database-grows)
4. [  04   2. External APIs Become Slow  ](#2-external-apis-become-slow)
5. [  05   3. The Task Processes Too Much Work  ](#3-the-task-processes-too-much-work)
6. [  06   4. The Server Is Under Load  ](#4-the-server-is-under-load)
7. [  07   5. The App Runs on Multiple Servers  ](#5-the-app-runs-on-multiple-servers)
8. [  08   Why This Problem Is Dangerous  ](#why-this-problem-is-dangerous)
9. [  09   Duplicate Emails  ](#duplicate-emails)
10. [  10   Duplicate Payments  ](#duplicate-payments)
11. [  11   Duplicate API Calls  ](#duplicate-api-calls)
12. [  12   Race Conditions  ](#race-conditions)
13. [  13   Database Locks and Deadlocks  ](#database-locks-and-deadlocks)
14. [  14   Wrong Reports  ](#wrong-reports)
15. [  15   Confusing Logs  ](#confusing-logs)
16. [  16   Why Local Development Does Not Reveal This  ](#why-local-development-does-not-reveal-this)
17. [  17   The Basic Fix: Use withoutOverlapping()  ](#the-basic-fix-use-codewithoutoverlappingcode)
18. [  18   Set a Reasonable Lock Expiration  ](#set-a-reasonable-lock-expiration)
19. [  19   Clear Stuck Scheduler Locks  ](#clear-stuck-scheduler-locks)
20. [  20   Multi-Server Deployments: Use onOneServer()  ](#multi-server-deployments-use-codeononeservercode)
21. [  21   The Cache Driver Matters  ](#the-cache-driver-matters)
22. [  22   The Problem with schedule:list  ](#the-problem-with-codeschedulelistcode)
23. [  23   Log Real Execution, Not Just Exceptions  ](#log-real-execution-not-just-exceptions)
24. [  24   Use Scheduler Hooks for Visibility  ](#use-scheduler-hooks-for-visibility)
25. [  25   Capture Command Output When Needed  ](#capture-command-output-when-needed)
26. [  26   Send Alerts for Critical Failures  ](#send-alerts-for-critical-failures)
27. [  27   Make Scheduled Work Idempotent  ](#make-scheduled-work-idempotent)
28. [  28   Use Transactions Carefully  ](#use-transactions-carefully)
29. [  29   Process Work in Batches  ](#process-work-in-batches)
30. [  30   Prefer Queue Jobs for Heavy Work  ](#prefer-queue-jobs-for-heavy-work)
31. [  31   Protect Queue Jobs Too  ](#protect-queue-jobs-too)
32. [  32   Add Job Uniqueness When Needed  ](#add-job-uniqueness-when-needed)
33. [  33   Always Use Timeouts for External Calls  ](#always-use-timeouts-for-external-calls)
34. [  34   Be Careful with runInBackground()  ](#be-careful-with-coderuninbackgroundcode)
35. [  35   Sub-Minute Tasks Need Extra Care  ](#sub-minute-tasks-need-extra-care)
36. [  36   Safe Record Claiming Pattern  ](#safe-record-claiming-pattern)
37. [  37   Add Recovery for Stuck Records  ](#add-recovery-for-stuck-records)
38. [  38   Use Database Constraints Where Possible  ](#use-database-constraints-where-possible)
39. [  39   Example: Unsafe Invoice Sending  ](#example-unsafe-invoice-sending)
40. [  40   Example: Safer API Sync  ](#example-safer-api-sync)
41. [  41   Think About Deployment Behavior  ](#think-about-deployment-behavior)
42. [  42   Do Not Schedule the Same Work Twice  ](#do-not-schedule-the-same-work-twice)
43. [  43   Do Not Run the Scheduler on Every Container by Accident  ](#do-not-run-the-scheduler-on-every-container-by-accident)
44. [  44   Monitor Scheduler Health  ](#monitor-scheduler-health)
45. [  45   Watch for Skipped Runs  ](#watch-for-skipped-runs)
46. [  46   Common Production Mistakes  ](#common-production-mistakes)
47. [  47   Mistake 1: Scheduling Critical Tasks Every Minute Without Protection  ](#mistake-1-scheduling-critical-tasks-every-minute-without-protection)
48. [  48   Mistake 2: Assuming schedule:list Means Everything Is Healthy  ](#mistake-2-assuming-codeschedulelistcode-means-everything-is-healthy)
49. [  49   Mistake 3: Using Local Cache in Multi-Server Deployments  ](#mistake-3-using-local-cache-in-multi-server-deployments)
50. [  50   Mistake 4: Processing All Records at Once  ](#mistake-4-processing-all-records-at-once)
51. [  51   Mistake 5: No HTTP Timeout  ](#mistake-5-no-http-timeout)
52. [  52   Mistake 6: No Idempotency  ](#mistake-6-no-idempotency)
53. [  53   Mistake 7: No Failure Alerts  ](#mistake-7-no-failure-alerts)
54. [  54   Mistake 8: Lock Expiration Is Too Long  ](#mistake-8-lock-expiration-is-too-long)
55. [  55   Recommended Production Pattern  ](#recommended-production-pattern)
56. [  56   When You Should Not Use withoutOverlapping()  ](#when-you-should-not-use-codewithoutoverlappingcode)
57. [  57   Production Checklist for Laravel Scheduled Tasks  ](#production-checklist-for-laravel-scheduled-tasks)
58. [  58   Final Thoughts  ](#final-thoughts)

       Laravel’s task scheduler is one of those features that feels simple in the best possible way.

Instead of managing many cron entries directly on the server, you define your scheduled commands inside your Laravel application:

```php
use Illuminate\Support\Facades\Schedule;

Schedule::command('reports:generate')->daily();
Schedule::command('orders:sync')->everyMinute();
Schedule::command('invoices:send')->hourly();

```

Clean. Readable. Version controlled.

But there is a production problem many teams do not notice until it causes real damage:

**Scheduled tasks can overlap.**

A scheduled command can start again while the previous execution is still running.

When that happens, the same command may process the same records twice, call the same external API twice, send duplicate emails, update the same rows at the same time, or create confusing production behavior that does not immediately look like a bug.

This is not just a scheduler issue.

It can become a business problem.

Duplicate invoices. Double notifications. Failed syncs. Wrong reports. Database locks. Angry customers. Confusing logs.

And the worst part?

Everything may still look normal when you run:

```bash
php artisan schedule:list

```

Your task can be correctly registered, the cron expression can be right, and the next run time can look perfect.

But production can still be silently failing.

This article explains what overlapping scheduled tasks are, why they happen, why they are dangerous, and how to design Laravel scheduled tasks that behave safely in production.

---

What Is an Overlapping Scheduled Task?
--------------------------------------

An overlapping scheduled task happens when a command starts again before the previous run has finished.

Example:

```php
use Illuminate\Support\Facades\Schedule;

Schedule::command('orders:sync')->everyMinute();

```

This task runs every minute.

That is fine if `orders:sync` usually takes 5 or 10 seconds.

But what if it sometimes takes 3 minutes?

```text
12:00 -> orders:sync starts
12:01 -> orders:sync starts again
12:02 -> orders:sync starts again
12:03 -> first orders:sync finishes

```

Now you have multiple copies of the same command running at the same time.

Each copy may read the same pending orders, call the same external API, and update the same database records.

That is overlap.

And in production, this can happen more easily than people expect.

---

Why Overlapping Happens in Production
-------------------------------------

Overlapping usually happens because the schedule frequency is shorter than the real execution time.

A task scheduled every minute must consistently finish in less than a minute. If it sometimes takes longer, overlap becomes possible.

The dangerous part is that tasks often become slower over time.

A command that was safe during development may become risky after months of real production data, traffic, and integrations.

Common reasons include:

### 1. The Database Grows

This may be fine with 200 rows:

```php
Order::where('status', 'pending')->get();

```

But with 2 million rows, the same query may become slow, memory-heavy, and unpredictable.

If the command takes longer than expected, the next scheduled run can start before the first one finishes.

### 2. External APIs Become Slow

Scheduled tasks often depend on payment gateways, shipping providers, CRMs, email services, SMS providers, ERPs, or analytics APIs.

A command may normally finish in 20 seconds.

Then one external service slows down, and suddenly the same command takes 5 minutes.

Laravel does not automatically know your business process is stuck. You need to protect the task.

### 3. The Task Processes Too Much Work

A common mistake is trying to do everything inside one scheduled command:

```php
User::where('active', true)->get()->each(function ($user) {
    // process user
});

```

This loads all matching users into memory.

It may work today.

It may fail later.

Scheduled tasks should usually process work in chunks, batches, or queue jobs.

### 4. The Server Is Under Load

Even if your command logic is reasonable, production conditions can make it slower:

- High CPU usage
- Low memory
- Slow disk I/O
- Database pressure
- Network latency
- Too many PHP processes
- Deployment timing
- Queue worker pressure

A scheduled task does not run in a perfect isolated world. It runs inside your real production environment.

### 5. The App Runs on Multiple Servers

This is one of the most common production traps.

If your application runs on multiple servers, containers, or replicas, the scheduler may run on more than one machine.

```text
Server A -> php artisan schedule:run
Server B -> php artisan schedule:run
Server C -> php artisan schedule:run

```

Now the same task may execute multiple times at the same scheduled moment.

This is not exactly the same as one task overlapping itself because it ran too long, but the result is similar:

```text
The same work runs more than once.
The same data may be processed more than once.
Production behavior becomes unpredictable.

```

---

Why This Problem Is Dangerous
-----------------------------

Overlapping scheduled tasks are dangerous because they often create silent business bugs.

They may not throw an exception.

They may not crash the application.

They may simply create incorrect side effects.

### Duplicate Emails

```php
Schedule::command('invoices:send')->everyMinute();

```

If two command instances read the same unsent invoices at the same time, both may send the same invoice email.

The customer receives two emails.

The logs may show two successful sends.

No exception is thrown.

But the user experience is broken.

### Duplicate Payments

Payment tasks are especially sensitive:

```php
Schedule::command('payments:capture')->everyMinute();

```

If two command instances try to capture or verify the same payment, you may get duplicate payment attempts, conflicting statuses, failed provider responses, incorrect order states, or support issues.

Even when the provider prevents true double charging, your application state can still become inconsistent.

### Duplicate API Calls

External APIs usually have rate limits and sometimes usage-based pricing.

If a sync task overlaps three times, you may send three times the expected number of requests.

That can lead to rate limiting, temporary bans, higher costs, slower syncs, and failed integrations.

### Race Conditions

A race condition happens when two processes read and write the same data at the same time in an unsafe order.

```php
$order = Order::find($id);

if ($order->status === 'pending') {
    $order->update(['status' => 'processing']);
}

```

If two command instances read the same order before either one updates it, both may think the order is still pending.

Both may process it.

That is the kind of bug that looks random until you understand the timing.

### Database Locks and Deadlocks

Overlapping commands may update the same tables or rows at the same time.

This can cause slow queries, lock waits, deadlocks, failed transactions, and increased database CPU.

A scheduled task should not make the main application slower, but overlapping tasks can do exactly that.

### Wrong Reports

Reports feel safe because they are often “read-only.”

But many report tasks write files, cache results, update statuses, or store generated rows.

If a report task overlaps, you may get duplicate files, partial exports, conflicting generated data, or wrong metrics.

### Confusing Logs

When commands overlap, logs become hard to reason about:

```text
orders:sync started
orders:sync started
orders:sync finished
orders:sync failed
orders:sync finished

```

Which run failed?

Which run updated the record?

Which server executed it?

Without run IDs and proper logging, debugging becomes guesswork.

---

Why Local Development Does Not Reveal This
------------------------------------------

Most developers do not notice overlapping tasks locally because local conditions are too perfect.

In development:

- The database is small.
- APIs are mocked or fast.
- There is usually only one server.
- Traffic is low.
- Queue workers are not under pressure.
- Commands are often run manually.
- Data volume is limited.

Production is different.

Production has real data, real users, real API delays, real server load, and real deployment interruptions.

A command that takes 5 seconds locally may take 5 minutes in production.

That is why scheduled tasks should be designed for worst-case behavior, not best-case behavior.

---

The Basic Fix: Use `withoutOverlapping()`
-----------------------------------------

Laravel provides `withoutOverlapping()` to prevent a scheduled task from starting again if the previous instance is still running.

```php
use Illuminate\Support\Facades\Schedule;

Schedule::command('orders:sync')
    ->everyMinute()
    ->withoutOverlapping();

```

This tells Laravel:

```text
Do not start this task if the previous run is still active.

```

So instead of this:

```text
12:00 -> starts
12:01 -> starts again
12:02 -> starts again

```

You get this:

```text
12:00 -> starts
12:01 -> skipped because previous run is still active
12:02 -> skipped because previous run is still active
12:03 -> previous run finishes
12:04 -> task can run again

```

This should be one of the first protections you add to scheduled tasks that modify data, send messages, call external APIs, or perform heavy processing.

---

Set a Reasonable Lock Expiration
--------------------------------

`withoutOverlapping()` uses a lock to know whether a previous run is still active.

You can pass an expiration time in minutes:

```php
Schedule::command('orders:sync')
    ->everyMinute()
    ->withoutOverlapping(10);

```

This means the lock can expire after 10 minutes.

The expiration matters because commands can crash before Laravel gets the chance to release the lock.

That can happen because of:

- Server restart
- Deployment interruption
- Fatal PHP error
- Memory limit
- Timeout
- Process kill
- Container shutdown

If the lock lives too long, the task may stop running for longer than necessary.

Bad:

```php
Schedule::command('orders:sync')
    ->everyMinute()
    ->withoutOverlapping(1440);

```

This can block the task for up to 24 hours.

Better:

```php
Schedule::command('orders:sync')
    ->everyMinute()
    ->withoutOverlapping(10);

```

A practical starting point:

```text
lock expiration = 2x to 5x the normal maximum runtime

```

If a command normally finishes in less than 2 minutes, 10 minutes may be reasonable.

If it normally takes 20 minutes, 60 minutes may be more realistic.

But do not guess forever. Measure runtime and adjust.

---

Clear Stuck Scheduler Locks
---------------------------

If a scheduled task becomes stuck because of a stale overlap lock, Laravel provides:

```bash
php artisan schedule:clear-cache

```

This clears cached scheduler mutexes.

Use it as an emergency tool, not as your normal solution.

If you often need to run `schedule:clear-cache`, that usually means something else is wrong:

- The task runs too long.
- The task crashes.
- The lock expiration is wrong.
- Deployments interrupt running commands.
- The server kills processes.
- Monitoring is weak.

Do not build production architecture around manually clearing scheduler locks.

---

Multi-Server Deployments: Use `onOneServer()`
---------------------------------------------

`withoutOverlapping()` protects a task from overlapping with itself.

But if your application runs on multiple servers, containers, or replicas, you also need to prevent multiple servers from running the same task at the same time.

Use `onOneServer()`:

```php
Schedule::command('orders:sync')
    ->everyMinute()
    ->onOneServer()
    ->withoutOverlapping(10);

```

Use this when your app is deployed with:

- Multiple VPS servers
- Load-balanced servers
- Docker replicas
- Kubernetes pods
- Laravel Cloud
- Vapor-style environments
- Horizontal scaling setups

For most important production tasks, use both:

```php
Schedule::command('reports:generate')
    ->hourly()
    ->onOneServer()
    ->withoutOverlapping(30);

```

This protects you from two different problems:

```text
1. The same task starting again before it finishes.
2. The same task running on multiple servers at once.

```

---

The Cache Driver Matters
------------------------

Scheduler locks depend on your cache system.

For `withoutOverlapping()` and `onOneServer()` to work reliably across servers, your app should use a shared central cache store.

Good production options include:

```text
Redis
Memcached
Database cache
DynamoDB

```

Bad idea in multi-server production:

```env
CACHE_STORE=file

```

If each server uses its own local file cache, one server may not know that another server already acquired the lock.

In a multi-server setup, use a shared cache store:

```env
CACHE_STORE=redis

```

And make sure all application servers connect to the same Redis instance.

---

The Problem with `schedule:list`
--------------------------------

`php artisan schedule:list` is useful, but it is not monitoring.

It tells you what is scheduled.

It does not prove that a task completed successfully in production.

A task can look correct while still having production problems:

```text
The task is registered.
The cron expression is correct.
The next run time looks right.
But the task is skipped because a lock exists.

```

Or:

```text
The task starts.
The task fails halfway.
The next run time still looks correct.
Nobody notices the data is wrong.

```

`schedule:list` answers:

```text
What is scheduled?

```

It does not fully answer:

```text
What actually ran?
Did it finish?
How long did it take?
Did it fail?
Was it skipped?
Did it overlap?
Which server ran it?

```

For that, you need logs, metrics, alerts, or a proper dashboard.

---

Log Real Execution, Not Just Exceptions
---------------------------------------

At minimum, every important scheduled command should log:

- When it started
- When it finished
- How long it took
- How many records it processed
- Whether it failed
- The exception message
- A unique run ID
- The server hostname

Example:

```php
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;

class SyncOrdersCommand extends Command
{
    protected $signature = 'orders:sync';

    protected $description = 'Sync pending orders with the external provider';

    public function handle(): int
    {
        $runId = (string) Str::uuid();
        $startedAt = microtime(true);

        Log::info('orders:sync started', [
            'run_id' => $runId,
            'host' => gethostname(),
        ]);

        try {
            $processed = 0;

            Order::query()
                ->where('status', 'pending')
                ->chunkById(100, function ($orders) use (&$processed) {
                    foreach ($orders as $order) {
                        SyncOrderJob::dispatch($order->id);
                        $processed++;
                    }
                });

            Log::info('orders:sync finished', [
                'run_id' => $runId,
                'processed' => $processed,
                'duration_ms' => round((microtime(true) - $startedAt) * 1000),
                'host' => gethostname(),
            ]);

            return self::SUCCESS;
        } catch (\Throwable $e) {
            Log::error('orders:sync failed', [
                'run_id' => $runId,
                'duration_ms' => round((microtime(true) - $startedAt) * 1000),
                'message' => $e->getMessage(),
                'exception' => $e::class,
                'host' => gethostname(),
            ]);

            throw $e;
        }
    }
}

```

When production fails, you do not want to guess.

You want to know exactly which run failed, when it failed, where it ran, and what it processed.

---

Use Scheduler Hooks for Visibility
----------------------------------

Laravel scheduled tasks support hooks that run before, after, on success, or on failure.

```php
use Illuminate\Support\Facades\Schedule;

Schedule::command('orders:sync')
    ->everyMinute()
    ->onOneServer()
    ->withoutOverlapping(10)
    ->before(function () {
        logger()->info('orders:sync is starting');
    })
    ->onSuccess(function () {
        logger()->info('orders:sync succeeded');
    })
    ->onFailure(function () {
        logger()->error('orders:sync failed');
    });

```

Hooks are useful for logging, notifications, monitoring, and audit trails.

Keep them focused.

Do not hide heavy business logic inside scheduler hooks.

---

Capture Command Output When Needed
----------------------------------

You can send scheduled command output to a file:

```php
Schedule::command('orders:sync')
    ->everyMinute()
    ->onOneServer()
    ->withoutOverlapping(10)
    ->appendOutputTo(storage_path('logs/orders-sync.log'));

```

Or replace the file each time:

```php
Schedule::command('orders:sync')
    ->everyMinute()
    ->sendOutputTo(storage_path('logs/orders-sync.log'));

```

Use `appendOutputTo()` when you want history.

Use `sendOutputTo()` when you only care about the latest output.

Be careful with large logs and use log rotation.

---

Send Alerts for Critical Failures
---------------------------------

Logs are not enough for critical tasks.

If payment processing fails, someone should know quickly.

```php
Schedule::command('payments:process')
    ->everyMinute()
    ->onOneServer()
    ->withoutOverlapping(10)
    ->onFailure(function () {
        logger()->critical('payments:process failed');

        // Send notification to Slack, email, Discord, etc.
    });

```

A failed payment task should not be discovered hours later by a customer complaint.

---

Make Scheduled Work Idempotent
------------------------------

The safest scheduled task is one that can run twice without causing damage.

That is idempotency.

Bad:

```php
$invoice->sendEmail();

$invoice->update([
    'sent_at' => now(),
]);

```

If the task crashes after sending the email but before updating `sent_at`, the next run may send the email again.

Better:

```php
$claimed = Invoice::query()
    ->whereKey($invoice->id)
    ->whereNull('sent_at')
    ->where('status', 'pending')
    ->update([
        'status' => 'sending',
        'sending_started_at' => now(),
    ]);

if ($claimed === 0) {
    return;
}

$invoice->sendEmail();

$invoice->update([
    'sent_at' => now(),
    'status' => 'sent',
]);

```

The important idea is simple:

```text
Do not trust that only one process will ever touch the record.
Protect the record itself.

```

Even if overlap accidentally happens, your data should still defend itself.

---

Use Transactions Carefully
--------------------------

Transactions can protect consistency, but they can also create performance problems if used badly.

Good:

```php
use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($order) {
    $order = Order::query()
        ->whereKey($order->id)
        ->lockForUpdate()
        ->first();

    if ($order->status !== 'pending') {
        return;
    }

    $order->update([
        'status' => 'processing',
    ]);
});

```

This prevents two processes from updating the same row at the exact same time.

But avoid long transactions.

Bad:

```php
DB::transaction(function () use ($order) {
    $order->update(['status' => 'processing']);

    Http::post('https://api.example.com/process', [
        'order_id' => $order->id,
    ]);

    $order->update(['status' => 'processed']);
});

```

This keeps a database transaction open during an HTTP request.

If the API is slow, your database lock stays open too long.

Better:

```php
$claimed = Order::query()
    ->whereKey($order->id)
    ->where('status', 'pending')
    ->update([
        'status' => 'processing',
    ]);

if ($claimed === 0) {
    return;
}

$response = Http::timeout(10)
    ->post('https://api.example.com/process', [
        'order_id' => $order->id,
    ]);

$order->update([
    'status' => 'processed',
]);

```

Keep transactions short.

Never keep a database lock open while waiting for a slow external service unless you have a very specific reason.

---

Process Work in Batches
-----------------------

Avoid loading all records at once.

Bad:

```php
$orders = Order::where('status', 'pending')->get();

foreach ($orders as $order) {
    // process
}

```

Better:

```php
Order::query()
    ->where('status', 'pending')
    ->chunkById(100, function ($orders) {
        foreach ($orders as $order) {
            SyncOrderJob::dispatch($order->id);
        }
    });

```

Batching helps with memory usage, database performance, retry logic, large datasets, and long-running commands.

For very large systems, the scheduled command should usually find work and dispatch jobs.

The queue should handle the heavy processing.

---

Prefer Queue Jobs for Heavy Work
--------------------------------

A strong production pattern is:

```text
Scheduler -> Command -> Dispatch Jobs -> Queue Workers process jobs

```

Scheduler:

```php
Schedule::command('orders:dispatch-sync-jobs')
    ->everyMinute()
    ->onOneServer()
    ->withoutOverlapping(10);

```

Command:

```php
use Illuminate\Console\Command;

class DispatchOrderSyncJobsCommand extends Command
{
    protected $signature = 'orders:dispatch-sync-jobs';

    public function handle(): int
    {
        Order::query()
            ->where('status', 'pending')
            ->chunkById(100, function ($orders) {
                foreach ($orders as $order) {
                    SyncOrderJob::dispatch($order->id);
                }
            });

        return self::SUCCESS;
    }
}

```

Job:

```php
use Illuminate\Contracts\Queue\ShouldQueue;

class SyncOrderJob implements ShouldQueue
{
    public int $tries = 3;

    public int $timeout = 120;

    public function __construct(
        public int $orderId
    ) {}

    public function handle(): void
    {
        $order = Order::findOrFail($this->orderId);

        // Sync order safely
    }
}

```

This gives you better retries, better scaling, better failure handling, and better visibility.

It also keeps the scheduler process short.

---

Protect Queue Jobs Too
----------------------

Protecting the scheduler is not always enough.

A scheduler may run once, but the command may still dispatch duplicate jobs if the logic is not careful.

Laravel provides queue middleware for preventing overlapping jobs:

```php
use Illuminate\Queue\Middleware\WithoutOverlapping;

public function middleware(): array
{
    return [
        new WithoutOverlapping('order-sync-'.$this->orderId),
    ];
}

```

Example:

```php
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;

class SyncOrderJob implements ShouldQueue
{
    public function __construct(
        public int $orderId
    ) {}

    public function middleware(): array
    {
        return [
            new WithoutOverlapping('order-sync-'.$this->orderId),
        ];
    }

    public function handle(): void
    {
        $order = Order::findOrFail($this->orderId);

        // Sync order
    }
}

```

Use this when multiple jobs could accidentally process the same resource at the same time.

---

Add Job Uniqueness When Needed
------------------------------

For some jobs, you may want Laravel to avoid dispatching duplicates.

Use unique jobs:

```php
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldBeUnique;

class SyncOrderJob implements ShouldQueue, ShouldBeUnique
{
    public function __construct(
        public int $orderId
    ) {}

    public function uniqueId(): string
    {
        return 'sync-order-'.$this->orderId;
    }

    public function handle(): void
    {
        // Sync order
    }
}

```

This helps prevent duplicate jobs from being added to the queue for the same resource.

Use it when a job should only exist once for a specific business entity.

---

Always Use Timeouts for External Calls
--------------------------------------

Scheduled tasks and jobs should not be allowed to hang forever.

For queue jobs:

```php
class SyncOrderJob implements ShouldQueue
{
    public int $timeout = 120;

    public int $tries = 3;
}

```

For HTTP calls:

```php
$response = Http::timeout(10)
    ->retry(3, 500)
    ->post('https://api.example.com/orders', [
        'order_id' => $order->id,
    ]);

```

Without HTTP timeouts, one slow external API can make your scheduled task run far longer than expected.

That is exactly how overlap starts.

---

Be Careful with `runInBackground()`
-----------------------------------

Laravel allows scheduled commands to run in the background:

```php
Schedule::command('reports:generate')
    ->daily()
    ->runInBackground();

```

This can be useful when you do not want one long-running task to block other scheduled tasks.

But it can also hide problems.

If you use `runInBackground()`, make sure you still have:

- Overlap protection
- Output logging
- Failure monitoring
- Timeouts
- Clear ownership of the process

For critical tasks:

```php
Schedule::command('reports:generate')
    ->daily()
    ->onOneServer()
    ->withoutOverlapping(60)
    ->runInBackground()
    ->appendOutputTo(storage_path('logs/reports-generate.log'));

```

Use it intentionally.

Do not add it just because a task is slow.

A slow task usually needs better design, not just background execution.

---

Sub-Minute Tasks Need Extra Care
--------------------------------

Modern Laravel supports scheduling tasks more frequently than once per minute.

```php
Schedule::command('metrics:collect')->everyTenSeconds();

```

This can be useful, but it increases overlap risk.

If a task runs every 10 seconds and sometimes takes 20 seconds, overlap can happen quickly.

Protect it:

```php
Schedule::command('metrics:collect')
    ->everyTenSeconds()
    ->withoutOverlapping();

```

But also ask a serious architecture question:

```text
Should this really be a scheduled task?
Would a queue worker, daemon, event, or stream processor be better?

```

High-frequency scheduling should not be used casually in production.

---

Safe Record Claiming Pattern
----------------------------

One of the best ways to prevent duplicate processing is to claim records atomically.

```php
$claimed = Order::query()
    ->whereKey($order->id)
    ->where('status', 'pending')
    ->update([
        'status' => 'processing',
        'processing_started_at' => now(),
    ]);

if ($claimed === 0) {
    return;
}

```

This means only one process can move the record from `pending` to `processing`.

If another process already claimed it, the update returns `0`, and your command skips the record safely.

This protects you even if overlap accidentally happens.

---

Add Recovery for Stuck Records
------------------------------

If you mark records as `processing`, you also need recovery.

A job may crash after claiming a record:

```text
pending -> processing -> crash

```

Now the record is stuck.

Add a cleanup rule:

```php
Order::query()
    ->where('status', 'processing')
    ->where('processing_started_at', '
