How does implementing `Isolatable` on a Laravel command prevent overlapping runs, and what should `isolatableId()` return for a per-customer invoice re-send command?
answer
- only active with --isolated
- atomic lock in the default cache store
- skipped run still exits 0 by default
- lock freed at the end, or after one hour
- isolatableId(): scope the lock per customer
basics
~20 sImplementing Isolatable adds an --isolated option; with it, Laravel takes an atomic cache lock named after the command before handle() runs and skips the run if the lock is held. isolatableId() should return the customer ID so different customers can run at once.
solid answer
~40 sImplementing `Illuminate\Contracts\Console\Isolatable` makes Laravel add an `--isolated` option to the command. Only when a run passes `--isolated` does Laravel try to take an **atomic lock** in the application's default cache store before calling `handle()`. If another run holds it, the command prints that it is already running and exits — with code 0 by default, or with the code you pass, as in `--isolated=12`. The lock is released when the command finishes, and expires after one hour if the process dies first; `isolationLockExpiresAt()` changes that. The key is the command name, plus `isolatableId()` if you define it. For `invoices:resend-failed {customer}`, return `$this->argument('customer')`: two runs for customer 42 cannot overlap, while customers 42 and 43 proceed in parallel. The cache store must support locks and be shared by every server that runs the command.
code
php · 28 lines<?php
namespace App\Console\Commands;
use DateInterval;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Isolatable;
#[Signature('invoices:resend-failed {customer}')]
class ResendFailedInvoices extends Command implements Isolatable
{
public function isolatableId(): string
{
return (string) $this->argument('customer');
}
public function isolationLockExpiresAt(): DateInterval
{
return new DateInterval('PT3H');
}
public function handle(): int
{
// ...
return self::SUCCESS;
}
}go deeper
Recall that Isolatable adds an --isolated option that stops two copies of the command running at once.
Explain the cache lock behind it, the one-hour default expiry, the default exit code of 0 for skipped runs, and isolatableId() for scoping.
Show production judgement: shared lock-capable cache, flag on every invocation path, expiry above worst-case runtime, visible skips, and idempotent work underneath.
Decide where overlap protection belongs — command, schedule or job — and how the team makes skipped and duplicated work observable.
## The problem it solves Some commands must not run twice at the same time. If two operators — or an operator and a scheduled run — start `invoices:resend-failed 42` together, both read the same list of failed invoices and the customer gets every email twice. Laravel's answer for console commands is the `Illuminate\Contracts\Console\Isolatable` interface: a marker interface with no methods that tells Artisan to guard the command with a lock. ## How it works 1. **The option.** When a command implements `Isolatable`, Laravel adds an `--isolated` option to its definition. You do not declare it in the signature. 2. **Opt-in per run.** The guard applies only when the run passes `--isolated`. Without the flag, the command runs unguarded — so every invocation path that matters (the schedule entry, the runbook, the deploy script) should pass it. 3. **The lock.** Before `handle()` runs, Laravel's command mutex tries to acquire an **atomic lock** in the default cache store, named after the command (`framework/command-invoices:resend-failed`), with `-` and the value of `isolatableId()` appended when that method exists. 4. **When the lock is taken.** The command prints `The [invoices:resend-failed] command is already running.` and returns without calling `handle()`. The exit code is `0` by default; `--isolated=12` makes it `12` instead. 5. **Release.** After `handle()` finishes — normally or by exception — the lock is released in a `finally` block. 6. **Expiry.** If the process dies before it can release the lock, the lock expires after **one hour** by default. Define `isolationLockExpiresAt()` returning a `DateTimeInterface` or `DateInterval` to change it. ## Scoping the lock with `isolatableId()` Without `isolatableId()`, the lock covers the command name only: while customer 42 is being processed, a run for customer 43 is refused too. For a per-customer command that is needlessly strict. Return the argument that identifies the unit of work: ```php public function isolatableId(): string { return (string) $this->argument('customer'); } ``` Now `invoices:resend-failed 42 --isolated` and `invoices:resend-failed 43 --isolated` run in parallel, while a second run for 42 is skipped. ## Requirements | Requirement | Why | |---|---| | Default cache store is `memcached`, `redis`, `dynamodb`, `database`, `file` or `array` | the lock is taken through the cache | | Every server uses the same central cache | a `file` or `array` store only sees one machine or one process | | Class-based command | a closure command cannot implement an interface | The Laravel 13 skeleton's default cache store is `database`, which is shared by every server using the same database, so isolation works across servers out of the box; a store swapped to `file` on a multi-server setup would silently stop protecting you. ## Pitfalls - **Skipped runs look successful.** The default exit code for "already running" is `0`, so cron, CI and the scheduler report success. Pass `--isolated=<code>` if a skip should be visible. - **Forgetting the flag.** Implementing the interface does nothing for runs without `--isolated`. - **Expiry versus runtime.** If a run can take longer than the lock lifetime, a second run may start while the first is still working. Set `isolationLockExpiresAt()` above the realistic worst case. - **Crashes that skip `finally`.** A killed process cannot release the lock; the next run waits for expiry. - **Isolation is not idempotency.** It stops *simultaneous* runs; a second run after the first finishes will still re-send anything the first did not mark as sent. Record what was sent. ## A worked example An operator starts `php artisan invoices:resend-failed 42 --isolated` at 10:00; it takes the lock `framework/command-invoices:resend-failed-42`. At 10:02 a colleague runs the same command for customer 42: it prints that the command is already running and exits `0` without calling `handle()`. At 10:03 someone runs it for customer 43: its key ends in `-43`, so it proceeds in parallel. At 10:20 the first run finishes and releases its lock, and the next run for customer 42 would proceed normally. ## How it relates to other guards `--isolated` protects any invocation of the command — by hand, from CI, or from the scheduler — because it lives in the command itself. The scheduler's own overlap options and job-level uniqueness are separate mechanisms owned by those features; the lock primitive underneath all of them is the cache lock.
- Why might a monitoring dashboard show an isolated command as succeeding every minute even though it never did any work?When the lock is already held, the command returns before `handle()` with the default isolated exit code, which is `0` (success). If a long first run holds the lock, every later attempt is skipped but reported as success. Pass `--isolated=<non-zero>` or alert on the command's own output so skips are visible.
- Your re-send command sometimes runs for two hours. What happens with the default lock settings, and how do you fix it?The default lock lifetime is one hour, so after an hour the lock can expire and a second `--isolated` run can start while the first is still sending. Define `isolationLockExpiresAt()` to return a lifetime above the realistic worst case — say three hours — and consider splitting the work so one run cannot take that long.
saying these in an interview costs you the question
- Implementing Isolatable protects every run even without the --isolated flag.
- A skipped isolated run exits with a failure code by default.
- Isolation works across servers even with the file cache driver.
- The isolation lock never expires if the process crashes.
- Isolatable guarantees the command never processes the same data twice.