Laravel · Часть 5 — CRUD: добавить, изменить, удалить
1 / 1
lesson.laravel · часть 5

Часть 5 — CRUD: добавить, изменить, удалить

Полный цикл жизни записи: список → форма добавления → сохранение → правка → удаление. Понадобится то, что уже стоит: модель User и база из части 4, маршруты и шаблоны из частей 2–3. Добавим три операции записи (store, update, destroy) и защитим их: CSRF-токен, валидация, белый список полей. Каждый вывод и снимок в части — настоящий захват со стенда, ничего не сочинено.

1листайте
Шаг 0 · Откуда стартуем

Что уже есть к началу части

В части 4 мы довели приложение до состояния «читаем из базы»: модель User с белым списком полей, миграция с колонкой phone, сидер с тестовыми пользователями и список с пагинацией. Чтение работает — записи нет.

  • есть: GET /users отдаёт список страницами;
  • нет: добавить, изменить, удалить — браузеру некуда отправить форму;
  • значит, не хватает маршрутов записи, методов контроллера и шаблонов форм.

CRUD — это Create, Read, Update, Delete. Read готов; сейчас добавим остальные три буквы и увидим, какие договорённости фреймворк даёт под них бесплатно.

2листайте
Шаг 1 · Маршруты

Заготовка из семи методов

Команда make:controller --resource --model=User создаёт контроллер с семью пустыми методами — по одному на операцию ресурса. Третьей командой файл удалили (rm): в уроке каждый метод пишем сами и понимаем, зачем он нужен.

student@mint: ~/recoursia/project-laravel780 мс
student@mint:~/recoursia/project-laravel$
>
>
Что даёт make:controller --resource — Показываем заготовку с семью методами и сразу удаляем: в уроке методы пишем сами, по одному.
Разбор команды

Смотрим, какую заготовку даёт генератор ресурсного контроллера, и тут же её удаляем: в уроке каждый метод пишем сами и понимаем, зачем он.

php artisan make:controller DemoController --resource --model=User
ТокенЗачем он и что меняет
phpзапуск интерпретатора PHP из PATH; на стенде 8.3 — в своих командах подставьте свою версию (php -v)
artisanскрипт artisan в корне проекта: единая точка входа команд Laravel
make:controllerкоманда-генератор: создаёт класс контроллера в app/Http/Controllers
DemoControllerимя класса и будущего файла DemoController.php; имя демонстрационное, чтобы не пересечься с настоящим UserController
--resourceфлаг «ресурсный контроллер»: создаёт сразу семь методов — index, create, store, show, edit, update, destroy
--model=Userфлаг со значением (равнозначно --model User): привязывает контроллер к модели User. Генератор добавляет use App\Models\User; и ставит тип User у аргументов show, edit, update, destroy — это основа Route Model Binding

Что произойдёт

Artisan печатает «INFO Controller [app/Http/Controllers/DemoController.php] created successfully.» и создаёт файл с пустыми методами ресурса: 7 методов, по одному на операцию.

На что смотреть

Смотрим на аргументы: store и update принимают Request $request, а show, edit, update и destroy — ещё и User $user. Тела всех методов пустые (//), генератор даёт структуру, а не логику.

Частые ошибки

  • Без --model=User аргументы останутся без типа модели, и Route Model Binding не заработает: запись придётся искать руками по id из URL.
cat app/Http/Controllers/DemoController.php
ТокенЗачем он и что меняет
catпечатает содержимое файла в терминал как есть
app/Http/Controllers/DemoController.phpотносительный путь от корня проекта: команда выполняется в /home/student/recoursia/project-laravel

Что произойдёт

На экран выходит весь код заготовки — 65 строк PHP: namespace, два use и 7 методов с doc-блоками.

На что смотреть

Doc-блоки и есть назначение методов: index — «Display a listing of the resource.», create — «Show the form for creating a new resource.», store — «Store a newly created resource in storage.» и так далее.

Частые ошибки

  • cat большого файла засоряет терминал; когда нужен кусок, берут фильтр строк — как sed -n "10,40p" в захвате 05-curl-create-page этой части.
rm app/Http/Controllers/DemoController.php
ТокенЗачем он и что меняет
rmудаляет файл безвозвратно: без подтверждения и без корзины
app/Http/Controllers/DemoController.phpпуть к заготовке, которую мы только что посмотрели

Что произойдёт

Файл удалён. При успехе rm не печатает ничего, поэтому после кода в захвате пусто.

На что смотреть

Успех rm молчалив: убедиться, что файла больше нет, можно отдельной командой — ls app/Http/Controllers.

Частые ошибки

  • rm необратим: опечатка в пути или флаг -r стирают нужные файлы проекта без возможности вернуть.

Посмотрите на аргументы: store и update принимают Request $request (тело запроса), а show, edit, update, destroy — ещё и User $user. Откуда объект модели берётся в аргументе вместо числа из URL — через два слайда, это Route Model Binding.

3листайте
Шаг 1 · Маршруты

Одна строка вместо шести: Route::resource

routes/web.phpphp
1<?php23use App\Http\Controllers\UserController;4use Illuminate\Support\Facades\Route;56Route::get('/', fn () => redirect('/users'));78Route::resource('users', UserController::class)->except(['show']);

Route::resource('users', UserController::class) регистрирует весь набор CRUD-маршрутов сразу и даёт им имена users.index … users.destroy. Имена важнее путей: в шаблонах мы пишем route('users.edit', $user), и если путь изменится, шаблоны править не придётся.

->except(['show']) убирает маршрут одиночного просмотра GET /users/{user}: страницы одного пользователя в приложении нет, а незарегистрированный маршрут лучше мёртвого метода-заглушки. Первая строка файла — из части 2: корень сайта сразу уводит на /users.

4листайте
Шаг 1 · Маршруты

Что зарегистрировалось: route:list

student@mint: ~/recoursia/project-laravel750 мс
student@mint:~/recoursia/project-laravel$
Шесть маршрутов ресурса
Разбор команды

Проверяем, что одна строка Route::resource('users', UserController::class) зарегистрировала весь набор маршрутов CRUD.

php artisan route:list --except-vendor
ТокенЗачем он и что меняет
phpинтерпретатор PHP (на стенде 8.3; своя версия — php -v)
artisanточка входа команд Laravel
route:listкоманда: печатает таблицу зарегистрированных маршрутов — METHOD, URI, имя маршрута и действие
--except-vendorфлаг: не показывать маршруты, которые добавляют пакеты из vendor/; в таблице остаются только маршруты приложения

Что произойдёт

Таблица из 7 строк: редирект / на /users из части 2 (routes/web.php:6) и 6 маршрутов ресурса users; итоговая строка — Showing [7] routes.

На что смотреть

Колонки имени и действия показывают связку: users.index › UserController@index … users.destroy › UserController@destroy. Строки GET users/{user} в таблице нет — её убрал ->except(['show']).

Частые ошибки

  • Без --except-vendor нужные строки тонут в служебных маршрутах пакетов.
  • Нового маршрута нет в списке — routes/web.php не сохранён или строка регистрации написана с ошибкой: route:list показывает то, что реально зарегистрировалось.
  • GET|HEAD и PUT|PATCH — это один маршрут на два метода HTTP, а не две разные записи.

Шесть строк ресурса плюс наш редирект / — итого Showing [7] routes. В части 2 маршруты писали руками по одному; здесь набор появился целиком из одной строки.

Метод HTTP Путь Имя маршрута Метод контроллера Зачем
GET users users.index index список страницами
GET users/create users.create create пустая форма добавления
POST users users.store store сохранить новую запись
GET users/{user}/edit users.edit edit форма правки с данными
PUT|PATCH users/{user} users.update update сохранить правку
DELETE users/{user} users.destroy destroy удалить запись

Метод HTTP несёт смысл операции: GET только читает, POST создаёт, PUT/PATCH изменяет, DELETE удаляет. Ниже увидим, почему это разделение — не формальность.

5листайте
Шаг 2 · Контроллер

Контроллер: чтение

app/Http/Controllers/UserController.phpphp
1<?php23namespace App\Http\Controllers;45use App\Models\User;6use Illuminate\Http\Request;7use Illuminate\Validation\Rule;89class UserController extends Controller10{11 public function index()12 {13 $users = User::query()->orderBy('name')->paginate(5);1415 return view('users.index', ['users' => $users]);16 }1718 public function create()19 {20 return view('users.create');21 }2223 public function store(Request $request)24 {25 $data = $this->validated($request);2627 User::create($data);2829 return redirect()->route('users.index')->with('status', 'Пользователь добавлен');30 }3132 public function edit(User $user)33 {34 return view('users.edit', ['user' => $user]);35 }3637 public function update(Request $request, User $user)38 {39 $data = $this->validated($request, $user);4041 if (empty($data['password'])) {42 unset($data['password']); // пароль не меняли — не трогаем хеш43 }4445 $user->fill($data)->save();4647 return redirect()->route('users.index')->with('status', 'Изменения сохранены');48 }4950 public function destroy(User $user)51 {52 $user->delete();5354 return redirect()->route('users.index')->with('status', 'Пользователь удалён');55 }5657 /**58 * Правила одни для добавления и правки: при правке email не должен59 * конфликтовать с самим собой, а пароль необязателен.60 */61 private function validated(Request $request, ?User $user = null): array62 {63 return $request->validate([64 'name' => ['required', 'string', 'max:120'],65 'phone' => ['required', 'string', 'regex:/^\+?[0-9()\-\s]{10,20}$/'],66 'email' => ['required', 'email', 'max:190', Rule::unique('users', 'email')->ignore($user?->id)],67 'password' => [$user ? 'nullable' : 'required', 'string', 'min:8'],68 ]);69 }70}

index() знаком по части 4: запрос к модели, сортировка по имени, paginate(5) — по пять записей на страницу. create() только отдаёт шаблон пустой формы: данных ещё нет, показывать нечего.

Весь CRUD живёт в одном контроллере: семь методов ресурса — это семь действий над одной сущностью, и держать их вместе проще, чем раскладывать по файлам.

6листайте
Шаг 2 · Контроллер

Контроллер: запись и удаление

app/Http/Controllers/UserController.phpphp
1<?php23namespace App\Http\Controllers;45use App\Models\User;6use Illuminate\Http\Request;7use Illuminate\Validation\Rule;89class UserController extends Controller10{11 public function index()12 {13 $users = User::query()->orderBy('name')->paginate(5);1415 return view('users.index', ['users' => $users]);16 }1718 public function create()19 {20 return view('users.create');21 }2223 public function store(Request $request)24 {25 $data = $this->validated($request);2627 User::create($data);2829 return redirect()->route('users.index')->with('status', 'Пользователь добавлен');30 }3132 public function edit(User $user)33 {34 return view('users.edit', ['user' => $user]);35 }3637 public function update(Request $request, User $user)38 {39 $data = $this->validated($request, $user);4041 if (empty($data['password'])) {42 unset($data['password']); // пароль не меняли — не трогаем хеш43 }4445 $user->fill($data)->save();4647 return redirect()->route('users.index')->with('status', 'Изменения сохранены');48 }4950 public function destroy(User $user)51 {52 $user->delete();5354 return redirect()->route('users.index')->with('status', 'Пользователь удалён');55 }5657 /**58 * Правила одни для добавления и правки: при правке email не должен59 * конфликтовать с самим собой, а пароль необязателен.60 */61 private function validated(Request $request, ?User $user = null): array62 {63 return $request->validate([64 'name' => ['required', 'string', 'max:120'],65 'phone' => ['required', 'string', 'regex:/^\+?[0-9()\-\s]{10,20}$/'],66 'email' => ['required', 'email', 'max:190', Rule::unique('users', 'email')->ignore($user?->id)],67 'password' => [$user ? 'nullable' : 'required', 'string', 'min:8'],68 ]);69 }70}

Схема трёх методов одинакова — проверить вход, записать, вернуться на список с flash-сообщением:

validated() — правила едины для store и updatevalidated()правила едины дляstore и updateUser::create / fill / deleteUser::create /fill / deleteMariaDB — INSERT / UPDATE / DELETEMariaDBINSERT / UPDATE /DELETE302 на список — + flash в сессию302 на список+ flash в сессию$data$datavalidated() — правила едины для store и updatevalidated()User::create / fill / deleteUser::create/ fill /deleteMariaDB — INSERT / UPDATE / DELETEMariaDB302 на список — + flash в сессию302 насписок
Схема записи: validate → модель → база → 302 с flash

Редирект после записи — обязательный приём (POST/Redirect/GET), вернёмся к нему на слайде про валидацию.

edit() стоит между ними: это ещё чтение (форма с данными), но уже с аргументом User $user.

7листайте
Шаг 2 · Контроллер

Route Model Binding: откуда берётся $user

Методы edit, update, destroy объявляют аргумент User $user, хотя из маршрута приходит строка — число из пути (users/{user}). Laravel смотрит на тип аргумента, берёт модель User и ищет запись с таким первичным ключом, фактически User::findOrFail($id).

URL users/{user} — например /users/14/editURL users/{user}например/users/14/editАргумент User $user — edit / update / destroyАргумент User$useredit / update /destroyПоиск по первичному ключу — User::findOrFailПоиск попервичному ключуUser::findOrFailОтвет 404 — метод не вызываетсяОтвет 404метод не вызываетсяМетод получает объект — id руками не достаёмМетод получаетобъектid руками не достаём14 из путизапись найденазаписи нет14 из путизапись найденазаписи нетURL users/{user} — например /users/14/editURLusers/{user}Аргумент User $user — edit / update / destroyАргументUser $userПоиск по первичному ключу — User::findOrFailПоиск попервичномуключуОтвет 404 — метод не вызываетсяОтвет 404Метод получает объект — id руками не достаёмМетодполучаетобъект
Число из URL → запись в базе или 404 без вызова метода

Связка «число в URL — строка в базе» описана один раз типом аргумента, а не кодом в каждом методе. Это продолжение идеи части 4: с базой работаем через модель, а не собираем SQL строками.

8листайте
Шаг 3 · Валидация

Правила валидации — одни для добавления и правки

app/Http/Controllers/UserController.phpphp
1<?php23namespace App\Http\Controllers;45use App\Models\User;6use Illuminate\Http\Request;7use Illuminate\Validation\Rule;89class UserController extends Controller10{11 public function index()12 {13 $users = User::query()->orderBy('name')->paginate(5);1415 return view('users.index', ['users' => $users]);16 }1718 public function create()19 {20 return view('users.create');21 }2223 public function store(Request $request)24 {25 $data = $this->validated($request);2627 User::create($data);2829 return redirect()->route('users.index')->with('status', 'Пользователь добавлен');30 }3132 public function edit(User $user)33 {34 return view('users.edit', ['user' => $user]);35 }3637 public function update(Request $request, User $user)38 {39 $data = $this->validated($request, $user);4041 if (empty($data['password'])) {42 unset($data['password']); // пароль не меняли — не трогаем хеш43 }4445 $user->fill($data)->save();4647 return redirect()->route('users.index')->with('status', 'Изменения сохранены');48 }4950 public function destroy(User $user)51 {52 $user->delete();5354 return redirect()->route('users.index')->with('status', 'Пользователь удалён');55 }5657 /**58 * Правила одни для добавления и правки: при правке email не должен59 * конфликтовать с самим собой, а пароль необязателен.60 */61 private function validated(Request $request, ?User $user = null): array62 {63 return $request->validate([64 'name' => ['required', 'string', 'max:120'],65 'phone' => ['required', 'string', 'regex:/^\+?[0-9()\-\s]{10,20}$/'],66 'email' => ['required', 'email', 'max:190', Rule::unique('users', 'email')->ignore($user?->id)],67 'password' => [$user ? 'nullable' : 'required', 'string', 'min:8'],68 ]);69 }70}

Правила собраны в приватном методе validated(): и store, и update вызывают его, поэтому добавление и правка проверяются одинаково. Разницу даёт второй аргумент: при правке это найденная запись, и два правила ведут себя иначе — об этом через слайд.

$request->validate() работает по договору: правила прошли — метод возвращает массив данных и код идёт дальше; не прошли — Laravel сам останавливает метод и уводит пользователя обратно на форму с ошибками. Поэтому в store и update нет ни одного if на тему «а вдруг поле пустое».

9листайте
Шаг 3 · Валидация

Что проверяют правила и почему на сервере

Поле Правило Почему так
name required, string, max:120 ФИО обязательно; длина совпадает с колонкой из миграции части 4
phone required, string, regex:/^\+?[0-9()\-\s]{10,20}$/ формат записи свободный, но только цифры и служебные символы; регулярки — из блока «Регулярные выражения»
email required, email, max:190 + Rule::unique(...) адрес должен быть адресом и не повторяться в таблице
password required / nullable, string, min:8 при добавлении обязателен, при правке — нет

required в HTML и проверки на JavaScript — подсказка честному пользователю: не отправить пустую форму случайно. Но запрос можно послать мимо браузера — тем же curl, как в захватах этой части. Сервер не имеет права верить клиенту: это мы проходили в блоке «SQL-инъекции», где правилом было «ни одному символу из запроса не верим».

10листайте
Шаг 3 · Валидация

Две тонкости: unique с ignore и nullable пароль

app/Http/Controllers/UserController.phpphp
1<?php23namespace App\Http\Controllers;45use App\Models\User;6use Illuminate\Http\Request;7use Illuminate\Validation\Rule;89class UserController extends Controller10{11 public function index()12 {13 $users = User::query()->orderBy('name')->paginate(5);1415 return view('users.index', ['users' => $users]);16 }1718 public function create()19 {20 return view('users.create');21 }2223 public function store(Request $request)24 {25 $data = $this->validated($request);2627 User::create($data);2829 return redirect()->route('users.index')->with('status', 'Пользователь добавлен');30 }3132 public function edit(User $user)33 {34 return view('users.edit', ['user' => $user]);35 }3637 public function update(Request $request, User $user)38 {39 $data = $this->validated($request, $user);4041 if (empty($data['password'])) {42 unset($data['password']); // пароль не меняли — не трогаем хеш43 }4445 $user->fill($data)->save();4647 return redirect()->route('users.index')->with('status', 'Изменения сохранены');48 }4950 public function destroy(User $user)51 {52 $user->delete();5354 return redirect()->route('users.index')->with('status', 'Пользователь удалён');55 }5657 /**58 * Правила одни для добавления и правки: при правке email не должен59 * конфликтовать с самим собой, а пароль необязателен.60 */61 private function validated(Request $request, ?User $user = null): array62 {63 return $request->validate([64 'name' => ['required', 'string', 'max:120'],65 'phone' => ['required', 'string', 'regex:/^\+?[0-9()\-\s]{10,20}$/'],66 'email' => ['required', 'email', 'max:190', Rule::unique('users', 'email')->ignore($user?->id)],67 'password' => [$user ? 'nullable' : 'required', 'string', 'min:8'],68 ]);69 }70}
  • Rule::unique('users', 'email')->ignore($user?->id): при правке запись уже занимает свой email в таблице. Без ignore валидация сравнивала бы адрес с самим собой и падала всегда («уже существует»). ignore говорит: «проверь все строки, кроме этой».
  • [$user ? 'nullable' : 'required', …]: при правке пустой пароль означает «не менять», а не «стереть». При добавлении пароля в базе ещё нет, поэтому он обязателен.
11листайте
Шаг 4 · Формы

Одна форма на две операции: _form.blade.php

resources/views/users/_form.blade.phpblade
1<div class="field">2 <label for="name">ФИО</label>3 <input id="name" name="name" value="{{ old('name', $user->name ?? '') }}" required>4 @error('name')<div class="error">{{ $message }}</div>@enderror5</div>67<div class="field">8 <label for="phone">Телефон</label>9 <input id="phone" name="phone" value="{{ old('phone', $user->phone ?? '') }}"10 placeholder="+7 (900) 000-00-00" required>11 @error('phone')<div class="error">{{ $message }}</div>@enderror12</div>1314<div class="field">15 <label for="email">Email</label>16 <input id="email" name="email" type="email" value="{{ old('email', $user->email ?? '') }}" required>17 @error('email')<div class="error">{{ $message }}</div>@enderror18</div>1920<div class="field">21 <label for="password">Пароль{{ $user ? ' (пусто — не менять)' : '' }}</label>22 <input id="password" name="password" type="password" autocomplete="new-password">23 @error('password')<div class="error">{{ $message }}</div>@enderror24</div>

Формы добавления и правки отличаются только заголовком и адресом отправки, поэтому поля вынесены в частичный шаблон _form и подключаются через @include. Дублировать четыре поля — значит потом чинить их в двух местах.

Две конструкции делают форму «помнящей»:

  • old('name', $user->name ?? '') — значение из прошлого запроса, если валидация вернула нас на форму; иначе значение из базы (при правке); иначе пусто (при добавлении);
  • @error('name') … @enderror — блок рисуется только если у поля есть ошибка; внутри доступен $message с текстом сообщения.
12листайте
Шаг 4 · Формы

Обёртки формы: @csrf и @method('PUT')

resources/views/users/create.blade.phpblade
1@extends('layouts.app')23@section('title', 'Добавить пользователя')45@section('content')6 <h1>Добавить пользователя</h1>78 <form class="form" method="POST" action="{{ route('users.store') }}">9 @csrf10 @include('users._form', ['user' => null])1112 <button class="btn" type="submit">Сохранить</button>13 <a class="btn btn--ghost" href="{{ route('users.index') }}">Отмена</a>14 </form>15@endsection
resources/views/users/edit.blade.phpblade
1@extends('layouts.app')23@section('title', 'Правка пользователя')45@section('content')6 <h1>Правка: {{ $user->name }}</h1>78 <form class="form" method="POST" action="{{ route('users.update', $user) }}">9 @csrf10 @method('PUT')11 @include('users._form')1213 <button class="btn" type="submit">Сохранить</button>14 <a class="btn btn--ghost" href="{{ route('users.index') }}">Отмена</a>15 </form>16@endsection

Обе обёртки короткие: заголовок, <form method="POST">, подключение _form и кнопки. Различий два, и оба обязательны:

  • в create стоит @csrf — без него Laravel не примет POST вовсе (код 419, увидим ниже);
  • в edit добавлен @method('PUT'): браузер отправляет формы только методами GET и POST, а маршрут правки — PUT|PATCH. Директива кладёт в форму скрытое поле _method=PUT, и Laravel читает его как настоящий метод запроса.

Адрес формы в обоих случаях даёт route(...): users.store для добавления и users.update для правки.

13листайте
Шаг 5 · CSRF

Токен в форме: что кладёт @csrf

student@mint: ~/recoursia/project-laravel507 мс
student@mint:~/recoursia/project-laravel$
HTML формы добавления — Видно скрытое поле _token — это и есть CSRF-защита.
Разбор команды

Забираем HTML формы добавления и ищем в нём скрытое поле _token — то самое, которое печатает директива @csrf.

curl -sS http://project-laravel.rs/users/create | sed -n "10,40p"
ТокенЗачем он и что меняет
curlHTTP-клиент: делает запрос и печатает тело ответа в stdout
-sSдва флага слитно: -s убирает прогресс-бар и статистику, -S возвращает показ ошибок. Без -s в вывод попал бы progress meter, а с одним -s ошибка соединения прошла бы молча
http://project-laravel.rs/users/createадрес формы добавления — маршрут users.create. Домен project-laravel.rs на стенде резолвит dnsmasq в 127.0.0.1, зона .rs настроена в lesson.linux.04–06
|пайп: stdout curl уходит на stdin sed
sedпотоковый редактор; здесь работает фильтром строк
-nне печатать строки по умолчанию: без -n sed выдал бы всё тело страницы, а затем ещё и выбранные строки
"10,40p"выражение sed: диапазон строк с 10-й по 40-ю и команда p (print). Кавычки собирают выражение в один аргумент

Что произойдёт

Печатается 31 строка HTML (40 − 10 + 1): от

до поля пароля — шапка приложения и форма добавления.

На что смотреть

Ищем строку <input type="hidden" name="_token" value="GOgzNSQTVIiHJ1ZpKGVxMI12wPJuqmLFESzCycLc": 40 символов, которые сервер положил в сессию и одновременно напечатал в форме. У полей value="" — форма пустая, old() ещё нечего возвращать.

Частые ошибки

  • 404 — такого маршрута нет: путь опечатан или users.create не зарегистрирован (проверка — php artisan route:list).
  • 500 и строка «No application encryption key has been specified.» — в .env пустой APP_KEY: ключом шифруются куки и сессии, без него запрос не стартует (разбираем в части 6).
  • 500 и в storage/logs/laravel.log «local.ERROR: tempnam(): file created in the system's temporary directory» — нет прав на запись в storage/framework/views, куда Blade компилирует шаблоны.
  • Пустой вывод — диапазон строк не попал в тело страницы; расширьте его: sed -n "1,60p".

Среди полей формы видно скрытое поле name="_token" — в нём CSRF-токен: случайная строка, которую сервер положил в сессию и одновременно напечатал в форме. Полностью токен не цитируем: он генерируется заново на каждую сессию, и в вашем выводе его значение будет другим. В прогоне захвата оно выглядело как CSRF-токен формы: rv3SUAvCUrZUjqYb... — первые 16 символов с многоточием, этого достаточно. При POST Laravel сверяет токен из тела запроса с токеном сессии: совпали — запрос пришёл с нашей формы.

Зачем: без токена любой чужой сайт мог бы заставить ваш браузер отправить POST на наш адрес — браузер сам приложил бы куки сессии. С токеном подделка бессильна: у чужой страницы нет значения из вашей сессии. В части 2 мы видели куку XSRF-TOKEN в ответе — это тот же механизм с другой стороны.

14листайте
Шаг 5 · CSRF

POST без токена: 419

student@mint: ~/recoursia/project-laravel438 мс
student@mint:~/recoursia/project-laravel$
POST без CSRF-токена: 419 — Laravel сверяет токен из формы с токеном сессии; без него запрос отклоняется до контроллера.
Разбор команды

Проверяем защиту в деле: отправляем данные мимо формы, без токена, и смотрим только на код ответа.

curl -sS -o /dev/null -w "POST /users без _token -> %{http_code}\n" -X POST http://project-laravel.rs/users --data "name=Хакер&phone=123&email=hack@x.ru&password=12345678"
ТокенЗачем он и что меняет
curlHTTP-клиент
-sSтихий режим без прогресс-бара, но с показом ошибок (-s и -S вместе)
-o /dev/nullтело ответа выбрасываем: нужна итоговая строка, а не HTML страницы ошибки
-w "POST /users без _token -> %{http_code}\n"write-out: шаблон строки, которую curl печатает после запроса
%{http_code}плейсхолдер -w — код ответа; в захвате это 419
\nперевод строки внутри шаблона -w, чтобы итог не слипся с следующим выводом
-X POSTявно задаёт метод POST. curl перешёл бы на него и сам из-за --data, но намерение видно сразу
http://project-laravel.rs/usersадрес сохранения пользователя — маршрут users.store (POST users)
--data "name=Хакер&phone=123&email=hack@x.ru&password=12345678"тело запроса в формате формы (application/x-www-form-urlencoded). Данные намеренно годные: пароль из 8 символов проходит min:8 — и это не спасает, проверка токена идёт раньше валидации
кавычки вокруг значения --dataобязательны: & без кавычек shell понял бы как «запустить в фоне», и тело оборвалось бы на первом же поле

Что произойдёт

Одна строка: «POST /users без _token -> 419». Запись в базе не появилась.

На что смотреть

419 — собственный код Laravel «страница устарела»: middleware проверки CSRF отклонил запрос до контроллера. Сам curl отработал штатно, поэтому код возврата команды 0.

Частые ошибки

  • 419 на собственной форме почти всегда означает, что в шаблоне забыли @csrf.
  • Вывода нет вовсе и код команды ненулевой — хост не резолвится или nginx не запущен; проверка: getent hosts project-laravel.rs и systemctl is-active nginx.

curl отправил те же данные, что отправила бы форма, но без _token — и получил 419. Это специальный код Laravel («страница устарела»): запрос отклонён ещё до контроллера, запись не создана. Данные в запросе были вполне годные по полям — и это не спасло бы: проверка токена стоит раньше валидации.

Вывод простой: 419 на собственной форме почти всегда означает, что в шаблоне забыли @csrf.

15листайте
Шаг 6 · Валидация в действии

POST/Redirect/GET: как ошибки доезжают до формы

student@mint: ~/recoursia/project-laravel623 мс
student@mint:~/recoursia/project-laravel$
>
>
>
>
Валидация: сервер вернул 302 и ошибки в сессии — Классика POST/Redirect/GET: после 302 браузер делает GET той же формы, а Laravel достаёт ошибки и старое значение из сессии. Снято после включения русской локали.
Разбор команды

Прогоняем весь путь POST/Redirect/GET из командной строки: берём токен и куки сессии, шлём заведомо битые данные и читаем ошибки на форме.

JAR=/tmp/cj.txt
ТокенЗачем он и что меняет
JAR=/tmp/cj.txtприсваивание переменной: путь к файлу cookie-jar. Пробелов вокруг = быть не должно, иначе shell примет строку за команду
/tmp/cj.txtимя файла произвольное; в него curl сложит куки, а дальше мы будем ссылаться на него как $JAR

Что произойдёт

Переменная JAR получает значение; на экран ничего не выводится.

На что смотреть

Одна переменная на все запросы прогона: путь к файлу кук не придётся повторять руками и рисковать опечаткой.

Частые ошибки

  • Пробел вокруг = (JAR = /tmp/cj.txt) — ошибка: shell станет искать команду JAR.
TOKEN=$(curl -sS -c $JAR http://project-laravel.rs/users/create | grep -o 'name="_token" value="[^"]*"' | head -1 | sed 's/.*value="//;s/"//')
ТокенЗачем он и что меняет
TOKEN=$(…)подстановка команды: переменная TOKEN получит stdout того, что выполнилось внутри $( )
curl -sSзапрос без прогресс-бара, но с показом ошибок
-c $JARcookie-jar: записать куки ответа в файл $JAR. Laravel отдаст куку сессии — без неё следующий POST придёт «анонимно» и получит 419
$JARподстановка значения переменной из первой строки прогона
http://project-laravel.rs/users/createстраница формы добавления: оттуда берём свежий токен
|пайп: тело страницы уходит в grep
grep -oпечатать только совпавший кусок строки, а не всю строку: от HTML остаётся сам атрибут токена
'name="_token" value="[^"]*"'шаблон в одинарных кавычках: имя поля _token и value="…". [^"]* — любые символы кроме кавычки, то есть ровно значение токена. Одинарные кавычки сохраняют двойные кавычки и звёздочку как есть
| head -1взять первое совпадение: токен в форме один, но так вывод не зависит от числа совпадений
| sed 's/.*value="//;s/"//'две подстановки через точку с запятой: отрезать всё до value=" и убрать закрывающую кавычку. Остаётся чистое значение токена

Что произойдёт

В TOKEN лежит CSRF-токен формы (40 символов), а в файле $JAR — кука сессии Laravel.

На что смотреть

Пустой TOKEN означает, что шаблон grep не совпал с вёрсткой или страница не отдалась. Следующий POST тогда вернёт 419, и причину надо искать здесь, а не в контроллере.

Частые ошибки

  • Без -c куки не сохранятся: каждый запрос curl окажется новой сессией, токен из чужой сессии не подойдёт — снова 419.
  • Шаблон grep взят в двойные кавычки — пришлось бы экранировать внутренние двойные кавычки; одинарные здесь проще.
echo "CSRF-токен формы: ${TOKEN:0:16}..."
ТокенЗачем он и что меняет
echoпечатает аргументы в stdout
"CSRF-токен формы: ${TOKEN:0:16}..."строка в двойных кавычках: текст плюс срез переменной. Двойные кавычки нужны, чтобы TOKEN раскрылся
${TOKEN:0:16}срез переменной: 16 символов с нулевой позиции. Фигурные скобки обязательны — это синтаксис среза. Полный токен не печатаем: показываем только начало, чтобы убедиться, что значение непустое
...буквальный текст — многоточие на месте обрезанного хвоста

Что произойдёт

Строка «CSRF-токен формы: rv3SUAvCUrZUjqYb...» — видно, что токен извлечён.

На что смотреть

Если после двоеточия сразу многоточие, TOKEN пуст: grep и sed из предыдущей строки ничего не нашли.

Частые ошибки

  • Одинарные кавычки вместо двойных напечатали бы ${TOKEN:0:16} текстом — раскрытия переменной не будет.
curl -sS -b $JAR -c $JAR -o /dev/null -w "POST с битыми данными -> %{http_code}, куда вернуло: %{redirect_url}\n" -X POST http://project-laravel.rs/users --data-urlencode "_token=$TOKEN" --data-urlencode "name=" --data-urlencode "phone=abc" --data-urlencode "email=не-емейл" --data-urlencode "password=123"
ТокенЗачем он и что меняет
-b $JARотправить куки из файла: сервер видит ту же сессию, в которой лежит токен
-c $JARи записать куки ответа обратно в тот же файл — сессия продолжается
-o /dev/nullтело ответа не печатать: нас интересует код и адрес редиректа
-w "POST с битыми данными -> %{http_code}, куда вернуло: %{redirect_url}\n"шаблон итоговой строки; \n в конце — перевод строки
%{http_code}плейсхолдер кода ответа: 302
%{redirect_url}плейсхолдер заголовка Location — куда Laravel отправляет браузер: http://project-laravel.rs/users/create
-X POSTметод запроса POST
http://project-laravel.rs/usersадрес сохранения — маршрут users.store; без /create
--data-urlencode "_token=$TOKEN"поле формы с URL-кодированием значения: в токене могут быть символы, которые в теле запроса нужно кодировать. Обычный --data ничего не кодирует
--data-urlencode "name="пустое ФИО — не проходит правило required
--data-urlencode "phone=abc"телефон буквами — не проходит regex-правило из контроллера
--data-urlencode "email=не-емейл"не адрес (правило email) и заодно пример, зачем нужно кодирование: значение с кириллицей
--data-urlencode "password=123"три символа — короче min:8

Что произойдёт

Одна строка: «POST с битыми данными -> 302, куда вернуло: http://project-laravel.rs/users/create». Сервер не рисует форму сам, а приказывает браузеру сделать GET той же страницы; ошибки и введённые значения уехали в сессию.

На что смотреть

302, а не 200 и не 422 — это и есть POST/Redirect/GET. Ошибок в теле ответа нет: они во flash-данных сессии, то есть в куке из файла $JAR.

Частые ошибки

  • 419 вместо 302 — токен пустой или куки не те: проверьте, что -b и -c указывают на один файл и что TOKEN извлечён.
  • --data вместо --data-urlencode на кириллическом значении отправит испорченное поле: сервер увидит не «не-емейл», а байтовую кашу.
  • 404 — неверный путь: сохранение идёт на POST users, а не на /users/create.
curl -sS -b $JAR http://project-laravel.rs/users/create | grep -B1 -A1 'class="error"' | head -16
ТокенЗачем он и что меняет
curl -sSзапрос без прогресс-бара, с ошибками
-b $JARте же куки: без них Laravel заведёт новую сессию, ошибок в ней не будет и страница придёт чистой
http://project-laravel.rs/users/createGET формы добавления — третья нога POST/Redirect/GET; браузер делает этот запрос сам после 302
|пайп: HTML страницы уходит в grep
grep -B1 -A1 'class="error"'ищем блоки ошибок с контекстом: -B1 — одна строка до совпадения (Before), -A1 — одна после (After). Рядом с каждой ошибкой видно само поле
| head -16обрезать вывод первыми 16 строками, чтобы поток не уехал на экран. Здесь grep выдал 14 строк, то есть ограничение не сработало

Что произойдёт

Блоков ошибок на русском — 4: «Поле name обязательно.», «Значение поля phone имеет некорректный формат.», «Значение поля email должно быть действительным электронным адресом.», «Количество символов в поле password должно быть не меньше 8.»

На что смотреть

У поля email строкой выше видно value="не-емейл" — сработал old(): введённое значение вернулось из сессии. У password значения нет, пароль обратно не подставляют. Строки -- между блоками печатает grep, разделяя несоседние группы совпадений.

Частые ошибки

  • Пустой вывод — запрос ушёл без кук (забыли -b $JAR): сессия новая, и ошибок в ней нет.
  • grep без -B1 показал бы только строки ошибок, без самих полей — не было бы видно, что value="не-емейл" сохранилось.

Разбор прогона по строкам:

  1. сначала забрали токен из формы и сохранили куки в файл (-c/-b) — без токена был бы 419;
  2. POST с битыми данными вернул 302 и адрес http://project-laravel.rs/users/create: сервер не рисует форму сам, он приказывает браузеру сделать GET той же формы;
  3. следующий GET достаёт из сессии ошибки и старые значения — поэтому в HTML видны <div class="error">Поле name обязательно.</div> рядом с полями, а value="не-емейл" сохранено.

Зачем такой крюк: после 302 в адресной строке остаётся GET-адрес, и обновление страницы (F5) не повторяет POST — запись не создастся второй раз случайно. Это шаблон POST/Redirect/GET, тот же, что в store/update/destroy после успешной записи.

Сообщения уже русские: захват снят в итоговом состоянии приложения, с включённой локалью ru. Как подключали переводы — покажет шаг «Локализация», а снимок браузера на следующем слайде — та же форма: ошибки под полями и сохранённое введённое значение.

16листайте
Шаг 6 · Валидация в действии

Та же форма в браузере: ошибки под полями

http://project-laravel.rs/users/create
Телефон «abc», занятый email и пароль из трёх цифр: сервер вернул три ошибки, введённое сохранено через old().
Телефон «abc», занятый email и пароль из трёх цифр: сервер вернул три ошибки, введённое сохранено через old().

Три ошибки: телефон abc не прошёл регулярку, email ivanov@example.com уже занят (сработал Rule::unique), пароль из трёх символов короче min:8. ФИО и остальные введённые значения остались в полях — это old() вернул их из сессии. Пароль не возвращается никогда: поле type="password" сознательно не подставляют обратно.

Сообщения русские, как и в выводе curl выше: оба захвата сняты в одном состоянии приложения, с включённой локалью ru. Откуда взялся русский перевод — следующий шаг.

17листайте
Шаг 7 · Локализация

Русские сообщения: пакет переводов

student@mint: ~/recoursia/project-laravel12150 мс
student@mint:~/recoursia/project-laravel$
  - Locking laravel-lang/locale-list (1.7.0)  - Locking laravel-lang/locales (2.11.0)  - Locking laravel-lang/native-country-names (1.8.0)  - Locking laravel-lang/native-currency-names (1.10.0)  - Locking laravel-lang/native-locale-names (2.9.0)  - Locking laravel-lang/publisher (16.8.0)Writing lock fileInstalling dependencies from lock file (including require-dev)Package operations: 13 installs, 0 updates, 0 removals  - Downloading dragon-code/contracts (2.25.0)  - Downloading dragon-code/support (6.17.1)  - Downloading laravel-lang/native-locale-names (2.9.0)  - Downloading laravel-lang/native-currency-names (1.10.0)  - Downloading laravel-lang/native-country-names (1.8.0)  - Downloading archtechx/enums (v1.1.2)  - Downloading laravel-lang/locale-list (1.7.0)  - Downloading laravel-lang/config (1.17.1)  - Downloading laravel-lang/locales (2.11.0)  - Downloading dragon-code/pretty-array (4.2.0)  - Downloading composer/semver (3.5.1)  - Downloading laravel-lang/publisher (16.8.0)  - Downloading laravel-lang/lang (15.37.4)  0/13 [>---------------------------]   0%  2/13 [====>-----------------------]  15%  4/13 [========>-------------------]  30%  7/13 [===============>------------]  53% 10/13 [=====================>------]  76% 12/13 [=========================>--]  92% 13/13 [============================] 100%  - Installing dragon-code/contracts (2.25.0): Extracting archive  - Installing dragon-code/support (6.17.1): Extracting archive  - Installing laravel-lang/native-locale-names (2.9.0): Extracting archive  - Installing laravel-lang/native-currency-names (1.10.0): Extracting archive  - Installing laravel-lang/native-country-names (1.8.0): Extracting archive  - Installing archtechx/enums (v1.1.2): Extracting archive  - Installing laravel-lang/locale-list (1.7.0): Extracting archive  - Installing laravel-lang/config (1.17.1): Extracting archive  - Installing laravel-lang/locales (2.11.0): Extracting archive  - Installing dragon-code/pretty-array (4.2.0): Extracting archive  - Installing composer/semver (3.5.1): Extracting archive  - Installing laravel-lang/publisher (16.8.0): Extracting archive  - Installing laravel-lang/lang (15.37.4): Extracting archive  0/13 [>---------------------------]   0%  3/13 [======>---------------------]  23% 12/13 [=========================>--]  92% 13/13 [============================] 100%3 package suggestions were added by new dependencies, use `composer suggest` to see details.Package dragon-code/pretty-array is abandoned, you should avoid using it. Use symfony/var-exporter instead.Package dragon-code/support is abandoned, you should avoid using it. Use illuminate/support instead.Generating optimized autoload files> Illuminate\Foundation\ComposerScripts::postAutoloadDump> @php artisan package:discover --ansi   INFO  Discovering packages.    laravel-lang/config ................................................... DONE  laravel-lang/lang ..................................................... DONE  laravel-lang/locales .................................................. DONE  laravel-lang/publisher ................................................ DONE  laravel/pail .......................................................... DONE  laravel/pao ........................................................... DONE  laravel/tinker ........................................................ DONE  nesbot/carbon ......................................................... DONE  nunomaduro/collision .................................................. DONE  nunomaduro/termwind ................................................... DONE90 packages you are using are looking for funding.Use the `composer fund` command to find out more!> @php artisan vendor:publish --tag=laravel-assets --ansi --force   INFO  No publishable resources for tag [laravel-assets].  No security vulnerability advisories found.Using version ^15.37 for laravel-lang/lang
Ставим пакет русских переводов — В каркасе Laravel сообщения валидации только английские; русские приходят пакетом laravel-lang/lang.
Разбор команды

В каркасе Laravel тексты ошибок только английские: ставим пакет переводов laravel-lang/lang в живой проект.

composer require laravel-lang/lang
ТокенЗачем он и что меняет
composerпакетный менеджер PHP (на стенде 2.7.1 из apt)
requireподкоманда: добавить пакет в composer.json, разрешить зависимости, зафиксировать их в composer.lock и установить в vendor/
laravel-lang/langимя пакета в формате vendor/package — каталог переводов строк Laravel на разные языки

Что произойдёт

Composer добавил пакет вместе с зависимостями: «Lock file operations: 13 installs, 0 updates, 0 removals», затем «Package operations: 13 installs, 0 updates, 0 removals»; сам laravel-lang/lang — версии 15.37.4. Последняя строка вывода: «Using version ^15.37 for laravel-lang/lang».

На что смотреть

Этапы установки идут в выводе по порядку: Locking — фиксация версий в composer.lock, Downloading — загрузка архивов пакетов и Installing … Extracting archive — распаковка в vendor/. Дальше идут пост-скрипты: «Generating optimized autoload files» и @php artisan package:discover --ansi, который находит 10 пакетов, среди них laravel-lang/config, laravel-lang/lang, laravel-lang/locales и laravel-lang/publisher.

Частые ошибки

  • Строки «Package dragon-code/pretty-array is abandoned…» и «90 packages you are using are looking for funding.» — предупреждения, а не ошибки: установка продолжается, код возврата 0.
  • Composer не смог разрешить зависимости — пакету не хватает версии PHP или расширения; причину читаем в первых строках вывода, а не в хвосте.
  • Обновились сразу три вещи: composer.json, composer.lock и vendor/. Это первый случай в уроке, когда Composer работает в живом проекте, а не в пустой папке.

В каркасе Laravel тексты ошибок только английские. Русские приходят пакетом laravel-lang/lang: composer require добавил его в composer.json, зафиксировал в lock-файле (13 installs) и поставил в vendor/ — тот же механизм, что мы разбирали в части 1 на примере nesbot/carbon. Последняя строка вывода — Using version ^15.37 for laravel-lang/lang: Composer сам подобрал совместимую мажорную версию.

Это первый случай в уроке, когда Composer работает в живом проекте, а не в пустой папке: обновились сразу composer.json, composer.lock и vendor/.

18листайте
Шаг 7 · Локализация

Публикуем переводы в lang/

student@mint: ~/recoursia/project-laravel815 мс
student@mint:~/recoursia/project-laravel$
Публикуем русские переводы в lang/
Разбор команды

Пакет установлен, но переводы лежат в vendor/: публикуем их в каталог lang/ нашего проекта.

php artisan lang:add ru
ТокенЗачем он и что меняет
phpинтерпретатор PHP (на стенде 8.3; своя версия — php -v)
artisanточка входа команд Laravel
lang:addкоманда, которую регистрирует пакет laravel-lang/publisher; в чистом каркасе её нет
ruаргумент — код локали: переводы какого языка копировать в lang/

Что произойдёт

Artisan собирает переводы (LaravelLang\Lang\Plugin … 10.93ms DONE) и записывает 5 файлов: ru.json, ru/auth.php, ru/pagination.php, ru/passwords.php и ru/validation.php.

На что смотреть

Главный для нас файл — ru/validation.php: из него берутся русские тексты ошибок валидации. Файлы легли в lang/ проекта, значит формулировки можно править под себя.

Частые ошибки

  • Команды lang:add нет в списке php artisan — пакет не установлен: сначала composer require laravel-lang/lang.
  • Переводы опубликованы, но сообщения остались английскими — в .env не выставлен APP_LOCALE=ru; это следующий шаг.

php artisan lang:add ru скопировал переводы пакета в наш каталог lang/: ru.json и четыре php-файла, среди них ru/validation.php — именно из него берутся тексты ошибок валидации. Файлы лежат в проекте, поэтому формулировки можно править под себя.

19листайте
Шаг 7 · Локализация

Включаем локаль в .env

.envini
1APP_NAME=Laravel2APP_ENV=local3APP_KEY=base64:eHDeneQeXvMOrx9o4u6vJWjuk5Icda2RlI7+XPhgXFY=4APP_DEBUG=true5APP_URL=http://project-laravel.rs67APP_LOCALE=ru8APP_FALLBACK_LOCALE=ru9APP_FAKER_LOCALE=en_US1011APP_MAINTENANCE_DRIVER=file12# APP_MAINTENANCE_STORE=database1314# PHP_CLI_SERVER_WORKERS=41516BCRYPT_ROUNDS=121718LOG_CHANNEL=stack19LOG_STACK=single20LOG_DEPRECATIONS_CHANNEL=null21LOG_LEVEL=debug2223DB_CONNECTION=mysql24DB_HOST=127.0.0.125DB_PORT=330626DB_DATABASE=project_laravel27DB_USERNAME=project_laravel28DB_PASSWORD=secret2930SESSION_DRIVER=database31SESSION_LIFETIME=12032SESSION_ENCRYPT=false33SESSION_PATH=/34SESSION_DOMAIN=null3536BROADCAST_CONNECTION=log37FILESYSTEM_DISK=local38QUEUE_CONNECTION=database3940CACHE_STORE=database41# CACHE_PREFIX=4243MEMCACHED_HOST=127.0.0.14445REDIS_CLIENT=phpredis46REDIS_HOST=127.0.0.147REDIS_PASSWORD=null48REDIS_PORT=63794950MAIL_MAILER=log51MAIL_SCHEME=null52MAIL_HOST=127.0.0.153MAIL_PORT=252554MAIL_USERNAME=null55MAIL_PASSWORD=null56MAIL_FROM_ADDRESS="hello@example.com"57MAIL_FROM_NAME="${APP_NAME}"5859AWS_ACCESS_KEY_ID=60AWS_SECRET_ACCESS_KEY=61AWS_DEFAULT_REGION=us-east-162AWS_BUCKET=63AWS_USE_PATH_STYLE_ENDPOINT=false6465VITE_APP_NAME="${APP_NAME}"
student@mint: ~/recoursia/project-laravel410 мс
student@mint:~/recoursia/project-laravel$
Проверяем локаль и каталог lang
Разбор команды

Проверяем двумя командами, что локаль в .env переключена и каталог lang/ действительно получил переводы.

grep -n "APP_LOCALE\|APP_FALLBACK" .env; ls lang | head -5
ТокенЗачем он и что меняет
grepищет в файле строки, подходящие под шаблон
-nпечатать номер строки: видно, что переменные стоят на 7-й и 8-й строках .env
"APP_LOCALE\|APP_FALLBACK"шаблон: | в базовом синтаксисе grep означает «или», поэтому оба ключа ищутся одним проходом. Обратный слэш нужен именно в базовом синтаксисе (без -E), а кавычки не дают shell разобрать выражение
.envфайл, в котором ищем; команда выполняется из корня проекта
;разделитель команд: вторая выполнится независимо от результата первой
ls langсписок каталога lang/
|пайп: вывод ls уходит в head
head -5ограничить вывод первыми пятью строками: языков в lang/ может оказаться больше

Что произойдёт

Две строки .env — «7:APP_LOCALE=ru» и «8:APP_FALLBACK_LOCALE=ru» — и содержимое lang/: каталог ru (внутри 4 файла) и файл ru.json. Ровно то, что положил lang:add.

На что смотреть

Номера строк 7 и 8 совпадают с подсветкой в окне .env выше. Если ls lang ничего не вывел — переводы не опубликованы, и русские сообщения не появятся.

Частые ошибки

  • Без кавычек и без обратного слэша shell примет | за пайп: grep получит только APP_LOCALE, а APP_FALLBACK уйдёт в имя следующей команды.
  • .env не найден — команда запущена не из корня проекта: grep ищет файл в текущем каталоге.

APP_LOCALE=ru говорит приложению, какой язык брать из lang/; APP_FALLBACK_LOCALE=ru — какой использовать, если перевода строки нет. grep подтверждает строки 7–8 в .env, а ls lang — что каталог ru и файл ru.json на месте. После этого по-русски говорят не только ошибки валидации, но и все системные сообщения фреймворка.

Файл .env длинный (65 строк), поэтому окно кода дано с прокруткой; подсвечены строки 7–8 — те, что мы поменяли относительно каркасного .env из части 1.

20листайте
Шаг 8 · Добавление

Добавление через браузер: flash-сообщение

http://project-laravel.rs/users
После успешного store() — redirect на список и сообщение из session('status').
После успешного store() — redirect на список и сообщение из session('status').

Форма принята: store() проверил данные, создал запись и ответил редиректом на список, а сверху списка видно сообщение «Пользователь добавлен». Оно приехало из сессии: метод положил его строкой ->with('status', 'Пользователь добавлен'), макет приложения прочитал его через session('status') и напечатал один раз.

Куда делся пароль: в базе лежит не secret123, а его хеш. Работает каст hashed из части 4 — модель сама пропускает пароль через bcrypt при сохранении (в .env видно BCRYPT_ROUNDS=12). Контроллер про хеширование не знает вовсе: User::create($data) и всё.

21листайте
Шаг 9 · Список с действиями

Список с кнопками: правка и удаление

resources/views/users/index.blade.phpblade
1@extends('layouts.app')23@section('title', 'Список пользователей')45@section('content')6 <h1>Пользователи <span class="muted">({{ $users->total() }})</span></h1>78 <table class="table">9 <thead>10 <tr>11 <th>ФИО</th>12 <th>Телефон</th>13 <th>Email</th>14 <th>Действия</th>15 </tr>16 </thead>17 <tbody>18 @forelse ($users as $user)19 <tr>20 <td>{{ $user->name }}</td>21 <td>{{ $user->phone }}</td>22 <td>{{ $user->email }}</td>23 <td>24 <div class="row-actions">25 <a class="btn btn--ghost" href="{{ route('users.edit', $user) }}">Правка</a>26 <form method="POST" action="{{ route('users.destroy', $user) }}"27 onsubmit="return confirm('Удалить пользователя?');">28 @csrf29 @method('DELETE')30 <button class="btn btn--danger" type="submit">Удалить</button>31 </form>32 </div>33 </td>34 </tr>35 @empty36 <tr>37 <td colspan="4">Пока никого нет — добавьте первого</td>38 </tr>39 @endforelse40 </tbody>41 </table>4243 <div class="pager">44 @if ($users->onFirstPage())45 <span>← назад</span>46 @else47 <a href="{{ $users->previousPageUrl() }}">← назад</a>48 @endif49 <span class="current">{{ $users->currentPage() }} / {{ $users->lastPage() }}</span>50 @if ($users->hasMorePages())51 <a href="{{ $users->nextPageUrl() }}">вперёд →</a>52 @else53 <span>вперёд →</span>54 @endif55 </div>56@endsection

К каждой строке списка добавились два действия. «Правка» — обычная ссылка GET на users.edit: открыть форму на чтение безопасно. «Удалить» — не ссылка, а форма: метод POST плюс @method('DELETE'), потому что маршрут удаления — DELETE, а другими методами браузер формы не отправляет.

onsubmit="return confirm('Удалить пользователя?');" спрашивает подтверждение до отправки: удаление необратимо, и один промах мышью не должен стоить записи.

22листайте
Шаг 10 · Правка

Форма правки: данные из базы, пароль пуст

http://project-laravel.rs/users/14/edit
Поля предзаполнены значениями из базы; пароль пуст — его можно не менять.
Поля предзаполнены значениями из базы; пароль пуст — его можно не менять.

Адрес /users/14/edit: число 14 из пути Route Model Binding превратил в объект User, edit() отдал его в шаблон, и old('name', $user->name ?? '') подставил значения из базы — поля предзаполнены. Подпись у поля пароля другая: «(пусто — не менять)». Пустой пароль при правке — норма: мы не хотим заставлять пользователя вводить пароль заново каждый раз, когда он поменял телефон.

23листайте
Шаг 10 · Правка

Правка сохранена: почему пароль не стирается

http://project-laravel.rs/users
update() нашёл запись через Route Model Binding, сохранил и вернул на список с flash-сообщением.
update() нашёл запись через Route Model Binding, сохранил и вернул на список с flash-сообщением.

Поменяли телефон и сохранили: update() получил запись через Route Model Binding, прогнал те же правила (validated($request, $user) — с ignore собственного email и nullable паролем) и вернулся на список с сообщением «Изменения сохранены».

Ключевые строки метода подсвечены в окне кода:

app/Http/Controllers/UserController.phpphp
1<?php23namespace App\Http\Controllers;45use App\Models\User;6use Illuminate\Http\Request;7use Illuminate\Validation\Rule;89class UserController extends Controller10{11 public function index()12 {13 $users = User::query()->orderBy('name')->paginate(5);1415 return view('users.index', ['users' => $users]);16 }1718 public function create()19 {20 return view('users.create');21 }2223 public function store(Request $request)24 {25 $data = $this->validated($request);2627 User::create($data);2829 return redirect()->route('users.index')->with('status', 'Пользователь добавлен');30 }3132 public function edit(User $user)33 {34 return view('users.edit', ['user' => $user]);35 }3637 public function update(Request $request, User $user)38 {39 $data = $this->validated($request, $user);4041 if (empty($data['password'])) {42 unset($data['password']); // пароль не меняли — не трогаем хеш43 }4445 $user->fill($data)->save();4647 return redirect()->route('users.index')->with('status', 'Изменения сохранены');48 }4950 public function destroy(User $user)51 {52 $user->delete();5354 return redirect()->route('users.index')->with('status', 'Пользователь удалён');55 }5657 /**58 * Правила одни для добавления и правки: при правке email не должен59 * конфликтовать с самим собой, а пароль необязателен.60 */61 private function validated(Request $request, ?User $user = null): array62 {63 return $request->validate([64 'name' => ['required', 'string', 'max:120'],65 'phone' => ['required', 'string', 'regex:/^\+?[0-9()\-\s]{10,20}$/'],66 'email' => ['required', 'email', 'max:190', Rule::unique('users', 'email')->ignore($user?->id)],67 'password' => [$user ? 'nullable' : 'required', 'string', 'min:8'],68 ]);69 }70}

Пустое поле приходит в массиве данных как пустое значение. Если его не вычеркнуть, fill() перезапишет хеш пароля пустотой, и пользователь останется без пароля. unset убирает ключ из массива до записи: нет ключа — нет и изменения.

24листайте
Шаг 11 · Удаление

Удаление: форма, а не ссылка

http://project-laravel.rs/users
Кнопка «Удалить» — это форма с @method('DELETE'); браузер спросил подтверждение, destroy() удалил строку.
Кнопка «Удалить» — это форма с @method('DELETE'); браузер спросил подтверждение, destroy() удалил строку.

Нажали «Удалить» в строке с тестовой записью — браузер показал диалог подтверждения, после «ОК» ушла форма POST с _method=DELETE, destroy() удалил строку и вернул на список с сообщением «Пользователь удалён». Записи в таблице больше нет.

Почему нельзя ссылкой вида GET /users/14/delete: GET по определению не меняет данные, и на это рассчитывают все вокруг — поисковые роботы, предзагрузка ссылок браузером, превью в мессенджерах. Любая из этих машин, пройдясь по ссылкам страницы, удалила бы пользователей одного за другим. Форма с POST/DELETE и токеном CSRF от случайного визитёра закрыта.

25листайте
Шаг 12 · Mass assignment

Белые списки полей: mass assignment

Данные из формы попадают в модель целиком массивом: User::create($data) и $user->fill($data). Это удобно и опасно одновременно: принимай модель любые ключи массива, злоумышленник добавил бы в форму своё поле (например, флаг администратора), и оно молча записалось бы в базу.

Защита — белый список в модели из части 4: атрибут #[Fillable] перечисляет поля, которые разрешено присваивать массивом. Всё, чего в списке нет, fill() и create() отбрасывают молча, без ошибки — поэтому лишнее поле в форме не «ломает» страницу, а просто не попадает в базу.

Отсюда правило: добавили колонку в миграцию — добавьте её и в #[Fillable], иначе она будет выглядеть «непрописанной». Валидация и белый список работают в паре: валидация решает, какие данные допустимы, белый список — какие из допустимых можно записать массивом.

26листайте
Шаг 13 · Flash

Flash-сообщения: один показ и исчезает

Все три операции записи заканчиваются одинаково — в контроллере это строки 29, 47 и 54, все три подсвечены:

app/Http/Controllers/UserController.phpphp
1<?php23namespace App\Http\Controllers;45use App\Models\User;6use Illuminate\Http\Request;7use Illuminate\Validation\Rule;89class UserController extends Controller10{11 public function index()12 {13 $users = User::query()->orderBy('name')->paginate(5);1415 return view('users.index', ['users' => $users]);16 }1718 public function create()19 {20 return view('users.create');21 }2223 public function store(Request $request)24 {25 $data = $this->validated($request);2627 User::create($data);2829 return redirect()->route('users.index')->with('status', 'Пользователь добавлен');30 }3132 public function edit(User $user)33 {34 return view('users.edit', ['user' => $user]);35 }3637 public function update(Request $request, User $user)38 {39 $data = $this->validated($request, $user);4041 if (empty($data['password'])) {42 unset($data['password']); // пароль не меняли — не трогаем хеш43 }4445 $user->fill($data)->save();4647 return redirect()->route('users.index')->with('status', 'Изменения сохранены');48 }4950 public function destroy(User $user)51 {52 $user->delete();5354 return redirect()->route('users.index')->with('status', 'Пользователь удалён');55 }5657 /**58 * Правила одни для добавления и правки: при правке email не должен59 * конфликтовать с самим собой, а пароль необязателен.60 */61 private function validated(Request $request, ?User $user = null): array62 {63 return $request->validate([64 'name' => ['required', 'string', 'max:120'],65 'phone' => ['required', 'string', 'regex:/^\+?[0-9()\-\s]{10,20}$/'],66 'email' => ['required', 'email', 'max:190', Rule::unique('users', 'email')->ignore($user?->id)],67 'password' => [$user ? 'nullable' : 'required', 'string', 'min:8'],68 ]);69 }70}

->with('status', …) кладёт строку в сессию с пометкой «на один запрос». Следующий запрос — тот самый GET после редиректа — её читает: макет приложения печатает session('status') над содержимым, и сообщение из сессии удаляется. Обновите страницу ещё раз — сообщения нет.

Почему через сессию, а не параметром в URL: текст не попадает в адресную строку и в журналы прокси и переживает редирект, который сам по себе кроме адреса ничего передать не может. То же хранилище, кстати, переносит и ошибки валидации вместе с old()-значениями — механизм один.

27листайте
Итог части

Шесть действий одной схемой

GET /users — users.index → index() · users/indexGET /usersusers.index → index()· users/indexGET /users/create — users.create → create() · users/createGET/users/createusers.create →create() ·users/createPOST /users — users.store → store()POST /usersusers.store → store()DELETE /users/{user} — users.destroy → destroy()DELETE/users/{user}users.destroy →destroy()PUT /users/{user} — users.update → update()PUT/users/{user}users.update →update()GET /users/{user}/edit — users.edit → edit() · users/editGET/users/{user}/editusers.edit → edit() ·users/edit200 · пустая формаPOST · _token302 + flash200 · форма с даннымиPUT · _method302 + flashDELETE · форма с _method302 + flash200 · пустая формаPOST · _token302 + flash200 · форма с даннымиPUT · _method302 + flashDELETE · форма с _method302 + flashGET /users — users.index → index() · users/indexGET /usersGET /users/create — users.create → create() · users/createGET /users/createPOST /users — users.store → store()POST /usersDELETE /users/{user} — users.destroy → destroy()DELETE/users/{user}PUT /users/{user} — users.update → update()PUT /users/{user}GET /users/{user}/edit — users.edit → edit() · users/editGET /users/{user}/edit
Шесть маршрутов ресурса: чтение отдаёт 200, запись возвращает на список по 302

Плюс два невидимых слоя защиты на каждом запросе записи: CSRF-токен (иначе 419) и валидация (иначе 302 обратно на форму с ошибками).

28листайте
Итог части

Схема MVC из части 1 теперь живая

Маршрут — routes/web.php · Route::resourceМаршрутroutes/web.php ·Route::resourceКонтроллер — UserController · шесть методовКонтроллерUserController · шестьметодовМодель — User · #[Fillable] и hashedМодельUser · #[Fillable] и hashedОтвет — 200 HTML · 302 + flashОтвет200 HTML · 302 + flashШаблон — resources/views/users/…Шаблонresources/views/users/…MariaDB — таблица usersMariaDBтаблица usersusers.index … users.destroy$data после validate()SELECT / INSERT / UPDATE / DELETEстроки или объектview('users.…')HTML в буфер302 + flashusers.index … users.destroy$data после validate()SELECT / INSERT / UPDATE / DELETEстроки или объектview('users.…')HTML в буфер302 + flashМаршрут — routes/web.php · Route::resourceМаршрутКонтроллер — UserController · шесть методовКонтроллерМодель — User · #[Fillable] и hashedМодельОтвет — 200 HTML · 302 + flashОтветШаблон — resources/views/users/…ШаблонMariaDB — таблица usersMariaDB
Маршрут → контроллер → модель → база → шаблон → ответ: каждая стрелка — настоящий вызов

Каждый узел схемы — настоящий файл из захватов, каждое ребро — вызов, который мы видели в этой части: route:list показал маршруты, контроллер — методы и view(...), а curl и снимки — ответы 200 и 302.

29листайте
Самопроверка

Проверьте себя

  1. Сколько маршрутов даёт Route::resource('users', …)->except(['show']) и какие имена у маршрутов «форма добавления» и «сохранить»?
  2. Браузер отправил POST на /users и получил 419. Первая причина и где в шаблоне её искать?
  3. Зачем в update() нужен unset($data['password']), если поле пароля пришло пустым?
  4. Что сломается при правке записи, если убрать ->ignore($user?->id) из правила для email?
  5. Почему «Удалить» — это форма с @method('DELETE'), а не ссылка GET /users/{user}/delete?
30листайте
Самопроверка

Ответы

  1. Шесть: users.index, users.store, users.create, users.edit, users.update, users.destroy. Форма добавления — users.create (GET /users/create), сохранение — users.store (POST /users).
  2. В форме нет CSRF-токена: забыта директива @csrf. Сервер сверяет _token с сессией ещё до контроллера.
  3. Пустое значение перезаписало бы хеш пароля в базе. unset убирает ключ из массива до fill(), и прежний пароль остаётся нетронутым.
  4. Rule::unique найдёт в таблице email самой редактируемой записи и всегда вернёт «уже существует»: правка станет невозможной без смены адреса.
  5. GET не должен менять данные: по ссылкам ходят роботы и предзагрузка браузеров. Удаление идёт формой POST с подменой метода на DELETE и с токеном CSRF.
31листайте
Что дальше

Что дальше: часть 6 — эксплуатация

Приложение умеет всё, что нужно от CRUD. Осталось научиться его чинить и переносить: ошибки прав на storage/, отсутствие APP_KEY, логи, кэши конфигурации и маршрутов, первый тест и чек-лист деплоя.

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

32листайте