Form Request и создание заявки
Оглавление серии «Laravel 13 и PHP: портал «Заявка»»
Добавим создание заявки владельцем. Форма передаёт только title и body, Form Request проверяет право и значения, контроллер назначает user_id из сессии и status open. Результат урока — узкий путь записи, в котором внешний клиент не выбирает ни владельца, ни состояние. После успешного POST следует redirect к карточке, чтобы повторная обычная загрузка страницы была чтением.
Полезно заранее отделить три вида проверки. CSRF подтверждает связь формы с текущей сессией, authorize определяет право создания, rules ограничивает данные. Они не заменяют друг друга. Корректный токен не делает пустой заголовок допустимым, а подходящий заголовок не разрешает гостю открыть чужую сессию. Такой разбор помогает понимать форму как серверный договор, а не набор HTML-атрибутов.
Form Request
Создадим полный app/Http/Requests/StoreTicketRequest.php. До правил нормализуем края строк. Если клиент отправил массив вместо строки, prepareForValidation не пытается вызвать trim и оставляет значение для отказа string.
<?php
namespace App\Http\Requests;
use App\Models\Ticket;
use Illuminate\Foundation\Http\FormRequest;
class StoreTicketRequest extends FormRequest
{
protected function prepareForValidation(): void
{
foreach (['title', 'body'] as $field) {
if (is_string($this->input($field))) $this->merge([$field => trim($this->input($field))]);
}
}
public function authorize(): bool { return $this->user()?->can('create', Ticket::class) ?? false; }
public function rules(): array
{
return ['title' => ['required', 'string', 'max:120'], 'body' => ['required', 'string', 'max:5000']];
}
}
Документация валидации Laravel описывает authorize, rules и validated. Здесь rules формулирует вход заявки, а prepareForValidation согласует пробельный ввод с требованием непустого текста. Проверка не объявляет HTML безопасным для любого вывода; Blade всё равно сохраняет обычное экранирование title и body.
Создадим полный app/Http/Controllers/TicketStoreController.php. Показанный validated содержит только поля правил. Владелец и статус получают отдельное серверное значение.
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreTicketRequest;
use App\Models\Ticket;
class TicketStoreController extends Controller
{
public function __invoke(StoreTicketRequest $request)
{
$ticket = new Ticket($request->validated());
$ticket->user_id = $request->user()->id;
$ticket->status = 'open';
$ticket->save();
return redirect()->route('tickets.show', $ticket)->with('status', 'Заявка создана.');
}
}
Форма и восстановление ввода
Ниже полный resources/views/tickets/create.blade.php. old возвращает прежнее значение после обычного HTML-отказа проверки, а @error помещает сообщение возле соответствующего поля. Паролей и частных токенов в этих полях нет, но передача каждого old-значения всё равно проходит экранирование шаблона.
@extends('layouts.app')
@section('content')
<h1>Новая заявка</h1>
<form method="post" action="{{ route('tickets.store') }}">
@csrf
<label>Название <input name="title" maxlength="120" required value="{{ old('title') }}"></label>
@error('title')<p role="alert">{{ $message }}</p>@enderror
<label>Описание <textarea name="body" maxlength="5000" required>{{ old('body') }}</textarea></label>
@error('body')<p role="alert">{{ $message }}</p>@enderror
<button type="submit">Создать заявку</button>
</form>
@endsection
Добавьте следующие маршруты и импорт StoreTicketRequest не нужен в routes, поскольку зависимость получает контроллер. Импорт TicketStoreController нужен. Это точные новые декларации; полный routes шага включён в архив.
Route::view('/tickets/create', 'tickets.create')->middleware('auth')->name('tickets.create');
Route::post('/tickets', TicketStoreController::class)->middleware('auth')->name('tickets.store');
В список добавляется ссылка на tickets.create, а в карточку — сообщение session status. Эти точные фрагменты получают место сразу после h1 списка и перед title карточки соответственно. Итоговые полные шаблоны сохраняются в состоянии урока.
<a href="{{ route('tickets.create') }}">Новая заявка</a>
@if(session('status'))<p role="status">{{ session('status') }}</p>@endif
Ожидаемый правильный POST создаёт открытую запись текущего пользователя, затем браузер переходит на её карточку. Неправильный title из пробелов после нормализации получает отказ, сохраняет допустимый body для исправления и не создаёт Ticket. Если запрос ожидает JSON, Framework использует другой вид ответа проверки, поэтому поведение HTML-формы нельзя автоматически переносить на API без настройки Accept.
Лишние поля внешнего запроса
Полезный неправильный случай — клиент добавляет user_id другого пользователя и status closed. Контроллер не читает их через all и не передаёт в new Ticket. validated ограничивается title и body, а прямые присваивания задают настоящий owner и open. fillable модели также ограничен. Такая последовательность делает причину безопасности видимой в коде, а не полагается на то, что официальная форма не рисует лишние inputs.
Если правила со временем получат вложенные данные, следует ограничить допустимые ключи и структуру отдельно. Наличие validated не является магическим доказательством, что любой будущий массив полностью очищен. В текущей модели два самостоятельных строковых поля достаточно ясны. Расширение входа нужно сопровождать новым договором, особенно если оно способно менять отношения или статус.
@csrf создаёт скрытое поле, а web middleware проверяет его. Документация защиты запросов Laravel 13 описывает выбранный механизм. Не отключайте защиту маршрута ради исправления неверной формы. Нужно проверить, что форма и сессия принадлежат одному приложению и что токен действительно выводится через Blade, а не скопирован статическим текстом из другого запуска.
Почему после POST нужен redirect
Redirect отделяет успешную запись от страницы её чтения. Обычное обновление карточки выполняет GET и не повторяет создание. Это полезный пользовательский эффект, но не полноценная идемпотентность POST: двойная отправка до redirect или повтор внешнего клиента всё ещё может создать два обращения. Для данного кабинета одинаковые обращения допустимы; если продукт требует защиту, понадобится отдельный ключ операции и серверное ограничение.
Сообщение flash относится к конкретному переходу после создания, а не к постоянному свойству заявки. Оно не должно сохраняться как body или status. Состояние записи остаётся open, а текст «Заявка создана» сообщает результат команды. Разделение пользовательского статуса страницы и предметного статуса модели предотвращает путаницу при дальнейших закрытии и уведомлении.
Перед редактированием проследите значение title через prepareForValidation, rules, validated, fillable и экранирование. Каждый этап решает отдельную задачу. Затем объясните, почему принадлежность записи не определяется скрытым полем user_id. Следующий урок применит тот же узкий вход к уже существующей заявке и добавит условие, запрещающее изменение закрытого обращения.
Пробелы, переносы и предметный текст
trim убирает только края строки. Переносы внутри body сохраняются в базе, хотя обычный HTML-абзац может показать их как пробелы. Это различие хранения и отображения. Если требуется визуально сохранить абзацы, можно добавить подходящее CSS white-space или безопасное преобразование текста, но не переключать весь body на неэкранированный HTML. Представление должно сохранять текстовый договор пользовательского описания.
Правило max для строки выбирается с учётом возможностей Framework и PHP-расширений среды. Оно не заменяется одним maxlength браузера, потому что внешний запрос не обязан проходить официальный input. В то же время HTML-ограничение полезно для обычного пользователя и должно совпадать с серверным смыслом. Несогласованная форма могла бы позволить ввод, который сервер неизбежно отклонит, или ограничить текст сильнее реального правила.
GET create сейчас защищён auth и показывает форму. Само право create повторяется в Form Request при POST; при будущих ограничениях лучше также проверять его перед выдачей формы, чтобы не предлагать заведомо запрещённое действие. Такая подсказка улучшает интерфейс, но окончательная проверка записи остаётся обязательной. Если policy изменится на роль или квоту, нужно пересмотреть оба пути, а не только скрыть ссылку списка.
Для самостоятельного воспроизведения результата «Сохранить только разрешённые и проверенные поля» выберите lesson-07 в архиве исходников. Статус выполнения этого состояния: not_run.