Search by

siberfx / laravel-mutex-lock

siberfx

Laravel integration for php-lock/lock: database-backed mutexes for MySQL/MariaDB, PostgreSQL and SQLite.

Package info

github.com/siberfx/laravel-mutex-lock

pkg:composer/siberfx/laravel-mutex-lock

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.1 2026-09-30 21:52 UTC

This package is auto-updated.

Last update: 2026-09-30 21:53:22 UTC


README

Laravel Mutex Lock

Database-backed mutexes for Laravel, built on php-lock/lock.

Only one process runs a critical section at a time, across web requests, queue workers, the scheduler and servers. The lock lives in the database you already run, so you don't need Redis.

Tests Latest Version PHP Laravel License

Table of contents

Why

Race conditions don't show up in development. They show up in production, when two requests or two workers read the same row at the same moment. This package gives you a simple, explicit way to prevent that:

Mutex::synchronized("wallet:{$wallet->id}", fn () => $wallet->withdraw($amount));
  • No extra infrastructure. Locks use the MySQL, MariaDB, PostgreSQL or SQLite database your app already uses.
  • Crash-safe. On MySQL, MariaDB and PostgreSQL, the server releases the lock when the process dies. On SQLite, locks expire.
  • Proven core. The locking logic comes from php-lock/lock. This package adds a Laravel-native API, config, a facade, queue middleware and a SQLite driver.
  • Modern stack. PHP 8.4+, Laravel 12 and 13, strict types.

Supported engines

Engine Mechanism Driver Released on crash Re-entrant*
MySQL 5.7+ / 8+ GET_LOCK() / RELEASE_LOCK() Malkusch\Lock\Mutex\MySQLMutex ✅ When the session closes ✅
MariaDB 10+ GET_LOCK() / RELEASE_LOCK() Malkusch\Lock\Mutex\MySQLMutex ✅ When the session closes ✅
PostgreSQL 12+ pg_advisory_lock() / pg_try_advisory_lock() Malkusch\Lock\Mutex\PostgreSQLMutex ✅ When the session closes ✅
SQLite 3.24+ Row in a lock table (atomic UPSERT) Siberfx\MutexLock\Mutex\SqliteMutex ⏱ After sqlite.expire seconds ❌

* Re-entrant means a nested lock with the same name on the same connection won't block itself.

Requirements

  • PHP 8.4 or 8.5
  • Laravel 12.x or 13.x
  • One of pdo_mysql, pdo_pgsql or pdo_sqlite

Installation

composer require siberfx/laravel-mutex-lock

The service provider and the Mutex facade are auto-discovered.

To publish the config file (optional):

php artisan vendor:publish --tag=mutex-lock-config

Quick start

use Siberfx\MutexLock\Facades\Mutex;

$result = Mutex::synchronized('reports:monthly', function () {
    // Only one process at a time runs this block.
    return Report::generateMonthly();
});

The lock is always released, whether the callback returns normally or throws.

Usage

Running code under a lock

synchronized() waits for the lock, runs your callback, releases the lock and returns the callback's result.

use Siberfx\MutexLock\Facades\Mutex;

$invoice = Mutex::synchronized("invoice:{$order->id}", function () use ($order) {
    return $order->invoice ?? $order->invoice()->create([
        'number' => Invoice::nextNumber(),
        'total'  => $order->total,
    ]);
});

Exceptions thrown inside the callback are rethrown unchanged, after the lock is released:

try {
    Mutex::synchronized('import', fn () => $importer->run());
} catch (ImportFailed $e) {
    // The lock has already been released at this point.
    report($e);
}

Timeouts

The timeout is how long to wait for the lock, in seconds. It does not limit how long your callback may run.

Mutex::synchronized('report', $callback);                // configured default (mutex-lock.timeout, 10s)
Mutex::synchronized('report', $callback, timeout: 30);   // wait up to 30 seconds
Mutex::synchronized('report', $callback, timeout: 2.5);  // fractional seconds are fine
Mutex::synchronized('report', $callback, timeout: 0);    // try once, don't wait
Mutex::synchronized('report', $callback, timeout: INF);  // wait as long as it takes

Note

MySQL and MariaDB round GET_LOCK() timeouts up to whole seconds. PostgreSQL and SQLite support fractional timeouts.

Handling a busy lock

If the lock can't be acquired in time, Malkusch\Lock\Exception\LockAcquireTimeoutException is thrown. Your callback does not run.

use Malkusch\Lock\Exception\LockAcquireTimeoutException;
use Siberfx\MutexLock\Facades\Mutex;

public function store(Request $request)
{
    try {
        $import = Mutex::synchronized(
            "import:{$request->user()->id}",
            fn () => Import::start($request->file('csv')),
            timeout: 0,
        );
    } catch (LockAcquireTimeoutException) {
        return back()->withErrors('An import is already running. Please wait for it to finish.');
    }

    return to_route('imports.show', $import);
}

A small helper for "skip if busy" semantics:

function whenFree(string $name, callable $callback, mixed $default = null): mixed
{
    try {
        return Mutex::synchronized($name, $callback, timeout: 0);
    } catch (LockAcquireTimeoutException) {
        return $default;
    }
}

Double-checked locking

Double-checked locking avoids taking a lock when there's nothing to do. The check runs before the lock is acquired and again after. The callback runs only when both checks pass.

$shipped = Mutex::check(
    "order:{$order->id}:ship",
    fn () => ! $order->fresh()->shipped_at,       // cheap check, no lock
)->then(
    fn () => $order->ship(),                      // runs under the lock
    fn () => false,                               // optional: runs if either check fails
);

Without the second callback, then() returns false when a check fails.

Choosing a connection

By default, locks use mutex-lock.connection, or your default database connection if that's not set. You can override it per call:

Mutex::synchronized('sync:crm', $callback, connection: 'pgsql');
Mutex::synchronized('legacy', $callback, timeout: 5, connection: 'legacy_mysql');

Important

Every process that competes for the same lock must use the same database server.

Dependency injection

The facade proxies Siberfx\MutexLock\MutexManager, a container singleton. You can inject it instead:

use Siberfx\MutexLock\MutexManager;

final class TransferMoney
{
    public function __construct(private readonly MutexManager $mutex) {}

    public function __invoke(Account $from, Account $to, int $cents): void
    {
        // Lock both accounts in a stable order to avoid deadlocks.
        [$first, $second] = collect([$from->id, $to->id])->sort()->values()->all();

        $this->mutex->synchronized("account:{$first}", fn () =>
            $this->mutex->synchronized("account:{$second}", fn () =>
                DB::transaction(function () use ($from, $to, $cents) {
                    $from->refresh()->decrement('balance', $cents);
                    $to->refresh()->increment('balance', $cents);
                })
            )
        );
    }
}

It's also available as app('mutex-lock').

Working with the raw php-lock mutex

Mutex::make() returns the underlying Malkusch\Lock\Mutex\Mutex. Use it to pass a mutex into code that is framework-agnostic:

$mutex = Mutex::make('nightly-export', timeout: 5);

$exporter = new Exporter($mutex);   // accepts any php-lock Mutex

$mutex->synchronized(fn () => $exporter->run());
$mutex->check(fn () => $exporter->isPending())->then(fn () => $exporter->run());

Queue job middleware

WithMutex wraps a job's handle() in a lock. By default it tries once. If the lock is busy, the job goes back on the queue.

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Siberfx\MutexLock\Jobs\Middleware\WithMutex;

class RecalculateInvoice implements ShouldQueue
{
    use Queueable;

    public int $tries = 10;

    public function __construct(public Invoice $invoice) {}

    public function middleware(): array
    {
        return [
            (new WithMutex("invoice:{$this->invoice->id}"))->releaseAfter(15),
        ];
    }

    public function handle(): void
    {
        $this->invoice->recalculate();
    }
}
Option Behaviour
new WithMutex($name) Try once and release the job immediately if busy
new WithMutex($name, timeout: 5) Wait up to 5 seconds for the lock before giving up
new WithMutex($name, connection: 'pgsql') Lock on a specific connection
->releaseAfter(30) If busy, release the job back onto the queue with a 30 second delay
->dontRelease() If busy, throw LockAcquireTimeoutException (the attempt fails)

Tip

Each release counts as an attempt, so set $tries (or retryUntil()) high enough for your contention level.

Recipes

Preventing double spending

public function withdraw(Wallet $wallet, int $cents): Transaction
{
    return Mutex::synchronized("wallet:{$wallet->id}", function () use ($wallet, $cents) {
        $wallet->refresh(); // always re-read state *inside* the lock

        throw_if($wallet->balance < $cents, InsufficientFunds::class);

        return DB::transaction(function () use ($wallet, $cents) {
            $wallet->decrement('balance', $cents);

            return $wallet->transactions()->create(['amount' => -$cents]);
        });
    });
}

Idempotent webhooks

Payment providers retry webhooks, sometimes several times in parallel. Lock on the event ID and check whether the event was already processed:

public function __invoke(Request $request)
{
    $eventId = $request->input('id');

    Mutex::check(
        "webhook:{$eventId}",
        fn () => ! ProcessedWebhook::whereKey($eventId)->exists(),
    )->then(function () use ($eventId, $request) {
        DB::transaction(function () use ($eventId, $request) {
            HandleStripeEvent::dispatchSync($request->all());
            ProcessedWebhook::create(['id' => $eventId]);
        });
    });

    return response()->noContent();
}

Inventory reservation

public function reserve(Product $product, int $qty): Reservation
{
    return Mutex::synchronized("stock:{$product->sku}", function () use ($product, $qty) {
        $available = $product->stock - $product->reservations()->active()->sum('qty');

        if ($available < $qty) {
            throw new OutOfStock($product, $available);
        }

        return $product->reservations()->create([
            'qty'        => $qty,
            'expires_at' => now()->addMinutes(15),
        ]);
    }, timeout: 3);
}

Artisan commands that must not overlap

This works across servers even without a shared cache:

use Illuminate\Console\Command;
use Malkusch\Lock\Exception\LockAcquireTimeoutException;
use Siberfx\MutexLock\Facades\Mutex;

class SyncProducts extends Command
{
    protected $signature = 'products:sync';

    public function handle(): int
    {
        try {
            return Mutex::synchronized('command:products:sync', function () {
                $this->info('Syncing…');
                app(ProductSync::class)->run();

                return self::SUCCESS;
            }, timeout: 0);
        } catch (LockAcquireTimeoutException) {
            $this->warn('Another sync is already running. Skipping.');

            return self::SUCCESS;
        }
    }
}
// routes/console.php
Schedule::command('products:sync')->everyFiveMinutes()->onOneServer();

Cache stampede protection

When a hot cache key expires, only one process rebuilds it. The others wait, then read the fresh value:

function rememberLocked(string $key, int $ttl, Closure $callback): mixed
{
    return Cache::get($key) ?? Mutex::check(
        "cache:{$key}",
        fn () => ! Cache::has($key),
    )->then(
        fn () => tap($callback(), fn ($value) => Cache::put($key, $value, $ttl)),
        fn () => Cache::get($key),
    );
}

$stats = rememberLocked('dashboard:stats', 300, fn () => Stats::expensiveQuery());

Lazy one-time initialisation

Mutex::check('tenant:bootstrap:' . $tenant->id, fn () => ! $tenant->bootstrapped_at)
    ->then(function () use ($tenant) {
        $tenant->createDefaultRoles();
        $tenant->seedSettings();
        $tenant->update(['bootstrapped_at' => now()]);
    });

Per-tenant locks

Use the prefix option when several apps share one database server:

MUTEX_PREFIX="billing-api:"

Or build names that include the tenant:

Mutex::synchronized("tenant:{$tenant->id}:invoice-numbering", fn () => $tenant->nextInvoiceNumber());

Configuration

config/mutex-lock.php:

return [
    'connection' => env('MUTEX_CONNECTION'),        // null = default DB connection
    'timeout'    => env('MUTEX_TIMEOUT', 10),       // seconds; 0 = try once; null = wait forever
    'prefix'     => env('MUTEX_PREFIX', ''),        // prepended to every lock name

    'sqlite' => [
        'table'             => env('MUTEX_SQLITE_TABLE', 'mutex_locks'),
        'expire'            => env('MUTEX_SQLITE_EXPIRE', 300),            // null = never expire
        'auto_create_table' => env('MUTEX_SQLITE_AUTO_CREATE_TABLE', true),
    ],
];
Key Env Default Description
connection MUTEX_CONNECTION null Connection to lock on (null = default connection)
timeout MUTEX_TIMEOUT 10 Seconds to wait for a lock (0 = try once, null = forever)
prefix MUTEX_PREFIX '' Prepended to every lock name
sqlite.table MUTEX_SQLITE_TABLE mutex_locks Lock table name
sqlite.expire MUTEX_SQLITE_EXPIRE 300 Seconds after which a lock left by a crashed process can be reclaimed (null = never)
sqlite.auto_create_table MUTEX_SQLITE_AUTO_CREATE_TABLE true Create the lock table on first use

Managing the SQLite table with migrations

If you prefer migrations over auto-creation:

MUTEX_SQLITE_AUTO_CREATE_TABLE=false
php artisan vendor:publish --tag=mutex-lock-migrations
php artisan migrate

MySQL, MariaDB and PostgreSQL don't need a table.

Exceptions

All exceptions come from php-lock and extend Malkusch\Lock\Exception\MutexException (a RuntimeException).

MutexException
├── LockAcquireException
│   └── LockAcquireTimeoutException     lock not acquired within the timeout
├── LockReleaseException                lock could not be released
│   └── ExecutionOutsideLockException   SQLite: callback outlived `sqlite.expire`
└── UnsupportedDriverException          (this package) connection driver not supported

When releasing the lock fails, LockReleaseException still gives you the outcome of your callback:

use Malkusch\Lock\Exception\LockReleaseException;

try {
    Mutex::synchronized('job', fn () => $job->run());
} catch (LockReleaseException $e) {
    $result    = $e->getCodeResult();     // what the callback returned
    $exception = $e->getCodeException();  // what the callback threw, if anything
}

API reference

use Siberfx\MutexLock\Facades\Mutex;
Method Returns Description
Mutex::synchronized(string $name, callable $callback, float|int|null $timeout = null, ?string $connection = null) mixed Acquire, run $callback, release and return its result
Mutex::check(string $name, callable $check, float|int|null $timeout = null, ?string $connection = null) DoubleCheckedLocking Start a double-checked lock. Call ->then($success, ?$fail) on the result
Mutex::make(string $name, float|int|null $timeout = null, ?string $connection = null) Malkusch\Lock\Mutex\Mutex Build the underlying php-lock mutex

$timeout: null uses the configured default, 0 tries once, INF waits forever, and any other value is a number of seconds.

How it works

MySQL / MariaDB. SELECT GET_LOCK(name, timeout) takes a named lock that belongs to the database session. RELEASE_LOCK(name) frees it. If the PHP process dies, the connection closes and the server frees the lock. Names are prefixed with php-malkusch-lock:. Names longer than 46 characters are hashed (sha1:…) to stay within MySQL's 64-character limit.

PostgreSQL. The lock name is hashed into two 32-bit keys for pg_advisory_lock(k1, k2). If the timeout is infinite, it blocks on the server. Otherwise it polls pg_try_advisory_lock with exponential backoff until the timeout runs out. Like MySQL, the lock is released when the session ends.

SQLite. SQLite has no advisory locks, so each lock is a row (name, token, expires_at) in the mutex_locks table. A single atomic UPSERT acquires the lock. It succeeds only if the row doesn't exist or has expired. Release deletes the row with the acquiring token, so a process can never release someone else's lock. If the database is busy, the attempt counts as "not acquired yet" and is retried with backoff.

Caveats

  • Take locks outside transactions. Wrap DB::transaction() inside synchronized(), not the other way around. On SQLite, a lock row written inside an uncommitted transaction is invisible to other connections.
  • Contention requires separate sessions. Laravel opens one connection per PHP process, so different requests and workers contend correctly. Within the same process and connection, MySQL/MariaDB and PostgreSQL locks are re-entrant: nesting the same name won't block. On SQLite, nesting the same name waits for itself and times out.
  • Reconnects drop server-side locks. If the database connection is lost while a MySQL or PostgreSQL lock is held, the server releases the lock. Keep critical sections short. Don't call DB::reconnect() inside them.
  • SQLite expiry. If a callback runs longer than sqlite.expire, another process may take the lock. You'll get an ExecutionOutsideLockException on release. Set expire comfortably above your longest critical section.
  • Timeout of 0 on PostgreSQL/SQLite. php-lock's spin loop doesn't run with a timeout of exactly 0, so the package uses a 10 ms window. This gives the same "try once" behaviour.
  • Lock order. When a callback needs several locks, always acquire them in the same order (for example, sorted by ID) to avoid deadlocks.

Mutex vs. Cache::lock()

Laravel ships atomic locks through the cache. Both are good tools. Pick based on your infrastructure and failure model:

Mutex (this package) Cache::lock()
Backend Your SQL database Redis, Memcached, DynamoDB, database, …
Released if the process dies Immediately (MySQL/PG), on expiry (SQLite) On TTL expiry
TTL you must estimate No (MySQL/PG) Yes
Blocking wait Server-side (MySQL, PG with INF) Polling
Double-checked locking Built in Manual

Testing your code

The facade can be mocked like any Laravel facade. To just run the callback in tests:

use Siberfx\MutexLock\Facades\Mutex;

Mutex::shouldReceive('synchronized')
    ->once()
    ->with('wallet:1', Mockery::type('callable'))
    ->andReturnUsing(fn ($name, $callback) => $callback());

To simulate a busy lock:

use Malkusch\Lock\Exception\LockAcquireTimeoutException;

Mutex::shouldReceive('synchronized')
    ->andThrow(LockAcquireTimeoutException::create(0));

$this->post('/imports')->assertSessionHasErrors();

The real implementation also works in tests on an in-memory SQLite database. No mocking is needed when you only care about the callback's effects.

Running the package tests

composer test                                                                  # SQLite (default)

DB_CONNECTION=mysql   DB_DATABASE=testing DB_USERNAME=root     vendor/bin/phpunit
DB_CONNECTION=mariadb DB_DATABASE=testing DB_USERNAME=root     vendor/bin/phpunit
DB_CONNECTION=pgsql   DB_DATABASE=testing DB_USERNAME=postgres vendor/bin/phpunit

DB_HOST, DB_PORT and DB_PASSWORD are also read. CI runs the full matrix: PHP 8.4/8.5 × Laravel 12/13 × SQLite/MySQL/MariaDB/PostgreSQL × lowest/latest dependencies.

Changelog

See CHANGELOG.md.

Credits

License

The MIT License (MIT). See LICENSE.