Laravel · Часть 3 — Контроллеры, Blade и список пользователей из массива
1 / 1
lesson.laravel · часть 3

Часть 3 — Контроллеры, Blade и список пользователей из массива

Вынесем логику из маршрута-замыкания в контроллер, соберём страницу списка пользователей и проследим, как данные проходят три слоя — от адреса в адресной строке до готового HTML.

МаршрутМаршрутКонтроллерКонтроллерШаблонШаблонОтветОтветМаршрутМаршрутКонтроллерКонтроллерШаблонШаблонОтветОтвет
Три слоя и результат: путь данных в этой части

Сделаем: контроллер UserController с действием index(), макет layouts/app.blade.php, стили public/css/app.css, шаблон users/index.blade.php и маршрут /users. Четыре новых файла и одна правка.

Понадобится: проект project-laravel из части 2 — он уже работает через nginx по адресу http://project-laravel.rs, — терминал в корне проекта и браузер.

Данные пока лежат в PHP-массиве. Базу подключим в части 4, когда три слоя станут привычными: так мы разделяем две задачи вместо того, чтобы решать их одновременно.

4 новых файла 2 маршрута 3 записи в списке

1листайте
Шаг 0 · Где мы

Что уже работает и что добавим

К концу части 2 у вас есть проект project-laravel в ~/recoursia/project-laravel, домен project-laravel.rs в локальной зоне .rs и vhost nginx с root в public/. Запрос доходит до public/index.php, роутер находит маршрут, ответ уходит обратно через буфер вывода — весь путь из урока lesson.http.i.01.

До сих пор действия жили прямо в маршрутах: Route::get('/hello/{name}', …). Для одной страницы-приветствия этого хватает. Для приложения, где будут список, форма добавления, редактирование и удаление, — нет.

project-laravel/├── app/│   └── Http/│       └── Controllers/│           └── UserController.php # новый: действие index()├── resources/│   └── views/│       ├── layouts/│       │   └── app.blade.php # новый: каркас страницы│       └── users/│           └── index.blade.php # новый: сам список├── public/│   └── css/│       └── app.css # новый: стили без сборщика└── routes/    └── web.php # правим: маршрут /users
index()routes/web.phplayouts/app.blade.phppublic/css/app.cssusers/index.blade.phpcurl, route:list, снимокКонтроллерКонтроллерДанные в нёмДанные в нёмМаршрутМаршрутМакетМакетСтилиСтилиШаблонШаблонПроверкаПроверкаindex()routes/web.phplayouts/app.blade.phppublic/css/app.cssusers/index.blade.phpcurl, route:list, снимокКонтроллерКонтроллерДанные в нёмДанные в нёмМаршрутМаршрутМакетМакетСтилиСтилиШаблонШаблонПроверкаПроверка
Порядок работы в части 3

Последний шаг — проверка тремя способами: curl (HTML), route:list (маршруты) и снимок браузера.

2листайте
Шаг 1 · Зачем контроллер

Маршрут-замыкание упирается в потолок

Замыкание — это функция, объявленная прямо внутри вызова Route::get(). У неё нет имени и нет дома, кроме файла маршрутов.

Что начинает мешать, когда действие перестаёт быть одним:

  • routes/web.php превращается в свалку. Десять замыканий по 30 строк — это 300 строк логики в файле, чья работа — быть таблицей соответствия «адрес → действие». Такую таблицу невозможно прочитать глазами.
  • Безымянное замыкание нельзя позвать откуда-то ещё. Ни из команды artisan, ни из теста, ни из соседнего маршрута: на него нечем сослаться.
  • Нет навигации. IDE не ответит на вопрос «кто обрабатывает /users», если у действия нет имени.

Контроллер решает это способом, который вы уже прошли в блоке ООП: действие становится методом класса.

  • класс UserController — всё, что приложение делает с пользователями;
  • метод index() — одно действие: показать список;
  • рядом встанут create(), store(), update(), destroy() — это часть 5.

Вспомните задачу «Герои»: вы переносили поведение из процедурного кода в методы, чтобы каждый объект отвечал за своё и не тянул всё в одну функцию. Здесь тот же приём: маршрут отвечает за адрес, контроллер — за действие, шаблон — за разметку.

3листайте
Шаг 2 · Генерация

artisan создаёт контроллер сам

student@mint: ~/recoursia/project-laravel695 мс
student@mint:~/recoursia/project-laravel$
Генерируем контроллер через artisan — artisan создаёт файл с готовым namespace — руками его писать не нужно.
Разбор команды

Маршрут из части 3 ссылается на класс и метод, поэтому сначала нужен сам класс. Файл создаёт artisan: он сам сводит путь и namespace, которые обязаны совпадать.

php artisan make:controller UserController
ТокенЗачем он и что меняет
phpзапуск интерпретатора из PATH; на стенде PHP 8.3 — его мы проверили в части 1 командой php -v
artisanне команда системы, а файл в корне проекта (18 строк, 424 байта): он подключает vendor/autoload.php, поднимает приложение и передаёт ему аргументы. Отсюда требование запускать команду из корня проекта
make:controllerимя artisan-команды; двоеточие разделяет группу make: (генераторы кода) и создаваемую сущность controller
UserControllerаргумент — имя класса. Из него artisan выводит путь app/Http/Controllers/UserController.php и namespace App\Http\Controllers

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

Создаёт файл app/Http/Controllers/UserController.php — заготовку на 10 строк и 120 байт: namespace, use Illuminate\Http\Request;, class UserController extends Controller и пустое тело с комментарием. Печатает одну строку INFO с путём к файлу. Код возврата 0, время 695 мс.

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

Смотрим на путь в квадратных скобках: он обязан совпадать с namespace класса — на этом совпадении держится автозагрузка Composer (PSR-4). 695 мс — это не запись файла (она мгновенная), а подъём приложения: автозагрузка, конфиги, сервисы.

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

  • Запуск не из корня проекта: php не найдёт файл и ответит «Could not open input file: artisan».
  • Файл переложили в другой каталог, не поправив namespace: путь перестаёт совпадать с именем класса, автозагрузчик не находит файл, и PHP сообщит, что класс не найден, — в момент первого обращения к классу, а не при сохранении файла.
  • Метода index() в заготовке нет: artisan не угадывает, какие действия понадобятся, их дописываем руками.

Что важно в этом выводе:

  • artisan печатает путь, а не «сделано вообще»: app/Http/Controllers/UserController.php. Путь не выбран произвольно — он следует из пространства имён класса, об этом следующий слайд.
  • Код возврата 0, одна строка INFO, 695 мс. Столько занимает не создание файла (это микросекунды), а подъём приложения: автозагрузка, конфиги, сервисы. Поэтому artisan-команды заметно тяжелее, чем touch, — и поэтому их не вызывают на каждый запрос.

Почему генератор, а не файл руками: Composer-автозагрузка работает по PSR-4, где пространству имён App\ соответствует каталог app/ (мы видели эту привязку в composer.json в части 1). Значит, обязаны совпасть две вещи: файл лежит в app/Http/Controllers/, и в нём написано namespace App\Http\Controllers;. make:controller гарантирует совпадение и заодно подставляет базовый класс.

4листайте
Шаг 2 · Генерация

Что лежит в созданном файле

app/Http/Controllers/UserController.php
<?phpnamespace App\Http\Controllers;use Illuminate\Http\Request;class UserController extends Controller{ //}

Десять строк, 120 байт. Разбираем построчно:

  • namespace App\Http\Controllers; — то самое совпадение с путём. Класс App\Http\Controllers\UserController лежит в app/Http/Controllers/UserController.php, автозагрузчик находит файл по имени класса. Поэтому ни в маршрутах, ни в контроллере нет ни одного require.
  • use Illuminate\Http\Request; — импорт класса запроса. В заготовке он есть, потому что большинство действий контроллера принимают запрос и читают из него данные. Нашему index() запрос не нужен: список не зависит от параметров адреса. В итоговом файле этой строки нет — сравните со следующим слайдом.
  • class UserController extends Controller — наследование базового класса. Импорта для Controller в файле нет, значит PHP ищет его в текущем пространстве имён — App\Http\Controllers, там же, где лежит сам файл.
  • // — пустое тело: artisan не угадывает, какие методы вам понадобятся.

Имя класса совпадает с именем файла. Это не соглашение проекта, а требование автозагрузки: при несовпадении автозагрузчик не найдёт файл, и PHP сообщит, что класс не найден, — в момент первого обращения к классу, а не при сохранении файла.

5листайте
Шаг 3 · Данные

Действие index(): данные пока в массиве

app/Http/Controllers/UserController.php
1<?php23namespace App\Http\Controllers;45class UserController extends Controller6{7 public function index()8 {9 $users = [10 ['name' => 'Иванов Иван Иванович', 'phone' => '+7 (912) 345-67-89', 'email' => 'ivanov@example.com'],11 ['name' => 'Петрова Анна Сергеевна', 'phone' => '+7 (903) 111-22-33', 'email' => 'petrova@example.com'],12 ['name' => 'Сидоров Пётр Алексеевич', 'phone' => '+7 (926) 555-44-33', 'email' => 'sidorov@example.com'],13 ];1415 return view('users.index', ['users' => $users]);16 }17}

Семнадцать строк, 619 байт: к заготовке добавился один метод и данные.

  • public function index() — публичный метод без аргументов. Публичный потому, что вызывает его не наш код, а фреймворк: роутер нашёл маршрут, создал объект контроллера и позвал метод по имени, записанному в маршруте.
  • $users — двумерный массив: три элемента, каждый сам является массивом с ключами name, phone, email. Ровно та структура, которую вы собирали в блоке «Циклы и массивы» (lesson.06.03 — обращение к вложенному элементу) и в ООП (lesson.oop.heroes.02 — герой как ассоциативный массив с ключами health и strength).
  • Ключи у всех трёх элементов одинаковые. Это не мелочь: шаблон перебирает массив и обращается к одним и тем же ключам. Если в одной строке ключа не окажется, PHP не упадёт — страница просто выведет пустую ячейку.
  • return view(…) — действие возвращает ответ, а не печатает его. Никаких echo: разметку соберёт фреймворк и отдаст в буфер вывода, дальше путь вам знаком — PHP-FPM → nginx → браузер.
6листайте
Шаг 3 · Данные

view(): как данные доезжают до шаблона

Последняя строка index() — return view('users.index', ['users' => $users]); (строка 15 файла, он показан на предыдущем слайде).

view(…) — строка 15 в index()view(…)строка 15 в index()'users.index' — аргумент 1 — имя шаблона'users.index'аргумент 1 — имяшаблонаindex.blade.php — resources/views/users/index.blade.phpresources/views/users/['users' => $users] — аргумент 2 — данные['users' =>$users]аргумент 2 — данные$users — переменная в шаблоне$usersпеременная в шаблонеШаблон Blade — @forelse ($users as $user)Шаблон Blade@forelse ($users as$user)точки → каталоги, + .blade.phpключ 'users' → имя переменной$user['name'] в ячейкеточки → каталоги, + .blade.phpключ 'users' → имя переменной$user['name'] в ячейкеview(…) — строка 15 в index()view(…)'users.index' — аргумент 1 — имя шаблона'users.index'index.blade.php — resources/views/users/index.blade.php['users' => $users] — аргумент 2 — данные['users' =>$users]$users — переменная в шаблоне$usersШаблон Blade — @forelse ($users as $user)Шаблон Blade
Два аргумента view(): имя шаблона и данные

Два аргумента — две разные обязанности:

  1. 'users.index' — имя шаблона. Точки работают как разделители каталогов, корень — resources/views/, расширение .blade.php Blade подставляет сам. Значит, будет открыт файл resources/views/users/index.blade.php.
  2. ['users' => $users] — данные для шаблона. Ключ массива становится именем переменной: внутри шаблона появится $users.

Имя переменной в контроллере и ключ во втором массиве — вещи независимые. Переменную можно назвать $rows или $list: шаблон увидит переменную с именем ключа, а не с именем переменной контроллера.

В блоке HTTP вы делали то же самое руками (lesson.http.ii.03): класс booking наследовал layout, переопределял content() и передавал данные в render(). Blade заменяет наследование классов директивами, а массив в render() — вторым аргументом view(). Механика та же, записи меньше.

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

Маршрут указывает на класс и метод

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

Восемь строк, 189 байт — и оба маршрута приложения.

  • use App\Http\Controllers\UserController; — импорт в начале файла. Без него короткое имя UserController означало бы класс в пространстве имён самого файла маршрутов, и роутер его бы не нашёл.
  • [UserController::class, 'index'] — массив из двух элементов: класс и имя метода. Для роутера это и есть «действие».
  • ::class — константа класса из ООП: она разворачивается в полную строку App\Http\Controllers\UserController. Смысл ровно тот же, что у строки 'App\Http\Controllers\UserController@index', но опечатку редактор подсветит сразу, а переименование класса пройдёт по всему проекту. Со строкой вы узнаете об ошибке только в момент запроса.
  • Route::get('/', fn () => redirect('/users')); — корень домена не показывает вторую копию списка, а отправляет на /users. Причина: страница должна жить по одному адресу, иначе закладки, ссылки и будущая пагинация начнут разъезжаться. fn () => — стрелочная функция: здесь она уместна, потому что тело состоит из одного выражения.
  • Маршрута /hello/{name} из части 2 в файле больше нет: routes/web.php мы переписали целиком.
8листайте
Шаг 5 · Макет

Один каркас страницы на все шаблоны

Список — не единственная страница. Дальше появятся форма добавления, форма редактирования и страница с ошибками валидации (часть 5). Общее у всех: <!DOCTYPE>, <head>, шапка с меню, подключение стилей.

Что будет без общего каркаса:

  • четыре копии <head> и четыре копии меню;
  • правка меню — это правка четырёх файлов, и один из них обязательно забудут;
  • подключение CSS в каждом шаблоне, со своим путём и своей версией.

Решение — макет: файл с каркасом и именованными точками, куда дочерние шаблоны подставляют своё содержимое.

Такую схему вы уже строили в чистом PHP (lesson.http.ii.03): класс booking наследовал layout, переопределял метод content(), а родительский render() вызывал content() в нужном месте. Blade делает то же самое директивами:

Чистый PHP из блока HTTP Blade Роль
class booking extends layout @extends('layouts.app') кто наследует каркас
переопределённый content() @section('content') … @endsection что подставляем
вызов content() внутри render() @yield('content') куда подставляем

Принципиальная разница одна: Blade-шаблон компилируется в обычный PHP и кэшируется в storage/framework/views/ — эти файлы мы смотрели в части 2.

9листайте
Шаг 5 · Макет

Макет layouts/app.blade.php

resources/views/layouts/app.blade.php
1<!DOCTYPE html>2<html lang="ru">3<head>4 <meta charset="utf-8">5 <meta name="viewport" content="width=device-width, initial-scale=1">6 <title>@yield('title', 'Пользователи')</title>7 <link rel="stylesheet" href="/css/app.css">8</head>9<body>10<header class="topbar">11 <a class="brand" href="/users">Учебное приложение Laravel</a>12 <nav>13 <a href="/users">Список</a>14 <a href="/users/create">Добавить</a>15 </nav>16</header>17<main class="page">18 @if (session('status'))19 <div class="flash flash--ok">{{ session('status') }}</div>20 @endif21 @yield('content')22</main>23</body>24</html>

Двадцать четыре строки, 670 байт. Разбор:

  • @yield('title', 'Пользователи') — точка подстановки со значением по умолчанию: если дочерний шаблон не объявит секцию title, в <title> попадёт Пользователи.
  • <link rel="stylesheet" href="/css/app.css"> — слэш в начале означает «от корня сайта», а не от текущего адреса. Корень сайта — public/ (так настроен vhost в части 2), поэтому браузер запросит /css/app.css, а nginx отдаст файл public/css/app.css как статику, не доходя до PHP. Тот же принцип «путь со слэша — это адрес, а не серверная директория» вы разбирали в lesson.http.i.04.
  • <header class="topbar"> с меню — общий для всех страниц. Ссылка «Добавить» ведёт на /users/create: такого маршрута пока нет, он появится в части 5.
  • @if (session('status')) — заготовка под flash-сообщение: текст, который показывается один раз, сразу после редиректа. Сейчас в сессии пусто, поэтому блок не выводится вовсе — это будет видно в HTML, который вернёт curl.
  • @yield('content') внутри <main class="page"> — сюда встанет список.

Порядок частей в файле и две точки подстановки — на схеме; номера строк те же, что в листинге выше.

<head> — doctype, meta, charset<head>doctype, meta,charset@yield('title', …) — точка подстановки со значением по умолчанию@yield('title',…)точка подстановки созначением поумолчанию<link /css/app.css> — public/css/app.css отдаёт nginx<link/css/app.css>public/css/app.cssотдаёт nginxheader.topbar — меню: Список и Добавитьheader.topbarменю: Список иДобавить@if (session('status')) — в сессии пусто — блок не выводится@if(session('status'))в сессии пусто — блокне выводится@yield('content') — точка подстановки содержимого@yield('content')точка подстановкисодержимогострока 6 макетастрока 7строки 10-16строки 18-20строка 21строка 6 макетастрока 7строки 10-16строки 18-20строка 21<head> — doctype, meta, charset<head>@yield('title', …) — точка подстановки со значением по умолчанию@yield('title', …)<link /css/app.css> — public/css/app.css отдаёт nginx<link/css/app.css>header.topbar — меню: Список и Добавитьheader.topbar@if (session('status')) — в сессии пусто — блок не выводится@if(session('status'))@yield('content') — точка подстановки содержимого@yield('content')
Каркас макета: жёсткие части и точки @yield
10листайте
Шаг 6 · Стили

Свои стили без сборщика

public/css/app.css
1:root {2 --bg: #f6f7f9;3 --card: #ffffff;4 --text: #16181d;5 --muted: #6b7280;6 --line: #e2e5ea;7 --accent: #b91c1c;8 --ok: #15803d;9 --err: #b91c1c;10}11* { box-sizing: border-box; }12body {13 margin: 0;14 background: var(--bg);15 color: var(--text);16 font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;17}18.topbar {19 display: flex; align-items: center; justify-content: space-between;20 padding: 14px 28px; background: var(--card); border-bottom: 1px solid var(--line);21}22.brand { font-weight: 700; color: var(--text); text-decoration: none; }23.topbar nav a { margin-left: 18px; color: var(--muted); text-decoration: none; }24.topbar nav a:hover { color: var(--text); }25.page { max-width: 960px; margin: 28px auto; padding: 0 20px; }26h1 { font-size: 24px; margin: 0 0 16px; }27.muted { color: var(--muted); font-weight: 400; }28.table { width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }29.table th, .table td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--line); }30.table th { background: #f0f2f5; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }31.table tr:last-child td { border-bottom: 0; }32.flash { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; }33.flash--ok { background: #e8f6ec; color: var(--ok); border: 1px solid #bfe6cb; }34.flash--err { background: #fdecec; color: var(--err); border: 1px solid #f3c6c6; }35.form { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 18px; max-width: 560px; }36.field { margin-bottom: 14px; }37.field label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; }38.field input { width: 100%; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; font: inherit; }39.field input:focus { outline: 2px solid color-mix(in oklab, var(--accent) 35%, transparent); }40.field .error { color: var(--err); font-size: 13px; margin-top: 4px; }41.btn { display: inline-block; padding: 8px 16px; border: 0; border-radius: 8px; background: var(--accent); color: #fff; font: inherit; cursor: pointer; text-decoration: none; }42.btn--ghost { background: transparent; color: var(--muted); border: 1px solid var(--line); }43.btn--danger { background: transparent; color: var(--err); border: 1px solid #f3c6c6; }44.row-actions { display: flex; gap: 8px; }45.pager { margin-top: 14px; display: flex; gap: 6px; }46.pager a, .pager span { padding: 4px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--card); color: var(--muted); text-decoration: none; font-size: 13px; }47.pager .current { background: var(--accent); color: #fff; border-color: var(--accent); }

Файл целиком — 47 строк, 2783 байта; окно кода длиннее 40 строк ограничено по высоте и листается внутри. Подсвечены две группы: строки 2–9 — переменные темы в :root, строки 28–31 — правила таблицы. Рядом с ними .page (строка 25) задаёт ширину контента max-width: 960px, а .muted (строка 27) приглушает счётчик в заголовке.

Почему просто CSS, а не Vite:

  • файл лежит в public/, то есть внутри корня сайта — nginx отдаёт его напрямую, PHP в этом запросе не участвует;
  • собирать нечего: ни препроцессора, ни JS-бандла в учебном приложении нет, значит, не нужны ни npm run build, ни манифест сборки;
  • правка видна после обычного обновления страницы.

Переменные в :root — одна точка правки палитры. Обратите внимание: --accent и --err заданы одним и тем же значением #b91c1c, поэтому смена акцентного цвета не требует обхода всех правил.

В файле уже есть классы на вырост: .form, .field, .btn, .btn--danger (формы и удаление — часть 5), .flash--ok и .flash--err (сообщения и ошибки валидации), .pager (пагинация — часть 4).

11листайте
Шаг 7 · Шаблон

Шаблон списка: users/index.blade.php

resources/views/users/index.blade.php
@extends('layouts.app')@section('title', 'Список пользователей')@section('content') <h1>Пользователи <span class="muted">({{ count($users) }})</span></h1> <table class="table"> <thead> <tr> <th>ФИО</th> <th>Телефон</th> <th>Email</th> </tr> </thead> <tbody> @forelse ($users as $user) <tr> <td>{{ $user['name'] }}</td> <td>{{ $user['phone'] }}</td> <td>{{ $user['email'] }}</td> </tr> @empty <tr> <td colspan="3">Пока никого нет</td> </tr> @endforelse </tbody> </table>@endsection

Тридцать строк, 772 байта: подключение макета, две секции и таблица с перебором массива. Дальше разберём шаблон по двум линиям — как он соединяется с макетом и как выводит данные.

12листайте
Шаг 7 · Шаблон

@extends, @section, @yield: как шаблон встречается с макетом

  • @extends('layouts.app') — «этот шаблон дочерний, каркас взять из resources/views/layouts/app.blade.php». Правило точек то же, что у view(): layouts.app → layouts/app.blade.php.
  • @section('title', 'Список пользователей') — короткая форма: значение задано вторым аргументом, поэтому @endsection не нужен. Именно оно попадёт в <title> и переопределит значение по умолчанию из макета.
  • @section('content') … @endsection — блочная форма: всё, что между директивами, подставится в @yield('content').
  • В шаблоне нет ни <html>, ни <head>, ни шапки с меню — их добавляет макет. Дублировать каркас в дочернем шаблоне не нужно и вредно: получится два <html> в одном ответе.

Порядок работы: Blade собирает секции дочернего шаблона, затем подставляет их в точки @yield макета. В итоговом HTML сначала идёт всё, что в макете стоит до @yield('content'), затем содержимое секции, затем остаток макета.

users/index.blade.php — дочерний шаблон, 30 строкusers/index.blade.phpдочерний шаблон, 30строк@section('title', …) — короткая форма@section('title',…)короткая форма@section('content') — блочная форма, @endsection@section('content')блочная форма,@endsectionlayouts/app.blade.php — макет, 24 строкиlayouts/app.blade.phpмакет, 24 строки@yield('title') и @yield('content') — точки подстановки@yield('title') и@yield('content')точки подстановкиГотовый HTML — title — строка 6, h1 — строка 18Готовый HTMLtitle — строка 6, h1 —строка 18@extends('layouts.app')@extends('layouts.app')users/index.blade.php — дочерний шаблон, 30 строкusers/index.blade.php@section('title', …) — короткая форма@section('title', …)@section('content') — блочная форма, @endsection@section('content')layouts/app.blade.php — макет, 24 строкиlayouts/app.blade.php@yield('title') и @yield('content') — точки подстановки@yield('title') и@yield('con…Готовый HTML — title — строка 6, h1 — строка 18Готовый HTML
Сборка страницы: секции шаблона встают в точки макета

Один нюанс, который видно в ответе curl: Blade не выравнивает отступы. На место директив подставляется текст как есть, поэтому <h1> в готовом HTML начинается с лишними пробелами. На отображение это не влияет — браузер схлопывает пробелы, — но в исходнике страницы заметно.

13листайте
Шаг 7 · Шаблон

@forelse и {{ }}: перебор и экранирование

@forelse ($users as $user) — foreach со встроенной проверкой на пустоту.

  • пока в массиве есть элементы, выполняется тело между @forelse и @empty;
  • если массив пуст, выполняется ветка @empty — строка <td colspan="3">Пока никого нет</td>;
  • @endforelse закрывает конструкцию.

Зачем это нужно: пустой список — нормальное состояние страницы. С обычным @foreach вы получили бы <tbody> без единой строки, то есть пустое место вместо внятного сообщения. colspan="3" объединяет три ячейки — по числу колонок (ФИО, Телефон, Email); сам приём вы разбирали в lesson.http.i.04.

{{ count($users) }} — обычная PHP-функция внутри {{ }}. В заголовке появится число записей: сейчас 3. В блоке HTTP count() считал записи для пагинации (lesson.http.ii.05) — здесь та же роль, только считает PHP, а не SQL.

{{ $user['name'] }} — обращение по ключу, потому что $user — элемент двумерного массива, а не объект. Когда данные приедут из базы (часть 4), $user станет объектом модели, и к полям мы будем обращаться через -> — как к свойствам класса из блока ООП.

{{ }} экранирует вывод. В части 2 мы смотрели, что значение внутри {{ }} проходит через e(), то есть через htmlspecialchars(), и угловые скобки превращаются в &lt; и &gt;.

Почему это важно именно здесь: сейчас данные в массив написали мы сами. В части 5 в эти же ячейки попадут значения из формы — текст, который ввёл пользователь. Экранирование вывода — сестра prepared statements из блока «SQL-инъекции»: та же идея «данные не становятся командой», только на стороне HTML. Prepared statements защищают запрос, {{ }} защищает разметку.

Для вставки готового HTML существует {!! !!} — без экранирования. В этом шаблоне таких мест нет, и это правильно.

14листайте
Шаг 8 · Проверка

Проверяем: curl отдаёт собранный HTML

student@mint: ~/recoursia/project-laravel433 мс
student@mint:~/recoursia/project-laravel$
    <table class="table">        <thead>        <tr>            <th>ФИО</th>            <th>Телефон</th>            <th>Email</th>        </tr>        </thead>        <tbody>                    <tr>                <td>Иванов Иван Иванович</td>                <td>+7 (912) 345-67-89</td>                <td>ivanov@example.com</td>            </tr>                    <tr>                <td>Петрова Анна Сергеевна</td>                <td>+7 (903) 111-22-33</td>                <td>petrova@example.com</td>            </tr>                    <tr>                <td>Сидоров Пётр Алексеевич</td>
HTML списка пользователей — Видно, как Blade собрал таблицу из массива.
Разбор команды

Страницу надо проверить без браузера: curl показывает ровно тот HTML, который собрало приложение. sed обрезает вывод до первых 40 строк, иначе ответ не влезет в окно терминала.

curl -sS http://project-laravel.rs/users | sed -n "1,40p"
ТокенЗачем он и что меняет
curlклиент HTTP: отправляет запрос и печатает тело ответа в stdout
-sSдва флага слитно: -s (silent) убирает прогресс-бар и статистику, -S (show-error) возвращает сообщения об ошибках, которые -s подавляет вместе с ними. Итог — чистый ответ, но проблему с соединением всё равно видно
http://project-laravel.rs/usersадрес запроса: домен project-laravel.rs из локальной зоны .rs (dnsmasq отдаёт 127.0.0.1, проверяли в части 1) и URI /users — маршрут из routes/web.php
|пайп: stdout curl становится stdin следующей команды, тело ответа не печатается на экран целиком
sedпотоковый редактор; здесь работает только фильтром строк и ничего не правит
-nотключает автоматическую печать каждой строки: без него ответ вывелся бы дважды — сам по себе и по команде p
"1,40p"адрес «строки 1–40» и команда p — напечатать их. Полный ответ длиннее: дальше идут закрывающие теги таблицы и документа
кавычкиоболочка передаёт 1,40p одним аргументом; запятая и p не специальные символы shell, но кавычки защищают всё sed-выражение целиком

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

Запрос проходит dnsmasq → nginx → PHP-FPM → роутер → UserController@index → Blade. На экран попадают первые 40 строк готового HTML (1275 байт в захвате), замер — 433 мс на весь путь от curl до собранной страницы. Код возврата 0.

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

В ответе нет ни одного PHP-тега и ни одной директивы Blade: массив из контроллера превратился в строки таблицы. Строка 6 — Список пользователей (секция шаблона переопределила значение по умолчанию из макета), строка 7 — подключение /css/app.css из макета, строка 18 — счётчик (3) из count($users), строки 29–33 и 34–38 — первые два , строки 39–40 — третий, обрезанный sed. Между

(строка 17) и

(строка 18) нет блока flash: условие @if (session('status')) в макете не сработало. Строка 18 начинается с отступа в 12 пробелов — Blade не выравнивает отступы за директивами.

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

  • Пустой вывод и ни одной ошибки: забыли -S, и curl промолчал о проблеме. Если dnsmasq с зоной .rs не запущен, имя не резолвится и curl завершается с кодом 6 — проверяется echo $?.
  • 404: маршрут не зарегистрирован или в URI опечатка. Первым делом смотрим php artisan route:list --except-vendor.
  • 500, а в storage/logs/laravel.log строка «tempnam(): file created in the system's temporary directory»: папка storage/framework/views закрыта на запись, Blade не может сохранить скомпилированный шаблон (воспроизведено в части 6 на GET /users).
  • 500 и в теле ответа «No application encryption key has been specified.»: в .env пустой APP_KEY (воспроизведено в части 6 на GET /users).
  • Страница без стилей: в HTML есть <link rel="stylesheet" href="/css/app.css">, но файла нет в public/css/ — тогда 404 на статику отдаст nginx, PHP в этом запросе не участвует.

Команда: curl -sS http://project-laravel.rs/users | sed -n "1,40p".

  • -s убирает прогресс-бар, -S возвращает показ ошибок — вместе они дают чистый ответ без служебных строк;
  • project-laravel.rs резолвится dnsmasq в 127.0.0.1 (зона .rs из части 1), запрос принимает nginx и передаёт в PHP-FPM через сокет;
  • sed -n "1,40p" печатает первые 40 строк: полный ответ длиннее, дальше идут закрывающие теги таблицы и документа;
  • замер команды — 433 мс за весь путь от curl до готового HTML.

Главное: в ответе нет ни одного PHP-тега. Массив из контроллера превратился в строки таблицы, директив Blade в разметке не осталось. Значит, сработали все три слоя: роутер нашёл маршрут, контроллер отдал данные, Blade собрал HTML.

15листайте
Шаг 8 · Проверка

Что именно мы видим в ответе

Номера строк — те, что напечатал curl выше.

Строки Что там Откуда взялось
6 <title>Список пользователей</title> @section('title', …) шаблона переопределил @yield('title', 'Пользователи') из макета
7 <link … href="/css/app.css"> макет
10–16 шапка topbar и меню макет
17 <main class="page"> макет
18 <h1>Пользователи <span class="muted">(3)</span></h1> шаблон; 3 — результат count($users)
20–28 <table class="table"> и шапка колонок шаблон
29–33 первый <tr>: ФИО, телефон, email элемент $users[0]
34–38 второй <tr> $users[1]
39–40 третий <tr>, обрезан sed $users[2]

И два наблюдения, которые легко пропустить:

  • между <main class="page"> (строка 17) и <h1> (строка 18) нет блока flash — условие @if (session('status')) в макете не сработало, в сессии пусто. Директива не оставила после себя ни комментария, ни пустого <div>;
  • строка 18 начинается с отступа в 12 пробелов: Blade подставил текст на место директив, не пересобирая отступы.

Три элемента массива дали ровно три <tr> — ни одного лишнего и ни одного потерянного. Это и есть проверка того, что @forelse отработал по массиву.

16листайте
Шаг 8 · Проверка

Маршруты после правок

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

Файл routes/web.php переписан целиком: корень отправляет на /users, а /users ведёт в контроллер. Команда показывает, что роутер действительно увидел оба маршрута.

php artisan route:list --except-vendor
ТокенЗачем он и что меняет
phpзапуск интерпретатора из PATH; на стенде PHP 8.3 — его мы проверили в части 1 командой php -v
artisanфайл в корне проекта: поднимает приложение и передаёт ему команду. Таблица маршрутов собирается из уже загруженного приложения, поэтому команда такая же тяжёлая, как и остальные artisan-команды (здесь 681 мс)
route:listимя команды: напечатать зарегистрированные маршруты — HTTP-методы, URI и действие
--except-vendorфлаг-фильтр: не показывать маршруты, которые зарегистрировали пакеты и сам каркас. Без него своих двух строк не найти в длинной таблице

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

Печатает таблицу маршрутов приложения: две строки и итог «Showing [2] routes». Оба маршрута отвечают на GET и HEAD. Код возврата 0, время 681 мс.

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

Колонка действия показывает, чем обслуживается адрес. «/» — замыкание, поэтому указан файл и строка routes/web.php:6. «users» — контроллер, поэтому указан класс и метод UserController@index. URI users напечатан без ведущего слэша, тогда как корень — как «/».

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

  • Адреса нет в таблице: маршрут не зарегистрирован, и запрос к нему вернёт 404. Проверка списка — первое действие при 404.
  • 404 с подписью nginx/1.24.0 (Ubuntu) — это ответил веб-сервер, а не Laravel: запрос не дошёл до index.php, в vhost нет try_files (разобрано в части 2). У Laravel страница ошибки своя.
  • Опечатка в имени метода в маршруте ([UserController::class, 'indx']): строка в таблице выглядит нормально, а ошибка всплывает только в момент запроса.
  • / обслуживает замыкание, поэтому в колонке действия стоит файл и строка: routes/web.php:6. Строка 6 — это как раз Route::get('/', fn () => redirect('/users'));.
  • users обслуживает контроллер, поэтому Laravel показывает класс и метод: UserController@index. Обратите внимание: URI напечатан без ведущего слэша, тогда как корень — как /.
  • GET|HEAD — один маршрут отвечает на оба метода: GET запрашивает страницу целиком, HEAD — только заголовки.
  • Showing [2] routes — маршрута два, и оба наши: флаг --except-vendor прячет маршруты, которые зарегистрировали пакеты и сам каркас.

Эта команда — карта приложения. К ней обращаются в двух случаях: когда нужно понять, кто обрабатывает адрес, и когда адрес отвечает 404 — тогда первым делом смотрят, есть ли он в списке вообще.

17листайте
Шаг 8 · Проверка

Как страница выглядит в браузере

http://project-laravel.rs/users
Таблица собрана Blade из PHP-массива: три строки, шапка из макета.
Таблица собрана Blade из PHP-массива: три строки, шапка из макета.

Адрес http://project-laravel.rs/users, окно браузера 1440×900. На снимке — тот же HTML, который мы разобрали по строкам, только отрендеренный: шапка из макета и таблица из трёх строк, собранная Blade из PHP-массива.

Сверьте два числа: 3 в скобках после «Пользователи» и три строки таблицы. Оба берутся из одного источника — массива в index(). Это легко проверить руками: допишите в массив четвёртый элемент и обновите страницу — появится четвёртая строка, а счётчик в заголовке станет 4. Править шаблон для этого не нужно.

18листайте
Шаг 9 · Три слоя

Данные прошли через три слоя

Карта MVC из части 1 — теперь не схема, а четыре файла нашего проекта.

Маршрут — routes/web.phpМаршрутroutes/web.phpКонтроллер — app/Http/Controllers/UserController.phpКонтроллерapp/Http/Controllers/UserController.phpДанные — массив $users, три записиДанныемассив $users, тризаписиШаблон — resources/views/users/index.blade.phpШаблонresources/views/users/index.blade.phpМакет — resources/views/layouts/app.blade.phpМакетresources/views/layouts/app.blade.phpОтвет — HTML в буфер выводаОтветHTML в буфер выводане читается с дискав части 4 — модель User/users → UserController@index$usersview('users.index', …)@extends('layouts.app')тело ответа/users → UserController@index$usersview('users.index', …)@extends('layouts.app')тело ответаМаршрут — routes/web.phpМаршрутКонтроллер — app/Http/Controllers/UserController.phpКонтроллерДанные — массив $users, три записиДанныеШаблон — resources/views/users/index.blade.phpШаблонМакет — resources/views/layouts/app.blade.phpМакетОтвет — HTML в буфер выводаОтветне читается с дискав части 4 — модель User
Путь данных: маршрут → контроллер → шаблон → ответ

Что знает каждый слой и чего он намеренно не знает:

  • Маршрут (routes/web.php) знает, какому адресу соответствует какое действие, — и не знает, где данные и как выглядит страница.
  • Контроллер (app/Http/Controllers/UserController.php) знает, где взять данные и какой шаблон позвать, — и не знает, как свёрстана страница.
  • Шаблон (resources/views/users/index.blade.php) знает, как вывести $users, — и не знает, откуда они взялись.
  • Макет (resources/views/layouts/app.blade.php) знает общий каркас и меню — и не знает, что именно будет внутри.

Буква M — модель — в этой схеме пока не участвует: её роль играет массив внутри контроллера. В части 4 на этом месте появится модель User и запрос к базе. Адрес /users при этом продолжит вести в UserController@index: изменится содержимое метода, а не маршрут.

Что разделение даёт на практике:

  • источник данных меняется в одном месте — в контроллере; шаблон при этом правится точечно ($user['name'] станет $user->name);
  • разметка меняется в макете и шаблоне, не задевая логику;
  • у одного класса появляется несколько действий: список, форма, сохранение — разные методы, один контроллер.
19листайте
Шаг 10 · Предел массива

Почему массив — тупик

Страница работает, запрос занимает 433 мс. Но данные в массиве не живут:

  • Между запросами ничего не сохраняется. PHP-процесс обрабатывает один запрос и завершается: переменная $users исчезает вместе с ним. Следующий запрос снова выполняет index() с начала и получает тот же массив, что записан в коде.
  • Добавить запись из формы нельзя. Даже приняв POST и дописав элемент в массив, мы потеряем изменение вместе с процессом. Чтобы «добавить» работало, нужно хранилище, которое переживает запрос.
  • Источник данных — исходный код. Чтобы изменить список, нужно править PHP-файл. Ни другой скрипт, ни другой человек данные изменить не могут.
  • На объёме нечего делать. В блоке HTTP вы считали страницы через count() и ceil() по результатам запроса к базе (lesson.http.ii.05). Массив из трёх элементов листать незачем, а отдавать тысячу записей одним куском уже нельзя: ни сортировки, ни фильтра, ни пагинации.
  • Копии разъезжаются. Когда страниц станет несколько, каждой нужен один и тот же список. Массив, скопированный в два контроллера, — это две версии правды.

Вывод: нужен носитель, который живёт дольше запроса. Это база данных — MariaDB, которую мы проверили в части 1.

Мост к части 4: работать с базой вы уже умеете — PDO и подготовленные запросы из блоков «Базы данных» и «SQL-инъекции». В Laravel тот же запрос описывается на уровне модели: не SQL-строка и не ручная привязка параметров, а вызов метода. Под капотом останется тот же prepared statement.

20листайте
Итог

Итог части

Файл Роль Строк Байт
app/Http/Controllers/UserController.php действие index() и данные 17 619
routes/web.php два маршрута: редирект с / и /users 8 189
resources/views/layouts/app.blade.php каркас страницы 24 670
resources/views/users/index.blade.php список и перебор массива 30 772
public/css/app.css стили без сборщика 47 2783

Итого 126 строк, 5033 байта — и рабочая страница списка, проверенная тремя способами: curl (HTML), route:list (маршруты) и снимок браузера.

Три вещи, которые стоит унести из части:

  1. Действие — это метод класса. Маршрут хранит пару [Класс::class, 'метод'], а не логику.
  2. Шаблон ничего не знает о данных. Он получает переменные вторым аргументом view() и выводит их через {{ }} с экранированием.
  3. Макет один, страниц много. @extends + @section/@yield — это наследование, которое вы уже писали руками на классах.
21листайте
Итог

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

  1. Почему make:controller кладёт файл именно в app/Http/Controllers/, и что сломается, если переложить его в другой каталог, не меняя namespace?
  2. Что означает запись [UserController::class, 'index'] в маршруте и чем она лучше строки 'App\Http\Controllers\UserController@index'?
  3. Как view('users.index', ['users' => $users]) превращается в конкретный файл на диске и в переменную внутри шаблона? Что увидит шаблон, если переменную в контроллере переименовать в $rows?
  4. Чем @forelse отличается от @foreach и при каком условии сработает ветка @empty?
  5. В макете есть блок <div class="flash flash--ok">, но в HTML, который вернул curl, его нет. Почему?
22листайте
Итог

Ответы

  1. Автозагрузка Composer работает по PSR-4: пространству имён App\ соответствует каталог app/. При переносе файла без правки namespace путь перестанет совпадать с именем класса, автозагрузчик не найдёт файл, и PHP сообщит, что класс не найден.
  2. Массив «класс + имя метода»: роутер создаёт объект контроллера и вызывает index(). ::class разворачивается в полную строку на этапе разбора кода, поэтому опечатку видно сразу, а переименование класса проходит по проекту; строка '…@index' ошибется молча и только в момент запроса.
  3. Точки — разделители каталогов, корень resources/views/, расширение .blade.php добавляет Blade: получается resources/views/users/index.blade.php. Ключ 'users' второго массива становится переменной $users в шаблоне. При переименовании в $rows шаблон продолжит ждать $users — имя берётся из ключа, а не из имени переменной.
  4. @forelse — это foreach с проверкой на пустоту: ветка @empty выполняется, если перебирать нечего, то есть при пустом массиве.
  5. Блок обёрнут в @if (session('status')). В сессии нет значения status, условие ложно, и Blade не выводит ничего — ни <div>, ни комментария.
23листайте
Что дальше

Что дальше

Часть 4 — база данных, миграции и Eloquent:

  • подключим MariaDB через .env и разберём, чем конфиг отличается от .env;
  • создадим таблицу users миграцией — файлом, который описывает схему и умеет откатываться;
  • наполним таблицу сидером, чтобы не вводить записи руками;
  • заменим массив в index() на запрос модели User и посмотрим, какой SQL при этом выполняется;
  • добавим пагинацию — уже на настоящих записях.

Три слоя, которые вы собрали в этой части, останутся на месте. Адрес /users продолжит вести в UserController@index, шаблон поменяет способ обращения к полю и счётчик, контроллер — источник данных. Это и есть проверка того, что разделение ответственности настоящее, а не нарисованное на схеме.

24листайте