Два воркера очереди могут одновременно обработать одну и ту же отправку: оба отметят заказ как отправленный, и клиент получит посылку дважды. В Laravel для таких случаев уже есть Cache::lock(), но при его использовании разработчику каждый раз приходится самостоятельно формировать ключ, работать с токеном владельца и не забывать освобождать блокировку в finally.
Пакет Laravel Lock от Md Mahedi Zaman Zaber скрывает эту работу за удобным builder-интерфейсом. Блокировку можно привязать к действию и конкретной цели, а хранить её можно в кэше или базе данных.
Получение и освобождение блокировки
Фасад Lock создаёт отложенную блокировку на основе названия действия и необязательной цели:
use ZaberDev\Lock\Facades\Lock;
$lock = Lock::for('shipment_dispatch', $shipment)->ttl(120);
if ($lock->acquire()) {
try {
$carrier->dispatch($shipment);
} finally {
$lock->release();
}
}Экземпляр блокировки следует сохранить в переменной, поскольку освобождать её нужно через тот же экземпляр.
Каждый builder при первой необходимости создаёт собственный UUID-токен владельца. Оба драйвера проверяют этот токен перед удалением блокировки. Если создать второй Lock::for(...) и вызвать на нём release(), операция ничего не сделает и вернёт false, поскольку у второго экземпляра будет другой токен.
Если получение и освобождение блокировки происходят в разных процессах, токен можно задать самостоятельно через owner():
$lock = Lock::for('shipment_dispatch', $shipment)
->owner('worker-7')
->ttl(120);По умолчанию блокировка действует 60 секунд. Для задания времени также доступны forSeconds() и forMinutes(). Метод refresh() позволяет продлить уже принадлежащую текущему процессу блокировку, не освобождая её предварительно.
Вместо ручного вызова acquire() и release() можно использовать block(). Метод получает callback и возвращает его результат:
$manifest = Lock::for('shipment_dispatch', $shipment)->block(
function () use ($shipment, $carrier) {
return $carrier->dispatch($shipment);
}
);Если блокировка уже занята, block() выбрасывает LockAcquisitionException, а не возвращает null. Исключение содержит информацию о блокировке, которая препятствует выполнению. Для задачи очереди это означает, что job завершится с ошибкой, а не просто молча пропустит операцию.
Оба метода позволяют некоторое время ждать освобождения блокировки. Для acquire() можно передать количество секунд ожидания:
$lock->acquire(blockSeconds: 5);Для block() время ожидания передаётся третьим аргументом:
Lock::for('stock_allocation', $warehouse)->block(
$callback,
60,
5
);Между попытками используется пауза в 250 миллисекунд.
Проверить состояние блокировки, не захватывая её, можно с помощью isLocked(), isOwnedByCurrent(), remaining() и info(). Метод enforce() выбрасывает исключение, если блокировка принадлежит другому владельцу.
Для принудительного удаления блокировки предусмотрен forceRelease(). Он удаляет запись независимо от владельца и может использоваться для очистки после завершения воркера, который завершился аварийно и оставил блокировку.
Блокировка модели
Пакет предоставляет трейт HasLocks. После его добавления к Eloquent-модели цель блокировки определяется автоматически:
use Illuminate\Database\Eloquent\Model;
use ZaberDev\Lock\HasLocks;
class Shipment extends Model
{
use HasLocks;
}После этого блокировку можно получить непосредственно через модель:
$lock = $shipment->lock('dispatch')->ttl(120);
$shipment->isLocked('dispatch');
$shipment->forceReleaseLock('dispatch');Ключ блокировки состоит из действия, morph-класса модели и её первичного ключа. Обратные слеши в имени класса заменяются символами подчёркивания.
Например:
dispatch:App_Models_Shipment:42При использовании morph map ключ становится короче за счёт зарегистрированного псевдонима.
В качестве цели можно использовать и скалярное значение. Например:
Lock::for('stock_allocation', $sku);Для SKU SKU-1180 ключ будет выглядеть так:
stock_allocation:SKU-1180Если цель не является Eloquent-моделью, можно реализовать интерфейс Lockable. Он требует одного метода getLockTargetIdentifier(): string. Возвращаемая им строка используется как идентификатор цели во второй части ключа.
При использовании database-драйвера HasLocks также предоставляет morph-связь locks(), через которую можно получить активные блокировки модели:
$shipment->locks()
->where('expires_at', '>', now())
->get();Защита маршрутов
Service provider пакета регистрирует middleware с именем lock. В него можно передать действие, TTL в секундах и, при необходимости, используемый драйвер:
Route::post(
'/warehouse/reconcile',
[ReconcileController::class, 'store']
)->middleware('lock:warehouse_reconcile,300');
Route::post(
'/warehouse/reindex',
[ReindexController::class, 'store']
)->middleware('lock:warehouse_reindex,600,database');В таком варианте блокировка не привязана к конкретной записи и действует для всего endpoint. Поэтому одновременно может выполняться только один запрос на reconciliation.
Блокировку можно ограничить конкретным ресурсом через параметр маршрута:
Route::post(
'/shipments/{shipment}/dispatch',
[ShipmentController::class, 'dispatch']
)->middleware('lock:shipment_dispatch:{shipment},60');В этом случае middleware заменяет {shipment} значением параметра маршрута. Если параметр соответствует связанной Eloquent-модели, используется её первичный ключ.
Два запроса для одной отправки не смогут выполняться одновременно, пока первый удерживает блокировку. При этом запросы для разных отправлений друг другу не мешают.
Middleware не ожидает освобождения блокировки. Если она уже занята, запрос отклоняется.
Когда блокировка занята, middleware выбрасывает LockAcquisitionException ещё до вызова контроллера. Исключение наследуется от RuntimeException, а не от HTTP-исключений Laravel. Поэтому необработанное исключение попадёт клиенту как HTTP 500.
Код исключения равен 423, что соответствует статусу 423 Locked, однако пакет самостоятельно не преобразует его в HTTP-ответ. При необходимости это можно сделать вручную:
use ZaberDev\Lock\Exceptions\LockAcquisitionException;
$exceptions->render(function (LockAcquisitionException $e) {
return response()->json([
'message' => 'Already processing. Try again in a moment.',
'retry_after' => $e->lockInfo?->remainingSeconds(),
], 429);
});Middleware освобождает блокировку внутри finally вокруг $next($request). Поэтому блокировка будет освобождена как после обычного ответа 200, так и после ошибки валидации или необработанного исключения.
Хранение в кэше или базе данных
Файл config/locks.php задаёт драйвер по умолчанию через переменную окружения LOCK_DRIVER. Для отдельной блокировки драйвер можно изменить методом using():
Lock::for('inventory_sync', $warehouse)
->using('cache')
->ttl(15)
->acquire();
Lock::for('stock_reconciliation', $warehouse)
->using('database')
->ttl(600)
->acquire();Cache-драйвер сохраняет небольшую запись с префиксом lock: и использует Cache::add() для атомарного создания блокировки. Это тот же механизм, на котором основаны атомарные блокировки Laravel. Такой вариант быстрее и подходит для коротких блокировок при использовании Redis или Memcached.
Database-драйвер записывает блокировку в таблицу locks, где колонка key имеет уникальное ограничение. Каждая попытка получения блокировки выполняется внутри транзакции: перед вставкой существующая запись выбирается с помощью lockForUpdate().
Записи database-драйвера сохраняются после очистки кэша или перезапуска Redis и доступны для запросов через Eloquent. Обратная сторона такого подхода — каждая операция получения блокировки требует записи в базу и блокировки строки.
Истёкшие блокировки удаляются двумя способами. При чтении истёкшая запись удаляется автоматически. Кроме того, LockModel использует трейт Laravel Prunable, поэтому оставшиеся записи можно удалять через планировщик:
use Illuminate\Support\Facades\Schedule;
use ZaberDev\Lock\Models\LockModel;
Schedule::command('model:prune', [
'--model' => LockModel::class,
])->daily();Если в конфигурации включено locks.events.dispatch, пакет генерирует три события.
LockAcquired содержит ключ, владельца, TTL, время окончания действия и объект LockInfo.
LockFailed содержит ключ, владельца и информацию о блокировке, которая помешала получить новую.
LockReleased содержит флаг $forced, позволяющий отличить обычное освобождение от вызова forceRelease().
Слушатель LockFailed позволяет определить, какие операции приложения фактически конкурируют за одни и те же блокировки.
LockManager наследуется от Laravel Manager, поэтому можно зарегистрировать собственный драйвер через Lock::extend():
Lock::extend(
'dynamodb',
fn ($app) => new DynamoLockDriver($app['dynamodb'])
);Установка
Laravel Lock требует PHP 8.2 или новее и поддерживает Laravel 11, 12 и 13.
Установить пакет можно через Composer:
composer require zaber-dev/laravel-lockКонфигурация и миграция публикуются отдельными командами. Миграция нужна только при использовании database-драйвера:
php artisan vendor:publish --tag=locks-config
php artisan vendor:publish --tag=locks-migrations
php artisan migrateПакет также содержит skill для Laravel Boost с примерами использования API. Он публикуется через тег locks-skill. Если Boost уже установлен или в проекте существует каталог .ai/skills, service provider копирует skill в .ai/skills/laravel-lock.