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:
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:
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:
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?
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:
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:
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.
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:
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
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:
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.
$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:
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.
use Illuminate\Support\Facades\Schedule;
Schedule::command('orders:sync')
->everyMinute()
->withoutOverlapping();
This tells Laravel:
Do not start this task if the previous run is still active.
So instead of this:
12:00 -> starts
12:01 -> starts again
12:02 -> starts again
You get this:
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:
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:
Schedule::command('orders:sync')
->everyMinute()
->withoutOverlapping(1440);
This can block the task for up to 24 hours.
Better:
Schedule::command('orders:sync')
->everyMinute()
->withoutOverlapping(10);
A practical starting point:
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:
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():
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:
Schedule::command('reports:generate')
->hourly()
->onOneServer()
->withoutOverlapping(30);
This protects you from two different problems:
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:
Redis
Memcached
Database cache
DynamoDB
Bad idea in multi-server production:
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:
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:
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:
The task starts.
The task fails halfway.
The next run time still looks correct.
Nobody notices the data is wrong.
schedule:list answers:
What is scheduled?
It does not fully answer:
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:
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.
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:
Schedule::command('orders:sync')
->everyMinute()
->onOneServer()
->withoutOverlapping(10)
->appendOutputTo(storage_path('logs/orders-sync.log'));
Or replace the file each time:
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.
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:
$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:
$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:
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:
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:
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:
$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:
$orders = Order::where('status', 'pending')->get();
foreach ($orders as $order) {
// process
}
Better:
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:
Scheduler -> Command -> Dispatch Jobs -> Queue Workers process jobs
Scheduler:
Schedule::command('orders:dispatch-sync-jobs')
->everyMinute()
->onOneServer()
->withoutOverlapping(10);
Command:
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:
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:
use Illuminate\Queue\Middleware\WithoutOverlapping;
public function middleware(): array
{
return [
new WithoutOverlapping('order-sync-'.$this->orderId),
];
}
Example:
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:
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:
class SyncOrderJob implements ShouldQueue
{
public int $timeout = 120;
public int $tries = 3;
}
For HTTP calls:
$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:
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:
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.
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:
Schedule::command('metrics:collect')
->everyTenSeconds()
->withoutOverlapping();
But also ask a serious architecture question:
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.
$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:
pending -> processing -> crash
Now the record is stuck.
Add a cleanup rule:
Order::query()
->where('status', 'processing')
->where('processing_started_at', '<', now()->subMinutes(30))
->update([
'status' => 'pending',
'processing_started_at' => null,
]);
Schedule it carefully:
Schedule::command('orders:release-stuck')
->everyTenMinutes()
->onOneServer()
->withoutOverlapping();
This prevents records from staying stuck forever.
Use Database Constraints Where Possible
Application-level checks are important.
Database-level constraints are stronger.
If duplicate records must never exist, enforce that at the database level.
Example:
Schema::table('external_syncs', function (Blueprint $table) {
$table->unique(['provider', 'external_id']);
});
Then even if two processes try to insert the same sync record, the database protects you.
Your application should be safe, but your database should enforce critical rules.
Example: Unsafe Invoice Sending
Risky command:
public function handle(): int
{
$invoices = Invoice::query()
->whereNull('sent_at')
->get();
foreach ($invoices as $invoice) {
Mail::to($invoice->customer_email)->send(new InvoiceMail($invoice));
$invoice->update([
'sent_at' => now(),
]);
}
return self::SUCCESS;
}
Problems:
- Loads all invoices at once
- No batching
- No atomic claiming
- Email can be sent twice if overlap happens
- Crash after email send can cause duplicate email later
- No job-level protection
- Weak visibility
A safer approach is to let the scheduler dispatch jobs:
public function handle(): int
{
Invoice::query()
->where('status', 'pending')
->chunkById(100, function ($invoices) {
foreach ($invoices as $invoice) {
SendInvoiceJob::dispatch($invoice->id);
}
});
return self::SUCCESS;
}
Scheduler:
Schedule::command('invoices:dispatch-send-jobs')
->everyMinute()
->onOneServer()
->withoutOverlapping(10);
Job:
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\Mail;
class SendInvoiceJob implements ShouldQueue
{
public int $tries = 3;
public int $timeout = 120;
public function __construct(
public int $invoiceId
) {}
public function middleware(): array
{
return [
new WithoutOverlapping('invoice-send-'.$this->invoiceId),
];
}
public function handle(): void
{
$claimed = Invoice::query()
->whereKey($this->invoiceId)
->where('status', 'pending')
->update([
'status' => 'sending',
'sending_started_at' => now(),
]);
if ($claimed === 0) {
return;
}
$invoice = Invoice::findOrFail($this->invoiceId);
Mail::to($invoice->customer_email)->send(new InvoiceMail($invoice));
$invoice->update([
'status' => 'sent',
'sent_at' => now(),
]);
}
}
This is much safer because the scheduler is short, the work is queued, the invoice is claimed before sending, and duplicate processing is harder.
Example: Safer API Sync
Unsafe version:
public function handle(): int
{
$products = Product::all();
foreach ($products as $product) {
Http::post('https://api.example.com/products', [
'sku' => $product->sku,
'price' => $product->price,
]);
}
return self::SUCCESS;
}
Problems:
- Loads all products
- No timeout
- No retry strategy
- No batching
- No status tracking
- No idempotency key
- Can overlap and duplicate requests
Safer command:
public function handle(): int
{
Product::query()
->where('needs_sync', true)
->chunkById(100, function ($products) {
foreach ($products as $product) {
SyncProductJob::dispatch($product->id);
}
});
return self::SUCCESS;
}
Job:
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Http;
class SyncProductJob implements ShouldQueue
{
public int $tries = 3;
public int $timeout = 60;
public function __construct(
public int $productId
) {}
public function handle(): void
{
$product = Product::findOrFail($this->productId);
if (! $product->needs_sync) {
return;
}
$response = Http::timeout(10)
->retry(3, 500)
->withHeaders([
'Idempotency-Key' => 'product-sync-'.$product->id.'-'.$product->updated_at->timestamp,
])
->post('https://api.example.com/products', [
'sku' => $product->sku,
'price' => $product->price,
]);
if ($response->successful()) {
$product->update([
'needs_sync' => false,
'synced_at' => now(),
]);
}
}
}
Scheduler:
Schedule::command('products:dispatch-sync-jobs')
->everyFiveMinutes()
->onOneServer()
->withoutOverlapping(15);
The goal is not only to prevent overlap.
The goal is to make the whole workflow safe even if something unexpected happens.
Think About Deployment Behavior
Deployments can interrupt scheduled tasks.
If your deployment restarts PHP, queue workers, containers, or supervisor processes, a scheduled command may stop halfway.
Before deploying scheduled tasks, ask:
What happens if the command is killed during processing?
What happens if the lock remains?
What happens if records are left in processing status?
What happens if the task runs again after deployment?
Use:
- Reasonable lock expiration
- Idempotency
- Atomic record claiming
- Retryable jobs
- Stuck-record cleanup
- Proper queue restart strategy
- Monitoring
Production tasks should be safe under interruption.
Do Not Schedule the Same Work Twice
Sometimes the problem is not overlap.
Sometimes the same work is scheduled in more than one place.
Examples:
Schedule::command('orders:sync')->everyMinute();
Schedule::job(new SyncOrdersJob)->everyMinute();
Or the same command is registered in two service providers.
Or cron is configured manually while Laravel scheduler also runs the same command.
Check your Laravel schedule:
php artisan schedule:list
And check your server cron:
crontab -l
In most Laravel apps, you should have one scheduler cron entry:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
Avoid manually adding separate cron entries for the same Laravel commands unless you have a clear reason.
Do Not Run the Scheduler on Every Container by Accident
In containerized deployments, it is easy to accidentally run the scheduler in every app container.
A cleaner pattern is usually:
web container -> handles HTTP
queue container -> handles queue jobs
scheduler container -> runs the scheduler
If you do run the scheduler in multiple containers, use onOneServer() for tasks that must only run once.
But operationally, a dedicated scheduler process is often easier to reason about.
Monitor Scheduler Health
Monitoring should answer:
Is the scheduler running?
Are important tasks finishing?
Are tasks taking longer than usual?
Are tasks failing?
Are tasks being skipped?
Are locks stuck?
At minimum, track:
- Last run time
- Last success time
- Last failure time
- Duration
- Exit code
- Processed records
- Error message
- Hostname
- Task output
You can store this in a database table:
Schema::create('scheduled_task_runs', function (Blueprint $table) {
$table->id();
$table->string('task');
$table->uuid('run_id');
$table->string('status');
$table->unsignedInteger('processed_count')->default(0);
$table->unsignedInteger('duration_ms')->nullable();
$table->text('error_message')->nullable();
$table->string('hostname')->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('finished_at')->nullable();
$table->timestamps();
});
This gives you real production history instead of guessing from logs.
Watch for Skipped Runs
When you use withoutOverlapping(), skipped runs may be expected.
But frequent skipped runs are a warning sign.
If a task scheduled every minute is skipped most of the time because the previous run is still active, one of these is probably true:
- The task is doing too much work.
- The schedule frequency is too aggressive.
- Database queries are slow.
- External APIs are slow.
- The lock expiration is wrong.
- The task should be queue-based.
- The task needs batching.
- The system needs more workers.
Skipping is better than overlapping, but it still tells you something important.
Do not ignore it.
Common Production Mistakes
Mistake 1: Scheduling Critical Tasks Every Minute Without Protection
Bad:
Schedule::command('payments:process')->everyMinute();
Better:
Schedule::command('payments:process')
->everyMinute()
->onOneServer()
->withoutOverlapping(10);
Mistake 2: Assuming schedule:list Means Everything Is Healthy
schedule:list shows what is planned.
It does not prove successful execution.
Mistake 3: Using Local Cache in Multi-Server Deployments
Bad:
CACHE_STORE=file
Better:
CACHE_STORE=redis
Mistake 4: Processing All Records at Once
Bad:
User::where('active', true)->get();
Better:
User::where('active', true)
->chunkById(500, function ($users) {
// process users
});
Mistake 5: No HTTP Timeout
Bad:
Http::post('https://api.example.com/sync', $payload);
Better:
Http::timeout(10)
->retry(3, 500)
->post('https://api.example.com/sync', $payload);
Mistake 6: No Idempotency
Bad:
Mail::to($user)->send(new WelcomeMail($user));
Better:
if (! $user->welcome_email_sent_at) {
Mail::to($user)->send(new WelcomeMail($user));
$user->update([
'welcome_email_sent_at' => now(),
]);
}
Even better: claim the record before sending.
Mistake 7: No Failure Alerts
Logs are useful.
But critical scheduled tasks need alerts.
If payment processing fails, someone should know quickly.
Mistake 8: Lock Expiration Is Too Long
Bad:
Schedule::command('orders:sync')
->everyMinute()
->withoutOverlapping(1440);
Better:
Schedule::command('orders:sync')
->everyMinute()
->withoutOverlapping(10);
Use a value that matches real runtime expectations.
Recommended Production Pattern
For important scheduled tasks, start with this pattern:
use Illuminate\Support\Facades\Schedule;
Schedule::command('orders:dispatch-sync-jobs')
->everyMinute()
->onOneServer()
->withoutOverlapping(10)
->appendOutputTo(storage_path('logs/orders-dispatch-sync-jobs.log'))
->onFailure(function () {
logger()->error('orders:dispatch-sync-jobs failed');
});
Then inside the command:
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class DispatchOrderSyncJobsCommand extends Command
{
protected $signature = 'orders:dispatch-sync-jobs';
public function handle(): int
{
$runId = (string) Str::uuid();
$startedAt = microtime(true);
$processed = 0;
Log::info('orders dispatch started', [
'run_id' => $runId,
'host' => gethostname(),
]);
Order::query()
->where('status', 'pending')
->chunkById(100, function ($orders) use (&$processed) {
foreach ($orders as $order) {
SyncOrderJob::dispatch($order->id);
$processed++;
}
});
Log::info('orders dispatch finished', [
'run_id' => $runId,
'processed' => $processed,
'duration_ms' => round((microtime(true) - $startedAt) * 1000),
'host' => gethostname(),
]);
return self::SUCCESS;
}
}
And inside the job:
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\Http;
class SyncOrderJob implements ShouldQueue
{
public int $tries = 3;
public int $timeout = 120;
public function __construct(
public int $orderId
) {}
public function middleware(): array
{
return [
new WithoutOverlapping('sync-order-'.$this->orderId),
];
}
public function handle(): void
{
$claimed = Order::query()
->whereKey($this->orderId)
->where('status', 'pending')
->update([
'status' => 'syncing',
'syncing_started_at' => now(),
]);
if ($claimed === 0) {
return;
}
$order = Order::findOrFail($this->orderId);
$response = Http::timeout(10)
->retry(3, 500)
->post('https://api.example.com/orders', [
'order_id' => $order->id,
'total' => $order->total,
]);
if ($response->successful()) {
$order->update([
'status' => 'synced',
'synced_at' => now(),
]);
return;
}
$order->update([
'status' => 'sync_failed',
]);
}
}
This pattern gives you:
- Scheduler-level overlap protection
- Multi-server protection
- Short scheduler execution
- Queue-based processing
- Job-level overlap protection
- Atomic record claiming
- HTTP timeouts
- Retry behavior
- Logging
- Better production visibility
When You Should Not Use withoutOverlapping()
Do not blindly use withoutOverlapping() everywhere.
Some tasks are safe to run more than once.
Examples:
- Read-only health checks
- Local cache warming
- Metrics collection designed per server
- Tasks intentionally running on every server
- Lightweight stateless commands
But for tasks that write data, call external APIs, send messages, or process business workflows, overlap protection is usually a smart default.
Production Checklist for Laravel Scheduled Tasks
Before deploying a scheduled task, ask:
Can this task run twice at the same time?
What happens if it overlaps?
What happens if it runs on two servers?
What happens if it crashes halfway?
What happens if the server restarts?
What happens if the external API is slow?
What happens if the database query becomes slow?
Is the task idempotent?
Does it process records in chunks?
Does it dispatch jobs?
Are jobs protected from overlap?
Is the cache store shared across servers?
Is there a reasonable lock expiration?
Is there output logging?
Is there failure monitoring?
Is there a way to recover stuck records?
Is there a maximum runtime alert?
If you cannot answer these questions, the scheduled task is not production-ready.
Final Thoughts
Laravel makes scheduled tasks easy to write.
But production scheduling is not only about writing:
Schedule::command('something')->everyMinute();
The real question is:
What happens when this task is slow, stuck, duplicated, interrupted, or running on multiple servers?
That is where production bugs happen.
A task can look correct in schedule:list and still fail silently.
A task can have the right cron expression and still duplicate work.
A task can run on time and still corrupt business logic.
For production Laravel applications, protect scheduled tasks like you protect API endpoints, queues, and database writes.
Use withoutOverlapping() when a task must not run again before it finishes.
Use onOneServer() when a task must only run once across multiple servers.
Use a shared cache store like Redis for reliable distributed locks.
Make commands short.
Move heavy work to queues.
Make jobs idempotent.
Use timeouts.
Log real execution.
Monitor failures.
Recover stuck records.
Because the most dangerous scheduled task is not the one that fails loudly.
It is the one that quietly runs twice.