skip to content

In Laravel's scheduler, what does withoutOverlapping() do, where is its lock stored, and how long does that lock last by default?

level: middleimportance: must knowfreq 55%

answer

  1. by default runs pile up
  2. a cache lock named per task
  3. expression plus command in the key
  4. 1440 minutes unless you pass one
  5. released when the run finishes

basics

~20 s

withoutOverlapping() 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 s

By 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
<?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

for a junior

Know that tasks overlap by default and that withoutOverlapping() skips a run while the previous one holds its lock.

for a middle

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.

for a senior

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.

for a principal

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.