Laravel · Часть 4 — База данных, миграции и Eloquent
1 / 1
lesson.laravel · часть 4

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

До сих пор список пользователей жил в PHP-массиве внутри контроллера: запрос закончился — данных нет. Здесь у приложения появляется настоящая база.

Что сделаем: переключим Laravel с SQLite на MariaDB, заведём базе отдельного пользователя, создадим схему миграциями, добавим колонку phone и откатим её, опишем таблицу моделью User, наполним базу сидером и выведем 12 записей списком с пагинацией по 5 на страницу.

Что понадобится: проект project-laravel из частей 1–3 (маршрут /users, контроллер, макет и шаблон), MariaDB из lesson.linux.04, терминал в корне проекта. Стенд: MariaDB 10.11.14-MariaDB-0ubuntu0.24.04.1, SQLite 3.45.1.

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

От массива — к базе

В части 3 UserController::index() передавал шаблону массив, записанный прямо в коде. Для страницы этого хватило, но данные у такого решения бумажные: PHP-FPM обрабатывает каждый запрос заново и ничего не помнит между запросами (жизненный цикл запроса вы разбирали в lesson.network.02). Пользователь не может ни добавить запись, ни исправить её — для этого пришлось бы менять код.

Хранить данные нужно вне процесса: в файле или в базе. Мы берём базу — ту самую MariaDB, с которой вы работали весь блок lesson.db.

.env — DB_* вместо sqlite.envDB_* вместо sqliteМиграции — migrate:fresh, затем phoneМиграцииmigrate:fresh, затемphoneМодель User — app/Models/User.phpМодель Userapp/Models/User.phpUsersSeeder — 12 записей, пароль → хешUsersSeeder12 записей, пароль →хешКонтроллер — orderBy('name'), paginate(5)КонтроллерorderBy('name'),paginate(5)Шаблон — users/index.blade.phpШаблонusers/index.blade.phpсоединениетаблица usersUser::create12 записей$usersсоединениетаблица usersUser::create12 записей$users.env — DB_* вместо sqlite.envМиграции — migrate:fresh, затем phoneМиграцииМодель User — app/Models/User.phpМодель UserUsersSeeder — 12 записей, пароль → хешUsersSeederКонтроллер — orderBy('name'), paginate(5)КонтроллерШаблон — users/index.blade.phpШаблон
План части: от строк в .env до страницы в браузере

Каждый этап заканчивается проверяемым результатом: выводом команды, строкой в базе или страницей в браузере.

2листайте
Шаг 1 · Соединение с MariaDB

По умолчанию Laravel работает с SQLite

student@mint: ~/recoursia/project-laravel404 мс
student@mint:~/recoursia/project-laravel$
База по умолчанию — SQLite — Каркас Laravel 13 стартует с SQLite: файл лежит в database/, миграции уже прошли при установке.
Разбор команды

Перед переключением смотрим, какое соединение задал каркас по умолчанию и есть ли файл базы SQLite.

grep -n "^DB_\|^# DB_" .env; ls -l database/database.sqlite
ТокенЗачем он и что меняет
grepутилита поиска строк по шаблону
-nпечатать номера строк: по ним видно, что блок DB_* занимает строки 23–28, и их легко найти в редакторе
"^DB_\|^# DB_"шаблон в двойных кавычках: ^DB_ — строки, начинающиеся с DB_, | — «или» в базовом синтаксисе grep, ^# DB_ — те же строки, но закомментированные. Без кавычек shell принял бы | за пайп
.envфайл, в котором ищем: конфигурация окружения Laravel, из неё читаются настройки соединения
;разделитель двух независимых команд в одной строке: вторая выполнится в любом случае, даже если grep ничего не нашёл
lsпоказать сведения о файле
-lдлинный формат: права, владелец, размер в байтах и время изменения
database/database.sqliteпуть к файлу базы SQLite относительно корня проекта

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

Печатает шесть строк блока DB_* из .env (строка 23 — DB_CONNECTION=sqlite, строки 24–28 начинаются с #) и строку ls: database/database.sqlite, 106496 байт, права -rw-rw-rw-, владелец student.

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

DB_CONNECTION=sqlite — соединение по умолчанию, а строки DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD закомментированы: каркас заранее приготовил вариант «серверная СУБД», его нужно раскомментировать и заполнить. Файл не пустой (106496 байт) — миграции в него уже прошли при установке проекта.

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

  • Без кавычек вокруг шаблона shell разрежет его по | и grep получит только половину условия.
  • grep возвращает код выхода 1, если совпадений нет: при соединении команд через && вторая команда не выполнилась бы вообще.

Смотрим два факта. В .env на строке 23 стоит DB_CONNECTION=sqlite, а строки 24–28 — DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD — закомментированы. Каркас заранее приготовил вариант «серверная СУБД»: его нужно только раскомментировать и заполнить.

Рядом лежит database/database.sqlite — 106496 байт, права -rw-rw-rw-, время создания Oct 17 10:20. Это не сервер, а обычный файл: миграции в него уже прошли при установке проекта (часть 1), поэтому он не пустой.

Почему SQLite выбран по умолчанию: ноль настройки. Ни демона, ни порта, ни пароля — один файл в каталоге проекта, удобно для тестов и быстрых демо.

Почему мы всё равно уходим с SQLite

SQLite — библиотека внутри процесса PHP: у неё нет отдельного сервера, нет пользователей и прав, и консольный клиент mysql с его SHOW TABLES, DESCRIBE и EXPLAIN к ней не применим. В блоке lesson.db вы работали именно с сервером MariaDB и именно этими командами; учебное приложение должно вести себя как боевое.

3листайте
Шаг 1 · Соединение с MariaDB

Приборная панель соединения: php artisan db:show

student@mint: ~/recoursia/project-laravel626 мс
student@mint:~/recoursia/project-laravel$
php artisan db:show до переключения
Разбор команды

Проверяем, куда приложение смотрит прямо сейчас, — до переключения на MariaDB.

php artisan db:show
ТокенЗачем он и что меняет
phpинтерпретатор PHP; стенд урока — 8.3, свою версию покажет php -v
artisanскрипт командной строки Laravel в корне проекта: все команды фреймворка вызываются как php artisan
db:showкоманда «приборная панель соединения»: драйвер и версия СУБД, база, хост, порт, пользователь, число таблиц и их размеры

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

Показывает SQLite 3.45.1, соединение sqlite, базу database/database.sqlite; Host, Port, Username и URL пустые; Tables 9, размеры таблиц — прочерки.

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

Девять таблиц появились без единой строчки SQL — их создали миграции каркаса при установке. Пустые Host, Port, Username — не поломка вывода: SQLite открывает файл сам процесс PHP, подключаться не к кому.

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

  • Прочерки в колонке Size — особенность драйвера SQLite: после переключения на MariaDB размеры таблиц появятся.

db:show отвечает на вопрос «куда приложение смотрит прямо сейчас». Здесь: SQLite 3.45.1, соединение sqlite, база — путь к файлу, а Host, Port, Username и URL пустые. Пустые не потому, что вывод сломался: подключаться не к кому, файл открывает сам процесс PHP. Tables 9, а размеры таблиц — прочерки: SQLite их так не отдаёт.

Запомните цифру 9: мы не написали ни строчки SQL, а схема уже есть. Её создали миграции каркаса при установке. Чуть ниже разберём, откуда они взялись и что в них.

4листайте
Шаг 1 · Соединение с MariaDB

MariaDB на месте, и root заходит без пароля

student@mint: ~/recoursia/project-laravel469 мс
student@mint:~/recoursia/project-laravel$
MariaDB через unix-сокет — root в MariaDB на Ubuntu/Mint входит без пароля через сокет (плагин unix_socket) — так вы работали в блоке БД.
Разбор команды

Убеждаемся, что сервер MariaDB запущен и что root заходит без пароля через unix-сокет.

sudo mysql -e "SELECT VERSION() AS version; SHOW DATABASES;"
ТокенЗачем он и что меняет
sudoвыполнить от имени root: учётка root@localhost в MariaDB проверяет ОС-пользователя, поэтому без sudo вход не сработает
mysqlконсольный клиент MariaDB — тот же, которым вы работали в блоке lesson.db
-eфлаг: выполнить SQL из следующего аргумента и выйти, без интерактивной сессии — поэтому вывод можно снять в терминал
"SELECT VERSION() AS version; SHOW DATABASES;"аргумент флага -e целиком: два запроса в двойных кавычках, разделённые точкой с запятой
SELECT VERSION() AS versionзапрос версии сервера; AS version задаёт имя колонки в выводе
;разделитель запросов внутри строки SQL
SHOW DATABASESсписок баз на сервере

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

Сервер отвечает версией 10.11.14-MariaDB-0ubuntu0.24.04.1 и списком четырёх служебных баз: information_schema, mysql, performance_schema, sys. Код выхода 0.

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

Пароль не спросили: соединение идёт через локальный unix-сокет, а плагин unix_socket проверяет ОС-пользователя (мы пришли через sudo), а не пароль. Нашей базы в списке пока нет.

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

  • Без sudo клиент придёт от имени student — такой учётки у MariaDB нет, и вход закончится отказом в доступе.

Это тот самый консольный клиент из lesson.db.vi.01 — только без привычных -u root -p: пароль не спросили. Версия на стенде — 10.11.14-MariaDB-0ubuntu0.24.04.1. В списке четыре служебные базы (information_schema, mysql, performance_schema, sys); нашей пока нет.

Почему пускает без пароля: у учётки root@localhost в MariaDB на Ubuntu и Mint включён плагин unix_socket. Сервер проверяет не пароль, а то, что соединение пришло через локальный сокет и что ОС-пользователь — root (мы вошли через sudo). Доверие берётся из операционной системы.

5листайте
Шаг 1 · Соединение с MariaDB

Но приложение так зайти не сможет

student@mint: ~/recoursia/project-laravelexit code 1355 мс
student@mint:~/recoursia/project-laravel$
А по TCP root не пускает — Типичная ошибка новичка: приложение ходит по TCP 127.0.0.1, а там у root нет права входа. Заводим отдельного пользователя.
Разбор команды

Проверяем, пустит ли сервер root по TCP, — именно так к базе подключается Laravel.

mysql -h 127.0.0.1 -u root -e "SELECT 1;"
ТокенЗачем он и что меняет
mysqlконсольный клиент MariaDB
-h 127.0.0.1хост: соединение по TCP вместо локального unix-сокета. Единственное отличие от прошлой команды — и оно решает всё
-u rootпользователь сервера; пароль не передаём, потому что у root@localhost его нет — работает плагин unix_socket
-e "SELECT 1;"выполнить один запрос и выйти; SELECT 1 — простейшая проверка «соединение есть»

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

Отказ в доступе: ERROR 1698 (28000): Access denied for user 'root'@'localhost', код выхода 1.

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

1698 — ответ плагина unix_socket на сетевое соединение: проверять ОС-пользователя нечего, поэтому root по TCP не проходит. Laravel ходит в базу по DB_HOST=127.0.0.1 и DB_PORT=3306, то есть ровно так же, — под root приложение не заработает, сколько бы раз вы ни перезапускали nginx и php-fpm.

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

  • Ошибка выглядит как «в терминале заходит, из кода — нет»: причина в способе соединения (сокет против TCP), а не в пароле.
  • Соблазн задать root пароль и вписать его в .env даёт коду права root на весь сервер: все базы и возможность выдавать права другим. Нужен отдельный пользователь с правами на одну базу.

Та же MariaDB, тот же root — и ERROR 1698 (28000): Access denied for user 'root'@'localhost', код выхода 1. Отличие от прошлой команды одно: -h 127.0.0.1, то есть мы пришли по TCP, а не через unix-сокет. Плагину unix_socket нечего проверять — у сетевого соединения нет ОС-пользователя, поэтому в доступе отказано.

Почему это касается нас напрямую: Laravel подключается к базе так, как написано в .env — DB_HOST=127.0.0.1, DB_PORT=3306, то есть по TCP. Значит, под root приложение не заработает никогда, сколько бы раз мы ни перезапускали nginx и php-fpm. Ошибка при этом выглядит обидно непонятной: «в терминале заходит, из кода — нет».

Чего делать не надо

Соблазн — задать root пароль и вписать его в .env. Заработает, но тогда у кода приложения появятся права root на весь сервер MariaDB: все базы, все таблицы, возможность выдавать права другим пользователям. Одна удачная SQL-инъекция (вы эксплуатировали такую в lesson.sql-injection.i.02) — и пострадает не только наша база.

6листайте
Шаг 1 · Соединение с MariaDB

Заводим базу и пользователя приложения

student@mint: ~/recoursia/project-laravel540 мс
student@mint:~/recoursia/project-laravel$
>
Создаём базу и пользователя приложения — Права даём только на одну базу — принцип минимальных прав из урока про SQL-инъекции.
Разбор команды

Заводим приложению отдельную базу и отдельного пользователя с правами только на неё — и сразу проверяем результат.

sudo mysql -e "CREATE DATABASE IF NOT EXISTS project_laravel CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER IF NOT EXISTS 'project_laravel'@'localhost' IDENTIFIED BY 'secret'; GRANT ALL PRIVILEGES ON project_laravel.* TO 'project_laravel'@'localhost'; FLUSH PRIVILEGES;"
ТокенЗачем он и что меняет
sudoклиент идёт через unix-сокет от root: создавать базы и пользователей может только администратор сервера
mysqlконсольный клиент MariaDB
-eфлаг: выполнить SQL из следующего аргумента и выйти, без интерактивной сессии
"..."весь SQL — один аргумент в двойных кавычках: четыре команды через точку с запятой; одинарные кавычки внутри shell не трогает
CREATE DATABASE IF NOT EXISTS project_laravelсоздать базу project_laravel; IF NOT EXISTS делает команду повторимой — второй запуск не упадёт с ошибкой «база уже существует»
CHARACTER SET utf8mb4кодировка базы: полноценный UTF-8, до четырёх байт на символ, чтобы кириллица не обрезалась
COLLATE utf8mb4_unicode_ciправила сравнения строк; ci означает «без учёта регистра». От collation зависит и сортировка кириллицы в ORDER BY name
CREATE USER IF NOT EXISTS 'project_laravel'@'localhost'учётка приложения; запись читается как «пользователь project_laravel, пришедший с localhost»
IDENTIFIED BY 'secret'пароль этой учётки; он же попадёт в .env как DB_PASSWORD
GRANT ALL PRIVILEGES ON project_laravel.*все права, но маска project_laravel.* ограничивает их таблицами одной базы и ничем за её пределами
TO 'project_laravel'@'localhost'кому выдаём права; host-часть должна совпадать с CREATE USER, иначе права уйдут другой учётке
FLUSH PRIVILEGESперечитать таблицы прав, чтобы изменения подействовали сразу, без перезапуска сервера
;разделитель SQL-команд внутри строки, переданной через -e
'...'одинарные кавычки — строки SQL (имя базы, пользователь, пароль); внутри двойных кавычек shell их не трогает

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

Успешный DDL ничего не печатает: вывод пустой, код выхода 0.

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

Тишина — признак успеха. Ошибка SQL напечаталась бы в stderr и дала ненулевой код выхода.

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

  • Права на . вместо project_laravel.* сделали бы пользователя приложения администратором всего сервера — смысл отдельной учётки потерялся бы.
  • Пароль, написанный в командной строке, попадает в историю shell и виден в списке процессов; на общей машине используйте -p без значения (клиент спросит пароль сам) или файл ~/.my.cnf.
sudo mysql -e "SHOW DATABASES;"
ТокенЗачем он и что меняет
sudoснова входим через unix-сокет от root
mysqlконсольный клиент MariaDB
-e "SHOW DATABASES;"контрольный запрос: список баз после создания

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

В списке появилась project_laravel — пять баз вместо четырёх служебных.

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

project_laravel видна серверу: значит, CREATE DATABASE отработал, а не только «не выдал ошибку».

Один вызов sudo mysql -e с четырьмя командами и контрольный SHOW DATABASES: в списке появилась project_laravel. Разбор по частям:

  • CREATE DATABASE IF NOT EXISTS project_laravel с CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci — имя базы совпадает с проектом и доменом project-laravel.rs из части 2. IF NOT EXISTS делает команду повторимой: второй запуск не упадёт. utf8mb4 — полноценный UTF-8, до четырёх байт на символ, поэтому кириллица и прочие символы не обрезаются; utf8mb4_unicode_ci — правила сравнения строк, ci в названии означает «без учёта регистра».
  • CREATE USER IF NOT EXISTS 'project_laravel'@'localhost' IDENTIFIED BY 'secret' — отдельная учётка именно для приложения. Запись 'project_laravel'@'localhost' читается как «пользователь project_laravel, пришедший с localhost».
  • GRANT ALL PRIVILEGES ON project_laravel.* TO 'project_laravel'@'localhost' — все права, но только внутри project_laravel: маска project_laravel.* — это все таблицы одной базы и ничего за её пределами.
  • FLUSH PRIVILEGES — перечитать таблицы прав, чтобы изменения подействовали сразу, без перезапуска сервера.

Смысл отдельного пользователя — минимальные права. Если пароль утечёт или запрос соберётся из пользовательских данных неправильно, ущерб ограничен одной базой. Это тот же принцип, что и в lesson.sql-injection.i.03: не «разрешить всё и потом проверять», а заранее сузить набор возможного.

Пароль в командной строке

secret — учебный пароль, и дальше мы будем вызывать клиент как mysql -u project_laravel -psecret. Так удобно снимать вывод, но пароль при этом попадает в историю shell и виден в списке процессов. На общей машине используйте -p без значения (клиент спросит пароль сам) или файл ~/.my.cnf.

7листайте
Шаг 1 · Соединение с MariaDB

Переключаем .env на MariaDB

.envini
1APP_NAME=Laravel2APP_ENV=local3APP_KEY=base64:eHDeneQeXvMOrx9o4u6vJWjuk5Icda2RlI7+XPhgXFY=4APP_DEBUG=true5APP_URL=http://project-laravel.rs67APP_LOCALE=en8APP_FALLBACK_LOCALE=en9APP_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}"

Файл .env целиком. Подсвечены строки, которые важны в этой части: 5 — APP_URL, 16 — BCRYPT_ROUNDS, 23–28 — блок DB_*.

student@mint: ~/recoursia/project-laravel465 мс
student@mint:~/recoursia/project-laravel$
Проверяем .env
Разбор команды

После правки .env проверяем, что блок DB_* раскомментирован и заполнен, а APP_URL совпадает с доменом проекта.

grep -n "^DB_\|^APP_URL" .env
ТокенЗачем он и что меняет
grepпоиск строк по шаблону
-nномера строк: видно, что блок DB_* занял строки 23–28, а APP_URL — строку 5
"^DB_\|^APP_URL"шаблон: строки, начинающиеся с DB_, или строки, начинающиеся с APP_URL; | — «или» в базовом синтаксисе grep
.envфайл окружения проекта: из него Laravel берёт настройки соединения и адрес приложения

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

Печатает APP_URL=http://project-laravel.rs на строке 5 и шесть строк DB_* на строках 23–28: mysql, 127.0.0.1, 3306, project_laravel, project_laravel, secret.

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

Строки DB_* больше не начинаются с # — значения действительно читаются. DB_CONNECTION=mysql подходит и MySQL, и MariaDB: протокол у них общий, поэтому переезд оказался заменой шести строк.

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

  • Если конфигурацию закэшировали командой php artisan config:cache, правки .env применяться не будут — кэш важнее файла (подробно в части 6).

Проверка: тот же файл, но только нужные строки и с номерами.

Шесть строк блока DB_* — всё, что нужно для соединения:

  • DB_CONNECTION=mysql — имя драйвера из config/database.php. Одно и то же значение подходит и MySQL, и MariaDB: протокол у них общий, поэтому «переезд» оказался заменой шести строк.
  • DB_HOST=127.0.0.1 и DB_PORT=3306 — TCP. Именно поэтому root с прошлого слайда не годится.
  • DB_DATABASE, DB_USERNAME, DB_PASSWORD — база project_laravel, пользователь с тем же именем и пароль secret.

Проверка справа — grep -n "^DB_\|^APP_URL" .env: строки 23–28 больше не закомментированы, а APP_URL=http://project-laravel.rs на строке 5 совпадает с доменом из части 2. Обратите внимание на BCRYPT_ROUNDS=12 (строка 16) — к этой строке вернёмся, когда будем смотреть на хеш пароля.

Почему настройки живут в .env, а не в коде

На рабочем сервере база другая, и менять ради неё файлы проекта неправильно: код один, окружения разные. .env не коммитят (часть 1), а значения читаются через config/database.php. Отсюда важное следствие: если конфигурацию закэшировали командой php artisan config:cache, правки .env применяться не будут — кэш важнее файла. Подробно разберём в части 6.

8листайте
Шаг 2 · Миграции

Миграция — это изменение схемы, записанное кодом

Структуру таблицы вы уже меняли руками: в lesson.dbii.03 тип поля category правили запросом ALTER TABLE из phpMyAdmin. Работает, но у такого изменения нет истории — оно осталось в базе и в памяти того, кто его делал.

Миграция — тот же ALTER TABLE (или CREATE TABLE), но записанный в PHP-файл в каталоге database/migrations/. У файла два метода: up() — что сделать, down() — как отменить. Файл попадает в git, и любая машина — ваша, коллеги, боевой сервер — получает одинаковую схему одной командой.

Вопрос Правка руками Миграция
Где живёт изменение в базе и в голове автора в файле в репозитории
Как повторить на другой машине вспомнить и набрать заново php artisan migrate
Как отменить писать обратный SQL по памяти php artisan migrate:rollback
Что уже применено неизвестно журнал в таблице migrations
В каком порядке применять на глаз по метке времени в имени файла

Порядок обеспечивает имя файла: 2026_10_17_102101_add_phone_to_users_table.php начинается с даты и времени создания. Laravel применяет файлы по алфавиту, то есть хронологически, и помнит в базе, какие уже выполнены.

9листайте
Шаг 2 · Миграции

Пересоздаём схему уже в MariaDB

student@mint: ~/recoursia/project-laravel1359 мс
student@mint:~/recoursia/project-laravel$
Пересоздаём таблицы уже в MariaDB — migrate:fresh удаляет все таблицы текущего соединения и прогоняет миграции заново.
Разбор команды

База только что создана и пуста: прогоняем в ней схему заново — служебную таблицу migrations и три миграции каркаса.

php artisan migrate:fresh
ТокенЗачем он и что меняет
phpинтерпретатор PHP (стенд — 8.3; свою версию покажет php -v)
artisanCLI-скрипт Laravel в корне проекта
migrate:freshудалить все таблицы текущего соединения и заново прогнать все файлы из database/migrations

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

Создана таблица migrations (30.43ms), затем применены три миграции каркаса: 0001_01_01_000000_create_users_table (211.97ms), 0001_01_01_000001_create_cache_table (135.60ms), 0001_01_01_000002_create_jobs_table (221.05ms) — все DONE.

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

Все три файла — не наши: они лежат в database/migrations с момента установки проекта. Они прошли одним запуском, поэтому получат общий номер батча — это видно в migrate:status.

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

  • На базе с реальными данными migrate:fresh уничтожит всё: для обычной работы есть php artisan migrate, он применяет только файлы, которых ещё нет в журнале.
  • Если .env не переключён, команда отработает по SQLite-файлу — соединение проверяют через php artisan db:show.

Читаем вывод сверху вниз:

  • Preparing database → Creating migration table ... 30.43ms DONE. Laravel создал служебную таблицу migrations — журнал применённых миграций. Её не было, потому что база project_laravel создана только что.
  • Running migrations → три файла, каждый со своим временем: 211.97ms, 135.60ms, 221.05ms. Это миграции каркаса, мы их не писали: они лежат в database/migrations/ с момента установки проекта.

Имена говорят сами за себя: create_users_table, create_cache_table, create_jobs_table. Внутри этих файлов — обычный PHP с вызовами Schema::create(...), из которых Laravel собирает SQL под текущую СУБД. Какие именно таблицы кто создаёт, посмотрим на следующем слайде.

migrate:fresh удаляет данные

fresh означает «снести все таблицы текущего соединения и прогнать миграции заново». На учебной базе это удобно: схема всегда чистая, без мусора от экспериментов. На базе с реальными данными команда уничтожит всё. Для обычной работы есть php artisan migrate — он применяет только те файлы, которых ещё нет в журнале.

10листайте
Шаг 2 · Миграции

Три файла каркаса: кто какие таблицы создаёт

database/migrations/0001_01_01_000000_create_users_table.phpphp
1<?php23use Illuminate\Database\Migrations\Migration;4use Illuminate\Database\Schema\Blueprint;5use Illuminate\Support\Facades\Schema;67return new class extends Migration8{9 /**10 * Run the migrations.11 */12 public function up(): void13 {14 Schema::create('users', function (Blueprint $table) {15 $table->id();16 $table->string('name');17 $table->string('email')->unique();18 $table->timestamp('email_verified_at')->nullable();19 $table->string('password');20 $table->rememberToken();21 $table->timestamps();22 });2324 Schema::create('password_reset_tokens', function (Blueprint $table) {25 $table->string('email')->primary();26 $table->string('token');27 $table->timestamp('created_at')->nullable();28 });2930 Schema::create('sessions', function (Blueprint $table) {31 $table->string('id')->primary();32 $table->foreignId('user_id')->nullable()->index();33 $table->string('ip_address', 45)->nullable();34 $table->text('user_agent')->nullable();35 $table->longText('payload');36 $table->integer('last_activity')->index();37 });38 }3940 /**41 * Reverse the migrations.42 */43 public function down(): void44 {45 Schema::dropIfExists('users');46 Schema::dropIfExists('password_reset_tokens');47 Schema::dropIfExists('sessions');48 }49};
database/migrations/0001_01_01_000001_create_cache_table.phpphp
1<?php23use Illuminate\Database\Migrations\Migration;4use Illuminate\Database\Schema\Blueprint;5use Illuminate\Support\Facades\Schema;67return new class extends Migration8{9 /**10 * Run the migrations.11 */12 public function up(): void13 {14 Schema::create('cache', function (Blueprint $table) {15 $table->string('key')->primary();16 $table->mediumText('value');17 $table->bigInteger('expiration')->index();18 });1920 Schema::create('cache_locks', function (Blueprint $table) {21 $table->string('key')->primary();22 $table->string('owner');23 $table->bigInteger('expiration')->index();24 });25 }2627 /**28 * Reverse the migrations.29 */30 public function down(): void31 {32 Schema::dropIfExists('cache');33 Schema::dropIfExists('cache_locks');34 }35};
database/migrations/0001_01_01_000002_create_jobs_table.phpphp
1<?php23use Illuminate\Database\Migrations\Migration;4use Illuminate\Database\Schema\Blueprint;5use Illuminate\Support\Facades\Schema;67return new class extends Migration8{9 /**10 * Run the migrations.11 */12 public function up(): void13 {14 Schema::create('jobs', function (Blueprint $table) {15 $table->id();16 $table->string('queue')->index();17 $table->longText('payload');18 $table->unsignedSmallInteger('attempts');19 $table->unsignedInteger('reserved_at')->nullable();20 $table->unsignedInteger('available_at');21 $table->unsignedInteger('created_at');22 });2324 Schema::create('job_batches', function (Blueprint $table) {25 $table->string('id')->primary();26 $table->string('name');27 $table->integer('total_jobs');28 $table->integer('pending_jobs');29 $table->integer('failed_jobs');30 $table->longText('failed_job_ids');31 $table->mediumText('options')->nullable();32 $table->integer('cancelled_at')->nullable();33 $table->integer('created_at');34 $table->integer('finished_at')->nullable();35 });3637 Schema::create('failed_jobs', function (Blueprint $table) {38 $table->id();39 $table->string('uuid')->unique();40 $table->string('connection');41 $table->string('queue');42 $table->longText('payload');43 $table->longText('exception');44 $table->timestamp('failed_at')->useCurrent();4546 $table->index(['connection', 'queue', 'failed_at']);47 });48 }4950 /**51 * Reverse the migrations.52 */53 public function down(): void54 {55 Schema::dropIfExists('jobs');56 Schema::dropIfExists('job_batches');57 Schema::dropIfExists('failed_jobs');58 }59};

Подсвечены вызовы Schema::create(...) — из них и складываются те девять таблиц, которые показал SHOW TABLES.

Миграция Таблицы Зачем они приложению
0001_01_01_000000_create_users_table users, password_reset_tokens, sessions пользователи; токены сброса пароля; сессии — в .env стоит SESSION_DRIVER=database
0001_01_01_000001_create_cache_table cache, cache_locks кэш — CACHE_STORE=database
0001_01_01_000002_create_jobs_table jobs, job_batches, failed_jobs очередь — QUEUE_CONNECTION=database

Внутри обычный PHP, и его видно в схеме: строка $table->string('email')->unique() из первой колонки превратилась в email varchar(255) NO UNI из DESCRIBE users. down() у каждой миграции зеркальный — Schema::dropIfExists(...) для тех же таблиц, поэтому откат каркасных миграций тоже работает.

11листайте
Шаг 2 · Миграции

Что физически появилось в базе

student@mint: ~/recoursia/project-laravel393 мс
student@mint:~/recoursia/project-laravel$
Что появилось в базе — Обратите внимание на предупреждение о пароле в командной строке — это реальное поведение клиента.
Разбор команды

Смотрим глазами клиента, что физически появилось в базе: список таблиц и схему users.

mysql -u project_laravel -psecret project_laravel -e "SHOW TABLES; DESCRIBE users;"
ТокенЗачем он и что меняет
mysqlконсольный клиент MariaDB
-u project_laravelпользователь приложения, которого мы создали; не root — проверка, что прав на одну базу хватает
-psecretпароль слитно с флагом: у -p значение пишется без пробела. Учебный приём — на общей машине так делать не стоит
project_laravelпозиционный аргумент: базу открыть сразу после подключения, чтобы SHOW/SELECT работали без USE
-eфлаг: выполнить SQL из следующего аргумента и выйти, без интерактивной сессии
"SHOW TABLES; DESCRIBE users;"два запроса одной строкой: список таблиц открытой базы и схема таблицы users — колонка, тип, Null, Key, Default, Extra

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

Девять таблиц (cache, cache_locks, failed_jobs, job_batches, jobs, migrations, password_reset_tokens, sessions, users) и восемь колонок users: id bigint(20) unsigned с PRI и auto_increment, name varchar(255), email varchar(255) с UNI, email_verified_at, password, remember_token, created_at, updated_at.

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

Предметная область здесь — только users; остальные таблицы появились потому, что в .env стоят SESSION_DRIVER=database, CACHE_STORE=database и QUEUE_CONNECTION=database. NO в столбце Null означает NOT NULL (значение обязательно), UNI — уникальный индекс на email.

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

  • Пробел после -p ломает команду: клиент спросит пароль интерактивно, а следующее слово примет за имя базы.
  • Пароль в командной строке виден в истории shell и в списке процессов.

Команда — тот же клиент mysql, но от имени пользователя приложения: mysql -u project_laravel -psecret project_laravel -e "SHOW TABLES; DESCRIBE users;" — пользователь, пароль, база и запрос в одну строку.

SHOW TABLES показывает девять таблиц. Наша предметная область здесь — только users. Остальные появились не «просто так»: в .env стоят SESSION_DRIVER=database, CACHE_STORE=database и QUEUE_CONNECTION=database, поэтому сессии, кэш и очередь Laravel хранит в той же базе. migrations — журнал из прошлого слайда.

DESCRIBE users — восемь колонок:

Колонка Тип и флаги Что это значит
id bigint(20) unsigned, NO, PRI, auto_increment первичный ключ, номер растёт сам; в lesson.db.iv.03 вы доставали его через PDO::lastInsertId()
name varchar(255), NO строка до 255 символов, значение обязательно
email varchar(255), NO, UNI уникальный: вторую запись с тем же адресом не пропустит сама база
email_verified_at timestamp, YES может быть NULL — адрес ещё не подтвердили
password varchar(255), NO здесь будет хеш, а не пароль — увидим ниже
remember_token varchar(100), YES токен «запомнить меня»
created_at, updated_at timestamp, YES метки времени; модель проставляет их сама

Столбец Null — это про NOT NULL: NO значит «значение обязательно», YES — «можно оставить пустым».

12листайте
Шаг 2 · Миграции

Журнал миграций: php artisan migrate:status

student@mint: ~/recoursia/project-laravel739 мс
student@mint:~/recoursia/project-laravel$
Статус миграций
Разбор команды

Проверяем журнал миграций: какие файлы применены и каким батчем.

php artisan migrate:status
ТокенЗачем он и что меняет
php artisanCLI Laravel: интерпретатор и скрипт проекта
migrate:statusкоманда печатает список файлов миграций с номером батча и статусом (Ran — применён, Pending — лежит на диске, но не выполнен)

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

Три строки: 0001_01_01_000000_create_users_table, 0001_01_01_000001_create_cache_table, 0001_01_01_000002_create_jobs_table — у всех батч [1] и статус Ran.

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

[1] — всё применилось одним запуском migrate:fresh. Откат работает батчами: migrate:rollback --step=1 отменяет последний из них.

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

  • Ситуация «локально работает, а на сервере нет колонки» почти всегда означает, что на сервере просто не запустили migrate: migrate:status показывает Pending-файлы за одну секунду.

Три строки — три применённые миграции, у всех батч [1] и статус Ran. Батч — это «пачка»: всё, что применилось одним запуском migrate, получает общий номер. Именно батчами работает откат: migrate:rollback --step=1 отменяет последний из них.

Зачем эта команда в ежедневной работе: она отвечает на два вопроса — что база уже видела и какие файлы лежат на диске, но ещё не применены. Ситуация «локально работает, а на сервере нет колонки» почти всегда означает, что на сервере просто не запустили migrate; migrate:status показывает это за одну секунду.

13листайте
Шаг 2 · Миграции

Нужна колонка phone — делаем новую миграцию

student@mint: ~/recoursia/project-laravel741 мс
student@mint:~/recoursia/project-laravel$
Миграция: добавляем колонку phone — Новую колонку не вписываем в старую миграцию, а делаем отдельной — история схемы должна оставаться неизменяемой.
Разбор команды

Колонки phone в таблице нет — создаём под неё отдельный файл миграции.

php artisan make:migration add_phone_to_users_table --table=users
ТокенЗачем он и что меняет
php artisanCLI Laravel
make:migrationгенератор файла миграции в database/migrations
add_phone_to_users_tableимя миграции: artisan превращает его в имя файла и добавляет префикс из даты и времени создания — по нему строится порядок применения
--table=usersподсказка генератору: менять существующую таблицу users. В заготовке появится Schema::table('users', ...), а не Schema::create(...)

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

Создан файл database/migrations/2026_10_17_102101_add_phone_to_users_table.php: INFO ... created successfully.

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

Дата и время в имени файла — не украшение: Laravel применяет файлы по алфавиту, то есть хронологически. Сам файл пока заготовка — код up() и down() пишем мы.

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

  • Дописывать колонку в уже применённую миграцию нельзя: на вашей машине ничего не произойдёт (файл есть в журнале), а на чистой схеме колонка появится — схемы разойдутся при одинаковом коде.

Приложение хранит ФИО, телефон, email и пароль, но колонки phone в таблице нет: каркас о ней не знает. Добавляем.

artisan превращает имя миграции в имя файла: add_phone_to_users_table → database/migrations/2026_10_17_102101_add_phone_to_users_table.php, где префикс — дата и время создания. Параметр --table=users подсказывает генератору, что менять будем существующую таблицу: в заготовке сразу появится Schema::table('users', ...), а не Schema::create(...).

Почему не вписать phone в старую миграцию

Файл 0001_01_01_000000_create_users_table.php уже применён и записан в журнал. Если дописать колонку в него:

  • на вашей машине не произойдёт ничего — migrate запускает только файлы, которых нет в таблице migrations;
  • на чистой машине (у коллеги или на сервере) колонка появится — и схемы разойдутся при одинаковом коде;
  • из истории исчезнет сам факт изменения: по коду будет не понять, что и когда менялось.

Правило простое: применённая миграция неизменяема, каждое следующее изменение схемы — новый файл. Как с коммитами: историю не переписывают, добавляют новый коммит.

14листайте
Шаг 2 · Миграции

Заготовка и наш код

Что создал artisan

database/migrations/2026_10_17_102101_add_phone_to_users_table.phpphp
1<?php23use Illuminate\Database\Migrations\Migration;4use Illuminate\Database\Schema\Blueprint;5use Illuminate\Support\Facades\Schema;67return new class extends Migration8{9 /**10 * Run the migrations.11 */12 public function up(): void13 {14 Schema::table('users', function (Blueprint $table) {15 //16 });17 }1819 /**20 * Reverse the migrations.21 */22 public function down(): void23 {24 Schema::table('users', function (Blueprint $table) {25 //26 });27 }28};

Что написали мы

database/migrations/2026_10_17_102101_add_phone_to_users_table.phpphp
1<?php23use Illuminate\Database\Migrations\Migration;4use Illuminate\Database\Schema\Blueprint;5use Illuminate\Support\Facades\Schema;67return new class extends Migration8{9 /**10 * Run the migrations.11 */12 public function up(): void13 {14 Schema::table('users', function (Blueprint $table) {15 $table->string('phone', 32)->after('name');16 });17 }1819 /**20 * Reverse the migrations.21 */22 public function down(): void23 {24 Schema::table('users', function (Blueprint $table) {25 $table->dropColumn('phone');26 });27 }28};

Заготовка — анонимный класс (return new class extends Migration) с двумя пустыми замыканиями и комментариями // внутри. Мы заполняем оба:

  • up(): $table->string('phone', 32)->after('name') — колонка phone типа VARCHAR(32), обязательная, встать сразу после name. Длины 32 хватает с запасом: самый длинный телефон в нашем сидере — +7 (912) 345-67-89, 18 символов.
  • down(): $table->dropColumn('phone') — точная обратная операция. Без неё миграция необратима и откатить её не получится.

Методы $table->... — не SQL, а PHP-обёртка: один и тот же код Laravel превратит в ALTER TABLE для MariaDB, SQLite или PostgreSQL. Именно поэтому переезд с SQLite на MariaDB не потребовал переписать ни одного файла миграций.

15листайте
Шаг 2 · Миграции

Применяем и смотрим схему

student@mint: ~/recoursia/project-laravel811 мс
student@mint:~/recoursia/project-laravel$
Применяем миграцию с phone
Разбор команды

Применяем только что написанную миграцию с колонкой phone.

php artisan migrate
ТокенЗачем он и что меняет
php artisanCLI Laravel
migrateприменить файлы, которых ещё нет в журнале migrations; в отличие от migrate:fresh ничего не удаляет и не пересоздаёт

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

Выполнен один файл — 2026_10_17_102101_add_phone_to_users_table, 48.72ms DONE. Три миграции каркаса не тронуты.

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

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

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

  • Если migrate «ничего не делает», файл уже есть в журнале — проверьте php artisan migrate:status.
student@mint: ~/recoursia/project-laravel465 мс
student@mint:~/recoursia/project-laravel$
Схема users после миграции
Разбор команды

Проверяем, что колонка phone действительно появилась в схеме таблицы.

mysql -u project_laravel -psecret project_laravel -e "DESCRIBE users;"
ТокенЗачем он и что меняет
mysqlконсольный клиент MariaDB
-u project_laravelпользователь приложения, которого мы создали; не root — проверка, что прав на одну базу хватает
-psecretпароль слитно с флагом: у -p значение пишется без пробела. Учебный приём — на общей машине так делать не стоит
project_laravelпозиционный аргумент: базу открыть сразу после подключения, чтобы SHOW/SELECT работали без USE
-eфлаг: выполнить SQL из следующего аргумента и выйти
"DESCRIBE users;"схема таблицы: Field, Type, Null, Key, Default, Extra

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

Девять колонок: между name и email встала phone varchar(32) с NO в столбце Null.

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

Порядок колонок подтверждает ->after('name') из миграции. Обязательная колонка добавилась без DEFAULT, потому что в таблице сейчас ноль строк — нечего заполнять.

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

  • На заполненной таблице обязательная колонка без значения по умолчанию так не добавится: старым записям нечего положить в новое поле — нужен DEFAULT или заполнение существующих строк отдельным шагом.

migrate запустил ровно один файл — наш, за 48.72ms. Три миграции каркаса он не трогал: они уже в журнале. Это и есть обычный рабочий цикл — добавили файл, выполнили migrate, ничего лишнего не пересоздалось.

DESCRIBE users теперь показывает девять колонок: phone varchar(32), NO в столбце Null, стоит третьей — сразу после name. Сработал ->after('name').

NOT NULL и пустая таблица

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

16листайте
Шаг 2 · Миграции

Миграцию можно откатить

student@mint: ~/recoursia/project-laravel1178 мс
student@mint:~/recoursia/project-laravel$
>
>
Откат и повтор: миграции обратимы — rollback --step=1 отменяет последнюю миграцию; колонка phone исчезает; повторный migrate возвращает её.
Разбор команды

Показываем, что миграция обратима: откатываем последний батч, проверяем, что колонки нет, и применяем миграцию снова.

php artisan migrate:rollback --step=1
ТокенЗачем он и что меняет
php artisanCLI Laravel
migrate:rollbackвыполнить down() у применённых миграций — отменить изменение схемы
--step=1сколько последних батчей откатить; 1 — последний. Совпадает с поведением по умолчанию, но делает намерение явным

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

Rolling back migrations: файл 2026_10_17_102101_add_phone_to_users_table откатился за 53.27ms, DONE.

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

Откатился именно наш файл — последний батч. Каркасные миграции остались применёнными.

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

  • Откат на живой базе уничтожает данные колонки вместе с ней: dropColumn('phone') — это потеря телефонов. На рабочем сервере откатывают редко и имея резервную копию.
mysql -u project_laravel -psecret project_laravel -e "SHOW COLUMNS FROM users LIKE 'phone';"
ТокенЗачем он и что меняет
mysqlконсольный клиент MariaDB
-u project_laravelпользователь приложения
-psecretпароль слитно с флагом -p
project_laravelбаза, которую открыть при подключении
-e "SHOW COLUMNS FROM users LIKE 'phone';"запрос колонок таблицы с фильтром по имени: если колонки нет, запрос не вернёт ни одной строки

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

Вывода нет вообще: между двумя блоками INFO в захвате пусто. Колонки phone больше нет — down() отработал.

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

Пустой вывод — тоже вывод. SHOW COLUMNS печатает заголовок таблицы только тогда, когда есть что печатать; тишина означает «не нашлось».

php artisan migrate
ТокенЗачем он и что меняет
php artisanCLI Laravel
migrateприменить файлы, которых нет в журнале: после отката наш файл снова стал неприменённым

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

Миграция 2026_10_17_102101_add_phone_to_users_table применилась заново за 43.53ms, DONE.

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

Круг замкнулся: колонка вернулась. Миграция — обратимая операция, а не «одноразовый SQL по памяти».

В захвате три команды подряд, и читать их надо вместе с выводом:

  1. php artisan migrate:rollback --step=1 — отменил последний батч: Rolling back migrations, наш файл, 53.27ms DONE.
  2. mysql ... -e "SHOW COLUMNS FROM users LIKE 'phone';" — между двумя блоками INFO в выводе пусто. Это и есть ответ: запрос не вернул ни одной строки, колонки phone больше нет. down() отработал.
  3. php artisan migrate — применил миграцию заново, 43.53ms DONE.

Пустой вывод — тоже вывод. SHOW COLUMNS ... LIKE печатает заголовок таблицы только тогда, когда есть что печатать; тишина означает «не нашлось».

Откат на живой базе

dropColumn('phone') уничтожает и данные колонки. На учебной базе это демонстрация обратимости; на базе с реальными пользователями откат — потеря телефонов. Поэтому down() пишут честно, но на рабочем сервере откатывают редко и только имея резервную копию.

17листайте
Шаг 3 · Модель и Eloquent

Модель User: класс, который описывает строку таблицы

app/Models/User.phpphp
1<?php23namespace App\Models;45// use Illuminate\Contracts\Auth\MustVerifyEmail;6use Database\Factories\UserFactory;7use Illuminate\Database\Eloquent\Attributes\Fillable;8use Illuminate\Database\Eloquent\Attributes\Hidden;9use Illuminate\Database\Eloquent\Factories\HasFactory;10use Illuminate\Foundation\Auth\User as Authenticatable;11use Illuminate\Notifications\Notifiable;1213#[Fillable(['name', 'phone', 'email', 'password'])]14#[Hidden(['password', 'remember_token'])]15class User extends Authenticatable16{17 /** @use HasFactory<UserFactory> */18 use HasFactory, Notifiable;1920 /**21 * Get the attributes that should be cast.22 *23 * @return array<string, string>24 */25 protected function casts(): array26 {27 return [28 'email_verified_at' => 'datetime',29 'password' => 'hashed',30 ];31 }32}

В блоке ООП (lesson.oop.heroes.06) вы превращали массив данных в объект: класс со свойствами и методами. Модель — то же самое. Один объект User — одна строка таблицы users; вызовы вида User::create(...) и User::where(...) — запросы ко всей таблице.

Связь с таблицей Laravel находит по соглашению: класс User в пространстве имён App\Models → таблица users. Ни имя таблицы, ни подключение указывать не нужно.

Что здесь важно:

  • #[Fillable(['name', 'phone', 'email', 'password'])] — белый список полей, которые разрешено заполнять одним массивом. Поля вне списка при массовом присваивании игнорируются.
  • #[Hidden(['password', 'remember_token'])] — эти поля не попадут в массив или JSON, если модель сериализовать: хеш пароля не должен утекать в ответе.
  • casts() — преобразования значений: email_verified_at читается как дата, а password при записи хешируется ('hashed').
  • extends Authenticatable — базовый класс для пользователей, которых можно аутентифицировать по паролю.

Про mass assignment и #[Fillable] подробно поговорим в части 5, когда данные придут из HTML-формы.

18листайте
Шаг 3 · Модель и Eloquent

Пробуем модель в tinker

student@mint: ~/recoursia/project-laravel1185 мс
student@mint:~/recoursia/project-laravel$
Создаём запись через Eloquent
Разбор команды

Пробуем модель в деле: создаём запись через Eloquent и смотрим, какой первичный ключ она получила.

php artisan tinker --execute='$u = App\Models\User::create(["name" => "Тестов Тест Тестович", "phone" => "+7 900 000-00-00", "email" => "test@example.com", "password" => "secret"]); echo "id=" . $u->id, PHP_EOL;'
ТокенЗачем он и что меняет
php artisanCLI Laravel
tinkerинтерактивная консоль внутри загруженного приложения: .env прочитан, соединение с базой установлено, все классы доступны
--execute='...'выполнить код одной строкой и выйти вместо запуска интерактивной сессии: проверку можно повторить и снять вывод
'...'одинарные кавычки shell вокруг кода PHP. Именно одинарные: $u и $u->id не раскрываются как переменные shell и доезжают до PHP как есть
$u =переменная PHP, в неё попадает созданная модель
App\Models\User::create([...])статический вызов модели: массив полей превращается в INSERT. Двойные кавычки внутри — строки PHP, их shell не трогает из-за внешних одинарных
["name" => ..., "phone" => ..., "email" => ..., "password" => ...]ассоциативный массив «колонка => значение»; все четыре поля есть в #[Fillable], поэтому все четыре и записались
;разделитель выражений PHP внутри одной строки
->обращение к свойству объекта: $u->id — первичный ключ вставленной строки
echo "id=" . $u->id, PHP_EOL;вывод результата: . — конкатенация строк, запятая передаёт echo второй аргумент, PHP_EOL — перевод строки

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

Печатает id=1: запись вставлена, объект знает свой первичный ключ (в курсе вы получали его вручную через PDO::lastInsertId()).

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

created_at и updated_at модель проставила сама — в массиве мы их не передавали. Пароль ушёл в базу не строкой secret, а bcrypt-хешем: сработал каст 'password' => 'hashed'.

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

  • В двойных кавычках shell раскрыл бы $u до запуска PHP, и код приехал бы поломанным: для --execute берите одинарные кавычки.
  • Поля вне #[Fillable] при массовом присваивании молча игнорируются — запись создастся, но не со всеми значениями.

php artisan tinker запускает интерактивную консоль внутри приложения: Laravel загружен, .env прочитан, соединение с базой установлено, все классы доступны. Флаг --execute='...' выполняет одну строку и выходит — так удобнее и писать проверки, и снимать вывод.

Что происходит в этой строке:

  • App\Models\User::create([...]) — статический вызов модели. Массив из четырёх полей превращается в INSERT. Все четыре есть в #[Fillable], поэтому все четыре и записались.
  • echo "id=" . $u->id — после вставки объект знает свой первичный ключ: id=1. В курсе вы получали его вручную через PDO::lastInsertId().
  • created_at и updated_at модель проставила сама: в DESCRIBE users эти колонки есть, а в массиве мы их не передавали.
19листайте
Шаг 3 · Модель и Eloquent

В базе лежит не пароль, а его хеш

student@mint: ~/recoursia/project-laravel846 мс
student@mint:~/recoursia/project-laravel$
В базе лежит хеш, а не пароль — Это то, чего не хватало в уроке http.iii.02, где пароль уходил в POST открытым текстом.
Разбор команды

Смотрим, что реально лежит в колонке password после создания записи.

php artisan tinker --execute='echo App\Models\User::where("email", "test@example.com")->value("password"), PHP_EOL;'
ТокенЗачем он и что меняет
php artisan tinkerконсоль внутри загруженного приложения
--execute='...'одна строка PHP и выход; одинарные кавычки защищают код от подстановок shell
App\Models\User::where("email", "test@example.com")условие выборки: колонка и значение; в SQL превратится в prepared statement с плейсхолдером
->value("password")вернуть значение одной колонки первой подходящей строки, а не объект модели
echo ..., PHP_EOL;напечатать значение и перевод строки

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

Строка из 60 символов: $2y$12$X8CYf5xpmRVEpuuKp0Et.ehSF7Ic0OMgNYLQTA/JFBKCRfe3X2YWu — хеш, а не переданный нами secret.

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

Префикс $2y$ — идентификатор алгоритма bcrypt, 12 — стоимость (число раундов) из BCRYPT_ROUNDS=12 в .env, дальше соль и результат в одной строке. Хеш необратим: из него нельзя получить пароль, можно только перебирать варианты.

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

  • Открытый пароль в колонке означает, что каст 'password' => 'hashed' в модели не сработал или значение писали в обход модели (например, строкой SQL).

Мы передали 'password' => 'secret', а запрос value('password') вернул строку из 60 символов. Разбираем префикс $2y$12$:

  • $2y$ — идентификатор алгоритма bcrypt;
  • 12 — стоимость, то есть число раундов; ровно столько задано в .env строкой BCRYPT_ROUNDS=12;
  • дальше — соль и результат в одной строке.

Хеширование сделал каст 'password' => 'hashed' из casts() модели: значение превращается в хеш в момент присваивания, до INSERT. В SQL-запрос открытый пароль не попадает.

Зачем это нужно. Хеш необратим: из $2y$12$... нельзя получить secret, можно только перебирать варианты и сравнивать. Стоимость 12 делает каждый перебор заметно дороже, чем «просто быстрый хеш». Если база утечёт, злоумышленник получит хеши, а не пароли, которыми люди пользуются на других сайтах.

В lesson.http.iii.02 вы собирали форму регистрации, и ведущий отдельно отметил: пароль уходит в POST открытым текстом. Открытым он и должен ехать — его защищает транспорт (HTTPS), — но храниться в базе в таком виде он не должен никогда. А #[Hidden] не даёт хешу вернуться обратно в HTML или JSON.

20листайте
Шаг 3 · Модель и Eloquent

Что Eloquent на самом деле отправляет в MariaDB

student@mint: ~/recoursia/project-laravel871 мс
student@mint:~/recoursia/project-laravel$
Какой SQL на самом деле выполняет Eloquent — Видно prepared statement с плейсхолдером — та же защита, что вы разбирали в SQL-инъекциях.
Разбор команды

Смотрим, какой SQL Eloquent на самом деле отправляет в MariaDB.

php artisan tinker --execute='DB::enableQueryLog(); App\Models\User::where("email", "like", "%example%")->get(); print_r(DB::getQueryLog());'
ТокенЗачем он и что меняет
php artisan tinker --execute='...'одна строка PHP внутри загруженного приложения; одинарные кавычки не дают shell раскрыть $ и подставить свои значения
DB::enableQueryLog();включить журнал запросов текущего соединения — по умолчанию он выключен, и getQueryLog() вернул бы пустой массив
App\Models\User::where("email", "like", "%example%")условие: колонка, оператор LIKE, шаблон со знаком % (любое число любых символов)
->get();выполнить запрос и получить коллекцию моделей
print_r(DB::getQueryLog());напечатать журнал: массив записей с ключами query, bindings, time и readWriteType

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

Один запрос: select * from users where email like ? со значением %example% в bindings; время выполнения — 2.71 ms.

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

Значения внутри строки запроса нет: на его месте ?, а само значение едет рядом, в bindings. Это prepared statement — то же, что вы делали руками через prepare() и execute(). Обратные кавычки вокруг users и email — способ SQL отличать имена таблиц и колонок от значений и служебных слов.

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

  • Журнал живёт только внутри текущего процесса: в веб-запросе его нужно включать заново.
  • Параметром можно подставить только значение: имя колонки, имя таблицы и направление сортировки через ? не передаются, поэтому orderBy() со значением из адресной строки остаётся дырой — нужен белый список.

DB::enableQueryLog() включает журнал запросов, DB::getQueryLog() его печатает. Один вызов User::where('email', 'like', '%example%')->get() дал один запрос — select * from `users` where `email` like ? — и отдельно массив bindings со значением %example%. Время выполнения — 2.71 ms.

Главное: значения внутри строки запроса нет. На его месте ?, а само значение едет рядом, в bindings. Это prepared statement — ровно то, что вы делали руками в lesson.db.04: шаблон запроса с маркерами, prepare() и execute() с параметрами. Laravel не отменяет эту защиту, он забирает себе рутину.

Обратные кавычки вокруг users и email — тот самый «gravis» из lesson.db.04: так SQL отличает имена таблиц и колонок от значений и от служебных слов.

Что prepared statement не защищает

Параметром можно подставить только значение. Имя колонки, имя таблицы и направление сортировки (ASC/DESC) через ? не передаются. Именно поэтому в lesson.sql-injection.i.01 инъекция жила в параметре sort, а защитой стал белый список полей с проверкой через in_array (lesson.sql-injection.i.03). В Laravel правило то же: orderBy() со значением, которое пришло прямо из адресной строки, — дыра; orderBy() со значением, проверенным по белому списку, — нет.

21листайте
Шаг 3 · Модель и Eloquent

Убираем тестовую запись

student@mint: ~/recoursia/project-laravel901 мс
student@mint:~/recoursia/project-laravel$
Убираем тестовую запись
Разбор команды

Убираем тестовую запись и заодно проверяем, сколько строк осталось в таблице.

php artisan tinker --execute='App\Models\User::where("email", "test@example.com")->delete(); echo "осталось: " . App\Models\User::count(), PHP_EOL;'
ТокенЗачем он и что меняет
php artisan tinker --execute='...'одна строка PHP внутри приложения и выход
App\Models\User::where("email", "test@example.com")условие: какую именно запись удалять
->delete();DELETE FROM users WHERE email = ? для строк, подходящих под условие
App\Models\User::count()SELECT count(*): число строк в таблице
echo "осталось: " . ..., PHP_EOL;вывод результата; кириллица внутри строки PHP проходит без проблем — база в utf8mb4

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

Печатает «осталось: 0» — таблица снова пуста, дальше наполним её сидером.

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

Запись с id=1 удалена, но следующая вставка получит id=2: AUTO_INCREMENT не возвращает освободившиеся номера — иначе удалённая запись и новая стали бы неразличимы в логах и ссылках.

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

  • delete() без условия where() удалил бы все строки таблицы.

where(...)->delete() — это DELETE FROM users WHERE email = ?, а count() — SELECT count(*). Осталось 0: таблица снова пуста, дальше наполним её сидером.

Заодно заметьте деталь, которая объяснит следующий вывод: запись с id=1 удалена, но следующая вставка получит id=2. AUTO_INCREMENT не возвращает освободившиеся номера — иначе удалённая запись и новая стали бы неразличимы в логах, ссылках и внешних системах.

22листайте
Шаг 4 · Сидер

Сидер: данные для разработки одной командой

student@mint: ~/recoursia/project-laravel709 мс
student@mint:~/recoursia/project-laravel$
Генерируем сидер
Разбор команды

Для пагинации нужны данные: генерируем класс-сидер.

php artisan make:seeder UsersSeeder
ТокенЗачем он и что меняет
php artisanCLI Laravel
make:seederгенератор файла сидера в database/seeders
UsersSeederимя класса: artisan создаёт файл UsersSeeder.php с заготовкой и методом run()

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

Создан файл database/seeders/UsersSeeder.php: INFO Seeder [database/seeders/UsersSeeder.php] created successfully.

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

run() — единственный метод, который вызывает db:seed; всё наполнение базы живёт в нём.

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

  • Имя класса и имя файла должны совпадать: db:seed --class=... ищет класс по имени.

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

php artisan make:seeder UsersSeeder создал файл database/seeders/UsersSeeder.php. Приём вам знаком: в lesson.db.04 вы вставляли 42 ресторана циклом foreach по массиву из парсера. Сидер — тот же цикл, только он лежит в проекте и вызывается командой db:seed.

23листайте
Шаг 4 · Сидер

UsersSeeder: 12 записей для практики

database/seeders/UsersSeeder.phpphp
1<?php23namespace Database\Seeders;45use App\Models\User;6use Illuminate\Database\Seeder;78class UsersSeeder extends Seeder9{10 public function run(): void11 {12 $rows = [13 ['Иванов Иван Иванович', '+7 (912) 345-67-89', 'ivanov@example.com'],14 ['Петрова Анна Сергеевна', '+7 (903) 111-22-33', 'petrova@example.com'],15 ['Сидоров Пётр Алексеевич', '+7 (926) 555-44-33', 'sidorov@example.com'],16 ['Кузнецова Мария Дмитриевна', '+7 (916) 200-10-20', 'kuznetsova@example.com'],17 ['Смирнов Алексей Олегович', '+7 (905) 777-88-99', 'smirnov@example.com'],18 ['Васильева Ольга Игоревна', '+7 (921) 444-55-66', 'vasilieva@example.com'],19 ['Михайлов Дмитрий Сергеевич', '+7 (999) 123-45-67', 'mikhailov@example.com'],20 ['Фёдорова Елена Андреевна', '+7 (911) 987-65-43', 'fedorova@example.com'],21 ['Николаев Павел Викторович', '+7 (902) 314-15-92', 'nikolaev@example.com'],22 ['Егорова Татьяна Максимовна', '+7 (913) 271-82-81', 'egorova@example.com'],23 ['Соколов Артём Романович', '+7 (925) 161-80-33', 'sokolov@example.com'],24 ['Морозова Дарья Кирилловна', '+7 (906) 646-46-46', 'morozova@example.com'],25 ];2627 foreach ($rows as [$name, $phone, $email]) {28 User::create([29 'name' => $name,30 'phone' => $phone,31 'email' => $email,32 // каст 'password' => 'hashed' в модели сам превратит это в bcrypt-хеш33 'password' => 'secret',34 ]);35 }36 }37}

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

  • run() — единственный метод, который вызывает db:seed; всё наполнение живёт в нём.
  • $rows — массив из 12 массивов: ФИО, телефон, email. Данные учебные, все адреса в домене example.com.
  • foreach ($rows as [$name, $phone, $email]) — деструктуризация: три переменные достаются из массива прямо в заголовке цикла.
  • User::create([...]) — вставка через модель, а не строкой SQL. Значит, работают и белый список #[Fillable], и касты, и метки времени.
  • 'password' => 'secret' — пароль один на всех. В базе он станет bcrypt-хешем (каст 'hashed'), но в настоящем проекте так не делают: у каждого пользователя свой пароль. Для учебной базы этого достаточно.

email в таблице уникальный (UNI в DESCRIBE users), поэтому второй запуск сидера поверх существующих данных нарушит ограничение уникальности, и база вставку отклонит. Начинают заново обычно так: migrate:fresh, а затем сидер.

24листайте
Шаг 4 · Сидер

Запускаем и смотрим, что попало в базу

student@mint: ~/recoursia/project-laravel4730 мс
student@mint:~/recoursia/project-laravel$
Наполняем базу
Разбор команды

Запускаем написанный сидер и наполняем базу учебными записями.

php artisan db:seed --class=UsersSeeder
ТокенЗачем он и что меняет
php artisanCLI Laravel
db:seedкоманда наполнения базы; без параметров запускает общий DatabaseSeeder
--class=UsersSeederкакой именно сидер выполнить: без флага наш UsersSeeder не вызвался бы, потому что его нет в DatabaseSeeder

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

Database\Seeders\UsersSeeder — RUNNING, затем DONE за 3,973 ms.

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

Время съедают не вставки, а 12 вычислений bcrypt со стоимостью 12: хеш намеренно дорогой, иначе его было бы дёшево перебирать.

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

  • Второй запуск сидера поверх существующих данных нарушит ограничение уникальности email (UNI в DESCRIBE users) — база вставку отклонит. Начинают заново так: migrate:fresh, затем сидер.
student@mint: ~/recoursia/project-laravel386 мс
student@mint:~/recoursia/project-laravel$
Данные в таблице users
Разбор команды

Смотрим, что именно сидер положил в таблицу users.

mysql -u project_laravel -psecret project_laravel -e "SELECT id, name, phone, email, LEFT(password, 7) AS pass FROM users ORDER BY id LIMIT 6;"
ТокенЗачем он и что меняет
mysqlконсольный клиент MariaDB
-u project_laravelпользователь приложения
-psecretпароль слитно с флагом -p, без пробела
project_laravelбаза, которую открыть при подключении
-eфлаг: выполнить SQL из следующего аргумента и выйти
"..."весь запрос — один аргумент в двойных кавычках: точка с запятой внутри разделяет части SQL, а не команды shell
SELECT id, name, phone, emailколонки, которые нам интересны: ключ, ФИО, телефон и адрес
LEFT(password, 7)первые семь символов хеша: префикс алгоритма и стоимость — весь хеш в терминал не тащим намеренно
AS passимя колонки в выводе для результата LEFT()
FROM usersтаблица, из которой читаем
ORDER BY idсортировка по первичному ключу: строки в порядке вставки, а не «как вернула база»
LIMIT 6первые шесть строк из двенадцати

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

Шесть строк: id от 2 до 7, кириллические ФИО, телефоны вида +7 (912) 345-67-89 (18 символов), адреса в домене example.com, pass у всех $2y$12$.

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

id начинается с 2, а не с 1: единицу заняла удалённая тестовая запись, AUTO_INCREMENT её не вернул. Кириллица в name сохранилась как есть — работает utf8mb4, заданный при создании базы.

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

  • Без ORDER BY MariaDB возвращает строки в порядке, который ничем не гарантирован: сравнивать выводы двух запусков станет неудобно.

Сидер отработал за 3,973 ms — и это не вставка такая медленная. Время съедают 12 вычислений bcrypt со стоимостью 12: хеш намеренно дорогой, иначе его было бы дёшево перебирать.

Что видно в первых шести строках из двенадцати:

  • id начинается с 2, а не с 1: единицу заняла удалённая тестовая запись, AUTO_INCREMENT её не вернул — ровно то, о чём мы говорили перед сидером;
  • кириллица в name сохранилась как есть: работает utf8mb4, который мы задали при создании базы;
  • телефоны вида +7 (912) 345-67-89 — 18 символов, в varchar(32) помещаются с запасом;
  • LEFT(password, 7) у всех строк даёт $2y$12$ — префикс алгоритма и стоимость. Полные хеши в вывод не тащим намеренно.
25листайте
Шаг 5 · Список из базы

Контроллер читает из базы, а не из массива

app/Http/Controllers/UserController.phpphp
1<?php23namespace App\Http\Controllers;45use App\Models\User;67class UserController extends Controller8{9 public function index()10 {11 $users = User::query()->orderBy('name')->paginate(5);1213 return view('users.index', ['users' => $users]);14 }15}

От части 3 изменилась одна строка: вместо массива в коде — запрос к модели.

  • User::query() — начать построитель запросов. Можно писать сразу User::orderBy(...), но query() делает намерение явным и ровно читается в цепочке.
  • orderBy('name') — ORDER BY name asc. Без сортировки MariaDB возвращает строки в порядке, который ничем не гарантирован, и страницы пагинации будут перемешиваться.
  • paginate(5) — вернуть не все строки, а страницу из пяти. Номер страницы пагинатор берёт сам из ?page= в адресной строке. В lesson.http.i.05 вы делали это руками: $_GET['page'], offset = (page - 1) * 50, подстановка в LIMIT.
  • view('users.index', ['users' => $users]) — тот же шаблон и то же имя переменной, что в части 3. Шаблону всё равно, откуда приехали данные: из массива или из базы. Это и есть разделение слоёв MVC.
26листайте
Шаг 5 · Список из базы

Шаблон: объект вместо массива

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 </tr>15 </thead>16 <tbody>17 @forelse ($users as $user)18 <tr>19 <td>{{ $user->name }}</td>20 <td>{{ $user->phone }}</td>21 <td>{{ $user->email }}</td>22 </tr>23 @empty24 <tr>25 <td colspan="3">Пока никого нет — добавьте первого</td>26 </tr>27 @endforelse28 </tbody>29 </table>3031 <div class="pager">32 @if ($users->onFirstPage())33 <span>← назад</span>34 @else35 <a href="{{ $users->previousPageUrl() }}">← назад</a>36 @endif37 <span class="current">{{ $users->currentPage() }} / {{ $users->lastPage() }}</span>38 @if ($users->hasMorePages())39 <a href="{{ $users->nextPageUrl() }}">вперёд →</a>40 @else41 <span>вперёд →</span>42 @endif43 </div>44@endsection

Каркас тот же, что в части 3: @extends('layouts.app'), @section('content'), {{ }} с автоматическим экранированием. Изменилось то, что связано с источником данных:

  • {{ $users->total() }} вместо count($users): $users теперь объект-пагинатор, внутри него только пять строк текущей страницы, а total() знает обо всех записях;
  • @forelse ($users as $user) перебирает страницу, а не всю таблицу; ветка @empty рисует строку-заглушку, если записей нет;
  • {{ $user->name }} — обращение к свойству модели. Под капотом это значение колонки из строки таблицы, но для шаблона выглядит как обычное свойство объекта из блока ООП;
  • ниже таблицы — блок .pager, собранный из методов пагинатора.
27листайте
Шаг 5 · Список из базы

Что мы спрашиваем у пагинатора

GET /users?page=2GET/users?page=2paginate(5) — объект-пагинаторpaginate(5)объект-пагинаторMariaDB — база project_laravelMariaDBбаза project_laravel12 всего, 5 строк — ответ на два запроса12 всего, 5строкответ на два запросаШаблон — @forelse ($users as $user)Шаблон@forelse ($users as$user)Блок .pager — «← назад · 2 / 3 · вперёд →»Блок .pager«← назад · 2 / 3 ·вперёд →»?page=22 запросаCOUNT и SELECT5 строкtotal(), lastPage()?page=22 запросаCOUNT и SELECT5 строкtotal(), lastPage()GET /users?page=2GET/users?page=2paginate(5) — объект-пагинаторpaginate(5)MariaDB — база project_laravelMariaDB12 всего, 5 строк — ответ на два запроса12 всего, 5строкШаблон — @forelse ($users as $user)ШаблонБлок .pager — «← назад · 2 / 3 · вперёд →»Блок .pager
Путь одной страницы: от ?page=2 до разметки .pager

Стили для .pager написаны в части 3 в public/css/app.css. Сама разметка последнего узла собрана из шести методов пагинатора:

Метод Что возвращает Где у нас используется
total() сколько всего записей (12) в заголовке
currentPage() номер текущей страницы 1 в подписи «1 / 3»
lastPage() номер последней страницы 3 в подписи «1 / 3»
onFirstPage() true, если страница первая «← назад» без ссылки
hasMorePages() true, если есть следующая «вперёд →» ссылкой
previousPageUrl(), nextPageUrl() готовый URL с ?page= href ссылок
28листайте
Шаг 5 · Список из базы

Почему разметка пагинации своя

lastPage() — это ваш ceil($count / $perPage) из lesson.http.ii.05, только считает его пагинатор сам: 12 записей по 5 на страницу → 3 страницы, последняя неполная.

У пагинатора есть готовый метод links(), который рисует ссылки на все страницы одной строкой. Мы его не используем: links() приносит собственную HTML-разметку, рассчитанную на классы Tailwind CSS, а у нас свои стили и свой класс .pager. Поэтому разметку «назад · 1 / 3 · вперёд» собираем сами из методов таблицы: их поведение предсказуемо, а внешний вид полностью наш.

29листайте
Шаг 5 · Список из базы

Одна страница = два запроса

student@mint: ~/recoursia/project-laravel901 мс
student@mint:~/recoursia/project-laravel$
Два запроса пагинации: данные и COUNT(*)
Разбор команды

Смотрим, сколько запросов на самом деле делает одна страница пагинации.

php artisan tinker --execute='DB::enableQueryLog(); App\Models\User::query()->orderBy("name")->paginate(5); foreach (DB::getQueryLog() as $q) { echo $q["query"], " | ", json_encode($q["bindings"]), PHP_EOL; }'
ТокенЗачем он и что меняет
php artisan tinker --execute='...'одна строка PHP внутри загруженного приложения; одинарные кавычки не дают shell раскрыть $q
DB::enableQueryLog();включить журнал запросов до выполнения кода — иначе он пуст
App\Models\User::query()начать построитель запросов к модели User
->orderBy("name")сортировка: в SQL станет order by name asc. Без неё MariaDB возвращает строки в негарантированном порядке и страницы пагинации будут перемешиваться
->paginate(5);вернуть страницу из пяти записей; номер страницы пагинатор берёт из ?page= в адресной строке (в консоли её нет, поэтому страница первая)
foreach (DB::getQueryLog() as $q)обход журнала: каждый элемент — массив с ключами query, bindings, time и readWriteType
{ echo $q["query"], " | ", json_encode($q["bindings"]), PHP_EOL; }тело цикла: напечатать строку SQL, разделитель и значения плейсхолдеров
$q["query"]строка запроса, которую Laravel отправил в базу
json_encode($q["bindings"])превратить массив значений в читаемую строку: пустой массив станет []

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

Два запроса: select count(*) as aggregate from users и select * from users order by name asc limit 5 offset 0; bindings пусты в обоих случаях — [].

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

Первый запрос даёт пагинатору общее число записей: из него lastPage() и наличие следующей страницы. Второй — сами строки: пять записей, начиная с нулевого смещения. Для ?page=2 второй запрос пойдёт с offset 5.

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

  • orderBy() со значением, пришедшим прямо из адресной строки, — дыра: имя колонки и направление сортировки через плейсхолдер не передаются, нужен белый список полей.

Тот же приём с журналом запросов: paginate(5) выполнил ровно два запроса.

  1. select count(*) as `aggregate` from `users` — сколько всего строк. Без этого числа пагинатор не знает ни lastPage(), ни есть ли следующая страница.
  2. select * from `users` order by `name` asc limit 5 offset 0 — сами данные: пять строк, начиная с нулевого смещения.

bindings пусты в обоих случаях: ни сортировка, ни размер страницы не пришли от пользователя, подставлять нечего. Для ?page=2 второй запрос пойдёт с offset 5.

Именно эту пару вы собирали руками в lesson.http.ii.05: SELECT COUNT(...) для числа страниц и LIMIT с OFFSET для среза. Laravel не придумал новый способ — он спрятал арифметику внутрь объекта.

30листайте
Шаг 5 · Список из базы

Проверяем через HTTP

student@mint: ~/recoursia/project-laravel535 мс
student@mint:~/recoursia/project-laravel$
HTML списка из базы
Разбор команды

Проверяем страницу списка через HTTP — тот же путь, который проходит браузер: DNS зоны .rs, nginx, PHP-FPM, Laravel, MariaDB.

curl -sS http://project-laravel.rs/users | sed -n "12,40p"
ТокенЗачем он и что меняет
curlHTTP-клиент: делает запрос и печатает тело ответа в stdout
-sтихий режим: без прогресс-бара, который в пайпе только мешал бы
-Sно показывать ошибки — флаг работает в паре с -s: без него curl промолчал бы даже при сбое соединения
http://project-laravel.rs/usersURL: домен зоны .rs из dnsmasq и маршрут приложения; ресурс users при переименовании проекта не меняется
|пайп: тело ответа уходит следующей команде, а не в терминал
sedпотоковый редактор: обрабатывает строки текста по заданному выражению
-nне печатать строки по умолчанию — выводим только то, что попросили явно
"12,40p"выражение: напечатать строки с 12-й по 40-ю. Кавычки защищают выражение от shell

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

HTML страницы: навигация со ссылками «Список» и «Добавить», открывающий

, заголовок

Пользователи (12)

, таблица с колонками ФИО, Телефон, Email и первые строки — Васильева, Егорова, Иванов.

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

(12) в заголовке — total() пагинатора, результат COUNT(*). Порядок по алфавиту подтверждает orderBy('name') вместе с collation utf8mb4_unicode_ci: кириллица сортируется по алфавиту, а не по кодам байтов. Вывод обрезан выражением sed, поэтому третья запись попала в кадр не целиком.

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

  • -sS печатает только тело ответа: по нему не отличить 200 от 404 или 500. Статус виден в заголовках (curl -i), а текст ошибки приложения — в storage/logs/laravel.log.
student@mint: ~/recoursia/project-laravel517 мс
student@mint:~/recoursia/project-laravel$
Вторая страница пагинации
Разбор команды

Проверяем вторую страницу пагинации и достаём из HTML только ячейки таблицы.

curl -sS "http://project-laravel.rs/users?page=2" | grep -o "<td>[^<]*</td>" | head -9
ТокенЗачем он и что меняет
curlHTTP-клиент
-sSтихий режим, но с выводом ошибок соединения
"http://project-laravel.rs/users?page=2"URL в двойных кавычках: ? — метасимвол подстановки имени файла, а & в URL с несколькими параметрами отправил бы команду в фон. Кавычки обязательны
|пайп: HTML уходит в grep
grepфильтрация строк по шаблону
-oпечатать не всю строку, а только совпавший фрагмент: каждая ячейка окажется на своей строке
"<td>[^<]*</td>"регулярное выражение: открывающий тег td, затем [^<]* — ноль и более любых символов, кроме < (содержимое ячейки без вложенных тегов), затем закрывающий тег
|пайп: найденные ячейки уходят в head
head -9первые девять строк вывода: три записи по три ячейки — ФИО, телефон, email

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

Девять ячеек — три записи: Морозова Дарья Кирилловна / +7 (906) 646-46-46 / morozova@example.com, Николаев Павел Викторович / +7 (902) 314-15-92 / nikolaev@example.com, Петрова Анна Сергеевна / +7 (903) 111-22-33 / petrova@example.com.

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

Это шестая, седьмая и восьмая фамилии по алфавиту — прямое продолжение первой страницы. Шаблон тот же, изменился только offset в запросе: для ?page=2 пагинатор берёт offset 5.

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

  • [^<]* не найдёт ячейку с вложенными тегами: если внутри появится ссылка или span, такая ячейка выпадет из вывода.
curl — http://project-laravel.rs/userscurlhttp://project-laravel.rs/usersDNS зоны .rs — 127.0.0.1DNS зоны .rs127.0.0.1nginx — vhost из части 2nginxvhost из части 2PHP-FPM — процесс без состоянияPHP-FPMпроцесс без состоянияLaravel — маршрут, контроллер, модельLaravelмаршрут, контроллер,модельMariaDB — база project_laravelMariaDBбаза project_laravelGET /users127.0.0.12 запросаHTMLGET /users127.0.0.12 запросаHTMLcurl — http://project-laravel.rs/userscurlDNS зоны .rs — 127.0.0.1DNS зоны .rsnginx — vhost из части 2nginxPHP-FPM — процесс без состоянияPHP-FPMLaravel — маршрут, контроллер, модельLaravelMariaDB — база project_laravelMariaDB
Путь запроса curl — тот же, что у браузера (lesson.network.02)

curl проходит весь этот путь и возвращается ответом HTML — ровно как браузер.

Что видно на первой странице:

  • <h1>Пользователи <span class="muted">(12)</span></h1> — total() вернул 12, это результат COUNT(*);
  • навигация и открывающий <main class="page"> — из макета layouts/app.blade.php (часть 3);
  • строки идут по алфавиту: Васильева, Егорова, Иванов — работает orderBy('name'), а collation utf8mb4_unicode_ci сортирует кириллицу по алфавиту, а не по кодам байтов;
  • сам вывод обрезан: команда sed -n "12,40p" показала строки 12–40 HTML, поэтому третья запись попала в кадр не целиком.

Вторая страница — ?page=2, а grep -o "<td>[^<]*</td>" | head -9 достал девять ячеек, то есть три записи: Морозова, Николаев, Петрова. Это шестая, седьмая и восьмая фамилии по алфавиту — прямое продолжение первой страницы. Шаблон тот же, изменился только offset в запросе.

31листайте
Шаг 5 · Список из базы

То же самое в браузере

http://project-laravel.rs/users
12 записей, по 5 на страницу; пагинация своя, на классах .pager.
12 записей, по 5 на страницу; пагинация своя, на классах .pager.
http://project-laravel.rs/users?page=2
Тот же шаблон, другой LIMIT в запросе.
Тот же шаблон, другой LIMIT в запросе.

Оба снимка — один маршрут и один шаблон, разница только в ?page=: 12 записей по 5 на страницу, то есть страница 1 из 3, а на второй — тот же шаблон и другой LIMIT в запросе. Пагинация своя, на классах .pager. Ссылка «← назад» на первой странице не кликабельна: onFirstPage() вернул true, и вместо <a> нарисован <span>.

32листайте
Шаг 5 · Список из базы

Приборная панель после переключения

student@mint: ~/recoursia/project-laravel782 мс
student@mint:~/recoursia/project-laravel$
php artisan db:show после переключения
Разбор команды

Та же приборная панель, что в начале части, — сравниваем соединение до переключения и после.

php artisan db:show
ТокенЗачем он и что меняет
php artisanCLI Laravel
db:showпоказать текущее соединение по умолчанию: драйвер, версию СУБД, базу, хост, порт, пользователя, число таблиц и их размеры

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

MariaDB 10.11.14, соединение mysql, база project_laravel, хост 127.0.0.1, порт 3306, пользователь project_laravel; таблиц 9, всего 304.00 KB, из них users — 64.00 KB.

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

Вместо пустых Host, Port и Username появились значения: приложение действительно ходит по TCP. Таблиц по-прежнему 9, но теперь у них есть размеры — MariaDB их отдаёт, а SQLite нет.

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

  • Если данные сохраняются, а вы их не видите (или наоборот), db:show показывает соединение, которое используется на самом деле: нередко проект смотрит не в ту базу или всё ещё в SQLite-файл.

Сравните с началом части. Вместо SQLite 3.45.1 и пустых Host/Port/ Username — MariaDB 10.11.14, 127.0.0.1, 3306, project_laravel, база project_laravel. Таблиц по-прежнему 9, но появились размеры: всего 304.00 KB, из них users — 64.00 KB.

Эта команда — первый ответ на вопрос «куда приложение вообще пишет?». Если данные сохраняются, а вы их не видите (или наоборот), db:show показывает соединение, которое используется на самом деле. Нередко оказывается, что проект смотрит не в ту базу или всё ещё в SQLite-файл.

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

Цепочка части 4: от .env до страницы

.env — DB_* → project_laravel.envDB_* → project_laravelМиграции каркаса — database/migrations/0001_*Миграции каркасаdatabase/migrations/0001_*Миграция phone — 2026_10_17_102101_add_phone…Миграция phone2026_10_17_102101_add_phone…Модель User — app/Models/User.phpМодель Userapp/Models/User.phpUsersSeeder — database/seeders/UsersSeederdatabase/seeders/UserController — app/Http/Controllers/UserControllerapp/Http/Controllers/users/index.blade.php — resources/views/users/users/index.blade.phpresources/views/users/не читается с дискаdb:showне читается с дискаSHOW TABLESне читается с дискаDESCRIBEне читается с дискаtinkerне читается с дискаdb:seedне читается с дискаquery logне читается с дискаcurl + снимкисоединение9 таблицphone varchar(32)User::create12 записей$users (пагинатор)соединение9 таблицphone varchar(32)User::create12 записей$users (пагинатор).env — DB_* → project_laravel.envМиграции каркаса — database/migrations/0001_*МиграциикаркасаМиграция phone — 2026_10_17_102101_add_phone…МиграцияphoneМодель User — app/Models/User.phpМодель UserUsersSeeder — database/seeders/UsersSeederUserController — app/Http/Controllers/UserControllerusers/index.blade.php — resources/views/users/users/index.blade.phpне читается с дискаdb:showне читается с дискаSHOW TABLESне читается с дискаDESCRIBEне читается с дискаtinkerне читается с дискаdb:seedне читается с дискаquery logне читается с дискаcurl + снимки
Слой → файл → что передаётся дальше; пилюля над узлом — чем проверено

Откат миграции phone проверен отдельно: migrate:rollback --step=1 и повторный migrate.

В числах: 9 таблиц, 12 записей, 3 страницы по 5 записей (последняя неполная), 2 запроса на страницу, 60 символов в хеше пароля, 304 KB на всю базу.

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

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

  1. Почему приложение не может подключиться к MariaDB как root, хотя в терминале sudo mysql пускает без пароля?
  2. Чем php artisan migrate:fresh отличается от php artisan migrate, и какая из этих команд уничтожает данные?
  3. Колонку phone можно было добавить прямо в миграцию create_users_table. Почему мы вместо этого создали новый файл?
  4. В create([...]) пароль передали как 'secret', а в таблице лежит $2y$12$.... Кто и в какой момент превратил одно в другое?
  5. paginate(5) выполняет два запроса. Какие это запросы и зачем нужен второй?
35листайте
Итог

Ответы

  1. Учётка root@localhost аутентифицируется плагином unix_socket: сервер проверяет, что соединение пришло через локальный сокет и что ОС-пользователь — root. Приложение соединяется по TCP 127.0.0.1:3306, там проверять нечего — ERROR 1698. Нужен отдельный пользователь с паролем.
  2. migrate применяет только те файлы, которых ещё нет в журнале migrations; migrate:fresh сначала удаляет все таблицы текущего соединения, а затем прогоняет миграции заново. Данные уничтожает вторая команда.
  3. Применённая миграция уже записана в журнал: на вашей машине правка ничего не изменит, на чистой — изменит, и схемы разойдутся при одинаковом коде. История схемы неизменяема, как история коммитов.
  4. Каст 'password' => 'hashed' в методе casts() модели: значение хешируется bcrypt при присваивании, до INSERT. Стоимость задаёт BCRYPT_ROUNDS=12 из .env.
  5. select count(*) as `aggregate` from `users` — общее число записей, из него пагинатор знает lastPage() и наличие следующей страницы; и select * from `users` order by `name` asc limit 5 offset 0 — сами пять строк текущей страницы.
36листайте
Что дальше

Что дальше

База подключена, схема описана кодом, данные есть, список показывается страницами. Но приложение пока умеет только читать: добавить, поправить и удалить пользователя нельзя. В макете есть ссылка «Добавить» на /users/create (её видно в HTML выше), а маршрута за ней пока нет.

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

  • ресурсные маршруты — одна строка вместо семи;
  • формы добавления и правки, @csrf и почему POST без токена отвечает 419;
  • валидация: правила для ФИО, телефона, email и пароля, русские сообщения об ошибках, проверка уникальности email с исключением текущей записи;
  • что происходит с паролем при правке, если поле оставили пустым;
  • удаление через форму, а не ссылкой, и почему так.

Всё нужное уже готово: модель User с белым списком полей, 12 записей в базе и шаблон списка, в который останется добавить кнопки действий.

Потренируйтесь сейчас

Пройдите весь цикл сами: создайте миграцию, которая добавит в users колонку city типа string(64), примените её, посмотрите DESCRIBE users, откатите и примените снова — ровно то, что мы сделали с phone. Затем добавьте города в UsersSeeder и пересоздайте базу.

37листайте