Часть 5 — CRUD: добавить, изменить, удалить
Полный цикл жизни записи: список → форма добавления → сохранение → правка → удаление.
Понадобится то, что уже стоит: модель User и база из части 4, маршруты и шаблоны из частей 2–3.
Добавим три операции записи (store, update, destroy) и защитим их: CSRF-токен, валидация, белый список полей.
Каждый вывод и снимок в части — настоящий захват со стенда, ничего не сочинено.
Что уже есть к началу части
В части 4 мы довели приложение до состояния «читаем из базы»: модель User с белым списком полей,
миграция с колонкой phone, сидер с тестовыми пользователями и список с пагинацией.
Чтение работает — записи нет.
- есть:
GET /usersотдаёт список страницами; - нет: добавить, изменить, удалить — браузеру некуда отправить форму;
- значит, не хватает маршрутов записи, методов контроллера и шаблонов форм.
CRUD — это Create, Read, Update, Delete. Read готов; сейчас добавим остальные три буквы и увидим, какие договорённости фреймворк даёт под них бесплатно.
Заготовка из семи методов
Команда make:controller --resource --model=User создаёт контроллер с семью пустыми методами —
по одному на операцию ресурса. Третьей командой файл удалили (rm): в уроке каждый метод пишем сами
и понимаем, зачем он нужен.
INFO Controller [app/Http/Controllers/DemoController.php] created successfully. <?phpnamespace App\Http\Controllers;use App\Models\User;use Illuminate\Http\Request;class DemoController extends Controller{ /** * Display a listing of the resource. */ public function index() { // } /** * Show the form for creating a new resource. */ public function create() { // } /** * Store a newly created resource in storage. */ public function store(Request $request) { // } /** * Display the specified resource. */ public function show(User $user) { // } /** * Show the form for editing the specified resource. */ public function edit(User $user) { // } /** * Update the specified resource in storage. */ public function update(Request $request, User $user) { // } /** * Remove the specified resource from storage. */ public function destroy(User $user) { // }}
Разбор команды
Смотрим, какую заготовку даёт генератор ресурсного контроллера, и тут же её удаляем: в уроке каждый метод пишем сами и понимаем, зачем он.
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.
Одна строка вместо шести: Route::resource
Route::resource('users', UserController::class) регистрирует весь набор CRUD-маршрутов сразу
и даёт им имена users.index … users.destroy. Имена важнее путей: в шаблонах мы пишем
route('users.edit', $user), и если путь изменится, шаблоны править не придётся.
->except(['show']) убирает маршрут одиночного просмотра GET /users/{user}: страницы одного
пользователя в приложении нет, а незарегистрированный маршрут лучше мёртвого метода-заглушки.
Первая строка файла — из части 2: корень сайта сразу уводит на /users.
Что зарегистрировалось: route:list
GET|HEAD / ......................................... routes/web.php:6 GET|HEAD users ................... users.index › UserController@index POST users ................... users.store › UserController@store GET|HEAD users/create .......... users.create › UserController@create PUT|PATCH users/{user} .......... users.update › UserController@update DELETE users/{user} ........ users.destroy › UserController@destroy GET|HEAD users/{user}/edit ......... users.edit › UserController@edit Showing [7] routes
Разбор команды
Проверяем, что одна строка 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 удаляет. Ниже увидим, почему это разделение — не формальность.
Контроллер: чтение
index() знаком по части 4: запрос к модели, сортировка по имени, paginate(5) — по пять записей
на страницу. create() только отдаёт шаблон пустой формы: данных ещё нет, показывать нечего.
Весь CRUD живёт в одном контроллере: семь методов ресурса — это семь действий над одной сущностью, и держать их вместе проще, чем раскладывать по файлам.
Контроллер: запись и удаление
Схема трёх методов одинакова — проверить вход, записать, вернуться на список с flash-сообщением:
Редирект после записи — обязательный приём (POST/Redirect/GET), вернёмся к нему на слайде про валидацию.
edit() стоит между ними: это ещё чтение (форма с данными), но уже с аргументом User $user.
Route Model Binding: откуда берётся $user
Методы edit, update, destroy объявляют аргумент User $user, хотя из маршрута приходит строка —
число из пути (users/{user}). Laravel смотрит на тип аргумента, берёт модель User и ищет запись
с таким первичным ключом, фактически User::findOrFail($id).
Связка «число в URL — строка в базе» описана один раз типом аргумента, а не кодом в каждом методе. Это продолжение идеи части 4: с базой работаем через модель, а не собираем SQL строками.
Правила валидации — одни для добавления и правки
Правила собраны в приватном методе validated(): и store, и update вызывают его, поэтому
добавление и правка проверяются одинаково. Разницу даёт второй аргумент: при правке это найденная
запись, и два правила ведут себя иначе — об этом через слайд.
$request->validate() работает по договору: правила прошли — метод возвращает массив данных и код
идёт дальше; не прошли — Laravel сам останавливает метод и уводит пользователя обратно на форму
с ошибками. Поэтому в store и update нет ни одного if на тему «а вдруг поле пустое».
Что проверяют правила и почему на сервере
| Поле | Правило | Почему так |
|---|---|---|
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-инъекции», где правилом было
«ни одному символу из запроса не верим».
Две тонкости: unique с ignore и nullable пароль
Rule::unique('users', 'email')->ignore($user?->id): при правке запись уже занимает свой email в таблице. Безignoreвалидация сравнивала бы адрес с самим собой и падала всегда («уже существует»).ignoreговорит: «проверь все строки, кроме этой».[$user ? 'nullable' : 'required', …]: при правке пустой пароль означает «не менять», а не «стереть». При добавлении пароля в базе ещё нет, поэтому он обязателен.
Одна форма на две операции: _form.blade.php
Формы добавления и правки отличаются только заголовком и адресом отправки, поэтому поля вынесены
в частичный шаблон _form и подключаются через @include. Дублировать четыре поля — значит потом
чинить их в двух местах.
Две конструкции делают форму «помнящей»:
old('name', $user->name ?? '')— значение из прошлого запроса, если валидация вернула нас на форму; иначе значение из базы (при правке); иначе пусто (при добавлении);@error('name') … @enderror— блок рисуется только если у поля есть ошибка; внутри доступен$messageс текстом сообщения.
Обёртки формы: @csrf и @method('PUT')
Обе обёртки короткие: заголовок, <form method="POST">, подключение _form и кнопки. Различий два,
и оба обязательны:
- в
createстоит@csrf— без него Laravel не примет POST вовсе (код 419, увидим ниже); - в
editдобавлен@method('PUT'): браузер отправляет формы только методами GET и POST, а маршрут правки —PUT|PATCH. Директива кладёт в форму скрытое поле_method=PUT, и Laravel читает его как настоящий метод запроса.
Адрес формы в обоих случаях даёт route(...): users.store для добавления и users.update для правки.
Токен в форме: что кладёт @csrf
<header class="topbar"> <a class="brand" href="/users">Учебное приложение Laravel</a> <nav> <a href="/users">Список</a> <a href="/users/create">Добавить</a> </nav></header><main class="page"> <h1>Добавить пользователя</h1> <form class="form" method="POST" action="http://project-laravel.rs/users"> <input type="hidden" name="_token" value="GOgzNSQTVIiHJ1ZpKGVxMI12wPJuqmLFESzCycLc" autocomplete="off"> <div class="field"> <label for="name">ФИО</label> <input id="name" name="name" value="" required> </div><div class="field"> <label for="phone">Телефон</label> <input id="phone" name="phone" value="" placeholder="+7 (900) 000-00-00" required> </div><div class="field"> <label for="email">Email</label> <input id="email" name="email" type="email" value="" required> </div><div class="field"> <label for="password">Пароль</label> <input id="password" name="password" type="password" autocomplete="new-password"> </div>
Разбор команды
Забираем HTML формы добавления и ищем в нём скрытое поле _token — то самое, которое печатает директива @csrf.
curl -sS http://project-laravel.rs/users/create | sed -n "10,40p"| Токен | Зачем он и что меняет |
|---|---|
curl | HTTP-клиент: делает запрос и печатает тело ответа в 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 в ответе — это тот же механизм с другой стороны.
POST без токена: 419
POST /users без _token -> 419Разбор команды
Проверяем защиту в деле: отправляем данные мимо формы, без токена, и смотрим только на код ответа.
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"| Токен | Зачем он и что меняет |
|---|---|
curl | HTTP-клиент |
-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.
POST/Redirect/GET: как ошибки доезжают до формы
CSRF-токен формы: rv3SUAvCUrZUjqYb...POST с битыми данными -> 302, куда вернуло: http://project-laravel.rs/users/create <input id="name" name="name" value="" required> <div class="error">Поле name обязательно.</div></div>-- placeholder="+7 (900) 000-00-00" required> <div class="error">Значение поля phone имеет некорректный формат.</div></div>-- <input id="email" name="email" type="email" value="не-емейл" required> <div class="error">Значение поля email должно быть действительным электронным адресом.</div></div>-- <input id="password" name="password" type="password" autocomplete="new-password"> <div class="error">Количество символов в поле password должно быть не меньше 8.</div></div>
Разбор команды
Прогоняем весь путь 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 $JAR | cookie-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/create | GET формы добавления — третья нога 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="не-емейл" сохранилось.
Разбор прогона по строкам:
- сначала забрали токен из формы и сохранили куки в файл (
-c/-b) — без токена был бы 419; - POST с битыми данными вернул
302и адресhttp://project-laravel.rs/users/create: сервер не рисует форму сам, он приказывает браузеру сделать GET той же формы; - следующий GET достаёт из сессии ошибки и старые значения — поэтому в HTML видны
<div class="error">Поле name обязательно.</div>рядом с полями, аvalue="не-емейл"сохранено.
Зачем такой крюк: после 302 в адресной строке остаётся GET-адрес, и обновление страницы (F5)
не повторяет POST — запись не создастся второй раз случайно. Это шаблон POST/Redirect/GET, тот же,
что в store/update/destroy после успешной записи.
Сообщения уже русские: захват снят в итоговом состоянии приложения, с включённой локалью ru.
Как подключали переводы — покажет шаг «Локализация», а снимок браузера на следующем слайде —
та же форма: ошибки под полями и сохранённое введённое значение.
Та же форма в браузере: ошибки под полями

Три ошибки: телефон abc не прошёл регулярку, email ivanov@example.com уже занят (сработал
Rule::unique), пароль из трёх символов короче min:8. ФИО и остальные введённые значения остались
в полях — это old() вернул их из сессии. Пароль не возвращается никогда: поле type="password"
сознательно не подставляют обратно.
Сообщения русские, как и в выводе curl выше: оба захвата сняты в одном состоянии приложения,
с включённой локалью ru. Откуда взялся русский перевод — следующий шаг.
Русские сообщения: пакет переводов
./composer.json has been updatedRunning composer update laravel-lang/langLoading composer repositories with package informationUpdating dependenciesLock file operations: 13 installs, 0 updates, 0 removals - Locking archtechx/enums (v1.1.2) - Locking composer/semver (3.5.1) - Locking dragon-code/contracts (2.25.0) - Locking dragon-code/pretty-array (4.2.0) - Locking dragon-code/support (6.17.1) - Locking laravel-lang/config (1.17.1) - Locking laravel-lang/lang (15.37.4) - 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 в живой проект.
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/.
Публикуем переводы в lang/
INFO Collecting translations... LaravelLang\Lang\Plugin ....................................... 10.93ms DONE INFO Storing changes... ru.json ........................................................ 0.95ms DONE ru/auth.php .................................................... 1.30ms DONE ru/pagination.php .............................................. 0.43ms DONE ru/passwords.php ............................................... 0.51ms DONE ru/validation.php .............................................. 3.13ms DONE
Разбор команды
Пакет установлен, но переводы лежат в 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 — именно из него берутся тексты ошибок валидации.
Файлы лежат в проекте, поэтому формулировки можно править под себя.
Включаем локаль в .env
7:APP_LOCALE=ru8:APP_FALLBACK_LOCALE=rururu.json
Разбор команды
Проверяем двумя командами, что локаль в .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.
Добавление через браузер: flash-сообщение

Форма принята: store() проверил данные, создал запись и ответил редиректом на список, а сверху
списка видно сообщение «Пользователь добавлен». Оно приехало из сессии: метод положил его строкой
->with('status', 'Пользователь добавлен'), макет приложения прочитал его через session('status')
и напечатал один раз.
Куда делся пароль: в базе лежит не secret123, а его хеш. Работает каст hashed из части 4 —
модель сама пропускает пароль через bcrypt при сохранении (в .env видно BCRYPT_ROUNDS=12).
Контроллер про хеширование не знает вовсе: User::create($data) и всё.
Список с кнопками: правка и удаление
К каждой строке списка добавились два действия. «Правка» — обычная ссылка GET на users.edit:
открыть форму на чтение безопасно. «Удалить» — не ссылка, а форма: метод POST плюс @method('DELETE'),
потому что маршрут удаления — DELETE, а другими методами браузер формы не отправляет.
onsubmit="return confirm('Удалить пользователя?');" спрашивает подтверждение до отправки:
удаление необратимо, и один промах мышью не должен стоить записи.
Форма правки: данные из базы, пароль пуст

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

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

Нажали «Удалить» в строке с тестовой записью — браузер показал диалог подтверждения, после «ОК» ушла
форма POST с _method=DELETE, destroy() удалил строку и вернул на список с сообщением
«Пользователь удалён». Записи в таблице больше нет.
Почему нельзя ссылкой вида GET /users/14/delete: GET по определению не меняет данные, и на это
рассчитывают все вокруг — поисковые роботы, предзагрузка ссылок браузером, превью в мессенджерах.
Любая из этих машин, пройдясь по ссылкам страницы, удалила бы пользователей одного за другим.
Форма с POST/DELETE и токеном CSRF от случайного визитёра закрыта.
Белые списки полей: mass assignment
Данные из формы попадают в модель целиком массивом: User::create($data) и $user->fill($data).
Это удобно и опасно одновременно: принимай модель любые ключи массива, злоумышленник добавил бы в форму
своё поле (например, флаг администратора), и оно молча записалось бы в базу.
Защита — белый список в модели из части 4: атрибут #[Fillable] перечисляет поля, которые разрешено
присваивать массивом. Всё, чего в списке нет, fill() и create() отбрасывают молча, без ошибки —
поэтому лишнее поле в форме не «ломает» страницу, а просто не попадает в базу.
Отсюда правило: добавили колонку в миграцию — добавьте её и в #[Fillable], иначе она будет выглядеть
«непрописанной». Валидация и белый список работают в паре: валидация решает, какие данные допустимы,
белый список — какие из допустимых можно записать массивом.
Flash-сообщения: один показ и исчезает
Все три операции записи заканчиваются одинаково — в контроллере это строки 29, 47 и 54, все три подсвечены:
->with('status', …) кладёт строку в сессию с пометкой «на один запрос». Следующий запрос — тот самый
GET после редиректа — её читает: макет приложения печатает session('status') над содержимым,
и сообщение из сессии удаляется. Обновите страницу ещё раз — сообщения нет.
Почему через сессию, а не параметром в URL: текст не попадает в адресную строку и в журналы прокси
и переживает редирект, который сам по себе кроме адреса ничего передать не может. То же хранилище,
кстати, переносит и ошибки валидации вместе с old()-значениями — механизм один.
Шесть действий одной схемой
Плюс два невидимых слоя защиты на каждом запросе записи: CSRF-токен (иначе 419) и валидация (иначе 302 обратно на форму с ошибками).
Схема MVC из части 1 теперь живая
Каждый узел схемы — настоящий файл из захватов, каждое ребро — вызов, который мы видели в этой части:
route:list показал маршруты, контроллер — методы и view(...), а curl и снимки — ответы 200 и 302.
Проверьте себя
- Сколько маршрутов даёт
Route::resource('users', …)->except(['show'])и какие имена у маршрутов «форма добавления» и «сохранить»? - Браузер отправил POST на
/usersи получил 419. Первая причина и где в шаблоне её искать? - Зачем в
update()нуженunset($data['password']), если поле пароля пришло пустым? - Что сломается при правке записи, если убрать
->ignore($user?->id)из правила для email? - Почему «Удалить» — это форма с
@method('DELETE'), а не ссылкаGET /users/{user}/delete?
Ответы
- Шесть:
users.index,users.store,users.create,users.edit,users.update,users.destroy. Форма добавления —users.create(GET /users/create), сохранение —users.store(POST /users). - В форме нет CSRF-токена: забыта директива
@csrf. Сервер сверяет_tokenс сессией ещё до контроллера. - Пустое значение перезаписало бы хеш пароля в базе.
unsetубирает ключ из массива доfill(), и прежний пароль остаётся нетронутым. Rule::uniqueнайдёт в таблице email самой редактируемой записи и всегда вернёт «уже существует»: правка станет невозможной без смены адреса.- GET не должен менять данные: по ссылкам ходят роботы и предзагрузка браузеров. Удаление идёт формой POST с подменой метода на DELETE и с токеном CSRF.
Что дальше: часть 6 — эксплуатация
Приложение умеет всё, что нужно от CRUD. Осталось научиться его чинить и переносить: ошибки прав
на storage/, отсутствие APP_KEY, логи, кэши конфигурации и маршрутов, первый тест и чек-лист деплоя.
В части 6 мы намеренно сломаем несколько вещей и починим их по логам — потому что умение читать ошибку дороже умения писать код без ошибок.