DB::transaction(Closure $callback, int $attempts = 1) — це тонка обгортка: beginTransaction(), виклик замикання, commit(), а на будь-якому Throwable — rollBack() і проброс винятку далі. Звідси перша практична порада: ніколи не ковтайте виняток усередині замикання. Якщо ви обгорнули частину коду в try/catch і нічого не кинули, Laravel дійде до commit() і збереже половину роботи — база не знає про вашу логіку, вона бачить лише успішне завершення. Ручні DB::beginTransaction()/DB::commit() потрібні рідко: коли транзакція має пережити межу одного методу або коли ви керуєте нею з тесту. У всіх інших випадках замикання надійніше, бо забути rollBack() у ньому неможливо.
Другий аргумент — це кількість спроб, і ретрай спрацьовує вибірково. Laravel перевіряє помилку через causedByConcurrencyError(), який ловить характерні повідомлення драйверів: Deadlock found when trying to get lock і Lock wait timeout exceeded у MySQL, deadlock detected та serialization failure у PostgreSQL, database is locked у SQLite. Звичайний ValidationException чи порушення NOT NULL не повторюються — вони просто відкочують транзакцію. Є ще одне обмеження: у handleTransactionException Laravel дивиться на transactionLevel(), і якщо ви всередині вкладеної транзакції (тобто фактично всередині SAVEPOINT), повтор не робиться взагалі. Головна ж вимога до ретраю — ідемпотентність: замикання виконається з нуля вдруге і втретє, тому все, що не можна зробити двічі, всередині йому не місце.
Саме тут з'являється afterCommit. Транзакція, яка ще не закомітилась, невидима для інших з'єднань — а воркер черги працює на окремому з'єднанні. Тому SendOrderReceipt::dispatch($order) всередині транзакції — класична гонка: Redis отримує job миттєво, воркер підхоплює його за мілісекунди й падає з ModelNotFoundException, бо orders.id ще не існує. Лікується трьома способами: ->afterCommit() на конкретному диспатчі, public bool $afterCommit = true; у класі job'а або 'after_commit' => true у конфігурації з'єднання черги — тоді правило діє глобально, а виняток робиться через ->beforeCommit(). Для подій і слухачів у Laravel 10+ є контракти ShouldDispatchAfterCommit (на самій події) і ShouldHandleEventsAfterCommit (на слухачі), а для довільного коду — DB::afterCommit(fn () => ...), який поза транзакцією просто виконується негайно. Модельні події created/updated за замовчуванням спрацьовують усередині транзакції, тож обсервер, який щось надсилає назовні, треба позначати явно.
Транзакція гарантує атомарність, але не гарантує, що між вашим SELECT і вашим UPDATE ніхто не втрутився. Класична дірка — read-modify-write: прочитали stock, порівняли з $qty у PHP, зменшили, зберегли. Два паралельних запити прочитають однакове значення й обидва вважатимуть, що товару вистачає. lockForUpdate() додає FOR UPDATE і перетворює читання на ексклюзивне: другий процес зупиняється на самому SELECT і продовжить лише після вашого COMMIT, причому побачить уже нове значення (у MySQL на REPEATABLE READ звичайний SELECT читає знімок, а блокувальний — останню закомічену версію). sharedLock() дає слабше блокування — lock in share mode у MySQL, for share у PostgreSQL: кілька процесів можуть читати паралельно, і жоден не змінить рядок, доки ви не завершите. Він доречний, коли ви читаєте довідник, від якого залежить запис в іншу таблицю, і категорично недоречний як «легша версія» lockForUpdate: два процеси з S-lock, які потім спробують зробити UPDATE, чекатимуть одне одного і дадуть deadlock замість черги.
Межі й ціна. Блокування живе рівно стільки, скільки транзакція, тому поза DB::transaction lockForUpdate() не робить нічого корисного, а всередині — тримає рядок увесь час, доки ви робите будь-що інше; HTTP-виклик до платіжного шлюзу під блокуванням гарантує, що сусідні запити впруться в innodb_lock_wait_timeout (50 секунд за замовчуванням у MySQL) або в lock_timeout PostgreSQL. Блокувати можна лише те, що існує: у сценарії «створити, якщо немає» рятує унікальний індекс, а не FOR UPDATE. І окрема пастка тестування: SQLite-грамотка Laravel просто ігнорує блокування — compileLock() повертає порожній рядок, — тому feature-тест на in-memory SQLite не доведе, що ваш lockForUpdate() узагалі потрапляє в SQL. Нарешті, часто блокування взагалі не потрібне: там, де все зводиться до одного оператора, атомарний UPDATE ... WHERE stock >= ? із перевіркою кількості змінених рядків дешевший, коротший і не створює жодного шансу на deadlock.
use Illuminate\Support\Facades\DB;
// Другий аргумент — кількість СПРОБ, а не таймаут: Laravel повторить
// замикання цілком, якщо драйвер повернув deadlock або lock wait timeout
$order = DB::transaction(function () use ($user, $productId, $qty) {
// FOR UPDATE: рядок заблоковано до COMMIT.
// Паралельний запит зупиниться саме тут, а не прочитає старий stock.
$product = Product::whereKey($productId)->lockForUpdate()->firstOrFail();
if ($product->stock < $qty) {
// Будь-який Throwable = автоматичний ROLLBACK і проброс далі
throw new OutOfStockException($product->id);
}
$product->decrement('stock', $qty);
$order = Order::create([
'user_id' => $user->id,
'product_id' => $product->id,
'quantity' => $qty,
]);
// Без afterCommit() воркер може взяти job раніше за COMMIT
// і впасти з ModelNotFoundException на свіжому $order->id
SendOrderReceipt::dispatch($order)->afterCommit();
// Довільний побічний ефект після успішного COMMIT;
// поза транзакцією замикання виконається негайно
DB::afterCommit(fn () => Cache::forget("stock:{$product->id}"));
return $order;
}, attempts: 3);
// Той самий сценарій без блокування взагалі: одна атомарна операція,
// 0 змінених рядків означає «залишку не вистачило»
$affected = Product::whereKey($productId)
->where('stock', '>=', $qty)
->decrement('stock', $qty);
Скажіть двома реченнями: «Транзакція гарантує атомарність, але не захищає від того, що хтось прочитав те саме значення, що і я — для цього потрібен lockForUpdate або атомарний UPDATE з умовою». І одразу додайте про повтори: «`DB::transaction($cb, 3)` виконає замикання вдруге цілком, тому все, що не можна зробити двічі, виноситься в `afterCommit`».