Laravel: закрытие заявки и транзакция
Оглавление серии «Laravel 13 и PHP: портал «Заявка»»
Закрытие заявки должно изменить status и оставить запись о решении. Если выполнить два отдельных сохранения без транзакции, сбой между ними даст закрытую заявку без истории либо историю закрытия у ещё открытой записи. Результат урока — атомарная операция: либо оба изменения фиксируются, либо ни одно. Повтор команды не должен добавлять вторую запись closure.
Сохраним правило владельца из TicketPolicy close. Сам переход разрешён только из open в closed и выражается условием SQL. Это важно для двух почти одновременных запросов: оба могут прочитать открытую заявку до записи, но только один должен выполнить условный переход. Policy проверяет право, а условное update и транзакция определяют целостность предметного результата.
Граница транзакции
Ниже полный новый app/Http/Controllers/TicketCloseController.php. DB transaction связывает обновление tickets и сохранение comments. Нулевое число переходов возвращает false, не создавая историю. Текст closure задаётся приложением, а не полем внешнего запроса.
<?php
namespace App\Http\Controllers;
use App\Models\Comment;
use App\Models\Ticket;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
class TicketCloseController extends Controller
{
public function __invoke(Request $request, Ticket $ticket)
{
Gate::authorize('close', $ticket);
$closed = DB::transaction(function () use ($request, $ticket) {
$changed = Ticket::query()->whereKey($ticket->id)
->where('user_id', $request->user()->id)->where('status', 'open')
->update(['status' => 'closed']);
if ($changed === 0) return false;
$comment = new Comment(['body' => 'Заявка закрыта владельцем.']);
$comment->user_id = $request->user()->id;
$comment->kind = 'closure';
$ticket->comments()->save($comment);
return true;
});
return redirect()->route('tickets.show', $ticket)
->with('status', $closed ? 'Заявка закрыта.' : 'Заявка уже была закрыта.');
}
}
Документация транзакций Laravel описывает автоматическую фиксацию callback и откат при исключении. Для портала важно поместить оба изменения внутри одной границы. Если исключение при комментарии поймать внутри callback и просто вернуть true, transaction сможет зафиксировать неполный результат. Поэтому отказ сохранения должен прервать операцию, а не превращаться в успешное сообщение.
Маршрут закрытия использует POST и имя tickets.close. Добавьте импорт контроллера. Полный routes нового состояния включён в архив.
Route::post('/tickets/{ticket}/close', TicketCloseController::class)->whereNumber('ticket')->middleware('auth')->name('tickets.close');
В show.blade.php перед обсуждением добавьте форму. Для уже закрытой заявки кнопка не показывается, однако повторный внешний POST всё равно получает корректную серверную обработку.
@if($ticket->status === 'open')
@can('close', $ticket)
<form method="post" action="{{ route('tickets.close', $ticket) }}">@csrf
<button type="submit">Закрыть заявку</button>
</form>
@endcan
@endif
Для различения заметки и истории замените строку комментария в show на следующую. Значение kind выбирает подпись, body по-прежнему экранируется.
<li>{{ $comment->kind === 'closure' ? 'Закрытие' : 'Заметка' }} — {{ $comment->user->name }}: {{ $comment->body }}</li>
Повтор и конкурентное закрытие
Первое корректное закрытие меняет open на closed и создаёт одну closure. Повтор на уже закрытой записи не находит строку с open, поэтому ничего не добавляет. Ожидаемое сообщение признаёт, что цель уже достигнута. Это ограниченная идемпотентность предметного перехода, а не возврат буквально одного сохранённого HTTP-ответа по ключу операции. Для данного действия достаточно избежать повторной истории.
У двух запросов по одной открытой заявке условие update проверяется в базе при записи. Нельзя заменить его предварительным if ticket status в PHP, затем безусловно сохранить closed: оба снимка могли бы пройти if. Транзакция сама по себе также не делает произвольный read-then-write алгоритм безопасным без понимания изоляции. Наш код выражает нужное условие в изменяющем запросе и связывает успешный переход с историей.
Для SQLite возможны особенности блокировки и ошибки занятости при конкуренции. Пример не измерял их и не выполнял одновременные запросы. Условная операция описывает правильную границу, но рабочая обработка временных ошибок базы требует самостоятельной проверки в выбранной среде. Не обещайте, что DB transaction устраняет любые deadlock или BusyError без отдельной стратегии повторения.
Полезный неправильный случай — создать comment до проверки changed. Повтор закрытия мог бы получить новую closure без нового перехода status. Другой вариант — поставить comment save после transaction. Тогда изменение tickets уже было бы зафиксировано при отказе истории. Порядок и принадлежность строк к общей границе важнее того, что в коде вообще присутствует слово transaction.
Объект маршрута и свежесть строки
Маршрутный $ticket остаётся снимком, полученным до update builder. В нём status может ещё быть open, хотя база уже closed. Redirect строится по id и выполняет новое чтение карточки, поэтому шаблон получает свежую строку. Если бы контроллер сразу возвращал view с прежним $ticket, пользователь мог бы увидеть старый статус после удачной записи. Тогда понадобилось бы refresh или явное обновление снимка.
comments save использует id родителя, поэтому устаревший status маршрутного объекта не мешает связыванию. Но это не причина считать весь снимок свежим. Каждый раз различайте поля, нужные для ссылки, и поля, описывающие текущий предметный результат. В следующем фоновом задании также будет передаваться стабильный id, а обработчик заново прочитает закрытую запись.
История без внешнего эффекта
Пока transaction работает только с базой. Она не включает отправку письма, сетевой вызов или запись удалённого файла. Внешний получатель не может автоматически откатить действие вместе с SQL. Поэтому будущую квитанцию вынесем в очередь после фиксации, сохранив различие между закрытием и доставкой результата. Нельзя просто поместить письмо внутри callback и объявить две системы одной атомарной операцией.
История closure хранит автора из текущей сессии и фиксированную подпись. Если необходимо сохранять причину закрытия, следует добавить отдельную проверенную строку и согласовать её с политикой. В текущем результате обязательная причина не предусмотрена, поэтому пользователь не теряет введённый текст при close. Body заявки остаётся прежним; обсуждение может продолжаться по правилу прошлого урока.
Для самостоятельного разбора нарисуйте две строки базы до закрытия и после: tickets получает closed, comments получает closure с тем же ticket_id. Затем рассмотрите отказ второго сохранения: ожидается прежний open и отсутствие closure. Наконец, повторите уже закрытую цель: новых строк быть не должно. Следующий урок улучшит ограниченный личный список фильтром и страницами, сохранив тот же серверный источник владельца.
Атомарность и сообщения после неё
Flash-сообщение формируется после завершения transaction. Это позволяет не назвать закрытие удачным, пока callback ещё способен откатиться. Закрытый status и closure являются результатом базы, а status сессии — временным объяснением пользовательского перехода. Если ответ браузеру потеряется после commit, база всё равно закрыта. Повтор команды увидит это и не создаст вторую историю. Такой сценарий показывает полезность предметно идемпотентного перехода даже без гарантии доставки самого HTTP-ответа.
Проверка user_id в update также удерживает границу, если контроллер со временем изменится. Но историю нельзя назначать чужому user_id из body запроса: её автор является тем, кто выполнил разрешённую команду. Внешний клиент получает только намерение закрыть выбранную заявку.
Для самостоятельного воспроизведения результата «Атомарно записать статус и историю закрытия» выберите lesson-10 в архиве исходников. Статус выполнения этого состояния: not_run.