Ви зберегли замовлення, але позиції замовлення так і не потрапили до бази даних. Або, можливо, лічильник запасів зменшився, платіжний шлюз видав помилку тайм-ауту, і тепер у клієнта є списання на картці, але немає запису про замовлення. Ці сценарії здаються поодинокими випадками, поки вони не стають реальністю. У високонавантаженому застосунку вони перетворюються на щоденну головну біль.

Першопричина майже завжди однакова: послідовність записів у базу даних, які розглядалися як окремі, ізольовані події. Коли одна з них завершується невдало, інші залишаються "висіти". Вирішенням є транзакція бази даних, а в Laravel інструментом для цього є DB::transaction().

Що насправді обіцяє транзакція

Транзакція бази даних об'єднує кілька операцій в єдиний блок роботи. Механізм бази даних гарантує, що все всередині або буде зафіксовано (commit) назавжди, або повністю скасовано (rollback). Серединного шляху не існує.

Уявіть банківський переказ. Система повинна списати кошти з одного рахунку та зарахувати їх на інший. Якщо зарахування не вдалося після списання, гроші не просто зникають у небутті. Банк скасовує списання. Це скасування і є rollback. Якщо обидва кроки пройшли успішно, переказ фіксується (commit), що означає надійне збереження нових балансів.

Така поведінка за принципом «все або нічого» підтримує цілісність ваших даних. Без неї часткові збої залишають напівзаписані записи в різних таблицях, і ці записи «гниють» у вашій базі даних, оскільки жоден автоматизований процес не знає, як безпечно їх очистити.

Як Laravel реалізує це зв'язування

У чистому SQL вам довелося б самостійно писати BEGIN, COMMIT та ROLLBACK, не забуваючи обробляти кожну можливу помилку, щоб транзакція ніколи не залишалася незавершеною. Laravel обгортає цей шаблонний код у єдиний метод.

Ви передаєте замикання (closure) у DB::transaction(). Laravel розпочинає транзакцію, виконує ваш код, і якщо замикання завершується без викидання винятку (exception), він автоматично виконує commit. Якщо щось іде не так, Laravel перехоплює виняток, скасовує всі зміни (rollback) і знову викидає помилку, щоб ваші логування та обробка помилок працювали як очікувало.

use Illuminate\Support\Facades\DB;

DB::transaction(function () {
    $order = Order::create([/* ... */]);
    
    foreach ($cart->items as $item) {
        OrderItem::create([
            'order_id' => $order->id,
            'product_id' => $item->product_id,
            'quantity' => $item->quantity,
        ]);
        
        Product::find($item->product_id)
               ->decrement('stock', $item->quantity);
    }
    
    Payment::create([
        'order_id' => $order->id,
        'amount' => $cart->total,
        'status' => 'completed',
    ]);
});

Якщо вставка платежу не вдається через відсутність зовнішнього ключа або розрив з'єднання з базою даних, то замовлення, позиції замовлення та зміни запасів — усе буде скасовано. Ви не залишитеся із зниклими без причини запасами та замовленням, яке потребує відправки, але за яке ніколи не було сплачено.

Де це рятує вас

Деякі робочі процеси критично залежать від транзакційної безпеки. Приклад із оформленням замовлення є найбільш очевидним, але цей патерн зустрічається всюди.

Реєстрація користувача. Створення рядка користувача, потім рядка профілю, а потім налаштувань за замовчуванням. Якщо вставка профілю не вдається через пограничний випадок валідації, користувач без профілю стає «профільним привидом». Будь-яка сторінка, яка припускає наявність профілю у кожного користувача, впаде або покаже зламаний інтерфейс.

Масовий імпорт. Завантаження CSV-файлу, яке вставляє п'ятдесят записів, не повинно залишати двадцять п'ять через те, що у двадцять шостого рядка була некоректна дата. Обгортання пакетної операції в транзакцію дозволяє всьому імпорту завершитися невдало як єдиному чистому блоку. Адміністратор виправляє файл і пробує знову, замість того щоб витрачати години на пошук рядків, які частково просочилися в базу.

Складський облік та бухгалтерія. Щоразу, коли одна таблиця відстежує фізичний ресурс, а інша — гроші або кредити, вони повинні змінюватися синхронно. Розділення цих процесів призводить до невідповідностей під час аудитів та до звітів, яким не можна довіряти.

Коли потрібно керувати вручну

Підхід із замиканням покриває більшість випадків, але іноді вам потрібен більший контроль. Складна умовна логіка всередині сервісного класу або необхідність вирішувати під час виконання, чи робити commit, може зробити використання одного замикання незручним. У таких випадках ви можете керувати транзакцією самостійно:

DB::beginTransaction();

try {
    // Run your operations
    $order = Order::create([/* ... */]);
    // ... more work ...
    
    if ($someBusinessRulePasses) {
        DB::commit();
    } else {
        DB::rollBack();
    }
} catch (\Throwable $e) {
    DB::rollBack();
    throw $e;
}

Зверніть увагу на порядок у блоці catch: спочатку rollback, потім throw. Якщо ви зробите throw до rollback, транзакція залишиться відкритою в з'єднанні. Це може заблокувати рядки, спричинити взаємне блокування (deadlock) інших запитів або вичерпати пул з'єднань. Ручні транзакції є потужними, але вони покладають тягар очищення на вас.

Не допускайте побічних ефектів у транзакції

Це правило, на якому часто «ловляться» команди в продакшені. Транзакція може скасувати лише роботу з базою даних. Вона не може відкликати надісланий електронний лист, видалити файл із хмарного сховища або повернути платіж через платіжний API.

Якщо ви помістите виклик Mail::send() всередину замикання транзакції, і база даних зробить rollback через два рядки, цей лист все одно потрапить у поштову скриньку клієнта. Отримувач отримає інвойс за замовлення, якого не існує у вашій системі. Те саме стосується сповіщень у Slack, завантаження файлів в S3 або відправки вебхуків.

The correct sequence is:

  1. Complete the transaction and capture any IDs or results you need.
  2. Only then trigger external side effects.

For example, queue the confirmation email after the commit, not inside it:

$order = DB::transaction(function () {
    // database work only
    return Order::create([/* ... */]);
});

// Side effects happen after the database state is solid
OrderConfirmationJob::dispatch($order);

There is a second reason to keep external calls outside the transaction: time. A transaction holds locks and keeps a database connection busy. Waiting three seconds for a Stripe API response while inside a transaction is three seconds of unnecessary lock time. Keep the transaction tight and fast.

Why This Bug Hides

On a development machine with a single user and a local database, related inserts almost always succeed. The network is stable, the disk never fills, and there is no competing load. The code looks correct because it usually works.

Production is different. Two customers submit orders at the exact same millisecond. A queue worker restarts mid-job during a deployment. A payment provider times out for thirty seconds. Without transactions, these events create orphan records and mismatched totals that are painful to trace. The worst part is that standard feature tests rarely catch them, because the failure mode is timing-dependent and tied to infrastructure, not simple logic errors.

The fix is not exotic. It is mechanical. You see two or more related writes, and you wrap them. Over time, it should become as automatic as validating a request.

Make It a Reflex

DB::transaction() does not add complexity. It removes the hidden complexity of trying to clean up partial data after the fact. If a group of database operations belongs together, treat them that way from the start. Your application will stay honest under load, your error logs will stay clean, and your database will not become a graveyard of half-finished records.