In Laravel's scheduler, what does withoutOverlapping() do, where is its lock stored, and how long does that lock last by default?
answer
- by default runs pile up
- a cache lock named per task
- expression plus command in the key
- 1440 minutes unless you pass one
- released when the run finishes
basics
~20 swithoutOverlapping() makes the scheduler take a cache lock before running a task and skip the task while that lock exists, so a slow run is not joined by a second copy. The lock expires after 1440 minutes unless you pass another value.
solid answer
~50 sBy default Laravel starts a task whenever it is due, even if the previous run is still going. `->withoutOverlapping()` adds a skip filter plus a lock: before running, the scheduler tries to create a mutex in the cache named after the task — by default a hash of its cron expression and command — and skips the run if it cannot. The lock is stored in the default cache store (or the one set with `Schedule::useCache()`), with a TTL of `expiresAt` minutes, **1440 (24 hours)** by default; `->withoutOverlapping(10)` makes it ten minutes. It is released when the run finishes or throws, and for foreground tasks also when `schedule:run` receives SIGTERM, SIGINT or SIGQUIT and the `pcntl` extension is loaded. Closures need `->name()` first, and on `Schedule::job()` the lock covers only the dispatch, not the job running on a worker.
code
php · 15 lines<?php
use App\Services\ReportBuilder;
use Illuminate\Support\Facades\Schedule;
// Lock expires after 15 minutes instead of the default 1440
Schedule::command('reports:generate')
->everyFiveMinutes()
->withoutOverlapping(15);
// A closure must be named before it can be locked
Schedule::call(fn (ReportBuilder $reports) => $reports->refreshTotals())
->name('reports:refresh-totals')
->everyMinute()
->withoutOverlapping(5);go deeper
Know that tasks overlap by default and that withoutOverlapping() skips a run while the previous one holds its lock.
Explain the cache mutex: the name from expression plus command, the TTL in minutes defaulting to 1440, and release on finish, exception or a trapped signal.
Set expiries near the worst run time, share the cache store across scheduler hosts, name locked closures, and know the lock only guards dispatch for queued jobs.
Decide which duplicate-run guarantees belong in the scheduler, the queue, or the task's own idempotency, rather than stacking locks everywhere.
## The problem: runs pile up Laravel's scheduler has no memory of previous runs. If `reports:generate` is scheduled `everyFiveMinutes()` and one run takes seven minutes, the next `schedule:run` starts a second copy while the first is still writing. Two copies of a report generator can duplicate rows, fight over files, or double the database load exactly when the system is already slow. `withoutOverlapping()` is the per-task guard against that. ```php use Illuminate\Support\Facades\Schedule; Schedule::command('reports:generate') ->everyFiveMinutes() ->withoutOverlapping(15); ``` ## How the lock works Calling `withoutOverlapping($expiresAt = 1440, $releaseOnTerminationSignals = true)` does three things to the event: 1. Sets its `withoutOverlapping` flag and its `expiresAt` value in **minutes**. 2. Adds a `skip()` filter that asks the event mutex whether the lock already exists; if it does, the run is skipped and `ScheduledTaskSkipped` is dispatched. 3. At run time, tries to **create** the mutex; if another process created it first, the run is skipped. The default event mutex, `CacheEventMutex`, uses the cache: - If the store supports atomic locks (it implements `LockProvider`, which database, Redis, Memcached and file stores do — DynamoDB is deliberately handled the other way), it acquires a cache lock with a TTL of `expiresAt * 60` seconds. - Otherwise it falls back to `Cache::add()` of a key with the same TTL. The **mutex name** is `framework/schedule-` plus a SHA-1 of the task's cron expression and normalised command. Two consequences: the same command scheduled at two different frequencies gets two different locks, so they can overlap each other; and a closure has no command string, so `Schedule::call()` must be given `->name('...')` before `withoutOverlapping()`, or a `LogicException` is thrown. ## When the lock is released | Situation | Lock released? | | --- | --- | | Run completes (any exit code) | Yes, after the after-hooks run | | Run throws before or during start | Yes | | Foreground task, `schedule:run` gets SIGTERM/SIGINT/SIGQUIT, `pcntl` loaded | Yes, then the process exits | | Background task (`runInBackground()`) | When the background command reports completion | | Only the child command process is killed | Yes — `schedule:run` sees the non-zero exit code and finishes normally | | `schedule:run` itself killed with SIGKILL, or the host dies | **No** — only when the TTL expires | The default 1440-minute TTL is a safety net for that last row: a crashed run blocks the task for up to a day. Pick an expiry comfortably above the longest normal run — `withoutOverlapping(15)` for a job that takes at most seven — so a crash costs minutes, not a day. ## Which cache, and which servers The lock lives in the scheduler's cache store: the default store unless `Schedule::useCache('redis')` (or the console kernel's `cache.schedule_store` config / `SCHEDULE_CACHE_STORE` env) points elsewhere. With a store local to one machine (`file`, `array`), the guard only stops overlaps on that machine. Stopping the same task from running on several servers in the same minute is a different option, `onOneServer()`; the two are often combined. ## What it does not cover - **Queued jobs.** `Schedule::job(new BuildReport)->withoutOverlapping()` guards only the moment of dispatch, which takes milliseconds. The job itself runs later on a worker, where overlap is controlled by queue-level tools such as unique jobs or job middleware. - **Manual runs.** `php artisan reports:generate` typed by hand does not take the scheduler's lock. - **Sibling tasks.** Two different commands touching the same table are not protected from each other. ## Checklist - Set an explicit expiry close to, but above, the worst realistic run time. - Name every closure you protect. - Use a shared cache store when more than one host runs the scheduler. - Watch `ScheduledTaskSkipped`: a task that skips every minute is usually holding a stale lock. ## Why interviewers ask it The option is one line of code, so the question is really about the model underneath: the scheduler is stateless between minutes, so any memory of a running task must live outside the process, in the cache. Candidates who see that can predict the rest — why the store matters across hosts, why a lock needs an expiry, why a closure needs a name to be locked, and why a queued job escapes the lock entirely.
- Why can the same Laravel command scheduled hourly and daily overlap even when both use withoutOverlapping()?The default mutex name is a hash of the cron expression plus the command string, so `0 * * * *` and `0 0 * * *` versions of `reports:generate` hold different locks. At midnight both are due and neither sees the other's lock. Give them a shared lock name with `createMutexNameUsing()`, or merge them into one task.
- Does withoutOverlapping() on Schedule::job in Laravel stop two copies of the job running on queue workers?No. The scheduled event only dispatches the job, so the lock is taken and released around a dispatch that takes milliseconds. If the previous job is still running on a worker, the next tick dispatches another. Overlap between queued jobs is controlled on the queue side, with unique jobs or job middleware.
saying these in an interview costs you the question
- Laravel never starts a task while its previous run is still going, even without extra options.
- withoutOverlapping() tracks running tasks by process ID in memory.
- The default lock expiry for withoutOverlapping() is one minute.
- withoutOverlapping() on a scheduled closure works without calling name().
- withoutOverlapping() on Schedule::job prevents two copies running on workers.