How to Prevent Overlapping Artisan Commands

Command Isolation with Isolatable In Laravel

Command Isolation with Isolatable In Laravel


Running recurring maintenance tasks, report generators or synchronization jobs via the Laravel scheduler is normal practice in a production system. But what happens if a long-running command takes more time to finish than the interval between its executions?

If the inventory sync command from the example takes 5 minutes to run but sometimes fails and has to be retried after 8 minutes, then the Laravel scheduler will happily launch the second instance while the first is still running.

This results in both processes trying to read the same database rows, process the same webhook events, potentially deadlocking the database or consuming all the server's memory. 

But with the withoutOverlapping() scheduler method, we can prevent the same command from being executed more than once at the same time. However, if someone runs the command manually or your deployment process executes it outside of the scheduler, this protection will not be applied.

Laravel provides an elegant solution for this problem and in this article, we'll explore how we can prevent overlapping command executions from different sources. We'll learn how to use Laravel's Isolatable contract to lock the command at the class level, customize the driver and timeout value, configure the cache key and clean up the background process if the script is interrupted.

Why withoutOverlapping() Is Not Always Enough

In Laravel's task scheduler (routes/console.php or app/Console/Kernel.php), you can prevent overlapping runs like this:

Schedule::command('reports:aggregate')->hourly()->withoutOverlapping();

This works well when jobs are triggered exclusively by the scheduler cron. However, withoutOverlapping() has some limitations,

  • it only applies to the scheduled event definition, not the command itself.
  • So if someone runs php artisan reports:aggregate manually over ssh, it will run at the same time as the scheduled process. 
  • if a queue worker or deployment webhook triggers the command with Artisan::call(), overlapping is not prevented. 

By implementing the Isolatable contract, it moves the concurrency lock into the command class itself, thus preventing multiple instances of the command, regardless of where or how it was triggered.

Step 1: Implementing the Isolatable Interface

To isolate a command, implement the Illuminate\Contracts\Console\Isolatable contract on your command class. No boilerplate lock methods are required. Laravel checks for this interface automatically before executing handle().

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Isolatable;

class SyncProductCatalogCommand extends Command implements Isolatable
{
    protected $signature = 'catalog:sync';
    protected $description = 'Sync product catalog from external supplier';

    public function handle(): int
    {
        $this->info('Starting catalog synchronization...');

        // Simulate long-running work
        sleep(10);
        $this->info('Catalog synchronized successfully.');
        return Command::SUCCESS;
    }
}

If you run php artisan catalog:sync in one terminal tab and immediately execute it in a second tab, the second command exits immediately with the following console output:

Command [catalog:sync] is already running.

The second execution exits with a non-zero status code (Command::FAILURE), preventing race conditions without executing a single line of business logic.

Step 2: Customizing Lock Expiration and Keys

By default Laravel will attempt to acquire an atomic cache lock using the command's class name or signature as the key. If for some reason your server goes down or is rebooted during the command's execution, you do not want the command to always be locked and unavailable to run again until the previously running instance has completed.

Laravel provides two additional methods you may use to override the default behavior of locking commands: isolationLockExpiresAt() and isolationKey().

namespace App\Console\Commands;

use DateTimeInterface;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Isolatable;

class GenerateMonthlyBillingCommand extends Command implements Isolatable
{
    protected $signature = 'billing:process {--tenant= : Optional tenant ID}';
    protected $description = 'Generate monthly billing statements';

    /**
     * Determine when the isolation lock expires.
     * Prevents permanent deadlocks if the process crashes unexpectedly.
     */
    public function isolationLockExpiresAt(): DateTimeInterface
    {
        // Expire the lock after 15 minutes
        return now()->addMinutes(15);
    }

    /**
     * Define the unique cache key for the lock.
     * Allows concurrent runs for different tenants while isolating identical ones.
     */
    public function isolationKey(): string
    {
        $tenantId = $this->option('tenant');

        return $tenantId ? "billing:process:tenant:{$tenantId}" : 'billing:process:global';
    }

    public function handle(): int
    {
        $tenant = $this->option('tenant') ?? 'All';
        $this->info("Processing billing for: {$tenant}");

        // Heavy billing calculation
        sleep(5);
        return Command::SUCCESS;
    }
}

With isolationKey() customized as shown above, running php artisan billing:process --tenant=1 and php artisan billing:process --tenant=2 simultaneously works without interference. However, triggering a duplicate run for --tenant=1 is blocked until the first completes.

Step 3: Configuring the Isolation Cache Store

Under the hood, command isolation utilizes Laravel's atomic lock features (Cache::lock()). By default, it uses your default cache store configured in config/cache.php.

If your default cache driver doesn't support atomic locks (e.g. the file or database driver, unless you have table locking enabled, though database locks are supported in modern versions), or you want locks to be stored in a centralized redis cluster separate than your local cache, you can specify the driver directly via isolationLockDriver():

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Isolatable;

class DispatchWebhooksCommand extends Command implements Isolatable
{
    protected $signature = 'webhooks:dispatch';

    /**
     * Specify a dedicated cache store for the isolation lock.
     */
    public function isolationLockDriver(): string
    {
        return 'redis';
    }

    public function handle(): int
    {
        // Webhook processing logic
        return Command::SUCCESS;
    }
}

In a multi-server setup behind a load balancer, using a shared Redis or Memcached store guarantees that server A and server B will never execute the same isolated command simultaneously.

Step 4: Running Isolated Commands in the Background

When running long-running tasks in CI/CD pipelines or deployment scripts, you often want the commands to execute in a background process so that the main script can continue right away.

Using Linux Shell Operators

On Unix servers, you can run an Artisan command in the background and redirect output using standard shell redirection:

# Run in background, redirect STDOUT and STDERR to a log file
php artisan catalog:sync > /var/log/catalog_sync.log 2>&1 &

Because the command implements Isolatable, if a cron job fires that same line before the background process finishes, the second run simply logs Command [catalog:sync] is already running. and terminates cleanly.

Background Scheduling via the Scheduler

If you run tasks through the Laravel scheduler, use runInBackground() alongside standard scheduling options:

use Illuminate\Support\Facades\Schedule;

Schedule::command('catalog:sync')
    ->everyTenMinutes()
    ->runInBackground();

By pairing runInBackground() with the Isolatable interface, the scheduler process spawns child sub-processes without waiting for completion, while the command's own isolation layer guarantees that parallel instances never collide.

withoutOverlapping() vs. Isolatable Contract

Feature Schedule withoutOverlapping() Isolatable Interface
Scope Applies only to scheduler runs. Applies to CLI, Scheduler, and Artisan::call().
Definition Point Console route file or schedule definition. Directly inside the Command class.
Multi-Tenant Custom Keys Requires custom mutex classes or parameters. Handled via isolationKey() using CLI options.
Lock Storage Cache mutex. Cache lock driver (configurable via method).

Important Things to Remember

  • 📌 Multi-server locking requires you to use a shared cache. Using the array or local file cache driver on multiple servers behind a load balancer won't work. You should configure Redis, Memcached or a central database cache for servers that need to share isolation.
  • ⚡ Consideration when overriding isolationLockExpiresAt(). If you override this method, you must ensure that the value returned is greater than your longest running command. A lock that expires too early will cause commands to run that should be isolated to run sequentially.
  • ⚠️ Isolated commands that are skipped return an exit status code of 1. This could cause problems if you are running isolated commands as part of a wider CI/CD pipeline, where the command may legitimately be running on another server at the same time.

Conclusion

Handling command overlap in your application's artisan command classes is an improvement over scheduling, and provides some benefits that a scheduler cannot provide. Implementing Laravel's Isolatable contract, defining a specific expiration period, and adding a dynamic key to allow for multi-tenancy provides a number of advantages. This will prevent overlapping, and race conditions on command execution, regardless of what method is used to trigger the command.

Thank you for reading this article 😊

For any query, do not hesitate to comment 💬


Previous Post Next Post

Contact Form