Перейти к содержанию

Редактирование заявки и возврат формы

Оглавление серии «Laravel 13 и PHP: портал «Заявка»»

Создание получает новый объект, а редактирование работает с существующим. Добавим форму изменения title и body открытой заявки. Результат урока — повторное использование проверки значений, право update и условие состояния непосредственно в записи. После закрытия обращения изменение текста должно получить отказ даже при ранее открытой форме: скрытие кнопки не обеспечивает эту гарантию.

Мы не вводим здесь версию всей заявки и не обещаем защиту от двух одновременных редакторов текста. Если два корректных запроса изменяют открытую запись, итог определяется порядком записи. Для сохранения обоих намерений понадобится отдельный механизм версии и конфликта. Результат нынешнего урока уже: чужая и закрытая заявка не изменяются через предложенную форму.

Право и правила существующей записи

Создадим полный app/Http/Requests/UpdateTicketRequest.php. Наследуем нормализацию и rules из StoreTicketRequest, заменяя только authorize. Имя параметра ticket совпадает с маршрутом и соответствует связанному объекту Ticket.

<?php
namespace App\Http\Requests;
use App\Models\Ticket;
class UpdateTicketRequest extends StoreTicketRequest
{
    public function authorize(): bool
    {
        $ticket = $this->route('ticket');
        return $ticket instanceof Ticket && ($this->user()?->can('update', $ticket) ?? false);
    }
}

Отдельный полный app/Http/Controllers/TicketEditController.php проверяет право при чтении редактора. Эта проверка нужна даже до POST, потому что форма содержит существующий текст заявки.

<?php
namespace App\Http\Controllers;
use App\Models\Ticket;
use Illuminate\Support\Facades\Gate;
class TicketEditController extends Controller
{
    public function __invoke(Ticket $ticket)
    {
        Gate::authorize('update', $ticket);
        return view('tickets.edit', compact('ticket'));
    }
}

Контроллер записи является отдельным полным app/Http/Controllers/TicketUpdateController.php. Условное update включает user_id и status open, поэтому изменение не превращает закрытую запись в редактируемую между policy и SQL.

<?php
namespace App\Http\Controllers;
use App\Http\Requests\UpdateTicketRequest;
use App\Models\Ticket;
class TicketUpdateController extends Controller
{
    public function __invoke(UpdateTicketRequest $request, Ticket $ticket)
    {
        $changed = Ticket::query()->whereKey($ticket->id)
            ->where('user_id', $request->user()->id)->where('status', 'open')
            ->update($request->validated());
        abort_if($changed === 0, 409, 'Заявка уже недоступна для изменения.');
        return redirect()->route('tickets.show', $ticket)->with('status', 'Текст заявки обновлён.');
    }
}

Документация Eloquent по массовому обновлению полезна рядом с этим листингом: builder обновляет совпавшие строки, а не вызывает save каждого полученного объекта. Поэтому модельные события не должны считаться автоматически выполненными для каждой записи. Здесь действие сознательно ограничено одной строкой по key и серверными условиями.

Форма редактирования

Ниже полный resources/views/tickets/edit.blade.php. HTML отправляет POST, а @method выражает PATCH для маршрутизатора Laravel. old с запасным значением сохраняет ошибочный ввод вместо повторного показа старого текста базы.

@extends('layouts.app')
@section('content')
<h1>Изменить заявку</h1>
<form method="post" action="{{ route('tickets.update', $ticket) }}">
  @csrf @method('PATCH')
  <label>Название <input name="title" maxlength="120" required value="{{ old('title', $ticket->title) }}"></label>
  @error('title')<p role="alert">{{ $message }}</p>@enderror
  <label>Описание <textarea name="body" maxlength="5000" required>{{ old('body', $ticket->body) }}</textarea></label>
  @error('body')<p role="alert">{{ $message }}</p>@enderror
  <button type="submit">Сохранить текст</button>
</form>
@endsection

В routes добавляются два имени и соответствующие импорты контроллеров. Полный файл состояния содержит все прежние маршруты и новые декларации.

Route::get('/tickets/{ticket}/edit', TicketEditController::class)->whereNumber('ticket')->middleware('auth')->name('tickets.edit');
Route::patch('/tickets/{ticket}', TicketUpdateController::class)->whereNumber('ticket')->middleware('auth')->name('tickets.update');

В show добавляется ссылка, видимая только при update. Это подсказка доступного действия, а не единственная защита.

@can('update', $ticket)<a href="{{ route('tickets.edit', $ticket) }}">Изменить текст</a>@endcan

Ожидается, что владелец открытой заявки видит редактор и после правильного PATCH получает обновлённую карточку. Ошибка поля возвращает форму с old input. Закрытая заявка не проходит authorize; если состояние изменилось позже проверки, условный SQL не находит допустимую строку и возвращает 409. Этот ответ относится к устаревшему условию доступности операции, а не к проверке версии текста.

Снимок формы и текущая строка

При открытии редактора пользователь получает снимок полей на тот момент. База может измениться до подтверждения. Policy при POST читает связанный объект заново, но после её проверки всё равно остаётся промежуток до SQL. Добавление where status выражает требование внутри самой записи. Такой приём помогает различать «проверено раньше» и «сохранено при нужном условии».

Полезный неправильный случай — вызвать $ticket update validated без повторного условия status. Он не обязательно изменит статус, но способен изменить текст уже закрытой заявки, если close прошёл после policy. Код будет выглядеть аккуратно и использовать validated, однако предметное правило нарушится. Проверка данных и проверка состояния операции решают разные задачи; ни одна не должна считаться универсальным ответом на другую.

where user_id повторён в SQL, хотя policy уже проверяла владельца. Здесь это ясное ограничение самой записи, полезное при изменении реализации. Оно не разрешает приём user_id от формы. Источником по-прежнему является request user. Если продукт допускает передачу заявки другому владельцу, такая операция должна иметь отдельный маршрут и политику, а не прятаться в текущем обновлении title и body.

Ограничения выбранной базы

Пример нацелен на SQLite локальной базы. При переносе на другую СУБД следует проверить трактовку числа затронутых строк и работу timestamp, прежде чем автоматически считать ноль именно конфликтом. Это не выполненная межбазовая проверка. Текущий курс не выдаёт поведение выбранного builder за одинаковую гарантию любого окружения. Материальное ограничение указано рядом с механизмом, который от него зависит.

Форма не переоткрывает заявку и не редактирует историю. Поля status и user_id не входят в rules, следовательно внешний запрос не получает такой поверхности обновления. Даже если добавить hidden status в разметку для удобства, контроллер должен игнорировать его в этой операции. Для следующего закрытия будет отдельное намерение и отдельная атомарная связь с записью истории.

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

Ошибка формы и отказ предметного состояния

Неправильная длина title является исправляемой ошибкой поля. Закрытие заявки является изменением допустимости самой операции. Они требуют разных сообщений: первая возвращает редактор с old input, второе может предложить свежую карточку, где редактирование больше недоступно. Не следует подписывать оба случая одинаковым «заполните поля правильно». Пользователь уже мог ввести правильный текст, когда серверное состояние изменилось.

Текущий abort 409 использует обычный ответ ошибки Framework. Он не реализует отдельную страницу сравнения черновика. Для рабочего редактора с ценной длинной записью потребуется сохранить намерение и объяснить выбор дальнейшего действия. Это отдельный результат, которого серия не заявляет. Здесь важно увидеть условный отказ без потери защищённости базы; удобство сложного восстановления можно развить позже.

Заголовок и body обновляются одним SQL-запросом. Поэтому пользователь не получает промежуточную строку с новым заголовком и старым описанием из-за двух последовательных update приложения. Такая атомарность относится к одной строке. Когда следующее действие затронет также comments, потребуется явная транзакция, поскольку две разные записи уже не связываются одной командой update.

Для самостоятельного воспроизведения результата «Обновлять открытую заявку с восстановлением ввода» выберите lesson-08 в архиве исходников. Статус выполнения этого состояния: not_run.