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

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

Что на самом деле гарантирует транзакция

Транзакция базы данных объединяет несколько операций в единую рабочую единицу. Движок базы данных гарантирует, что всё внутри либо будет зафиксировано (commit) навсегда, либо полностью откачено (rollback). Промежуточного состояния не существует.

Представьте банковский перевод. Система должна списать средства с одного счета и зачислить их на другой. Если зачисление не удается после списания, деньги не должны просто исчезнуть в никуда. Банк отменяет списание. Эта отмена и есть откат (rollback). Если оба шага прошли успешно, перевод фиксируется (commit), что означает надежное сохранение новых балансов.

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

Как Laravel настраивает эту логику

В чистом SQL вам пришлось бы самостоятельно писать BEGIN, COMMIT и ROLLBACK, не забывая обрабатывать каждую возможную ошибку, чтобы транзакция не осталась «висеть». Laravel оборачивает этот шаблонный код в один метод.

Вы передаете замыкание (closure) в DB::transaction(). Laravel начинает транзакцию, выполняет ваш код, и если замыкание завершается без исключения, он автоматически фиксирует изменения (commit). Если что-то идет не так, Laravel перехватывает исключение, откатывает все изменения и повторно выбрасывает ошибку, чтобы ваша система логирования и обработки ошибок работала как обычно.

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-файла, которая вставляет пятьдесят записей, не должна оставлять после себя двадцать пять, если в двадцать шестой строке была некорректная дата. Обертывание пакета в транзакцию позволяет всему импорту завершиться ошибкой как единому целому. Администратор исправляет файл и пробует снова, вместо того чтобы тратить часы на поиск строк, которые частично просочились в базу.

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

Когда нужно управлять вручную

Подход с замыканием покрывает большинство случаев, но иногда вам нужен больший контроль. Сложная условная логика внутри сервисного класса или необходимость решать во время выполнения, фиксировать изменения или нет, могут сделать использование одного замыкания неудобным. В такие моменты вы можете управлять транзакцией самостоятельно:

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). Если вы выбросите исключение до отката, транзакция останется открытой в соединении. Это может привести к блокировке строк, взаимной блокировке (deadlock) других запросов или исчерпанию пула соединений. Ручные транзакции мощны, но они перекладывают бремя очистки на вас.

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

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

Если вы поместите вызов Mail::send() внутрь замыкания транзакции, и база данных откатит изменения через две строки, это письмо всё равно попадет на почту клиенту. Получатель увидит счет за заказ, которого не существует в вашей системе. То же самое относится к уведомлениям в Slack, загрузке файлов в S3 или отправке вебхуков.

Правильная последовательность действий:

  1. Завершите транзакцию и сохраните все необходимые ID или результаты.
  2. Только после этого инициируйте внешние побочные эффекты.

Например, ставьте подтверждающее письмо в очередь после коммита, а не внутри него:

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

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

Есть и вторая причина выносить внешние вызовы за пределы транзакции: время. Транзакция удерживает блокировки и занимает соединение с базой данных. Ожидание ответа от Stripe API в течение трех секунд внутри транзакции — это три секунды ненужного времени удержания блокировки. Делайте транзакции короткими и быстрыми.

Почему этот баг трудно обнаружить

На машине разработчика с одним пользователем и локальной базой данных связанные вставки почти всегда проходят успешно. Сеть стабильна, диск никогда не переполняется, а конкурирующая нагрузка отсутствует. Код кажется правильным, потому что он обычно работает.

В продакшене всё иначе. Два клиента оформляют заказы в одну и ту же миллисекунду. Воркер очереди перезапускается прямо посреди задачи во время деплоя. Провайдер платежей выдает таймаут на тридцать секунд. Без транзакций эти события создают «осиротевшие» записи и неверные итоги, которые крайне сложно отследить. Хуже всего то, что стандартные функциональные тесты редко их ловят, так как характер ошибки зависит от времени выполнения и привязан к инфраструктуре, а не к простым логическим ошибкам.

Сделайте это рефлексом

DB::transaction() не усложняет код. Он устраняет скрытую сложность, связанную с попытками очистить неполные данные постфактум. Если группа операций с базой данных должна выполняться вместе, относитесь к ним именно так с самого начала. Ваше приложение будет работать корректно под нагрузкой, логи ошибок останутся чистыми, а база данных не превратится в кладбище незавершенных записей.