Laravel
Цикл запроса, роутинг, middleware, валидация, Eloquent, кеш, очереди, события, scheduler, авторизация, Octane и long-running окружения. Глубокий middle/senior разбор с примерами и pitfalls каждого механизма.
Laravel — фреймворк, чьё освоение в глубину занимает годы. Поверхностное знание (роуты, контроллеры, миграции) хватает на CRUD'ы, но любая нетривиальная задача — расширяемый модуль, тонкая авторизация, надёжная асинхронность, Octane-окружение — требует понимания, как фреймворк устроен изнутри. Этот раздел даёт средне-старший уровень: что происходит при HTTP-запросе, как middleware вмешивается в pipeline, как очереди гарантируют доставку, чем gate отличается от policy, и какие ловушки ждут в long-running режиме.
- Полный цикл HTTP-запроса от
public/index.phpдо response; - Routing с привязкой моделей, scoped bindings, route caching;
- Middleware: handle/terminate, parameters, groups, before/after;
- Eloquent (базовое; deep — в KB_12), Cache c тегами и locks;
- Queues: драйверы, retry, batching, chains, rate limiting, race conditions;
- Events & Listeners, Scheduler с overlapping и withoutOverlapping;
- Auth: guards, providers, gates и policies, before-хуки;
- Octane: state leaks, request-scope, что ломается и как лечить.
- KB_1 — PHP OOP, конструкторы, интерфейсы;
- KB_2 — SQL и индексы (для Eloquent и Queues на БД-драйвере);
- Поверхностное знакомство с Laravel — что такое контроллер, миграция, фабрика.
| Тема | Где глубокий разбор |
|---|---|
| Service Container, DI, providers | KB_13 |
| Eloquent advanced (полиморфизм, observers, race) | KB_12 |
| Хелперы и фасады | KB_10 |
| Наследование Controller/Model/FormRequest | KB_9 |
| Тестирование Laravel-приложений | KB_6 + KB_14 |
Laravel — это пакет Composer, а не отдельная программа. Поэтому ставят не «Laravel», а связку: интерпретатор PHP нужной версии, менеджер пакетов Composer и, если во фронтенде есть сборка, Node с npm. Всё остальное фреймворк подтянет сам.
| Что | Версия | Зачем |
|---|---|---|
| PHP | 8.3 и выше для Laravel 13 | Требование записано в composer.json самого фреймворка: "php": "^8.3" |
| Composer | 2.x | Ставит сам фреймворк и все пакеты |
| Node и npm | Текущая LTS | Сборка CSS и JavaScript через Vite. Чистому API не нужен |
| База данных | SQLite идёт из коробки | MySQL или PostgreSQL ставят отдельно, когда нужны |
Расширения PHP, которые фреймворк требует явно — они почти всегда уже включены в сборках для разработки:
# из composer.json фреймворка
ext-ctype ext-filter ext-hash ext-mbstring ext-openssl ext-session ext-tokenizer
# проверить, что всё на месте
php -m | grep -E 'ctype|filter|hash|mbstring|openssl|session|tokenizer'
# версия PHP и Composer
php -v
composer --version
pdo_mysql, для PostgreSQL — pdo_pgsql, для SQLite — pdo_sqlite. Их нет в списке выше, потому что фреймворк не знает заранее, какую базу вы возьмёте. Симптом отсутствия: could not find driver при первой же миграции.Если PHP и Composer ещё не стоят, официальная документация предлагает скрипт, который ставит сразу всё — интерпретатор, Composer и установщик Laravel:
# macOS
/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.5)"
# Linux
/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)"
После него терминал нужно перезапустить. На macOS есть и привычный путь через Homebrew, если вы предпочитаете управлять версиями сами:
brew install php composer
brew install node # если нужен фронтенд
# несколько версий PHP рядом
brew install php@8.3 php@8.4
brew link --overwrite --force php@8.3
php, composer, laravel, node, npm. Любой проект в каталоге ~/Herd автоматически доступен по адресу вида имя-папки.test, без ручной настройки веб-сервера. Платная версия добавляет локальные MySQL, PostgreSQL и Redis.Два равноценных способа. Первый — через установщик Laravel, он задаёт вопросы о стартовом наборе и тестовом фреймворке:
composer global require laravel/installer # один раз на машину
laravel new example-app
Второй — напрямую через Composer, без установщика. Удобен, когда нужна конкретная версия или установка идёт в скрипте:
composer create-project laravel/laravel example-app
# конкретная ветка
composer create-project laravel/laravel:^13.0 example-app
Дальше — установка фронтенд-зависимостей и запуск. В Laravel 13 есть готовый скрипт dev, который поднимает сразу три процесса: веб-сервер, обработчик очередей и Vite.
cd example-app
npm install && npm run build
composer run dev
Приложение открывается на http://localhost:8000. Если фронтенд не нужен, достаточно обычного сервера:
php artisan serve # http://127.0.0.1:8000
php artisan serve --port=8080 # другой порт
Address already in use означает, что на 8000 уже кто-то висит — часто это ваш же прошлый artisan serve, не закрытый в другом окне. Найти владельца: lsof -i :8000, затем kill по номеру процесса.Установщик копирует .env.example в .env и генерирует ключ приложения. Если вы клонируете чужой репозиторий, этих шагов не произойдёт — файла .env в репозитории нет и быть не должно, поэтому их делают руками:
cp .env.example .env
php artisan key:generate
APP_KEY приложение не поднимется. Ошибка звучит как No application encryption key has been specified. Ключ используется для шифрования сессий и cookie, поэтому фреймворк отказывается работать без него. Второе следствие: если поменять ключ на рабочем проекте, все существующие сессии и зашифрованные значения станут нечитаемыми.По умолчанию приложение настроено на SQLite: файл database/database.sqlite создаётся при установке, и миграции по нему уже прогнаны. Для разработки этого достаточно. Переход на MySQL — это правка .env и запуск миграций:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=
php artisan migrate # создать таблицы
php artisan migrate --seed # и наполнить тестовыми данными
Unknown database 'laravel' — сначала CREATE DATABASE laravel;, потом миграции. С SQLite проще: достаточно пустого файла, touch database/database.sqlite.Порядок отличается от создания нового: каталог vendor и файл .env в репозиторий не попадают, поэтому их восстанавливают локально.
git clone git@github.com:user/project.git
cd project
composer install # строго по composer.lock, без обновления версий
cp .env.example .env
php artisan key:generate
touch database/database.sqlite # если проект на SQLite
php artisan migrate --seed
npm install && npm run build # если есть фронтенд
php artisan serve
install против update. composer install ставит ровно те версии, что записаны в composer.lock — у всех в команде и на сервере окружение получается одинаковым. composer update ищет более свежие версии в рамках ограничений и переписывает lock. На рабочем сервере выполняют только install.| Пакет | Зачем | Команда |
|---|---|---|
| Laravel Sanctum | Токены для API и авторизация SPA | composer require laravel/sanctum |
| Laravel Horizon | Панель и метрики очередей на Redis | composer require laravel/horizon |
| Laravel Telescope | Отладка: запросы, SQL, джобы, письма | composer require laravel/telescope --dev |
| Laravel Pint | Форматирование кода по стандарту | Входит в состав новых проектов |
| Debugbar | Панель с запросами прямо на странице | composer require barryvdh/laravel-debugbar --dev |
| IDE Helper | Подсказки редактора для фасадов и моделей | composer require barryvdh/laravel-ide-helper --dev |
--dev не косметика. Он кладёт пакет в require-dev, и на сервере с composer install --no-dev тот не установится. Telescope и Debugbar в продакшене — это и лишняя нагрузка, и утечка данных: они пишут содержимое запросов, включая заголовки с токенами.php artisan about # версии, драйверы, окружение одним экраном
php artisan route:list # какие маршруты зарегистрированы
php artisan migrate:status # какие миграции применены
php artisan tinker # консоль с загруженным приложением
php artisan about — первое, что стоит выполнить в незнакомом проекте. Он за один экран показывает версию фреймворка и PHP, драйверы кэша, сессий, очередей и почты, а также включено ли кэширование конфигурации и маршрутов.| Вопрос | Короткий ответ |
|---|---|
Чем composer install отличается от update? | install ставит версии из composer.lock, update ищет более свежие и переписывает lock. На сервере только install |
Зачем нужен APP_KEY? | Шифрование сессий и cookie. Без него приложение не стартует, при смене на рабочем проекте — ломаются сессии и зашифрованные данные |
Почему .env не хранят в репозитории? | В нём доступы. Плюс значения отличаются на каждой машине и сервере |
| Что делать после клонирования проекта? | composer install, копия .env, key:generate, миграции, при необходимости сборка фронтенда |
| Какая минимальная версия PHP у Laravel 13? | 8.3 — записано в composer.json фреймворка |
В официальной документации Laravel есть раздел Architecture Concepts из четырёх страниц. Это не набор приёмов, а описание того, как фреймворк вообще устроен внутри. Всё остальное — роутинг, Eloquent, очереди — надстройки над этими четырьмя механизмами.
| Концепция | Отвечает на вопрос | Главный механизм |
|---|---|---|
| Request Lifecycle | Что происходит с запросом от index.php до ответа | Kernel, bootstrappers, middleware-пайплайн |
| Service Container | Кто создаёт объекты и подставляет зависимости | Рефлексия, биндинги, автоматическое внедрение |
| Service Providers | Где регистрируются и настраиваются компоненты | register() → boot() |
| Facades | Как короткий статический синтаксис работает поверх контейнера | __callStatic() + прокси к биндингу |
Это самое важное, что нужно понять про Laravel. Ты нигде не пишешь new для контроллеров, сервисов, репозиториев и джобов. Их создаёт контейнер, попутно разрешая всю цепочку зависимостей.
// Как это выглядело бы без контейнера — ручная сборка графа объектов
$pdo = new PDO($dsn, $user, $pass);
$connection = new Connection($pdo);
$repository = new UserRepository($connection);
$mailer = new Mailer(new SmtpTransport($config));
$controller = new UserController($repository, $mailer);
$response = $controller->store($request);
// добавил зависимость в UserRepository — правь каждое место, где он создаётся
// Как это в Laravel — объявляешь, что нужно, остальное не твоя забота
class UserController extends Controller
{
public function __construct(
protected UserRepository $users,
protected Mailer $mailer,
) {}
}
// Роут Route::post('/users', [UserController::class, 'store']) —
// и всё. Контейнер создаст контроллер, увидит типы в конструкторе,
// рекурсивно построит UserRepository, Connection, PDO, Mailer…
Откуда контейнер знает, что подставить
Через рефлексию. Контейнер читает сигнатуру конструктора, берёт тип каждого параметра и пытается разрешить его как класс. Если у того тоже есть зависимости — рекурсивно повторяет. Никаких XML- и YAML-конфигов, как в старых фреймворках.
В документации это называется Zero Configuration Resolution:
Где автоматическое внедрение работает
| Место | Куда объявлять тип |
|---|---|
| Контроллеры | Конструктор и любой метод-экшен |
| Замыкания в роутах | Аргументы замыкания |
| Middleware | Конструктор |
| Event listeners | Конструктор |
| Queued jobs | Метод handle() |
| Artisan-команды | Конструктор и handle() |
| Любой callable | Через App::call() |
// Внедрение в метод, а не только в конструктор
public function store(StoreUserRequest $request, UserRepository $users)
{
// И $request, и $users подставлены контейнером.
// Параметры роута при этом тоже работают — они идут после зависимостей.
}
// Ручной вызов метода с внедрением зависимостей
$stats = App::call([new PodcastStats, 'generate']);
$result = App::call(function (AppleMusic $apple) { /* ... */ });
Путь запроса — всегда один и тот же. Детальный разбор каждого шага в разделе Request Lifecycle, здесь — карта целиком.
register() у всех провайдеров, и только потом boot() у всех. Именно поэтому в boot() можно рассчитывать, что все биндинги уже зарегистрированы, а в register() — нельзя обращаться к сервисам других провайдеров.Что это значит на примере
Контроллеру нужно получать подкасты из внешнего источника. Вместо того чтобы создавать клиент внутри себя, он объявляет потребность — и получает её готовой.
namespace App\Http\Controllers;
use App\Services\AppleMusic;
use Illuminate\View\View;
class PodcastController extends Controller
{
public function __construct(
protected AppleMusic $apple,
) {}
public function show(string $id): View
{
return view('podcasts.show', [
'podcast' => $this->apple->findPodcast($id)
]);
}
}
Главное следствие, на которое указывает документация: поскольку сервис внедряется, его легко подменить — мокнуть или подставить заглушку AppleMusic при тестировании. Если бы контроллер делал new AppleMusic внутри, тест бы полез в реальный Apple Music.
Куда именно «внедряются» зависимости
| Способ | Как выглядит | Делает ли контейнер сам |
|---|---|---|
| Через конструктор | __construct(AppleMusic $apple) | Да — основной путь |
| Через параметры метода | show(Request $r, UserRepo $u) | Да — в контроллерах, handle() джобов, App::call() |
| Через сеттер | setApple(AppleMusic $apple) | Нет — автоматически не вызывается |
Container вызывается $reflector->getConstructor(), и разрешаются его параметры. Слова setter в коде контейнера нет вообще. Сеттер-инъекцию делают руками — либо вызовом сеттера в замыкании биндинга, либо через хук:
$this->app->resolving(Podcast::class, fn ($p, $app) => $p->setApple($app->make(AppleMusic::class)));Разбор идёт по структуре официальной документации. Контейнер — это набор ассоциативных массивов; каждый способ биндинга пишет в один из них, и по тому, куда он пишет, сразу понятны время жизни и приоритет.
| Способ | Что с чем связывает | Время жизни | Хранилище |
|---|---|---|---|
bind() | абстракт → фабрика | Новый объект на каждый make() | $bindings, shared=false |
bindIf() | то же, но только если не забиндено | — | $bindings |
singleton() | абстракт → фабрика | Один на процесс | $bindings, shared=true |
singletonIf() | то же, если не забиндено | Один на процесс | $bindings |
scoped() | абстракт → фабрика | Один на запрос/джоб | $bindings + $scopedInstances |
scopedIf() | то же, если не забиндено | Один на запрос/джоб | $bindings |
instance() | абстракт → готовый объект | Этот объект, всегда | $instances |
bind(И, Р) | интерфейс → реализация | Как у bind | $bindings |
#[Bind], #[BindWhen] | интерфейс → реализация, на самом интерфейсе | Зависит от #[Singleton]/#[Scoped] | Читается из атрибута |
when()->needs()->give() | (потребитель + потребность) → реализация | — | $contextual |
| Контекстные атрибуты | параметр → источник значения | — | Читается из атрибута |
needs('$var')->give() | примитивный параметр → значение | — | $contextual |
tag() / tagged() | тег → список абстрактов | — | $tags |
extend() | абстракт → декоратор | На каждое разрешение | $extenders |
$instances, затем контекстный биндинг для текущего потребителя, затем обычный $bindings, и только потом автоматическая сборка через рефлексию. Поэтому instance() перебивает всё.Simple Bindings — bind()
Назначение: объяснить контейнеру, как собрать объект, когда он не может догадаться сам. Новый объект на каждое разрешение.
Связь: абстракт → замыкание-фабрика. Замыкание получает контейнер и может разрешать подзависимости.
// Почти все биндинги регистрируются в сервис-провайдерах,
// где контейнер доступен через $this->app
$this->app->bind(Transistor::class, function (Application $app) {
return new Transistor($app->make(PodcastParser::class));
});
// Вне провайдера — через фасад App
App::bind(Transistor::class, function (Application $app) { /* ... */ });
// Только если биндинга ещё нет — для пакетов, чтобы не перетирать приложение
$this->app->bindIf(Transistor::class, function (Application $app) { /* ... */ });
// Абстракт можно не указывать — он берётся из возвращаемого типа замыкания
App::bind(function (Application $app): Transistor {
return new Transistor($app->make(PodcastParser::class));
});
Binding a Singleton — singleton()
Назначение: объект должен разрешаться один раз; все последующие обращения получают тот же экземпляр. Для дорогих в создании и не хранящих пользовательское состояние сервисов: менеджеры соединений, клиенты API, реестры.
Связь: абстракт → фабрика с shared = true; результат переезжает в $instances.
$this->app->singleton(Transistor::class, function (Application $app) {
return new Transistor($app->make(PodcastParser::class));
});
$this->app->singletonIf(Transistor::class, function (Application $app) { /* ... */ });
app(Transistor::class) === app(Transistor::class); // true
Singleton Attribute — то же самое без провайдера, пометкой на классе или интерфейсе:
use Illuminate\Container\Attributes\Singleton;
#[Singleton]
class Transistor {}
Binding Scoped Singletons — scoped()
Назначение: один экземпляр в пределах одного жизненного цикла запроса или джоба. Документация прямо указывает: такие экземпляры сбрасываются, когда Octane-воркер берёт новый запрос или queue-воркер — новый джоб.
Связь: как у singleton, плюс абстракт помечается в $scopedInstances; между циклами вызывается forgetScopedInstances().
$this->app->scoped(Transistor::class, function (Application $app) {
return new Transistor($app->make(PodcastParser::class));
});
$this->app->scopedIf(Transistor::class, function (Application $app) { /* ... */ });
Scoped Attribute:
#[Scoped]
class Transistor {}
singleton против scoped. В обычном PHP-FPM разницы нет: процесс умирает вместе с запросом. Разница проявляется под Octane и в queue-воркере, где процесс живёт дальше. Там singleton, захвативший Request или текущего пользователя, отдаст их следующему запросу — то есть чужому человеку. Состояние, привязанное к запросу или пользователю, — только scoped.Binding Instances — instance()
Назначение: зарегистрировать уже созданный объект. Документация: заданный экземпляр будет возвращаться при всех последующих обращениях. Основное применение — подмена сервиса в тестах.
Связь: абстракт → конкретный объект, сразу в $instances. Фабрики нет.
$service = new Transistor(new PodcastParser);
$this->app->instance(Transistor::class, $service);
Binding Interfaces to Implementations
Назначение: главный случай, когда биндинг обязателен. Контейнер не может угадать, какую реализацию подставить вместо интерфейса.
Связь: интерфейс → класс реализации. Второй аргумент — строка, не замыкание: контейнер соберёт реализацию сам.
$this->app->bind(EventPusher::class, RedisEventPusher::class);
// Теперь интерфейс объявляется в конструкторе любого класса,
// который создаётся контейнером
public function __construct(
protected EventPusher $pusher,
) {}
Bind Attribute — то же решение прямо на интерфейсе, без регистрации в провайдере. Несколько #[Bind] задают разные реализации под разные окружения:
use Illuminate\Container\Attributes\Bind;
#[Bind(RedisEventPusher::class)]
#[Bind(FakeEventPusher::class, environments: ['local', 'testing'])]
interface EventPusher {}
// Время жизни задаётся тут же
#[Bind(RedisEventPusher::class)]
#[Singleton]
interface EventPusher {}
#[BindWhen] — реализация по произвольному условию. Замыкание получает контейнер и возвращает true, когда биндинг нужно применить. Bind и BindWhen вычисляются в порядке объявления.
use Illuminate\Container\Attributes\BindWhen;
#[BindWhen(BetaEventPusher::class, static fn () => Feature::active('beta-events'))]
interface EventPusher {}
#[BindWhen] требует PHP 8.5 или выше.Contextual Binding
Назначение: два класса зависят от одного интерфейса, но каждому нужна своя реализация.
Связь: трёхместная — (кто запрашивает) + (что ему нужно) → что подставить.
$this->app->when(PhotoController::class)
->needs(Filesystem::class)
->give(function () {
return Storage::disk('local');
});
// Одно правило на несколько потребителей
$this->app->when([VideoController::class, UploadController::class])
->needs(Filesystem::class)
->give(function () {
return Storage::disk('s3');
});
Contextual Attributes
Назначение: контекстный биндинг чаще всего нужен для драйверов и значений конфига — атрибуты позволяют не описывать его в провайдере вообще.
Связь: конкретный параметр → источник значения, объявляется прямо в сигнатуре.
public function __construct(
#[Auth('web')] protected Guard $auth,
#[Cache('redis')] protected Repository $cache,
#[Config('app.timezone')] protected string $timezone,
#[Context('uuid')] protected string $uuid,
#[Context('ulid', hidden: true)] protected string $ulid,
#[DB('mysql')] protected Connection $connection,
#[Give(DatabaseRepository::class)] protected UserRepository $users,
#[Log('daily')] protected LoggerInterface $log,
#[RequestAttribute('organization')] protected Organization $organization,
#[RouteParameter] protected Photo $photo,
#[Storage('local')] protected Filesystem $filesystem,
#[Tag('reports')] protected iterable $reports,
) {}
// Текущий пользователь — отдельным атрибутом, работает и в роуте
Route::get('/user', fn (#[CurrentUser] User $user) => $user)
->middleware('auth');
#[RouteParameter]разрешает параметр маршрута по имени переменной; имя указывается явно только при несовпадении —#[RouteParameter('photo')].#[RequestAttribute('organization')]берёт значение из attribute bag запроса — того, куда middleware кладёт данные через$request->attributes->set().
Свои атрибуты — в подразделе «Свой контекстный атрибут» ниже.
Binding Primitives
Назначение: классу нужен не объект, а скалярное значение — число, строка, массив. Контейнер такое через рефлексию не разрешит.
Связь: (потребитель) + (имя переменной со знаком $) → значение.
// Обычное значение
$this->app->when(UserController::class)
->needs('$variableName')
->give($value);
// Массив всех биндингов с тегом
$this->app->when(ReportAggregator::class)
->needs('$reports')
->giveTagged('reports');
// Значение из файла конфигурации
$this->app->when(ReportAggregator::class)
->needs('$timezone')
->giveConfig('app.timezone');
Обрати внимание на знак $ внутри строки: он и отличает примитивный параметр от типа класса.
Binding Typed Variadics
Назначение: конструктор принимает переменное число типизированных объектов — Filter ...$filters.
Связь: (потребитель) + (тип элемента) → массив реализаций.
class Firewall
{
protected $filters;
public function __construct(
protected Logger $logger,
Filter ...$filters,
) {
$this->filters = $filters;
}
}
// Замыканием — когда нужен контроль над сборкой каждого
$this->app->when(Firewall::class)
->needs(Filter::class)
->give(function (Application $app) {
return [
$app->make(NullFilter::class),
$app->make(ProfanityFilter::class),
$app->make(TooLongFilter::class),
];
});
// Короче — просто массив имён классов
$this->app->when(Firewall::class)
->needs(Filter::class)
->give([
NullFilter::class,
ProfanityFilter::class,
TooLongFilter::class,
]);
Variadic Tag Dependencies — если вариативная зависимость типизирована (Report ...$reports), всю группу по тегу можно отдать одной строкой:
$this->app->when(ReportAggregator::class)
->needs(Report::class)
->giveTagged('reports');
Tagging
Назначение: разрешить сразу всю «категорию» биндингов — например, все реализации интерфейса Report для анализатора отчётов.
Связь: тег → список абстрактов. Сам тег ничего не создаёт, он только группирует уже зарегистрированные биндинги.
$this->app->bind(CpuReport::class, function () { /* ... */ });
$this->app->bind(MemoryReport::class, function () { /* ... */ });
$this->app->tag([CpuReport::class, MemoryReport::class], 'reports');
// Разрешить всю группу
$this->app->bind(ReportAnalyzer::class, function (Application $app) {
return new ReportAnalyzer($app->tagged('reports'));
});
Extending Bindings — extend()
Назначение: изменить уже разрешённый сервис — обернуть декоратором или донастроить, не переписывая исходный биндинг.
Связь: абстракт → замыкание-декоратор. Замыкание получает собранный сервис и контейнер, возвращает замену.
$this->app->extend(Service::class, function (Service $service, Application $app) {
return new DecoratedService($service);
});
Применяется на каждое разрешение, поэтому расширителей может быть несколько — они накладываются друг на друга в порядке регистрации.
Resolving — make(), makeWith(), bound()
Назначение: достать объект из контейнера вручную, когда автоматическое внедрение неприменимо.
$transistor = $this->app->make(Transistor::class);
// Если часть зависимостей контейнер разрешить не может — передать руками
$transistor = $this->app->makeWith(Transistor::class, ['id' => 1]);
// Есть ли явный биндинг
if ($this->app->bound(Transistor::class)) { /* ... */ }
// Там, где нет $app
$transistor = App::make(Transistor::class);
$transistor = app(Transistor::class);
// Сам контейнер можно получить внедрением
public function __construct(
protected Container $container,
) {}
Automatic Injection
Назначение: основной способ получения зависимостей. Документация: «в практике именно так большинство объектов и должно разрешаться контейнером».
Тип объявляется в конструкторе класса, который создаёт контейнер — контроллеры, listeners, middleware и прочие. У джобов зависимости объявляются в handle().
class PodcastController extends Controller
{
public function __construct(
protected AppleMusic $apple,
) {}
public function show(string $id): Podcast
{
return $this->apple->findPodcast($id);
}
}
Method Invocation and Injection — App::call()
Назначение: вызвать метод на объекте так, чтобы контейнер сам подставил зависимости этого метода. Принимает любой PHP callable, включая замыкание.
class PodcastStats
{
public function generate(AppleMusic $apple): array
{
return [ /* ... */ ];
}
}
$stats = App::call([new PodcastStats, 'generate']);
$result = App::call(function (AppleMusic $apple) {
// ...
});
Что означает «resolve»
Resolve (разрешить) — процесс, в котором контейнер создаёт объект и подставляет все его зависимости. «Разрешить» здесь значит «удовлетворить все требования класса»: найти и передать всё, что он просит в конструкторе.
Когда ты пишешь app(Transistor::class) — или когда контейнер сам создаёт контроллер — происходит ровно это:
- Контейнер смотрит на класс
Transistor. - Читает его конструктор через рефлексию.
- Для каждого параметра конструктора находит зависимость — рекурсивно, то есть у зависимостей тоже разрешаются их зависимости.
- Создаёт объект:
new Transistor(...$dependencies). - Возвращает готовый экземпляр.
Весь этот процесс и называется resolve. Результат — собранный объект.
Полный порядок внутри resolve()
Понимать порядок важно: от него зависит, что можно сделать на каком этапе. Это фактическая последовательность из Container::resolve():
resolving() — подписка на создание объекта
Назначение: донастроить объект после сборки, но до того, как он уйдёт потребителю. Колбэк получает сам объект и контейнер.
// Для конкретного типа
$this->app->resolving(Transistor::class, function (Transistor $transistor, Application $app) {
$transistor->setDebugMode($app->isLocal());
});
// Без первого аргумента — на ЛЮБОЙ объект, для отладки и аудита
$this->app->resolving(function (mixed $object, Application $app) {
Log::info('Resolved: ' . get_class($object));
});
// beforeResolving — до сборки, объекта ещё нет, есть только тип и параметры
$this->app->beforeResolving(Transistor::class, function ($abstract, $parameters, $app) {});
// afterResolving — сразу после resolving, тоже до возврата
$this->app->afterResolving(Transistor::class, function (Transistor $transistor) {
$transistor->boot();
});
| Сценарий | Что делает хук |
|---|---|
| Отладка | Логирует, какие классы создаются и в каком порядке |
| Донастройка | Выставляет свойства объекта после сборки |
| Метрики | Считает количество созданных объектов |
| Тестирование | Проверяет, что нужный объект вообще создавался |
| Подмена объекта | Не умеет — для этого extend() |
Три вещи, в которых легко ошибиться
resolving() нельзя подменить объект. Возвращаемое значение колбэка игнорируется — в fireCallbackArray() стоит просто $callback($object, $this); без присваивания. Объект можно мутировать (вызвать сеттер, выставить свойство), но вернуть вместо него другой — нет. Подмена делается через extend(), который выполняется раньше и результат замыкания как раз использует: $object = $extender($object, $this);afterResolving срабатывает до возврата объекта, а не после. Оба хука живут внутри одного fireResolvingCallbacks(), и порядок там жёсткий: глобальные resolving → типизированные resolving → afterResolving. Разница между ними только в этой очерёдности, а не в том, что один «до возврата», а другой «после». К моменту return $object отработали уже оба.make() контейнер попадает в ранний выход if (isset($this->instances[$abstract])) return $this->instances[$abstract]; — до любого вызова хуков. Поэтому счётчик созданных объектов на resolving() покажет для singleton единицу независимо от числа обращений, а донастройка применится только к первой сборке. Для транзиентных bind() хуки работают на каждое разрешение.Rebinding — подписка на переопределение биндинга
Назначение: отследить, что сервис перебиндили — зарегистрировали заново или переопределили после первого биндинга. Это не то же самое, что resolving: тут событие про изменение правила, а не про создание объекта.
$this->app->bind(PodcastPublisher::class, SpotifyPublisher::class);
$this->app->rebinding(
PodcastPublisher::class,
function (Application $app, PodcastPublisher $newInstance) {
//
},
);
// Новый биндинг запустит замыкание выше
$this->app->bind(PodcastPublisher::class, TransistorPublisher::class);
Сахар над этим — refresh($abstract, $target, $method): подписывается на переопределение и сам вызывает сеттер у объекта, который держит устаревшую зависимость.
PSR-11
Контейнер Laravel реализует интерфейс PSR-11, поэтому в типе можно объявлять стандартный ContainerInterface.
use Psr\Container\ContainerInterface;
Route::get('/', function (ContainerInterface $container) {
$service = $container->get(Transistor::class);
});
| Ситуация | Исключение |
|---|---|
| Идентификатор никогда не биндился | Psr\Container\NotFoundExceptionInterface |
| Биндился, но разрешить не удалось | Psr\Container\ContainerExceptionInterface |
Контейнер — это ядро, он задействован везде. Но полезно различать четыре разных вещи: где он создаётся, где настраивается, где используется и где реализован. Напрямую руками его трогают в основном в провайдерах; всё остальное — косвенно, через DI.
1. Создание и загрузка
| Файл | Что делает |
|---|---|
public/index.php | Точка входа всех HTTP-запросов. Подключает автолоадер Composer, получает приложение из bootstrap/app.php, передаёт запрос в HTTP-ядро |
bootstrap/app.php | Создаёт экземпляр Application — это и есть контейнер. Здесь же настраиваются маршруты, middleware, обработка исключений |
artisan | То же самое для консоли: поднимает то же приложение и передаёт команду в консольное ядро |
bootstrap/providers.php | Список сервис-провайдеров приложения (Laravel 11+). Именно они наполняют контейнер |
Вот как это выглядит в этом проекте — public/index.php почти пустой, вся работа в двух строках:
require __DIR__.'/../vendor/autoload.php';
$app = require_once __DIR__.'/../bootstrap/app.php'; // ← контейнер
$app->handleRequest(Request::capture());
И artisan — тот же контейнер, другой вход:
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
bootstrap/app.php обслуживает и веб, и консоль. Разница только в методе: handleRequest() против handleCommand().2. Где регистрируются биндинги
| Файл | Что регистрирует |
|---|---|
app/Providers/AppServiceProvider.php | Основные биндинги проекта: bind, singleton, scoped |
app/Providers/*ServiceProvider.php | Свои провайдеры под отдельные группы зависимостей — когда AppServiceProvider разрастается |
bootstrap/app.php | Быстрые биндинги без провайдера через withBindings() и withSingletons() (Laravel 11+) |
config/app.php | Список провайдеров — только в Laravel ≤ 10. В 11+ переехал в bootstrap/providers.php |
| На самих классах | #[Bind], #[BindWhen], #[Singleton], #[Scoped] — регистрация в провайдере не нужна вовсе |
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->bind(PaymentGateway::class, StripeGateway::class);
$this->app->singleton(ReportGenerator::class);
}
// bootstrap/app.php — без создания провайдера
return Application::configure(basePath: dirname(__DIR__))
->withBindings([PaymentGateway::class => StripeGateway::class])
->withSingletons([ReportGenerator::class])
->create();
В этом проекте bootstrap/providers.php минимальный — один провайдер:
return [
AppServiceProvider::class,
];
3. Где контейнер работает через DI
Здесь ты его не вызываешь — он сам подставляет зависимости по типам в сигнатуре.
| Файл | Как используется |
|---|---|
app/Http/Controllers/*.php | Тип в конструкторе или в методе-экшене |
app/Http/Middleware/*.php | Тип в конструкторе |
app/Listeners/*.php | Тип в конструкторе |
app/Jobs/*.php | Тип в handle() — конструктор джоба сериализуется, туда сервисы класть нельзя |
app/Console/Commands/*.php | Тип в конструкторе и в handle() |
app/Providers/*.php | Явно: $this->app->bind(...), $this->app->make(...) |
routes/web.php, routes/api.php | Типы в аргументах замыкания, либо app(...) внутри |
tests/*.php | $this->app->instance(...) для подмены моком, app(...) для получения сервиса |
// Контроллер
class OrderController extends Controller
{
public function __construct(private PaymentGateway $gateway) {}
}
// Middleware
class CheckRole
{
public function __construct(private RoleService $roles) {}
}
// Artisan-команда — зависимости в handle()
class SendReports extends Command
{
public function handle(ReportService $service)
{
$service->send();
}
}
// Замыкание роута — можно и типом, и вручную
Route::get('/test', function (OrderService $service) { // предпочтительно
return $service->run();
});
Route::get('/test', function () {
return app(OrderService::class)->run(); // тоже работает
});
4. Хелперы и фасады — ручной доступ
| Инструмент | Что делает |
|---|---|
app() | Без аргументов возвращает сам контейнер; с аргументом — разрешает тип: app(Foo::class) |
resolve() | Полный аналог app($name, $parameters), отличается только именем |
App::make(...) | Фасад Illuminate\Support\Facades\App |
$this->app | Прямое свойство внутри сервис-провайдера |
Container::getInstance() | Статический доступ к текущему контейнеру — для кода вне жизненного цикла приложения |
$app['config'] | ArrayAccess — то же, что make('config') |
app() оставить для замыканий роутов, провайдеров и мест, где конструктора просто нет.5. Где контейнер реализован
Внутри vendor, но знать полезно — я сверялся с этими файлами по всему разделу.
Файл в Illuminate/ | Роль |
|---|---|
Container/Container.php | Сам контейнер: bind, make, resolve, build, хуки |
Foundation/Application.php | Наследует Container и добавляет всё специфичное для Laravel: пути, окружение, загрузку провайдеров |
Container/BoundMethod.php | Логика вызова методов с внедрением — за этим стоит App::call() |
Container/ContextualBindingBuilder.php | Fluent-цепочка when()->needs()->give() |
Container/Attributes/* | Все контекстные атрибуты: Bind, Config, CurrentUser и остальные |
Container/Util.php | Вспомогательное: поиск контекстного атрибута у параметра, разворачивание замыканий |
Container/RewindableGenerator.php | То, что возвращает tagged() — ленивый перебираемый набор |
Container/EntryNotFoundException.php | Исключение PSR-11, когда идентификатор не найден |
Application наследует Container. Поэтому $this->app, app() и Container::getInstance() — это один и тот же объект, и у него доступны и методы контейнера, и методы приложения.Итог одной картинкой
Прямо руками контейнер трогают в основном в провайдерах. Везде остальное он работает сам — и именно поэтому его легко не замечать.
Сначала — сервисы это не контейнеры
Частая путаница: кажется, что «DI-контейнеры» — это папка app/Services. Нет. Контейнер один на всё приложение, сервисов — много.
| Понятие | Что это |
|---|---|
| DI-контейнер | Один глобальный объект — экземпляр Illuminate\Foundation\Application. Создаётся один раз в bootstrap/app.php и живёт всю обработку запроса. Хранит биндинги, создаёт объекты, подставляет зависимости |
| Сервис | Обычный класс с бизнес-логикой: OrderService, PaymentService. Контейнером не является — он его содержимое |
| Биндинг | Запись в контейнере: «когда просят это — отдавай то» |
| DI | Механизм, которым контейнер подставляет сервисы в конструкторы |
Аналогия: контейнер — склад деталей и сборщик; сервисы — изделия, которые из деталей собираются; контроллеры, джобы и команды — заказчики. Склад один, изделий много, заказчиков ещё больше.
app/Services — соглашение, а не требование. Laravel эту папку не знает и автоматически не ищет. Сервисы кладут и в app/Actions (одноразовые действия), app/Domain (DDD), app/Support (вспомогательные). Работает это не потому, что папка так названа, а потому что класс зарегистрирован в контейнере или разрешается автоматически по типу.Пример 1: рекурсивное разрешение цепочки
Показывает главное свойство контейнера: он собирает весь граф объектов, а не один объект.
// Нижний уровень — без зависимостей
class Logger
{
public function log(string $message): void
{
Log::info($message);
}
}
// Зависит от логгера
class OrderRepository
{
public function __construct(private Logger $logger) {}
public function save(array $data): void
{
$this->logger->log('Saving order: ' . json_encode($data));
}
}
// Зависит от ПРИМИТИВА — автоматически не соберётся, нужен биндинг
class StripeGateway
{
public function __construct(private string $apiKey) {}
public function charge(float $amount): bool { return true; }
}
// Верхний уровень
class OrderService
{
public function __construct(
private OrderRepository $repository,
private StripeGateway $gateway,
) {}
public function process(array $data): void
{
$this->repository->save($data);
$this->gateway->charge($data['total']);
}
}
Единственное, что нужно зарегистрировать — класс с примитивом в конструкторе:
// AppServiceProvider::register()
$this->app->bind(StripeGateway::class, function ($app) {
return new StripeGateway(config('services.stripe.key'));
});
Что происходит на app(OrderService::class):
Контейнер спускается по графу вниз, а собирает снизу вверх: Logger → OrderRepository → замыкание для StripeGateway → OrderService. Ни одной строки new ты не пишешь — только одна регистрация там, где контейнер угадать не может.
Использование — просто тип в сигнатуре:
class OrderController extends Controller
{
public function store(Request $request, OrderService $service)
{
$service->process($request->validated());
return response()->noContent();
}
}
OrderService зависит от конкретного StripeGateway, а не от интерфейса. Работать будет, но подменить платёжную систему или замокать её в тесте уже не получится — придётся править сам OrderService. Как это исправить — следующий пример.Пример 2: внешний SDK за интерфейсом
Внешние SDK (Stripe, AWS, Twilio) не рассчитаны на DI: свои конструкторы, статические методы, глобальные ключи. Решение — адаптер за своим интерфейсом.
// 1. Контракт — то, что знает твой код
namespace App\Contracts;
interface PaymentGateway
{
public function charge(float $amount, string $currency = 'usd'): array;
public function refund(string $chargeId, float $amount): array;
}
// 2. Адаптер — SDK заперт внутри, наружу не торчит
class StripePaymentGateway implements PaymentGateway
{
private StripeClient $client;
public function __construct(string $apiKey)
{
$this->client = new StripeClient($apiKey);
}
public function charge(float $amount, string $currency = 'usd'): array
{
return $this->client->charges->create([
'amount' => $amount * 100,
'currency' => $currency,
])->toArray();
}
public function refund(string $chargeId, float $amount): array
{
return $this->client->refunds->create([
'charge' => $chargeId,
'amount' => $amount * 100,
])->toArray();
}
}
// 3. Фейк для тестов и локальной разработки — тот же контракт
class FakePaymentGateway implements PaymentGateway
{
public function charge(float $amount, string $currency = 'usd'): array
{
return ['id' => 'fake_charge_' . uniqid(), 'status' => 'succeeded'];
}
public function refund(string $chargeId, float $amount): array
{
return ['id' => 'fake_refund_' . uniqid(), 'status' => 'succeeded'];
}
}
Биндинг решает, какая реализация поедет в прод, а какая — в тесты:
// AppServiceProvider::register()
// singleton, а не bind: SDK-клиент дорог в создании и состояния запроса не держит
$this->app->singleton(PaymentGateway::class, function ($app) {
return $app->environment('production')
? new StripePaymentGateway(config('services.stripe.key'))
: new FakePaymentGateway();
});
Либо то же самое атрибутом на интерфейсе, вообще без провайдера:
#[Bind(StripePaymentGateway::class)]
#[Bind(FakePaymentGateway::class, environments: ['local', 'testing'])]
interface PaymentGateway {}
Код-потребитель не знает, что под капотом:
class CheckoutController extends Controller
{
public function __construct(private PaymentGateway $gateway) {}
public function pay(Request $request)
{
return response()->json($this->gateway->charge($request->total));
}
}
config() внутри замыкания. Это безопасно и здесь уместно: замыкание выполняется в момент разрешения, а не регистрации. Конфиг к тому времени давно загружен — bootstrapper LoadConfiguration отрабатывает раньше RegisterProviders. Вытаскивать config() из замыкания в тело register() не нужно: получишь жадное чтение вместо ленивого.Пример 3: свой сервис в контроллере, джобе и тесте
Тот же приём без внешних SDK — простой биндинг интерфейса на класс.
interface PaymentService
{
public function charge(User $user, Order $order): bool;
public function refund(Order $order): bool;
}
class DefaultPaymentService implements PaymentService
{
public function __construct(private Logger $logger) {}
public function charge(User $user, Order $order): bool
{
return DB::transaction(function () use ($user, $order) {
$this->logger->log("Charging user {$user->id} for order {$order->id}");
$order->update(['status' => 'paid']);
return true;
});
}
public function refund(Order $order): bool
{
$this->logger->log("Refunding order {$order->id}");
$order->update(['status' => 'refunded']);
return true;
}
}
// Биндинг — одна строка, замыкание не нужно: Logger собирается сам
$this->app->bind(PaymentService::class, DefaultPaymentService::class);
В контроллере — конструктор:
class OrderController extends Controller
{
public function __construct(private PaymentService $payment) {}
public function pay(Request $request, Order $order)
{
$this->payment->charge($request->user(), $order);
return redirect()->route('orders.show', $order);
}
}
В джобе — данные в конструктор, сервисы в handle():
class ProcessPayment implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
private User $user,
private Order $order,
) {}
public function handle(PaymentService $payment): void
{
$payment->charge($this->user, $this->order);
}
}
SerializesModels ужимаются до ключа и восстанавливаются при обработке), а зависимости объявляются в handle() — его аргументы контейнер разрешает уже в воркере.В тесте — подмена через instance():
public function test_order_can_be_paid(): void
{
$this->app->instance(PaymentService::class, new FakePaymentService);
$this->post('/orders/1/pay')->assertRedirect();
}
instance() кладёт объект прямо в $instances, а он проверяется первым — поэтому подмена перебивает любой существующий биндинг.
Что показывает каждый пример
| Пример | Чему учит |
|---|---|
| Цепочка зависимостей | Контейнер собирает весь граф рекурсивно. Регистрировать нужно только то, что он не может угадать — примитивы и интерфейсы |
| Внешний SDK | Адаптер прячет SDK, интерфейс отвязывает код от вендора, биндинг выбирает реализацию по окружению |
| Свой сервис | Один и тот же сервис получается в контроллере, в джобе и в тесте — разными путями, но из одного контейнера |
Атрибут не заменяет параметр, а уточняет, как его разрешить. Это две разные вещи в одной строке объявления.
Route::get('/user', function (#[CurrentUser] User $user) {
return $user;
})->middleware('auth');
| Часть | Что это | Роль |
|---|---|---|
User $user | Параметр: переменная с типом | Куда положить результат |
#[CurrentUser] | Атрибут: метаданные параметра | Как этот результат получить |
Что делает контейнер без атрибута
Порядок разрешения параметра такой:
- Имя параметра совпадает с именем параметра маршрута (
{user}→$user) и тип — Eloquent-модель → срабатывает implicit route model binding. - Тип — класс, совпадения с маршрутом нет → контейнер создаёт объект. Для
User $userэто будетnew User— пустая модель, не связанная с текущим пользователем. - Тип — примитив → Laravel ищет значение среди параметров маршрута.
Второй пункт и есть причина, по которой #[CurrentUser] вообще нужен: без него ты получишь не текущего пользователя, а пустую модель.
#[CurrentUser] — текущий аутентифицированный
// Illuminate\Container\Attributes\Authenticated — реальная реализация
public function __construct(public ?string $guard = null) {}
public static function resolve(self $attribute, Container $container)
{
return call_user_func($container->make('auth')->userResolver(), $attribute->guard);
}
Эквивалент auth()->user() внутри метода. Можно указать guard: #[CurrentUser('api')].
#[CurrentUser] — это алиас: class CurrentUser extends Authenticated {}. Оба атрибута делают одно и то же, выбор — вопрос читаемости.#[RouteParameter('name')] — значение из URL
Нужен, когда имя переменной не совпадает с именем параметра маршрута.
// Имена совпадают → атрибут не нужен, работает implicit binding
Route::get('/users/{user}', fn (User $user) => $user);
// Имена НЕ совпадают: в URL {user}, а переменная $profile
Route::get('/users/{user}', fn (#[RouteParameter('user')] User $profile) => $profile);
#[RouteParameter] без аргумента разрешает параметр маршрута по имени переменной, а имя указывается только при несовпадении: #[RouteParameter('photo')]. В более старых сборках ветки (например 13.6.0) конструктор был __construct(public string $parameter) без значения по умолчанию, и запись без аргумента падала с ArgumentCountError. Проверяй vendor/laravel/framework/src/Illuminate/Container/Attributes/RouteParameter.php в своём проекте.User::find($id), как можно подумать. Его resolve() — это одна строка: $container->make('request')->route($parameter). То есть он берёт уже разрешённый параметр маршрута. Если по нему сработал route model binding — вернётся модель; если нет — сырая строка из URL. Сам атрибут модель не ищет.Полный список атрибутов в Laravel 13
| Атрибут | Откуда берёт значение |
|---|---|
#[Authenticated] / #[CurrentUser] | auth()->userResolver()($guard) |
#[RouteParameter('p')] | request()->route('p') |
#[Config('key')] | config('key') |
#[Context('key')] | Repository контекста, с поддержкой hidden: true |
#[Database] / #[DB] | db->connection($name) |
#[Cache('store')] | Конкретное хранилище кеша |
#[Storage('disk')] | Конкретный диск файловой системы |
#[Log('channel')] | Конкретный канал логов |
#[Auth('guard')] | Guard, а не пользователя |
#[Give(Class::class)] | Конкретная реализация для этого параметра |
#[RequestAttribute('key')] | attribute bag запроса — то, что положил middleware |
#[Tag('tag')] | Все биндинги с тегом |
#[Bind], #[BindWhen], #[Singleton], #[Scoped] | Ставятся на класс/интерфейс, а не на параметр. BindWhen — по условию, требует PHP 8.5+ |
#[DB] — алиас #[Database], ровно как #[CurrentUser] для #[Authenticated].
Нужен атрибут для данных из middleware? Его придётся написать
Для данных из $request->attributes в актуальной версии есть штатный #[RequestAttribute('ключ')] — он читает значение из attribute bag запроса. Если его в проекте нет (появился в ветке 13.x позже 13.6.0), тот же атрибут делается самостоятельно — и это заодно канонический пример своего контекстного атрибута:
// Middleware кладёт данные
$request->attributes->set('tenant', Tenant::find(1));
// Свой атрибут — контракт ContextualAttribute + статический resolve()
use Illuminate\Contracts\Container\ContextualAttribute;
#[Attribute(Attribute::TARGET_PARAMETER)]
class RequestAttribute implements ContextualAttribute
{
public function __construct(public string $key, public mixed $default = null) {}
public static function resolve(self $attribute, Container $container)
{
return $container->make('request')->attributes->get($attribute->key, $attribute->default);
}
}
// Теперь можно так
Route::get('/dashboard', fn (#[RequestAttribute('tenant')] Tenant $tenant) => $tenant);
ContextualAttribute — пустой маркерный интерфейс, он не объявляет методов. Контейнер ищет статический resolve() по соглашению, а не по контракту. Если ни resolve(), ни внешнего handler'а нет — контейнер падает с BindingResolutionException: Contextual binding attribute [...] has no registered handler. Подробно — в подразделе «Свой контекстный атрибут» ниже.Когда атрибут нужен, а когда нет
| Ситуация | Без атрибута | С атрибутом |
|---|---|---|
Имя переменной = имя {param} | Работает само | Не нужен |
| Имена не совпадают | Не работает | #[RouteParameter('param')] |
| Нужен текущий пользователь | Вернёт пустую модель | #[CurrentUser] |
| Нужно значение из конфига | config('key') в теле | #[Config('key')] |
| Нужно нестандартное соединение/диск/канал | when()->needs()->give() в провайдере | #[DB], #[Storage], #[Log] |
| Данные из middleware | $request->attributes->get(...) | Только свой атрибут |
Всё вместе
Route::get('/organizations/{organization}/users/{user}', function (
#[RouteParameter('organization')] Organization $org,
#[RouteParameter('user')] User $targetUser,
#[CurrentUser] User $currentUser,
#[Config('app.timezone')] string $timezone,
) {
// $org, $targetUser — из URL
// $currentUser — из auth
// $timezone — из config
})->middleware('auth');
Контекстные атрибуты появились в ветке Laravel 11 и убирают из тела метода ручные app()->make(), auth()->user(), config() — источник данных виден прямо в сигнатуре.
Встроенных атрибутов шестнадцать, но механизм открыт: любой свой атрибут может участвовать во внедрении зависимостей. Нужны три части.
Анатомия
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Container\ContextualAttribute;
#[Attribute(Attribute::TARGET_PARAMETER)] // 1. где разрешено вешать
class Config implements ContextualAttribute // 2. маркер для контейнера
{
public function __construct(
public string $key,
public mixed $default = null,
) {}
// 3. контейнер вызовет это сам
public static function resolve(self $attribute, Container $container): mixed
{
return $container->make('config')->get($attribute->key, $attribute->default);
}
}
resolve() — три аргумента. По документации: resolve(self $attribute, Container $container, ReflectionParameter $parameter). Третий аргумент даёт рефлексию параметра — имя переменной, тип, остальные атрибуты. В старых сборках ветки 13.x контейнер вызывал handler двумя аргументами ($handler($instance, $this) в Container::resolveFromAttribute()), и тогда третий параметр нужно объявлять с значением по умолчанию, иначе ArgumentCountError. Если не уверен в своей сборке — поставь ?ReflectionParameter $parameter = null, это работает в обоих случаях.Три уровня, которые легко спутать
| Что | Роль | Влияет на значение? |
|---|---|---|
TARGET_PARAMETER | Флаг PHP: где разрешено вешать атрибут | Нет |
public string $key | Свойство класса атрибута — куда попадёт переданный аргумент | Да, через него |
'services.stripe.key' | Конкретное значение в месте применения | Да, это и есть ключ |
resolve() | Логика: откуда брать | Да, определяет источник |
TARGET_PARAMETER ничего не подставляет. Это только ограничение области применения — есть ещё TARGET_CLASS, TARGET_METHOD, TARGET_PROPERTY, TARGET_ALL. Поменяешь на TARGET_CLASS — атрибут станет нельзя вешать на параметр, PHP выдаст ошибку при разборе класса, но логика resolve() останется той же.
Свойства называй как угодно: $key, $store, $default — важно лишь, чтобы resolve() их использовал.
Как контейнер различает атрибуты на разных параметрах
Через рефлексию: атрибут привязан к конкретному параметру, и для каждого создаётся свой экземпляр со своими значениями.
// Illuminate\Container\Util
public static function getContextualAttributeFromDependency($dependency)
{
return $dependency->getAttributes(
ContextualAttribute::class, ReflectionAttribute::IS_INSTANCEOF
)[0] ?? null;
}
Комбинировать разные атрибуты в одном конструкторе можно свободно — каждый резолвится своим классом:
public function __construct(
#[Config('reports.format')] private string $format, // config()
#[CurrentUser] private User $user, // auth()
#[RouteParameter('report')] private Report $report, // request()->route()
#[FromCache('reports.latest')] private array $cached, // свой атрибут
) {}
IS_REPEATABLE повтор запрещён на уровне языка:
Error: Attribute "Config" must not be repeatedА если флаг добавить, контейнер всё равно возьмёт первый атрибут — в
Util стоит [0] ?? null, не последний. Нужно несколько значений — делай несколько свойств в одном атрибуте, а не несколько атрибутов.Атрибут с несколькими свойствами и именованными аргументами
#[Attribute(Attribute::TARGET_PARAMETER)]
class FromCache implements ContextualAttribute
{
public function __construct(
public string $key,
public ?string $store = null,
public mixed $default = null,
) {}
public static function resolve(self $attribute, Container $container): mixed
{
$cache = $container->make('cache');
if ($attribute->store) {
$cache = $cache->store($attribute->store);
}
return $cache->get($attribute->key, $attribute->default);
}
}
// Именованные аргументы работают как в обычном конструкторе
public function __construct(
#[FromCache('stats.daily')] private array $daily,
#[FromCache('stats.weekly', store: 'redis', default: [])] private array $weekly,
) {}
Альтернатива resolve(): внешний handler
Метод resolve() в самом атрибуте — не единственный путь. Handler можно зарегистрировать снаружи, в провайдере. Полезно, когда атрибут приходит из чужого пакета и править его нельзя.
// AppServiceProvider::boot()
$this->app->whenHasAttribute(CurrentTenant::class, function ($attribute, $container) {
return $container->make(TenantContext::class)->current();
});
Приоритет такой: сначала контейнер ищет зарегистрированный handler, и только если его нет — статический resolve() на атрибуте. Если нет ни того, ни другого:
BindingResolutionException: Contextual binding attribute [App\Attributes\X]
has no registered handler.
Хук after() — донастройка уже созданного объекта
Помимо resolve() контейнер поддерживает необязательный метод after(): он вызывается после того, как объект разрешён, и позволяет его донастроить.
// Container::fireAfterResolvingAttributeCallbacks()
if (method_exists($instance, 'after')) {
$instance->after($instance, $object, $this);
}
Простой пример: текущий тенант
#[Attribute(Attribute::TARGET_PARAMETER)]
class CurrentTenant implements ContextualAttribute
{
public static function resolve(self $attribute, Container $container): Tenant
{
return $container->make(TenantContext::class)->current();
}
}
// Конструктор атрибуту не нужен — ему нечего принимать
class ReportController
{
public function index(#[CurrentTenant] Tenant $tenant)
{
return view('reports', compact('tenant'));
}
}
Связка с мультитенантностью разобрана в KB_5 → Мультитенантность: такой атрибут — аккуратная замена вызову tenant() в теле каждого метода.
Обычный атрибут против контекстного
| Обычный атрибут | Контекстный атрибут | |
|---|---|---|
| Кто читает | Ты сам, вручную через рефлексию | Контейнер, автоматически |
| Участвует в DI | Нет | Да — результат подставляется в параметр |
| Что нужно | Только #[Attribute] | #[Attribute] + ContextualAttribute + resolve() |
| Примеры | #[Deprecated], свои маркеры | #[Config], #[CurrentUser], #[RouteParameter] |
config(), auth()->user() и app()->make() исчезают из бизнес-кода. Цена — лишний уровень косвенности: чтобы понять, откуда пришло значение, нужно открыть класс атрибута.Провайдеры — центральное место всего бутстрапа приложения. И твой код, и все сервисы ядра Laravel поднимаются через них.
Внутри Laravel использует десятки провайдеров, чтобы поднять mailer, очереди, кеш и остальное. Многие из них отложенные (deferred): они не грузятся на каждый запрос, а только когда предоставляемый ими сервис реально понадобился.
Все пользовательские провайдеры регистрируются в bootstrap/providers.php.
Как написать провайдер
Все провайдеры наследуют Illuminate\Support\ServiceProvider и обычно содержат два метода — register и boot.
php artisan make:provider RiakServiceProvider
Artisan сгенерирует класс и сам пропишет его в bootstrap/providers.php.
Метод register()
register() можно только биндить вещи в контейнер. Никогда не регистрируй здесь слушателей событий, маршруты или любую другую функциональность — иначе рискуешь воспользоваться сервисом, чей провайдер ещё не загрузился.Внутри любого метода провайдера доступно свойство $app — это контейнер.
namespace App\Providers;
use App\Services\Riak\Connection;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
class RiakServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Connection::class, function (Application $app) {
return new Connection(config('riak'));
});
}
}
Свойства $bindings и $singletons
Если провайдер регистрирует много простых биндингов, вместо ручных вызовов можно объявить два свойства. Фреймворк проверит их при загрузке провайдера и зарегистрирует сам.
class AppServiceProvider extends ServiceProvider
{
/** Все биндинги, которые нужно зарегистрировать. */
public $bindings = [
ServerProvider::class => DigitalOceanServerProvider::class,
];
/** Все синглтоны, которые нужно зарегистрировать. */
public $singletons = [
DowntimeNotifier::class => PingdomDowntimeNotifier::class,
ServerProvider::class => ServerToolsProvider::class,
];
}
Обрабатывается это в Application: при регистрации провайдера фреймворк проходит foreach ($provider->bindings ...) и foreach ($provider->singletons ...). Только формат «интерфейс ⇒ класс»; замыкание так не задать.
Метод boot()
А что если нужно зарегистрировать view composer? Это делается в boot(). Метод вызывается после того, как зарегистрированы все остальные провайдеры — значит, доступны все сервисы, которые успел зарегистрировать фреймворк.
class ComposerServiceProvider extends ServiceProvider
{
public function boot(): void
{
View::composer('view', function () {
// ...
});
}
}
Внедрение зависимостей в boot(). Его аргументы можно типизировать — контейнер подставит что нужно:
use Illuminate\Contracts\Routing\ResponseFactory;
public function boot(ResponseFactory $response): void
{
$response->macro('serialized', function (mixed $value) {
// ...
});
}
register() так делать нельзя — там зависимостей ещё может не быть. Внедрение в аргументы работает только у boot().register() против boot()
register() | boot() | |
|---|---|---|
| Когда | Первый проход по всем провайдерам | Второй проход, после всех register() |
| Что можно | Только биндинги в контейнер | Всё: события, view composers, макросы, маршруты, политики, Blade-директивы |
| Другие сервисы | Недоступны — провайдер мог ещё не загрузиться | Доступны все |
| Внедрение в аргументы | Нет | Да |
Регистрация провайдеров
Все провайдеры перечислены в bootstrap/providers.php — файл возвращает массив имён классов.
// bootstrap/providers.php
return [
App\Providers\AppServiceProvider::class,
];
Команда make:provider добавляет новый провайдер в этот массив автоматически. Если класс создан руками — дописать нужно самому:
return [
App\Providers\AppServiceProvider::class,
App\Providers\ComposerServiceProvider::class,
];
Отложенные провайдеры (Deferred Providers)
Если провайдер только регистрирует биндинги в контейнер, его загрузку можно отложить до момента, когда один из этих биндингов реально понадобится. Это ускоряет приложение: провайдер не читается с диска на каждый запрос.
Laravel компилирует и хранит список всех сервисов, которые предоставляются отложенными провайдерами, вместе с именем класса провайдера. Провайдер подгружается только при попытке разрешить один из этих сервисов.
Чтобы отложить загрузку — реализуй Illuminate\Contracts\Support\DeferrableProvider и опиши метод provides(), возвращающий зарегистрированные биндинги:
use Illuminate\Contracts\Support\DeferrableProvider;
class RiakServiceProvider extends ServiceProvider implements DeferrableProvider
{
public function register(): void
{
$this->app->singleton(Connection::class, function (Application $app) {
return new Connection($app['config']['riak']);
});
}
/** @return array<int, string> */
public function provides(): array
{
return [Connection::class];
}
}
boot() с событиями, маршрутами или макросами — отложить нельзя: этот код должен отработать на каждом запросе, а отложенный провайдер может не загрузиться вовсе.Фасады дают «статический» интерфейс к классам, доступным в сервис-контейнере. Laravel поставляется с множеством фасадов, закрывающих почти все возможности фреймворка.
Все фасады лежат в пространстве имён Illuminate\Support\Facades:
use Illuminate\Support\Facades\Cache;
Route::get('/cache', function () {
return Cache::get('key');
});
Хелперы — дополнение к фасадам
В дополнение к фасадам есть глобальные хелперы: view, response, url, config и другие. Они доступны глобально, импортировать классы не нужно.
use Illuminate\Support\Facades\Response;
Route::get('/users', function () {
return Response::json([ /* ... */ ]);
});
Route::get('/users', function () {
return response()->json([ /* ... */ ]);
});
Когда использовать фасады
Плюсы: короткий запоминающийся синтаксис, не нужно держать в голове длинные имена классов и внедрять их вручную. За счёт динамических методов PHP они при этом легко тестируются.
Фасады против внедрения зависимостей
Главный плюс DI — возможность подменить реализацию. Обычный статический метод подменить нельзя. Но фасады используют динамические методы, проксируя вызовы к объекту из контейнера, — поэтому тестируются они так же, как внедрённый экземпляр.
Route::get('/cache', function () {
return Cache::get('key');
});
// Тест: проверяем, что Cache::get вызвался с нужным аргументом
public function test_basic_example(): void
{
Cache::shouldReceive('get')
->with('key')
->andReturn('value');
$this->get('/cache')->assertSee('value');
}
Фасады против хелперов
Многие хелперы делают ровно то же, что соответствующий фасад. Эти два вызова эквивалентны:
return Illuminate\Support\Facades\View::make('profile');
return view('profile');
cache('key') вызывает метод get у того же класса, что стоит за фасадом Cache. Поэтому даже код на хелперах тестируется через Cache::shouldReceive('get') — точно так же, как код на фасадах.Как фасады работают внутри
Фасад — класс, дающий доступ к объекту из контейнера. Вся механика в базовом Illuminate\Support\Facades\Facade, который использует магический метод __callStatic(), чтобы переадресовать вызов объекту, разрешённому из контейнера.
Если заглянуть в Illuminate\Support\Facades\Cache, никакого статического метода get там нет — весь класс выглядит так:
class Cache extends Facade
{
/** Получить зарегистрированное имя компонента. */
protected static function getFacadeAccessor(): string
{
return 'cache';
}
}
Задача getFacadeAccessor() — вернуть имя биндинга в контейнере. Когда ты обращаешься к любому статическому методу фасада, Laravel разрешает биндинг cache из контейнера и выполняет запрошенный метод на полученном объекте.
// Illuminate\Support\Facades\Facade — ядро механизма
public static function __callStatic($method, $args)
{
$instance = static::getFacadeRoot();
if (! $instance) {
throw new RuntimeException('A facade root has not been set.');
}
return $instance->$method(...$args);
}
То есть Cache::get('key') разворачивается в app('cache')->get('key'). Фасад проксирует к контракту Illuminate\Contracts\Cache\Factory.
Фасад, хелпер или DI — что выбирать
| Способ | Пример | Чем хорош / чем плох |
|---|---|---|
| Фасад | Cache::get('k') | Короткий синтаксис, мокается. Зависимость не видна в сигнатуре класса |
| Хелпер | cache('k') | Практической разницы с фасадом нет: тот же биндинг, тестируется так же |
| DI | __construct(Repository $cache) | Зависимости явные, подменяются без хелперов фреймворка. Конструктор растёт — и это полезный сигнал |
Real-Time Facades
Любой свой класс можно использовать как фасад. Без них, чтобы опубликовать подкаст, пришлось бы прокидывать Publisher аргументом:
class Podcast extends Model
{
public function publish(Publisher $publisher): void
{
$this->update(['publishing' => now()]);
$publisher->publish($this);
}
}
Внедрение позволяет тестировать метод изолированно, но заставляет передавать Publisher при каждом вызове. Real-time facade сохраняет тестируемость и убирает аргумент. Чтобы его получить, добавь к пространству имён импортируемого класса префикс Facades:
use Facades\App\Contracts\Publisher;
class Podcast extends Model
{
public function publish(): void
{
$this->update(['publishing' => now()]);
Publisher::publish($this);
}
}
Реализация разрешается из контейнера по той части имени, что идёт после префикса Facades. В тестах мокается штатными хелперами:
use Facades\App\Contracts\Publisher;
public function test_podcast_can_be_published(): void
{
$podcast = Podcast::factory()->create();
Publisher::shouldReceive('publish')->once()->with($podcast);
$podcast->publish();
}
Справочник фасадов
Каждый фасад и класс, который за ним стоит. Третья колонка — ключ биндинга в контейнере, где он есть.
| Фасад | Класс | Ключ биндинга |
|---|---|---|
| App | Illuminate\Foundation\Application | app |
| Artisan | Illuminate\Contracts\Console\Kernel | artisan |
| Auth (Instance) | Illuminate\Contracts\Auth\Guard | auth.driver |
| Auth | Illuminate\Auth\AuthManager | auth |
| Blade | Illuminate\View\Compilers\BladeCompiler | blade.compiler |
| Broadcast (Instance) | Illuminate\Contracts\Broadcasting\Broadcaster | — |
| Broadcast | Illuminate\Contracts\Broadcasting\Factory | — |
| Bus | Illuminate\Contracts\Bus\Dispatcher | — |
| Cache (Instance) | Illuminate\Cache\Repository | cache.store |
| Cache | Illuminate\Cache\CacheManager | cache |
| Config | Illuminate\Config\Repository | config |
| Context | Illuminate\Log\Context\Repository | — |
| Cookie | Illuminate\Cookie\CookieJar | cookie |
| Crypt | Illuminate\Encryption\Encrypter | encrypter |
| Date | Illuminate\Support\DateFactory | date |
| DB (Instance) | Illuminate\Database\Connection | db.connection |
| DB | Illuminate\Database\DatabaseManager | db |
| Event | Illuminate\Events\Dispatcher | events |
| Exceptions (Instance) | Illuminate\Contracts\Debug\ExceptionHandler | — |
| Exceptions | Illuminate\Foundation\Exceptions\Handler | — |
| File | Illuminate\Filesystem\Filesystem | files |
| Gate | Illuminate\Contracts\Auth\Access\Gate | — |
| Hash | Illuminate\Contracts\Hashing\Hasher | hash |
| Http | Illuminate\Http\Client\Factory | — |
| Lang | Illuminate\Translation\Translator | translator |
| Log | Illuminate\Log\LogManager | log |
Illuminate\Mail\Mailer | mailer | |
| Notification | Illuminate\Notifications\ChannelManager | — |
| Password (Instance) | Illuminate\Auth\Passwords\PasswordBroker | auth.password.broker |
| Password | Illuminate\Auth\Passwords\PasswordBrokerManager | auth.password |
| Pipeline (Instance) | Illuminate\Pipeline\Pipeline | — |
| Process | Illuminate\Process\Factory | — |
| Queue (Base Class) | Illuminate\Queue\Queue | — |
| Queue (Instance) | Illuminate\Contracts\Queue\Queue | queue.connection |
| Queue | Illuminate\Queue\QueueManager | queue |
| RateLimiter | Illuminate\Cache\RateLimiter | — |
| Redirect | Illuminate\Routing\Redirector | redirect |
| Redis (Instance) | Illuminate\Redis\Connections\Connection | redis.connection |
| Redis | Illuminate\Redis\RedisManager | redis |
| Request | Illuminate\Http\Request | request |
| Response (Instance) | Illuminate\Http\Response | — |
| Response | Illuminate\Contracts\Routing\ResponseFactory | — |
| Route | Illuminate\Routing\Router | router |
| Schedule | Illuminate\Console\Scheduling\Schedule | — |
| Schema | Illuminate\Database\Schema\Builder | — |
| Session (Instance) | Illuminate\Session\Store | session.store |
| Session | Illuminate\Session\SessionManager | session |
| Storage (Instance) | Illuminate\Contracts\Filesystem\Filesystem | filesystem.disk |
| Storage | Illuminate\Filesystem\FilesystemManager | filesystem |
| URL | Illuminate\Routing\UrlGenerator | url |
| Validator (Instance) | Illuminate\Validation\Validator | — |
| Validator | Illuminate\Validation\Factory | validator |
| View (Instance) | Illuminate\View\View | — |
| View | Illuminate\View\Factory | view |
| Vite | Illuminate\Foundation\Vite | — |
Строки вида «Auth (Instance)» означают, что фасад может указывать и на менеджер, и на конкретный экземпляр драйвера — в зависимости от того, какой метод вызван.
BindingResolutionException: Target [App\Contracts\X] is not instantiable. Лечится биндингом в провайдере или атрибутом #[Bind] на интерфейсе.singleton под Octane. Синглтон, в который при создании попал Request или текущий пользователь, переживёт запрос и отдаст чужие данные следующему. Для такого состояния — scoped.register(). Порядок провайдеров не гарантирован, нужный биндинг может быть ещё не зарегистрирован. Всё, что требует готовых сервисов, — только в boot().register(). Фасад тянет объект из контейнера в момент вызова; если биндинг ещё не на месте — RuntimeException: A facade root has not been set.app(UserRepository::class) в середине метода вместо объявления в конструкторе прячет зависимость: по сигнатуре класса не видно, что ему нужно, и тест нельзя собрать, не читая тело. Явное внедрение в конструктор — норма, app() — исключение.- Контейнер — это ядро. Объект
Applicationи есть контейнер; всё остальное живёт в нём. - Ты не пишешь
newдля контроллеров, сервисов и джобов — контейнер строит граф объектов по типам в конструкторе через рефлексию. - Биндинг нужен в двух случаях: интерфейс в типе и разработка пакета. Конкретные классы разрешаются без конфигурации.
- Провайдеры — место сборки приложения:
register()только биндинги,boot()всё остальное, и строго в этом порядке по всем провайдерам. - Фасад — не статика, а
__callStatic()поверх биндинга. Отсюда и тестируемость, и риск незаметного разрастания класса.
Понимание цикла запроса — основа диагностики любого «магического» поведения Laravel. От ошибки 500 до неожиданного middleware-эффекта: всё лечится тем, что разработчик знает, в какой момент запускается какая часть фреймворка. Цикл одинаков и для классического PHP-FPM, и (с оговорками) для Octane — отличие в том, что в Octane bootstrap-фаза происходит один раз на процесс, а не на запрос.
Bootstrap = «загрузка / инициализация». Термин из идиомы «pull yourself up by your bootstraps» — «поднять себя за шнурки собственных ботинок». В контексте фреймворка это код, который выполняется до того, как приложение начнёт обрабатывать запрос.
Bootstrap собирает объект Application (DI-контейнер), регистрирует сервис-провайдеры, читает конфиги, вешает middleware — и только потом отдаёт управление роутеру.
Точка входа: public/index.php → подключает bootstrap/app.php → получает готовое приложение → $app->handle($request).
bootstrap/app.php (Laravel 11+)С Laravel 11 конфигурация приложения переехала из app/Http/Kernel.php, app/Console/Kernel.php и app/Exceptions/Handler.php в один декларативный файл bootstrap/app.php.
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->append(EnsureUserIsActive::class);
$middleware->alias(['admin' => AdminOnly::class]);
})
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (ApiException $e) {
return response()->json(['error' => $e->getMessage()], 422);
});
})
->create();
| Метод | Что настраивает |
|---|---|
configure(basePath: ...) | Стартует построение Application. basePath — корень проекта. |
withRouting | Пути к файлам маршрутов: web (с сессией/CSRF), api (без), commands (artisan). health: '/up' — авто-эндпоинт для healthcheck. |
withMiddleware | Регистрирует глобальные middleware, группы и алиасы. Пример: append() добавляет в конец глобального стека; alias() создаёт короткое имя для роутов. |
withExceptions | Кастомная обработка исключений. Пример: ApiException → JSON-ответ 422. |
create() | Финализирует и возвращает готовый Application. Дальше его подхватит public/index.php. |
Http/Kernel.php, Console/Kernel.php, Exceptions/Handler.php). Теперь всё в одном месте — bootstrap/app.php — как декларативная цепочка вызовов.
DI (Dependency Injection)
Внедрение зависимостей — зависимости не создаются внутри класса, а передаются извне. Класс перестаёт «знать», как строить свои сервисы — он их только принимает и использует.
// Без DI — класс сам создаёт зависимость
class UserService {
public function save() {
$db = new PDO('mysql:...'); // жёстко привязан к PDO
$db->exec(...);
}
}
// С DI — зависимость передаётся снаружи
class UserService {
public function __construct(private PDO $db) {}
public function save() {
$this->db->exec(...);
}
}
Laravel-контейнер сам разрешает эти зависимости при вызове: увидит PDO $db в конструкторе → сам создаст или подставит singleton. Подробнее — в KB_13 Service Container.
Декларативный vs Императивный
| Императивный | Декларативный | |
|---|---|---|
| Что описываешь | Как сделать — пошаговые инструкции | Что хочешь получить — фреймворк решает как |
| Кто собирает | Ты — руками через new, if, циклы |
Фреймворк — по твоей конфигурации |
| Пример | for ($i=0;$i<10;$i++) echo $i; |
SELECT * FROM users WHERE age > 18 |
Пример в Laravel: старый Laravel 10 требовал императивно править 3 файла — регистрируй middleware в $middleware, обработчик в Handler::register(). Laravel 11+ — декларативно: «мои middleware — вот эти, мои исключения — вот такие», а как это подключить — забота фреймворка.
Провайдеры (Service Providers)
Классы, где ты регистрируешь свои сервисы в контейнере и бутстрапишь их. Два ключевых метода:
register()— только биндинги в контейнер:$this->app->bind(PaymentGateway::class, StripeGateway::class). Никаких обращений к другим сервисам — они могут быть ещё не зарегистрированы.boot()— вызывается, когда всё зарегистрировано: сюда вешаешь Blade-директивы, observers, policies, кастомные валидаторы, макросы для коллекций/query builder.
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// Только биндинги — другие сервисы ещё могут быть не готовы
$this->app->bind(PaymentGateway::class, StripeGateway::class);
$this->app->singleton(Cache::class, fn() => new RedisCache());
}
public function boot(): void
{
// Всё зарегистрировано — можно вешать хуки, макросы, директивы
Blade::directive('money', fn($amount) => "<?= number_format($amount, 2) ?>");
User::observe(UserObserver::class);
Validator::extend('phone_kz', [PhoneRule::class, 'validate']);
}
}
bootstrap/providers.php (раньше — массив 'providers' в config/app.php). Пример:
// bootstrap/providers.php
return [
App\Providers\AppServiceProvider::class,
App\Providers\PaymentServiceProvider::class,
];
Порядок выполнения: Laravel сначала вызывает register() у ВСЕХ провайдеров, затем boot() у ВСЕХ. Отсюда правило «в register — только биндинги, в boot — всё что использует другие сервисы».
Ядро (Kernel) в Laravel — центральный диспетчер, через который проходят все запросы к приложению. Как «дирижёр оркестра», который решает, какие инструменты (middleware, сервис-провайдеры, маршруты) должны быть задействованы для обработки каждого конкретного запроса.
Два типа ядер
В Laravel существует два ядра, каждое отвечает за свой тип входящих запросов.
1. HTTP-ядро — Illuminate\Foundation\Http\Kernel
Назначение: обрабатывает все веб-запросы, поступающие через public/index.php.
Точка входа: public/index.php вызывает метод handleRequest() у Application (Laravel 11+), который внутри дёргает Http\Kernel::handle($request).
// public/index.php (Laravel 11+)
$app = require __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());
2. Консольное ядро — Illuminate\Foundation\Console\Kernel
Назначение: обрабатывает все команды Artisan, запускаемые из терминала (php artisan migrate, php artisan queue:work, etc).
Точка входа: файл artisan в корне проекта вызывает метод handleCommand().
// artisan (Laravel 11+)
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);
Основные задачи ядра
Независимо от типа, ядро выполняет две ключевые функции.
1. Запуск загрузчиков (Bootstrappers)
Перед обработкой запроса ядро выполняет серию подготовительных задач:
- Настройка обработки ошибок — регистрирует Whoops / Handler
- Настройка логирования — конфигурация каналов Monolog
- Определение окружения — читает
APP_ENVиз.env(local,production,testing) - Загрузка всех сервис-провайдеров — самый важный шаг: они регистрируют и настраивают базу данных, очереди, валидацию, маршрутизацию, кеш и всё остальное
2. Управление промежуточным слоем (Middleware)
Ядро отвечает за то, чтобы запрос прошёл через стек middleware до того, как попадёт в контроллер. Эти middleware решают критически важные задачи, общие для всех или многих запросов:
- Чтение и запись HTTP-сессий (
StartSession) - Проверка CSRF-токена для защиты от межсайтовых подделок запросов (
ValidateCsrfToken) - Проверка, не находится ли приложение в режиме обслуживания (
PreventRequestsDuringMaintenance) - Trim пробелов, конвертация пустых строк в null, обработка CORS, доверие прокси, шифрование cookies
Сравнительная таблица
| HTTP Kernel | Console Kernel | |
|---|---|---|
| Класс | Illuminate\Foundation\Http\Kernel |
Illuminate\Foundation\Console\Kernel |
| Точка входа | public/index.php → handleRequest() |
artisan → handleCommand() |
| Что обрабатывает | HTTP-запросы: браузер, API, webhooks | CLI-команды: migrate, queue:work, cron |
| Middleware | Полный стек (CSRF, session, cors, throttle...) | Нет (нет HTTP-запроса) |
| Регистрация в L11+ | withMiddleware() в bootstrap/app.php |
withCommands(), withSchedule() |
| Особая фаза | terminate() после отправки ответа клиенту |
Возвращает exit-code для shell |
- Запускают bootstrappers (env, config, facades, service providers)
- Регистрируют exception handler
- Отдают управление роутеру / командному диспетчеру
- После обработки — вызывают
terminate()для пост-хуков
1. public/index.php — точка входа
Веб-сервер (nginx/Apache/built-in artisan serve) направляет любой URL в public/index.php. Этот файл загружает автозагрузчик Composer и bootstrap/app.php, который создаёт экземпляр Application (наследник IoC-контейнера).
2. Bootstrap: bootstrap/app.php
Конфигурируется ядро: список middleware, провайдеров, обработчик исключений, маршруты. В Laravel 11 это стало декларативной API в одном файле (раньше — отдельные классы в app/Http/Kernel.php).
3. Kernel handle
Http\Kernel::handle($request) прогоняет запрос через bootstrappers (загрузка .env, конфига, фасадов, провайдеров) — это происходит один раз в начале запроса.
4. Service Providers: register → boot
Все провайдеры сначала проходят register() (только биндинги в контейнер), потом boot() (всё остальное: маршруты, события, фасады, observers). Подробно — в KB_13.
5. Pipeline: глобальные middleware
Запрос идёт через очередь глобальных middleware (TrustProxies, HandleCors, PreventRequestsDuringMaintenance, ValidatePostSize, TrimStrings, ConvertEmptyStringsToNull). Каждый может либо передать запрос дальше, либо вернуть response самостоятельно.
6. Router: подбор маршрута
Router находит маршрут по методу + URL, прогоняет через middleware группы (web/api), затем route-specific middleware. Привязки моделей (route model binding) разрешаются на этом шаге.
7. Controller / Closure
Вызывается обработчик с разрешёнными зависимостями через контейнер. Возвращает Response или то, что фреймворк превратит в Response (массив → JSON, view → HTML, модель → JSON).
8. Pipeline в обратном порядке + terminate
Response поднимается обратно по middleware (теперь после $next($request)). После отправки клиенту вызываются terminate у middleware, поддерживающих такую фазу — здесь делается долгая работа (логирование, аналитика), не влияющая на время ответа.
public/index.php: Laravel 10 vs 11+Точка входа для веб-запросов эволюционировала между версиями. Смысл тот же (запрос → ядро → ответ → клиент), но синтаксис в Laravel 11 стал значительно лаконичнее.
Laravel 10 и ниже — явное создание ядра
Здесь виден полный цикл: получить ядро из контейнера, передать ему запрос, отправить ответ, вызвать terminate().
// public/index.php
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$response = $kernel->handle(
$request = Illuminate\Http\Request::capture()
);
$response->send();
$kernel->terminate($request, $response);
Laravel 11+ — цепочка вызовов через handleRequest()
С Laravel 11 код в index.php стал ещё более лаконичным. Вместо явного создания ядра и вызова handle() используется «цепочка» вызовов, которая заканчивается методом handleRequest().
// public/index.php
(require_once __DIR__.'/../bootstrap/app.php')
->handleRequest(Request::capture());
Метод handleRequest() внутри Application сам делает то же самое: получает Kernel из контейнера, передаёт запрос, отправляет ответ, вызывает terminate. Просто скрыто под красивую цепочку.
Аналогично для artisan (консольные команды):
// artisan — Laravel 11+
$status = (require_once __DIR__.'/bootstrap/app.php')
->handleCommand(new Symfony\Component\Console\Input\ArgvInput);
exit($status);
// Простой способ увидеть, что происходит — расширить базовый middleware
final class TraceRequest
{
public function handle(Request $request, Closure $next): Response
{
$start = microtime(true);
Log::debug('request.before', ['url' => $request->fullUrl()]);
$response = $next($request); // уходим в следующий middleware → controller → обратно
Log::debug('request.after', [
'status' => $response->getStatusCode(),
'duration_ms' => round((microtime(true) - $start) * 1000, 2),
]);
return $response;
}
public function terminate(Request $request, Response $response): void
{
// Здесь — после отправки клиенту. Дорогая отправка в аналитику.
Analytics::track($request, $response);
}
}
boot() провайдера. boot() выполняется на каждом запросе. Тяжёлый код (загрузка большого JSON, обращение к API) накапливается. Используйте deferred providers или ленивую инициализацию.web/api).terminate без поддержки. Метод terminate вызывается только если веб-сервер поддерживает fastcgi_finish_request (FPM да; built-in artisan serve — нет). На built-in сервере terminate работает синхронно, удлиняя ответ.DB::enableQueryLog() на проде раздувает память: каждый запрос хранится в массиве до конца запроса.config() без config:cache. Без php artisan config:cache Laravel перечитывает все конфиги с диска на каждом запросе (десятки файлов). На проде config:cache обязателен.storage/logs/laravel.log при больших объёмах. Один файл, отсутствует ротация по умолчанию — за неделю несколько ГБ. Используйте daily канал или внешний агрегатор.Handler::render может вернуть response, но забыть закрыть транзакцию или освободить ресурс. Handler::report и render разделены неспроста: report для логов, render для ответа клиенту.Список провайдеров лежит не в bootstrap/app.php, а в отдельном файле bootstrap/providers.php. Это просто массив:
// bootstrap/providers.php
return [
App\Providers\AppServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
];
В bootstrap/app.php тоже есть метод withProviders(), но он для особых случаев (например, подгрузить провайдеры из другой директории). Дефолтный путь — providers.php.
// app/Providers/SmsServiceProvider.php
class SmsServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(SmsSender::class, function ($app) {
return new MobizonSender(
config('services.mobizon.key'),
$app->make(HttpClient::class),
);
});
}
}
Тут нет логики. Ни одной SMS не отправлено. Это инструкция: «когда кто-то попросит SmsSender — вот рецепт, как его собрать».
Смотри на конструктор MobizonSender. Ему нужен string $apiKey. Контейнер про строки ничего не знает — он не может угадать, какую строку туда подставить. Автоматика ломается.
Плюс SmsSender — интерфейс. Его вообще нельзя инстанцировать, new SmsSender() невозможен.
Поэтому и нужен провайдер: он закрывает оба этих пробела.
class OrderController
{
public function __construct(private SmsSender $sms) {} // просит интерфейс
public function store(Request $request)
{
$order = Order::create($request->validated());
$this->sms->send($order->phone, "Заказ №{$order->id} принят");
}
}
Контроллер просит SmsSender. Контейнер лезет в свой реестр, находит запись, которую положил туда провайдер, выполняет замыкание, отдаёт готовый MobizonSender.
Если провайдер не зарегистрирован в bootstrap/providers.php — его register() никогда не выполнится, в реестре пусто, и ты получишь:
Target [App\Contracts\SmsSender] is not instantiable.
// app/Services/OrderService.php
class OrderService
{
public function __construct(
private OrderRepository $repo, // конкретный класс
private SmsSender $sms, // интерфейс, но он уже забинден выше
) {}
public function complete(Order $order): void
{
$this->repo->markCompleted($order);
$this->sms->send($order->phone, 'Заказ выполнен');
}
}
Это тоже сервис. Но регистрировать его нигде не надо — контейнер соберёт его сам: OrderRepository это конкретный класс (autowiring справится), SmsSender уже описан провайдером.
Просто пишешь в контроллере public function __construct(private OrderService $service) — и всё работает. Ноль конфигурации.
| Сервис | Провайдер | |
|---|---|---|
| Что делает | Бизнес-логику | Настраивает контейнер |
| Когда выполняется | Когда ты его вызвал | Один раз при старте приложения, всегда |
| Сколько раз в проекте | Десятки-сотни классов | Обычно 1-5 |
| Где лежит | app/Services, app/Actions, где угодно | app/Providers |
| Регистрация | Не нужна (autowiring) | Обязательна, в bootstrap/providers.php |
| Нужен, если... | Есть логика, которую надо где-то держать | Контейнер сам не догадается, как собрать объект |
Большая часть классов подключается сама, и это главное, что стоит понять:
| Что | Как находится |
|---|---|
| Контроллеры | По имени в роуте + autowiring контейнера |
| Модели Eloquent | Просто new User / через контейнер |
| Middleware-классы | Указываешь в роуте или в withMiddleware() |
| Jobs, Events, Listeners, Notifications | Автодискавери по неймспейсу |
| Form Requests | Type-hint в методе контроллера |
| Console-команды | Автосканирование app/Console/Commands |
| Миграции, сидеры, фабрики | По конвенции путей |
| Policies | Автодискавери по имени (Post → PostPolicy) |
Работает это на конвенциях + Composer autoload (PSR-4). Composer знает, что App\ → app/, поэтому App\Services\OrderService находится по пути app/Services/OrderService.php без единой строчки конфига.
Ключевая разница: у всех перечисленных выше классов есть триггер, по которому Laravel понимает, когда их создавать. Роут пришёл → создай контроллер. Событие вылетело → найди слушателя.
У провайдера триггера нет. Провайдер — это код, который должен выполниться безусловно, на каждом старте приложения, до всего остального. Laravel не может «догадаться», что тебе нужно зарегистрировать биндинг — он должен получить явный список: вот эти классы прогони через register(), потом через boot().
Плюс порядок важен: register() у всех провайдеров вызывается раньше, чем boot() у любого из них — именно поэтому в register() нельзя дёргать чужие сервисы. Автодискавери такой порядок не гарантирует.
Пакеты из vendor свои провайдеры регистрируют сами — через extra.laravel.providers в своём composer.json (package auto-discovery). Поэтому после composer require обычно ничего руками прописывать не надо.
bootstrap/app.phpПолная картина методов:
return Application::configure(basePath: dirname(__DIR__))
->withRouting(...)
->withMiddleware(...)
->withExceptions(...)
->withCommands(...)
->withSchedule(...)
->withEvents(...)
->withBindings(...)
->withSingletons(...)
->withProviders(...)
->create();
Основные, которые реально трогают в проектах:
1. withRouting() — точки входа
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up', // готовый эндпоинт для healthcheck
apiPrefix: 'api/v1',
then: function () { // свои файлы роутов
Route::middleware('web')
->prefix('admin')
->group(base_path('routes/admin.php'));
},
)
2. withMiddleware() — глобальный конвейер запроса
->withMiddleware(function (Middleware $middleware) {
$middleware->append(SetLocale::class); // в конец глобального стека
$middleware->prepend(TrustProxies::class); // в начало
$middleware->web(append: [EnsureProfileComplete::class]); // в группу web
$middleware->api(prepend: [ForceJsonResponse::class]);
$middleware->alias(['admin' => AdminOnly::class]); // короткое имя для роутов
$middleware->remove(ValidateCsrfToken::class); // выкинуть дефолтный
$middleware->throttleApi('60,1');
$middleware->trustProxies(at: '*');
$middleware->validateCsrfTokens(except: ['webhooks/*']);
})
3. withExceptions() — что делать с ошибками
->withExceptions(function (Exceptions $exceptions) {
$exceptions->dontReport(BusinessRuleException::class);
$exceptions->render(function (ModelNotFoundException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json(['message' => 'Не найдено'], 404);
}
});
$exceptions->report(function (PaymentFailed $e) {
Sentry::captureException($e);
});
$exceptions->context(fn () => ['tenant_id' => tenant()?->id]);
})
4. withSchedule() — крон-задачи
->withSchedule(function (Schedule $schedule) {
$schedule->command('reports:daily')->dailyAt('03:00');
})
Хотя чаще их пишут в routes/console.php.
5. withBindings() / withSingletons() — быстрые биндинги без провайдера
->withSingletons([
PaymentGateway::class => StripeGateway::class,
])
Удобно для мелочи, но для чего-то серьёзного всё равно лучше провайдер — там есть boot() и логика группируется по смыслу.
| Место | Что там |
|---|---|
bootstrap/app.php | Конфигурация ядра: middleware, роуты, обработка исключений |
bootstrap/providers.php | Список провайдеров |
config/*.php | Значения: параметры БД, кеша, почты, сторонних сервисов |
.env | Секреты и то, что различается между dev/prod |
routes/*.php | Маршруты |
Всё остальное в app/ | Само подхватывается по PSR-4 и конвенциям |
bootstrap/ отвечает на вопрос «как собрать приложение», config/ — «с какими параметрами оно работает».
Обе сущности «подключаются к жизненному циклу», но решают разные задачи. Важно не путать: провайдер — про подготовку приложения, middleware — про обработку конкретного запроса.
| Сущность | Когда выполняется | Что делает | Пример |
|---|---|---|---|
| Middleware | На каждый HTTP-запрос — в цепочке до и после контроллера | Обрабатывает входящий запрос и исходящий ответ (модифицирует, проверяет, логирует) | Проверка CSRF, CORS, сжатие ответа (gzip), логирование времени, throttle rate limit |
| Service Provider | Один раз при старте приложения (register() + boot() для каждого запроса, но до роутинга) |
Регистрирует сервисы в контейнере, загружает маршруты, события, observers, конфиги, Blade-директивы, макросы | EventServiceProvider, RouteServiceProvider, кастомный провайдер для платёжного шлюза |
register() и boot() подробноВ Laravel каждый провайдер имеет два ключевых метода, которые вызываются в строгом порядке.
register() — только привязки в контейнер
- Здесь запрещено использовать какие-либо другие сервисы (БД, кеш, конфиги, роутинг) — они ещё не загружены.
- Единственное, что можно — это
$this->app->bind(...)или$this->app->singleton(...). - Выполняется для всех провайдеров (в порядке их перечисления) прежде чем у какого-либо провайдера вызовется
boot().
boot() — всё остальное
- Здесь уже зарегистрированы все провайдеры, поэтому можно использовать любые сервисы.
- Маршруты, события, Blade-директивы, observers, валидационные правила, макросы для Collection / Query Builder, gate policies.
- Выполняется после того, как у всех провайдеров отработал
register().
Почему порядок именно такой — пример:
// Provider A
class LoggerServiceProvider extends ServiceProvider
{
public function register(): void
{
// Биндим интерфейс к реализации
$this->app->bind(LoggerInterface::class, FileLogger::class);
}
}
// Provider B
class AuditServiceProvider extends ServiceProvider
{
public function boot(LoggerInterface $logger): void
{
// Пытаемся использовать LoggerInterface, забинденный в Provider A.
// Работает, ПОТОМУ ЧТО:
// 1. У ВСЕХ провайдеров сначала выполнился register()
// 2. Только потом Laravel начал вызывать boot()
// Если бы порядок был другой (register+boot по одному провайдеру),
// Provider B мог бы попытаться использовать LoggerInterface
// раньше чем Provider A его забиндил → ошибка.
$logger->info('Audit provider booted');
}
}
boot() любого провайдера все биндинги от других провайдеров уже доступны. Никогда не обращайтесь к другим сервисам из register().
Route::get(...) или DB::table(...) в register(). Роутер и БД ещё не зарегистрированы → BindingResolutionException или тихая поломка. Всё что использует другие сервисы — только в boot().
Router связывает входящий URL с обработчиком. На простом уровне — это таблица «URL → callable». На продвинутом — механизм с route model binding, scoped bindings, route caching, проверкой подписей, привязкой по типам и сложной системой middleware groups. Понимание этих возможностей даёт компактный читаемый routing и предсказуемое поведение под нагрузкой.
Route model binding (implicit)
Route::get('/users/{user}', fn (User $user) => ...) — Laravel автоматически делает User::find($id) и кидает 404 если нет. По умолчанию ищет по PK; для другого ключа — $user:slug или getRouteKeyName() на модели.
Explicit binding
В RouteServiceProvider::boot: Route::bind('user', fn ($value) => User::where(...)->firstOrFail()) — кастомная логика разрешения. Полезно для мультитенантности, scope'ов.
Scoped bindings
Route::get('/users/{user}/posts/{post:slug}', ...)->scopeBindings() — Post ищется в контексте User (через relation). Гарантирует, что /users/1/posts/foo не вернёт пост, принадлежащий другому пользователю.
Middleware на маршруте/группе
Маршрут получает middleware из своей группы (web/api), плюс заявленные через ->middleware('auth'). Порядок: глобальные → групповые → маршрутные. Параметры middleware: auth:sanctum, throttle:60,1.
Route caching
php artisan route:cache компилирует все маршруты в единый PHP-файл bootstrap/cache/routes-v7.php — кратно ускоряет startup. Сброс — route:clear. Разбор — в подразделе «Кеширование маршрутов» ниже.
Signed routes
URL::signedRoute('verify', ['user' => $id]) — URL с подписью, защищающей от подмены параметров. Используется для подтверждения email, magic-link login и пр. Middleware signed проверяет.
// Уязвимо: без кастомного ключа и без scopeBindings() — скоупа НЕТ
Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
// Post::find($id) глобально — чужой пост откроется
return $post;
});
// Вариант 1: явно включить скоуп
Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
return $post;
})->scopeBindings();
// Вариант 2: кастомный ключ — скоуп включается САМ, без scopeBindings()
Route::get('/users/{user}/posts/{post:slug}', function (User $user, Post $post) {
return $post;
});
// Оба варианта: Post ищется через $user->posts(), чужой — 404
/users/{user}/posts/{post:slug} с подписью «без scopeBindings() уязвимо». Это неверно: при кастомном ключе у вложенного параметра Laravel скоупит запрос сам. В исходниках это одно условие — Illuminate\Routing\ImplicitRouteBinding:$route->enforcesScopedBindings() || array_key_exists($parameterName, $route->bindingFields())То есть
scopeBindings() нужен ровно тогда, когда кастомного ключа нет.Implicit Binding (неявная привязка)
Laravel сам догадывается, как найти модель по ID в URL. Указываешь в параметре роута имя переменной, совпадающее с именем модели, и Laravel делает Model::find($id). Если не находит — автоматически 404.
Route::get('/users/{user}', function (User $user) {
return $user; // Laravel сам вызывает User::find($user)
});
По умолчанию ищет по первичному ключу (id). Чтобы искать по другому полю (например, slug) — либо в модели определить getRouteKeyName(), либо передать в параметре {user:slug}.
// Способ 1: параметр в URL
Route::get('/users/{user:slug}', fn (User $user) => $user);
// Способ 2: на уровне модели — глобально
class User extends Model
{
public function getRouteKeyName(): string
{
return 'slug';
}
}
Explicit Binding (явная привязка)
Ты сам говоришь, как именно доставать модель. Нужно, если логика сложнее чем просто find(): мультитенантность, скоуп по владельцу, дополнительные условия.
// В RouteServiceProvider::boot() или bootstrap/app.php (L11)
Route::bind('user', function ($value) {
return User::where('tenant_id', auth()->id())
->where('id', $value)
->firstOrFail();
});
// Теперь при каждом /users/{user} — используется твоя логика
Route::get('/users/{user}', fn (User $user) => $user);
// Юзер чужого tenant → 404
Scoped Bindings (привязка с контекстом)
Частный случай implicit binding, но с дополнительным условием: модель ищется в рамках отношения с другой моделью. Пример: у пользователя есть посты. URL /users/{user}/posts/{post}. Без scoped binding Laravel найдёт пост по slug глобально, даже если он не принадлежит этому пользователю.
Route::get('/users/{user}/posts/{post:slug}', function (User $user, Post $post) {
return $post;
})->scopeBindings();
// С scopeBindings() Laravel строит запрос через отношение $user->posts():
// Post::where('user_id', $user->id)->where('slug', $post)->firstOrFail()
// Если поста с таким slug у данного пользователя нет — 404
Closure Routes (замыкание вместо контроллера)
Closure — анонимная функция, которая прямо в роуте обрабатывает запрос. Удобно для быстрых тестов, health-check эндпоинтов, роутов на 1 строчку.
Route::get('/hello', function () {
return 'Hello, world!';
});
laravel/serializable-closure. Широко распространённое «closure ломает route:cache» — устаревшее знание. Держать логику в контроллерах стоит ради читаемости и тестируемости, а не из-за кеша.
Invokable Classes (инвокабельные классы)
Классы с единственным методом __invoke(). Используются как обработчики роута вместо контроллеров с несколькими методами. Выглядит чище для простых действий (Single Action Controllers).
// app/Http/Controllers/ShowProfile.php
class ShowProfile
{
public function __invoke($id)
{
return view('profile', ['user' => User::findOrFail($id)]);
}
}
// routes/web.php
Route::get('/profile/{id}', ShowProfile::class);
// Laravel сам создаст экземпляр и вызовет __invoke()
// Кешируется при route:cache без сериализации — в кеш ложится имя класса
Signed Routes (подписанные маршруты)
URL с добавленной криптографической подписью (хеш) — защищает параметры от подмены. Используются для подтверждения email, ссылок для сброса пароля, magic login.
use Illuminate\Support\Facades\URL;
// Создаёшь подписанный URL
$url = URL::signedRoute('verify', ['user' => 1]);
// https://yourapp.com/verify?user=1&signature=abc123...
// Временный (с истечением) — предпочтительно для magic-link
$url = URL::temporarySignedRoute('verify', now()->addMinutes(15), ['user' => 1]);
// В роуте применяешь middleware signed
Route::get('/verify', function (Request $request) {
// Подпись валидна, параметры не подменены
})->name('verify')->middleware('signed');
// Если кто-то изменит user=1 на user=2 — подпись невалидна, middleware вернёт 403
Оба механизма влияют на то, какая модель окажется в аргументе контроллера, но работают на разных уровнях. Explicit заменяет сам способ поиска, Scoped — лишь область поиска.
| Explicit Binding | Scoped Bindings | |
|---|---|---|
| Суть | «Как искать модель» | «Где искать модель» |
| Что делает | Полностью заменяет логику разрешения — find() не вызывается вообще | Оставляет обычный implicit binding, но сужает запрос до отношения с родителем |
| Кто пишет запрос | Ты, целиком, в колбэке | Laravel, по имени отношения |
| Где регистрируется | Глобально: Route::bind() в AppServiceProvider::boot() | Точечно: ->scopeBindings() на маршруте или группе |
| Область действия | Все маршруты с параметром этого имени | Только тот маршрут / группа, где включено |
| Типичное применение | Мультитенантность, мягкое удаление, нестандартный ключ, предзагрузка связей | Вложенные ресурсы: /users/{user}/posts/{post} |
Explicit Binding — «как искать»
Регистрируется один раз и применяется ко всем маршрутам, где встречается параметр с таким именем: и /users/{user}, и /admin/users/{user}, и /api/users/{user}.
// AppServiceProvider::boot()
Route::bind('user', function ($value) {
return User::where('tenant_id', tenant()->id)
->where('id', $value)
->firstOrFail();
});
Ключевое: запрос пишешь ты целиком. Laravel не делает find(), он просто вызывает твой колбэк и берёт результат.
Scoped Bindings — «искать в контексте родителя»
Логика поиска остаётся стандартной, добавляется ограничение: дочерняя модель достаётся через отношение родительской.
Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
return $post;
})->scopeBindings();
// Laravel строит запрос через отношение, имя угадывает по имени параметра:
// $user->posts()->where('id', $value)->firstOrFail()
Ключевое: ты не пишешь запрос, а сообщаешь Laravel: «ищи пост среди постов этого пользователя». Имя отношения выводится из имени параметра во множественном числе — {post} → $user->posts().
scopeBindings() обязателен, а когда нет. Скоуп применяется, если выполнено хотя бы одно условие: вызван scopeBindings() или у вложенного параметра задан кастомный ключ ({post:slug}). Отсюда практическое правило:
•
{post:slug} — скоуп уже работает, вызывать ничего не нужно;
•
{post} — скоупа нет, нужен явный ->scopeBindings().Управление скоупом на группе и его отключение
// Включить для всей группы маршрутов
Route::scopeBindings()->group(function () {
Route::get('/users/{user}/posts/{post}', [PostController::class, 'show']);
Route::get('/users/{user}/orders/{order}', [OrderController::class, 'show']);
});
// Принудительно отключить, даже при кастомном ключе
Route::get('/users/{user}/posts/{post:slug}', fn (User $user, Post $post) => $post)
->withoutScopedBindings();
Включать скоуп на группе надёжнее, чем на каждом маршруте: забыть один вызов в списке из двадцати вложенных ресурсов — вопрос времени.
Третий способ: переопределение на модели
Если логика поиска — свойство самой модели, а не маршрута, её можно задать прямо в модели. Это работает и там, где Route::bind() был бы слишком грубым (он перехватывает параметр во всём приложении).
class Post extends Model
{
// Обычный implicit binding
public function resolveRouteBinding($value, $field = null)
{
return $this->where($field ?? 'id', $value)
->where('status', 'published')
->firstOrFail();
}
// Вызывается вместо предыдущего, когда действует scoped binding
public function resolveChildRouteBinding($childType, $value, $field)
{
return parent::resolveChildRouteBinding($childType, $value, $field);
}
}
Route::bind('post', ...), то твой колбэк получает только сырое значение из URL — родительская модель в него не передаётся, и никакого скоупа Laravel не добавит. Совмещать мультитенантность через Route::bind() с вложенными ресурсами нужно осознанно: проверку принадлежности родителю в этом случае пишешь сам, внутри колбэка./users/{user}/posts/{post} со скоупом по-прежнему отдаст чужой пост, если в URL подставить чужой {user}. Policy всё так же обязательна.php artisan route:cache — Artisan-команда, которая компилирует все маршруты приложения в один PHP-файл bootstrap/cache/routes-v7.php. Если файл существует, Laravel на старте подключает его напрямую и полностью пропускает регистрацию маршрутов.
Что происходит без кеша и с ним
| Без кеша — на каждый запрос | С кешем | |
|---|---|---|
| Файлы маршрутов | Парсятся и исполняются routes/web.php, api.php и все подключённые | Не читаются вообще |
| Объекты Route | Создаются заново для каждого маршрута | Восстанавливаются из готового массива |
| Регулярные выражения | Компилируются для каждого URI с параметрами | Уже скомпилированы и сохранены |
| Стоимость | Растёт линейно с числом маршрутов | Одно подключение файла |
Команды
# Собрать кеш маршрутов
php artisan route:cache
# Сбросить кеш
php artisan route:clear
# Собрать сразу всё: config + events + routes + views
php artisan optimize
# Сбросить всё разом
php artisan optimize:clear
# Список маршрутов — работает и при активном кеше
php artisan route:list
optimize — это не отдельная магия, а последовательный запуск config:cache, event:cache, route:cache и view:cache. Стандартный шаг продакшен-деплоя.
Closure-маршруты и кеш
Правило «замыкания нельзя закешировать» разошлось по статьям и до сих пор встречается в подготовительных материалах. В актуальных версиях Laravel оно неверно: замыкание сериализуется через пакет laravel/serializable-closure.
// Illuminate\Routing\Route::prepareForSerialization()
if ($this->action['uses'] instanceof Closure) {
$this->action['uses'] = serialize(
SerializableClosure::unsigned($this->action['uses'])
);
}
Раньше на этом месте бросалось исключение LogicException: Unable to prepare route [...] for serialization. Uses Closure. — сейчас его в коде нет. Держать логику в контроллерах по-прежнему стоит, но ради читаемости и тестируемости, а не из-за кеша.
Подводные камни
route:cache обязан быть шагом деплоя, а не ручной операцией «когда вспомню».routes/web.php просто не видны. Симптом — «я поменял маршрут, а ничего не изменилось». Лечится route:clear.LogicException: ... Another route has already been assigned name [...] в AbstractRouteCollection. Без кеша дубль имени тихо переопределяет предыдущий маршрут и не проявляется — то есть route:cache заодно работает валидатором.routes/*.php, в кеш попадёт то состояние, которое было в момент сборки. Изменения в БД на маршруты больше не влияют до следующего route:cache.config:cache. optimize собирает и конфиг тоже, а при закешированном конфиге env() вне файлов config/*.php возвращает null. Вызов env() внутри замыкания маршрута после optimize молча сломается.route:cache — обязательный шаг деплоя и бесплатное ускорение старта на больших приложениях. В разработке — не включать. Аргумент про closure устарел; актуальные причины писать контроллеры — читаемость, тестируемость и переиспользование, а не возможность собрать кеш.Подписанный URL — обычная ссылка, к которой добавлен параметр signature: HMAC-хеш от самого URL, посчитанный на секрете приложения. Любая правка параметров меняет хеш, и middleware signed отдаёт 403.
Что именно подписывается
Всё сводится к одной строке в UrlGenerator::hasCorrectSignature():
hash_hmac('sha256', $original, $key)
// $original = полный URL с query-строкой, но БЕЗ параметра signature
// $key = APP_KEY приложения
// сравнение — через hash_equals(), защита от timing-атак
При генерации параметры проходят через ksort() — порядок в подписанной ссылке всегда алфавитный, поэтому подпись воспроизводима. Срок жизни — это не отдельный механизм, а обычный query-параметр expires с unix-таймстампом, который тоже попадает под подпись.
// Illuminate\Routing\Exceptions\InvalidSignatureException
parent::__construct(403, 'Invalid signature.');
expires. Если ссылка ушла в переписку, попала в логи прокси или в историю браузера — она продолжит работать. Для настоящей одноразовости нужен собственный признак использования: токен в БД, смена состояния модели или запись в кеш. Верификация email у Laravel работает не потому, что ссылка одноразовая, а потому что повторный вызов markEmailAsVerified() идемпотентен.Абсолютная и относительная подпись
По умолчанию подписывается абсолютный URL — вместе со схемой и доменом. Значит подпись развалится, если ссылка сгенерирована на одном хосте, а проверяется на другом: воркер с другим APP_URL, балансировщик, подменяющий схему на http, смена домена.
// Относительная подпись — домен и схема в хеш не входят
$url = URL::signedRoute('verify', ['user' => 1], null, false);
// И проверять её нужно соответствующим вариантом middleware
Route::get('/verify', VerifyController::class)
->middleware('signed:relative');
// Вручную:
$request->hasValidRelativeSignature();
Несовпадение режимов — типичная причина «у меня локально работает, а на проде 403».
Лишние query-параметры ломают подпись
Хеш считается от всей query-строки. Почтовые сервисы и трекеры любят дописывать своё: utm_source, параметры Outlook SafeLinks, метки рассылок. Любая такая добавка делает подпись невалидной.
// Игнорировать конкретные параметры при проверке
use Illuminate\Routing\Middleware\ValidateSignature;
Route::get('/verify', VerifyController::class)
->middleware(ValidateSignature::absolute(['utm_source', 'utm_campaign']));
// Относительный вариант с тем же игнором
ValidateSignature::relative(['utm_source']);
Игнорируемые параметры выбрасываются из строки до подсчёта хеша. Добавлять в этот список стоит только то, что не влияет на логику: подпись их больше не защищает.
Ротация APP_KEY
Подпись завязана на APP_KEY. Смена ключа мгновенно обесценивает все выданные ссылки — незавершённые верификации email и висящие magic-link'и перестанут открываться. Поэтому проверка идёт по списку ключей:
// .env
APP_KEY=base64:новый...
APP_PREVIOUS_KEYS=base64:старый...
// Генерация — всегда текущим ключом ($key[0])
// Проверка — перебором по всем ключам, пока один не совпадёт
signature и expires зарезервированы. Попытка передать свой параметр с таким именем роняет генерацию: InvalidArgumentException: "Signature" is a reserved parameter when generating signed routes. Проверка стоит в ensureSignedRouteParametersAreNotReserved().expires.expires живёт вечно. signatureHasNotExpired() возвращает true, если параметра expires просто нет. То есть signedRoute() без срока — бессрочный пропуск. Для писем и magic-link всегда temporarySignedRoute().| Термин | Суть |
|---|---|
| Implicit binding | Автоматический поиск модели по ID из URL. Laravel сам делает Model::find() и кидает 404 если нет. |
| Explicit binding | Твоя кастомная логика поиска модели, регистрируется через Route::bind() в провайдере или bootstrap/app.php. |
| Scoped binding | Implicit binding + дополнительное условие через отношение — чтобы нельзя было достать чужую запись. Активируется ->scopeBindings(). |
| Closure route | Роут с обработчиком в виде анонимной функции. Удобно для health-check и редиректов; в актуальных версиях кешируется наравне с контроллерами. |
| Invokable class | Класс с методом __invoke() — альтернатива контроллеру с одним действием. Кешируется. |
| Signed route | URL с криптоподписью, защищает параметры от подмены. Проверяется middleware signed. |
route:cache после деплоя. Кеш — слепок маршрутов на момент сборки. Выкатили новый маршрут и не пересобрали кеш — на проде 404, а локально всё работает. route:cache обязан быть шагом деплоя, а не ручной операцией./users/{user} и /users/me — первый поймает me как параметр. Объявляйте конкретные маршруты раньше параметризованных.findOrFail. Если параметр маршрута не существует, Laravel вернёт 404 автоматически. Но если в кастомной логике написать where(...)->first() — может вернуть null. Используйте firstOrFail.signedRoute без temporarySignedRoute валиден вечно. Для magic-link нужен короткий expiry (15 минут).throttle:60,1 без указания ключа ограничивает по IP. За NAT пользователь может «съесть» лимит для всех. Используйте throttle на user_id для аутентифицированных.php artisan down, доступ к маршрутам блокируется. Исключения — через --secret и middleware preventDuringMaintenance.request()->input(...) вместо параметров маршрута: значение тянется из тела, query или route — приоритет неочевиден. Явно типизируйте через FormRequest.fallback ловит всё. Route::fallback(...) срабатывает на любой не-matched URL. Если в нём опечатка в check'е — пользователь может попасть на код, не предназначенный для публичного доступа.Контроллер — класс, который группирует связанные обработчики HTTP-запросов. По конвенции живёт в app/Http/Controllers/. Роутер вызывает нужный метод контроллера, передавая туда Request и параметры маршрута.
Типичная структура:
app/Http/Controllers/
├── Controller.php # базовый — от него наследуются все
├── HomeController.php # для веба
├── PostController.php # resource controller
├── Auth/
│ ├── LoginController.php
│ └── RegisterController.php
├── Api/
│ ├── V1/
│ │ ├── PostController.php # API resource
│ │ └── UserController.php
│ └── V2/...
└── Admin/
└── DashboardController.php
Создание через artisan:
php artisan make:controller PostController # пустой
php artisan make:controller PostController --resource # 7 методов CRUD
php artisan make:controller PostController --api # 5 методов (без create/edit)
php artisan make:controller PostController --resource --model=Post # + type-hints модели
php artisan make:controller ShowProfile --invokable # один __invoke
php artisan make:controller Api/V1/PostController # в подпапку
PostController, UserController), а не по «функциям». Один контроллер = одна сущность / одна тема. Веб и API — обычно отдельно (Api/V1/).
Ключевой архитектурный принцип: контроллер должен быть тонким. Его задача — принять запрос, делегировать работу, вернуть ответ. Всю бизнес-логику вынести в отдельные классы (Actions / Services).
Толстый контроллер — плохо: логика размазана, тяжело тестировать, невозможно переиспользовать:
class OrderController extends Controller
{
public function store(Request $request)
{
$data = $request->validate([/* ... */]);
// Валидация бизнес-правил
if (Cart::total() < 100) { abort(422, 'Мин. сумма 100'); }
// Создание заказа
$order = Order::create($data);
// Списание товаров со склада
foreach ($data['items'] as $item) {
Inventory::reserve($item['sku'], $item['qty']);
}
// Оплата
$payment = new StripeGateway(config('stripe.key'));
$payment->charge($order->total);
// Уведомление
Mail::to($order->email)->send(new OrderPlaced($order));
return redirect()->route('orders.show', $order);
}
}
Тонкий контроллер — хорошо: только оркестрация, вся логика в Action:
class OrderController extends Controller
{
public function store(StoreOrderRequest $request, PlaceOrder $action)
{
$order = $action->handle($request->validated());
return redirect()->route('orders.show', $order);
}
}
Что контроллер делает, а что нет:
| Контроллер должен | Контроллер НЕ должен |
|---|---|
| Принимать запрос через FormRequest | Валидировать бизнес-правила («сумма > 100») |
| Вызвать Action / Service | Работать с БД напрямую (кроме простых Model::find) |
| Формировать ответ (view/json/redirect) | Отправлять email / SMS / push |
| Возвращать HTTP-статус | Вызывать внешние API |
| Транзакции БД | |
| Хешировать пароли, генерировать токены |
Подробно про Actions / Services — в KB_13 Service Container и в отдельном разделе «Actions & Services» (см. sidebar).
Толстый контроллер — антипаттерн, но не абсолютное зло. В очень маленьких проектах (пара страниц, никакой сложной логики, никто не собирается это поддерживать долго) — держать код прямо в контроллере иногда допустимо. Однако как только проект начинает расти, толстый контроллер приводит к типовым проблемам:
- Сложно тестировать — нужно поднимать HTTP-окружение, моки Request, сессию, авторизацию.
- Сложно переиспользовать — та же операция из CLI-команды / job / другого контроллера превращается в копипаст.
- Нарушение SRP — контроллер знает про транзакции, платёжные шлюзы, email, склад одновременно.
- Бизнес-правила «размазаны» — половина в контроллере, половина в модели, что-то в middleware — концы не сойдутся.
Тонкий контроллер — лучшая практика для проектов любого размера, где ожидается развитие. Отвечает только за: приём + валидация запроса (FormRequest) → вызов Action/Service → формирование HTTP-ответа. Всё остальное — в отдельные классы: services, actions, events, listeners, jobs, репозитории.
if, цикл, вызов внешнего API, транзакция, работа с БД (кроме простого find), отправка письма/уведомления — это нужно вынести. Контроллер = «посредник», не «исполнитель».
Resource Controller — стандартный паттерн Laravel для CRUD-ресурса. Одна команда генерирует контроллер с 7 методами, одна строка в роутере — 7 маршрутов.
Генерация:
php artisan make:controller PostController --resource --model=Post
Регистрация в роутах:
Route::resource('posts', PostController::class);
Одна строка создаст следующие 7 маршрутов:
| Метод | URL | Действие | Route name |
|---|---|---|---|
| GET | /posts | index — список | posts.index |
| GET | /posts/create | create — форма создания | posts.create |
| POST | /posts | store — сохранить новый | posts.store |
| GET | /posts/{post} | show — показать один | posts.show |
| GET | /posts/{post}/edit | edit — форма редактирования | posts.edit |
| PUT/PATCH | /posts/{post} | update — обновить | posts.update |
| DELETE | /posts/{post} | destroy — удалить | posts.destroy |
Частичная регистрация — если не все 7 методов нужны:
// Только эти
Route::resource('posts', PostController::class)->only(['index', 'show']);
// Все кроме этих
Route::resource('posts', PostController::class)->except(['create', 'edit']);
Пример скелета контроллера с Route Model Binding + FormRequest:
class PostController extends Controller
{
public function index() {
return view('posts.index', ['posts' => Post::latest()->paginate(15)]);
}
public function create() {
return view('posts.create');
}
public function store(StorePostRequest $request) {
$post = Post::create($request->validated());
return redirect()->route('posts.show', $post);
}
public function show(Post $post) { // Route Model Binding
return view('posts.show', compact('post'));
}
public function edit(Post $post) {
return view('posts.edit', compact('post'));
}
public function update(UpdatePostRequest $request, Post $post) {
$post->update($request->validated());
return redirect()->route('posts.show', $post);
}
public function destroy(Post $post) {
$post->delete();
return redirect()->route('posts.index');
}
}
Проверить все маршруты одной командой:
php artisan route:list --path=posts
apiResource — то же самое, но для APIДля API-роутов методы create и edit не нужны — они возвращают HTML-формы. Для этого есть apiResource:
Route::apiResource('posts', Api\V1\PostController::class);
Регистрирует только 5 методов: index, store, show, update, destroy. Без create и edit.
Массовая регистрация нескольких API-ресурсов:
Route::apiResources([
'posts' => Api\V1\PostController::class,
'comments' => Api\V1\CommentController::class,
'tags' => Api\V1\TagController::class,
]);
Генерация контроллера сразу под API — с 5 методами:
php artisan make:controller Api/V1/PostController --api --model=Post
Nested resource — вложенные ресурсы через точку. Полезно когда URL отражает иерархию: комментарии принадлежат посту.
Route::resource('posts.comments', CommentController::class);
Регистрирует маршруты вида:
GET /posts/{post}/commentsPOST /posts/{post}/commentsGET /posts/{post}/comments/{comment}— оба параметра в URLPUT /posts/{post}/comments/{comment}DELETE /posts/{post}/comments/{comment}
Shallow nested — для операций над конкретным комментарием (show, edit, update, destroy) не нужен ID поста, потому что ID комментария уже уникален. Делаем URL короче:
Route::resource('posts.comments', CommentController::class)->shallow();
Получаем:
GET /posts/{post}/comments— вложено (список комментов к посту)POST /posts/{post}/comments— вложено (создать)GET /comments/{comment}— не вложеноPUT /comments/{comment}— не вложеноDELETE /comments/{comment}— не вложено
->scopeBindings() (см. раздел Routing), чтобы комментарий искался только среди комментариев конкретного поста, а не глобально.
В Laravel 10 и ниже — middleware назначались в конструкторе контроллера:
class PostController extends Controller
{
public function __construct()
{
$this->middleware('auth');
$this->middleware('admin')->only(['destroy']);
$this->middleware('verified')->except(['index', 'show']);
}
}
В Laravel 11+ — метод middleware() в контроллере убран. Middleware назначаются в роутах:
Route::resource('posts', PostController::class)
->middleware(['auth'])
->middleware('admin')->only(['destroy']);
// Или через группы
Route::middleware(['auth', 'verified'])->group(function () {
Route::resource('posts', PostController::class);
Route::resource('comments', CommentController::class);
});
Если хочется сохранить старый стиль в Laravel 11+ — контроллер должен реализовать интерфейс HasMiddleware:
use Illuminate\Routing\Controllers\HasMiddleware;
use Illuminate\Routing\Controllers\Middleware;
class PostController extends Controller implements HasMiddleware
{
public static function middleware(): array
{
return [
'auth',
new Middleware('admin', only: ['destroy']),
new Middleware('verified', except: ['index', 'show']),
];
}
}
->middleware()). Это делает конфигурацию явной — открыл routes/web.php и сразу видишь всю картину доступа.
__invokeКогда контроллер выполняет одно действие — не нужно раздувать класс до 7 методов. Используется класс с методом __invoke().
php artisan make:controller ShowProfile --invokable
class ShowProfile extends Controller
{
public function __invoke(User $user)
{
return view('profile', compact('user'));
}
}
// routes/web.php
Route::get('/users/{user}/profile', ShowProfile::class);
// Никакого второго параметра — не нужно указывать имя метода
route:cache и хорошо ложатся на паттерн Action (один класс = одно действие).
Laravel автоматически превращает возвращаемое значение в HTTP-ответ. Что можно вернуть из метода контроллера:
| Что вернуть | Что получится | Пример |
|---|---|---|
| Строка | Content-Type: text/html | return 'Hello'; |
| Массив / коллекция | Автоматически JSON | return ['status' => 'ok']; |
| Eloquent-модель | Автоматически JSON (через toArray) | return User::find(1); |
view(...) | Отрендеренный HTML | return view('posts.show', compact('post')); |
response()->json(...) | Явный JSON с control | return response()->json($data, 201); |
redirect(...) | HTTP 302 + Location | return redirect()->route('home'); |
back() | Редирект на предыдущую страницу | return back()->withErrors($errors); |
response()->download(...) | Файл с Content-Disposition: attachment | return response()->download('/path/file.pdf'); |
response()->streamDownload(fn()) | Стрим — файл создаётся на лету | Для больших CSV / отчётов |
response()->noContent() | HTTP 204 без тела | После destroy в API |
JsonResource | Форматированный JSON через API Resource | return new PostResource($post); |
Примеры типичных возвратов:
// Web — форма после создания
return redirect()->route('posts.show', $post)->with('status', 'Пост создан');
// API — JSON с статусом 201 Created
return response()->json(['data' => $post], 201);
// API — через ресурс с автоматическим 201
return (new PostResource($post))->response()->setStatusCode(201);
// API — успешное удаление, ничего не возвращаем
return response()->noContent(); // HTTP 204
// Скачать файл отчёта
return response()->download(storage_path('reports/report.pdf'));
// Стрим большого CSV — генерируется на лету, не хранится в памяти
return response()->streamDownload(function () {
$handle = fopen('php://output', 'w');
Order::chunk(1000, fn ($orders) =>
$orders->each(fn ($o) => fputcsv($handle, $o->toArray()))
);
}, 'orders.csv');
Подробно про HTTP-объекты (RedirectResponse, JsonResponse, Response) — в разделе HTTP-объекты Laravel.
compact() для передачи данных в viewcompact() — встроенная функция PHP: создаёт массив, используя имена переменных как ключи. Синтаксический сахар для сокращения записи при передаче переменных во view.
public function show(Post $post)
{
// Вариант 1 — явный массив
return view('posts.show', ['post' => $post]);
// Вариант 2 — то же самое через compact()
return view('posts.show', compact('post'));
}
Как работает: если есть переменная $post, вызов compact('post') вернёт ['post' => $post]. Несколько переменных: compact('post', 'user') → ['post' => $post, 'user' => $user].
compact('psot') (вместо 'post') в PHP 7.4− молча возвращал []. В PHP 8.0+ бросает Warning, но не Error — во view переменная просто окажется undefined. IDE обычно подсвечивают. Явный массив ['post' => $post] статически проверяем лучше.Контроллер отвечает только за приём HTTP-запроса и формирование HTTP-ответа. Всё что «внутри» — бизнес-логика, транзакции, работа с внешними сервисами, отправка писем — должно жить в отдельных классах. Их называют по-разному: Services, Actions, UseCases, Interactors. Все они делают одно и то же — инкапсулируют операцию, чтобы её можно было переиспользовать, тестировать и менять независимо от HTTP-слоя.
Проблемы толстого контроллера:
- Дублирование. Если ту же логику нужно вызвать из консольной команды / очереди / другого контроллера — придётся копировать.
- Сложное тестирование. Юнит-тест логики требует поднимать HTTP-контекст (
$request, роутер, middleware). - Смешение слоёв. Контроллер знает про SQL, транзакции, отправку email, платёжный шлюз — нарушение SRP.
- Невозможно переиспользовать между веб/API. Веб-контроллер редиректит, API-контроллер отдаёт JSON, а логика одна и та же — не выносится.
Что даёт вынесение в Actions/Services:
- Один класс — одна операция, легко читать и найти в коде.
- Тестируется без HTTP-контекста, зависимости мокаются через DI.
- Переиспользуется из контроллера, job, artisan-команды, теста, listener.
- Контроллер становится оркестратором: принял запрос → делегировал → вернул ответ.
Service — класс, объединяющий несколько связанных операций одной темы. Один сервис = одна доменная область (пользователи, заказы, платежи, отчёты).
Типичный сервис:
// app/Services/UserService.php
class UserService
{
public function __construct(
private UserRepository $users,
private Mailer $mailer,
) {}
public function register(array $data): User
{
$user = $this->users->create($data);
$this->mailer->sendWelcome($user);
return $user;
}
public function changePassword(User $user, string $newPassword): void
{
$user->update(['password' => bcrypt($newPassword)]);
$this->mailer->sendPasswordChanged($user);
}
public function deactivate(User $user): void
{
$user->update(['active' => false]);
Auth::logoutOtherDevices(...);
}
}
// Контроллер — тонкий
public function register(RegisterRequest $request, UserService $users)
{
$user = $users->register($request->validated());
return response()->json($user, 201);
}
Признаки хорошего сервиса:
- Все зависимости объявлены в конструкторе (DI, а не
app()/new). - Методы возвращают доменные объекты (модели, DTO), а не
Response. - Не знает про HTTP: не принимает
Request, не возвращаетResponse, не делаетredirect. - Не работает с сессиями, кукисами, заголовками.
- Может быть вызван из любого места: контроллера, job'а, artisan-команды, теста.
UserRegistrationService, UserProfileService, UserSecurityService) или переходите на Actions (одна операция = один класс).
Action — класс, инкапсулирующий одну операцию (одно бизнес-действие). Единственный публичный метод, обычно handle() или execute(). Часто применяется вместо Service, когда операция сложная и не нуждается в объединении с другими.
Типичный Action:
// app/Actions/PlaceOrder.php
class PlaceOrder
{
public function __construct(
private PaymentGateway $payment,
private Inventory $inventory,
private EventDispatcher $events,
) {}
public function handle(User $user, array $items): Order
{
return DB::transaction(function () use ($user, $items) {
$order = Order::create(['user_id' => $user->id]);
$this->inventory->reserve($items);
$this->payment->charge($user, $order->total);
$this->events->dispatch(new OrderPlaced($order));
return $order;
});
}
}
// Контроллер
public function store(StoreOrderRequest $request, PlaceOrder $action)
{
$order = $action->handle($request->user(), $request->validated()['items']);
return redirect()->route('orders.show', $order);
}
Признаки Action:
- Один публичный метод (
handle/execute/__invoke). - Имя класса — глагол в императиве:
PlaceOrder,CancelSubscription,SendPasswordReset,PublishPost. - Зависимости через конструктор (DI).
- Возвращает результат операции (модель, DTO, void).
- Атомарен: всё что нужно для завершения одной бизнес-операции — внутри одного класса.
__invoke() вместо handle(), тогда вызов сокращается до $action($user, $items). Также подходит для Route::get(..., PlaceOrder::class) — Laravel сам вызовет __invoke.
Разные команды используют разные названия для одной и той же идеи. Ниже — как различаются на практике:
| Service | Action / UseCase / Interactor | |
|---|---|---|
| Гранулярность | Один класс — несколько связанных методов | Один класс — одна операция |
| Метод | register(), changePassword(), deactivate()... |
Один handle() / execute() / __invoke() |
| Именование класса | Существительное (тема): UserService, OrderService |
Глагол (действие): RegisterUser, PlaceOrder, CancelSubscription |
| Размер класса | Обычно 100-400 строк, 5-15 методов | 30-150 строк, 1 метод |
| Когда подходит | Простой домен, несколько операций одной темы | Сложная операция с транзакцией, многими шагами, событиями |
| Проблема при росте | «God Service» на 40 методов | Взрыв количества файлов (100+ actions) |
Названия в разных школах:
- Service — Laravel-community, DDD старой школы, .NET
- Action — Laravel-community (популяризировано Spatie), Rails, Django
- UseCase — Clean Architecture (Robert Martin), DDD
- Interactor — Ruby (Trailblazer, Interactor gem)
Разница чисто в названии и гранулярности. По сути — все они инкапсулируют бизнес-операцию. Выбирайте Actions когда операции сложные и хочется единый метод-контракт, Services когда операции лёгкие и логично группируются по теме.
Repository — класс, инкапсулирующий работу с хранилищем данных (обычно БД). Идея: сервис не знает про Eloquent — он вызывает UserRepository::findByEmail($email), а тот уже делает User::where('email', $email)->first().
// app/Repositories/UserRepository.php
class UserRepository
{
public function find(int $id): ?User
{
return User::find($id);
}
public function findByEmail(string $email): ?User
{
return User::where('email', $email)->first();
}
public function create(array $data): User
{
return User::create($data);
}
}
User::find() в UserRepository::find() — часто писать один класс поверх другого без выгоды.
Когда Repository оправдан:
- Планируется реальная замена БД (не гипотетическая): Postgres → MongoDB, БД → внешний API.
- Сложные query нужно переиспользовать (можно и через Query Scope в модели).
- Строгий DDD с полной изоляцией доменной модели от Eloquent.
- Тесты — но mockаются модели легко и без Repository через
Model::factory()->make().
Laravel не навязывает структуру для Services/Actions — можете класть куда удобно. Типичные варианты:
app/
├── Actions/ # популярный вариант
│ ├── Users/
│ │ ├── RegisterUser.php
│ │ ├── DeactivateUser.php
│ │ └── ChangePassword.php
│ ├── Orders/
│ │ ├── PlaceOrder.php
│ │ ├── CancelOrder.php
│ │ └── RefundOrder.php
│ └── Notifications/
│ └── SendPasswordReset.php
│
├── Services/ # альтернатива / дополнение
│ ├── PaymentGateway.php
│ ├── SmsSender.php
│ └── ReportGenerator.php
│
├── Repositories/ # опционально, если реально нужен
│ └── UserRepository.php
│
└── Http/
└── Controllers/
└── OrderController.php # тонкий, вызывает Actions
Практические правила именования:
- Actions — глагол в императиве:
PlaceOrder,SendInvoice,PublishPost. - Services — существительное:
PaymentGateway,SmsSender,ReportGenerator. - Группировать по доменной области:
Actions/Users/,Actions/Orders/— легче найти всё связанное. - Если у вас модульная структура (например,
modules/Billing/) — Actions/Services живут внутри модуля, а не в глобальнойapp/.
Разница подходов:
app/Actions/— один класс = одна операция (single-purpose).app/Services/— один класс = набор связанных методов (multi-purpose) или интеграция с внешним сервисом.- Часто используют оба: Actions для бизнес-операций (PlaceOrder), Services для инфраструктуры (PaymentGateway).
Главная выгода Actions/Services — они тестируются как обычные классы, без HTTP-контекста.
Юнит-тест Action:
class PlaceOrderTest extends TestCase
{
use RefreshDatabase;
public function test_places_order_and_charges_payment()
{
$user = User::factory()->create();
$items = [['sku' => 'ABC', 'qty' => 2]];
// Мокаем внешнюю зависимость — платёжный шлюз
$payment = Mockery::mock(PaymentGateway::class);
$payment->shouldReceive('charge')->once()->andReturn(true);
$this->app->instance(PaymentGateway::class, $payment);
// Разрешаем Action через контейнер — зависимости подставятся
$order = app(PlaceOrder::class)->handle($user, $items);
$this->assertInstanceOf(Order::class, $order);
$this->assertEquals($user->id, $order->user_id);
}
}
Зачем это важно:
- Не нужен
$this->post('/orders', ...)— быстрее (миллисекунды vs сотни мс). - Не нужны CSRF, session, middleware — тестируется чистая логика.
- Можно мокать любую зависимость через контейнер:
$this->app->instance(...). - Один и тот же Action покрыт unit-тестами один раз — и работает одинаково из контроллера, job, artisan-команды.
$this->post()) остаются — проверяют интеграцию контроллер + Action + middleware + validation. Но основную бизнес-логику тестируйте юнит-тестами Actions/Services — их проще писать и они на порядок быстрее.
Пошаговый разбор реального рефакторинга: как жирный OrderController::store «худеет» до 4 строк. Каждый шаг — одно ответственное изменение, каждое улучшение можно смёрджить отдельно.
Что делает OrderController::store в исходном виде:
- Валидация полей через
$request->validate()(без FormRequest) - Бизнес-проверка: сумма корзины < 100 → ошибка
- Создание заказа в БД
- Резервирование товаров на складе
- Оплата через Stripe
- Отправка уведомления на email синхронно
Проблемы: валидация бизнес-правила смешана с созданием · БД и внешние сервисы (склад, Stripe) внутри контроллера · синхронная почта тормозит ответ · тестовой изоляции нет.
// app/Http/Requests/StoreOrderRequest.php
class StoreOrderRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'items' => 'required|array|min:1',
'email' => 'required|email',
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
// бизнес-правило: сумма корзины >= 100
if (Cart::total() < 100) {
$validator->errors()->add('cart', 'Минимальная сумма заказа 100');
}
});
}
}
Контроллер больше не занимается валидацией. Бизнес-правила проверяются в withValidator — падают тем же 422, что и обычные rules.
// app/Actions/Order/PlaceOrderAction.php
namespace App\Actions\Order;
use App\Models\Order;
use App\Services\InventoryService;
use App\Contracts\PaymentGateway;
use Illuminate\Support\Facades\DB;
use App\Events\OrderPlaced;
class PlaceOrderAction
{
public function __construct(
private InventoryService $inventory,
private PaymentGateway $paymentGateway,
) {}
public function handle(array $data): Order
{
return DB::transaction(function () use ($data) {
// 1. Создаём заказ
$order = Order::create([
'email' => $data['email'],
'total' => $this->calculateTotal($data['items']),
]);
// 2. Резервируем товары
foreach ($data['items'] as $item) {
$this->inventory->reserve($item['sku'], $item['qty']);
}
// 3. Оплата — через интерфейс, не hardcoded Stripe
$this->paymentGateway->charge($order->total, $order->id);
// 4. Побочные эффекты — через событие
event(new OrderPlaced($order));
return $order;
});
}
private function calculateTotal(array $items): float { /* ... */ }
}
Что дал этот шаг:
- Зависимости
InventoryServiceиPaymentGatewayвнедрены через конструктор — легко подменить в тестах. DB::transaction()обеспечивает атомарность: любой ошибка — откат всех операций.- Оплата ушла за интерфейс
PaymentGateway. - Отправка письма заменена на событие
OrderPlaced— обработается асинхронным listener'ом.
// app/Contracts/PaymentGateway.php
interface PaymentGateway
{
public function charge(float $amount, int $orderId): void;
}
// app/Services/StripePaymentGateway.php
class StripePaymentGateway implements PaymentGateway
{
public function charge(float $amount, int $orderId): void
{
// вызов Stripe API
}
}
// Регистрируем биндинг в AppServiceProvider::register()
$this->app->bind(PaymentGateway::class, StripePaymentGateway::class);
Теперь Action не привязан к Stripe. Завтра меняем на YooKassaPaymentGateway — только биндинг в провайдере, Action не трогаем. В тестах — FakePaymentGateway.
// app/Events/OrderPlaced.php
class OrderPlaced
{
use SerializesModels;
public function __construct(public Order $order) {}
}
// app/Listeners/SendOrderConfirmation.php
class SendOrderConfirmation implements ShouldQueue
{
public function handle(OrderPlaced $event): void
{
Mail::to($event->order->email)->send(new OrderPlacedMail($event->order));
}
}
// EventServiceProvider::$listen
protected $listen = [
OrderPlaced::class => [SendOrderConfirmation::class],
];
ShouldQueue у listener'а → письмо уходит через очередь, не блокируя ответ. Пользователь получает redirect за 50мс, а email доставляется параллельно.
class OrderController extends Controller
{
public function store(StoreOrderRequest $request, PlaceOrderAction $action)
{
$order = $action->handle($request->validated());
return redirect()->route('orders.show', $order)
->with('success', 'Заказ оформлен!');
}
}
Контроллер стал тонким: принять запрос → передать в Action → вернуть редирект. Вся сложная логика вынесена.
Тестируемость
PlaceOrderAction тестируется отдельно от HTTP — просто $action->handle($data) с моками зависимостей. Никаких Request/Response.
Переиспользование
Один PlaceOrderAction можно вызывать из: контроллера, Artisan-команды, другого Action, Job, Console kernel. Логика не привязана к HTTP.
Асинхронность
Тяжёлые побочные эффекты (email) — через ShouldQueue listener. Ответ пользователю моментальный.
Подмена платёжного шлюза
Смена Stripe → YooKassa = одна строка биндинга в провайдере. Тесты — FakePaymentGateway без реальных запросов.
- Валидация (включая бизнес-правила через
withValidator) →FormRequest - Бизнес-логика + транзакции →
Action/Service - Побочные эффекты (почта, внешние API, уведомления) →
Event+Listener (ShouldQueue)илиJob - Инфраструктурные зависимости (платёж, склад) → интерфейсы + DI
- Контроллер — 3–4 строки:
$action->handle($request->validated())+return redirect(...)
Полный пример реальной бизнес-операции: разместить заказ. Валидация в FormRequest, тонкий контроллер, вся логика в Action, транзакция БД, событие для side-effects.
create. Это не альтернативы: один create атомарен сам по себе, и оборачивать его незачем. Транзакция нужна потому, что запросов тут несколько — заказ, позиции, сумма, — и без неё сбой на втором шаге оставит в базе заказ без позиций. Разбор с примером теста на откат — в модуле Eloquent Advanced, раздел «Транзакции».// 1. FormRequest — только валидация
class StoreOrderRequest extends FormRequest
{
public function rules(): array
{
return [
'items' => 'required|array|min:1',
'items.*.sku' => 'required|string|exists:products,sku',
'items.*.qty' => 'required|integer|min:1',
];
}
}
// 2. Action — вся бизнес-логика
class PlaceOrder
{
public function __construct(
private PaymentGateway $payment,
private Inventory $inventory,
) {}
public function handle(User $user, array $items): Order
{
return DB::transaction(function () use ($user, $items) {
$order = Order::create([
'user_id' => $user->id,
'status' => 'pending',
]);
$order->items()->createMany($items);
$this->inventory->reserve($items);
$this->payment->charge($user, $order->total());
$order->update(['status' => 'paid']);
// Событие вне транзакции гарантированно после commit
OrderPaid::dispatch($order)->afterCommit();
return $order;
});
}
}
// 3. Контроллер — тонкий, только оркестрация
class OrderController extends Controller
{
public function store(StoreOrderRequest $request, PlaceOrder $action)
{
$order = $action->handle(
$request->user(),
$request->validated()['items']
);
return redirect()->route('orders.show', $order);
}
}
// 4. Listener подписан на OrderPaid — асинхронный, отправляется в очередь
class SendOrderConfirmation implements ShouldQueue
{
public function handle(OrderPaid $event)
{
Mail::to($event->order->user->email)
->send(new OrderConfirmationMail($event->order));
}
}
Что происходит: FormRequest валидирует и авторизует; Controller остаётся тонким; Action — единица бизнес-логики; транзакция оборачивает все БД-операции; afterCommit гарантирует что событие уйдёт после commit'а; событие OrderPaid подключает listener'ов без правок Action; каждый слой можно тестировать отдельно; логику можно переиспользовать из job / artisan / другого контроллера.
- Service Layer — сервисы с несколькими методами по теме (
UserService). - Action — один класс, одна операция, метод
handle()/execute()/__invoke()(PlaceOrder). - UseCase / Interactor — то же что Action, только терминология из Clean Architecture / Ruby.
- Repository — оправдан только когда есть конкретная причина, в 90% Laravel-проектов Eloquent используется напрямую.
- Куда класть —
app/Actions/{Domain}/,app/Services/, группировать по доменной области. - Тесты — юнит-тестируйте Actions/Services напрямую, feature-тесты покрывают интеграцию через контроллер.
Middleware — слой между HTTP-запросом и обработчиком. Каждый middleware может: (а) изменить запрос, (б) вернуть ответ напрямую (не доходя до controller), (в) вмешаться в response после controller, (г) сделать дорогую работу после отправки ответа (terminate). Это основа кросс-cutting concerns: аутентификация, CORS, rate limiting, логирование, локализация.
Global
Применяются ко всем запросам. Регистрируются в bootstrap/app.php (Laravel 11+) или app/Http/Kernel.php::$middleware. Типичные: TrustProxies, HandleCors, TrimStrings.
Group (web / api)
Применяются ко всем маршрутам группы. web: session, CSRF, cookies. api: throttle, без session. Группы кастомизируются через $middlewareGroups.
Route-specific
Через ->middleware(['auth', 'verified']) или alias ->middleware('auth:sanctum'). Aliases объявляются в $middlewareAliases.
Параметризованный middleware
throttle:60,1 — middleware ThrottleRequests с параметрами 60 запросов в 1 минуту. Параметры приходят в handle($request, $next, ...$params).
terminate
Метод вызывается после отправки response клиенту. Здесь дорогая работа (логи в БД, отправка метрик), не влияющая на TTFB. Работает только под FPM с fastcgi_finish_request.
Каждый middleware в Laravel — это слой, через который проходит запрос. Все middleware собраны в конвейер (Pipeline). Схематично:
// Упрощённо
$request → middleware1 → middleware2 → middleware3 → контроллер → ответ
Каждый middleware получает два аргумента: $request и замыкание $next. Когда вы вызываете $next($request), вы говорите: «передай запрос следующему middleware в цепочке».
final class ExampleMiddleware
{
public function handle(Request $request, Closure $next): Response
{
// ── Код ДО $next: выполнится на пути к контроллеру ──
$start = microtime(true);
$response = $next($request); // ← передать дальше по цепочке
// ── Код ПОСЛЕ $next: выполнится на обратном пути ──
$duration = microtime(true) - $start;
$response->headers->set('X-Duration', $duration);
return $response;
}
}
$next)После того как запрос прошёл все middleware и достиг контроллера, тот возвращает ответ. Теперь ответ «поднимается» обратно по цепочке — каждый middleware получает уже готовый $response и может его модифицировать.
Основные сценарии обратного прохода:
- Добавление HTTP-заголовков — CORS, кеширование (
Cache-Control), безопасность (X-Frame-Options,Strict-Transport-Security). - Сжатие ответа — gzip / brotli перед отправкой.
- Логирование времени выполнения — засекли время до
$next, вычли после. - Преобразование формата — например, JSON → XML, или обёртка ответа в
{ data: ..., meta: ... }. - Обработка ошибок — если в контроллере выброшено исключение, middleware может перехватить его через
try/catchи вернуть красивый JSON вместо стандартной страницы 500.
$next — а это невозможно, потому что ответ ещё не сгенерирован. Обратный проход даёт middleware второй шанс уже с готовым response.
terminate — что это и зачемНекоторые middleware реализуют метод terminate():
public function terminate($request, $response)
{
// Тяжёлая работа: логирование в БД, аналитика, очистка ресурсов
}
Важнейшая особенность: terminate вызывается после того, как ответ уже отправлен клиенту. Это значит, что время выполнения этого метода не влияет на скорость загрузки страницы для пользователя.
Зачем это нужно:
- Логирование деталей запроса — запись в БД (медленная операция).
- Отправка метрик в мониторинговые системы (Prometheus, Datadog, Sentry).
- Закрытие соединений, очистка временных файлов.
- Всё, что требует времени, но не критично для пользователя.
Как это работает технически
В PHP-FPM (и некоторых других SAPI) есть возможность выполнить код после отправки ответа. Laravel использует механизм fastcgi_finish_request() (если доступен) или просто вызывает terminate синхронно после $response->send().
| SAPI | Как работает terminate | Пользователь ждёт? |
|---|---|---|
| PHP-FPM (nginx/apache prod) | Асинхронно через fastcgi_finish_request() — response уходит клиенту, PHP-процесс продолжает работать | Нет |
| Apache mod_php | Обычно синхронно — fastcgi_finish_request() недоступен | Да |
php artisan serve (built-in) | Синхронно — нет асинхронности | Да |
| Laravel Octane (Swoole/RoadRunner) | Свои события RequestTerminated, поведение зависит от драйвера | Обычно нет |
artisan serve и в тестах terminate удлиняет ответ — не полагайтесь на «незаметность» тяжёлой работы. В production под FPM всё нормально.
final class EnsureFeatureEnabled
{
public function handle(Request $request, Closure $next, string $feature): Response
{
if (! Features::enabled($feature, $request->user())) {
return response()->json(['error' => 'Feature disabled'], 403);
}
return $next($request);
}
}
// Маршрут
Route::get('/beta', BetaController::class)
->middleware('feature:beta-dashboard');
handle. $request->merge(['foo' => 'bar']) работает, но следующие middleware и controller получат изменённый объект. Это может ломать тесты, ожидающие исходный запрос.handle без $next. Если middleware вернул response, дальнейшие middleware и controller не выполняются — это используется для редиректов, авторизации. Удобно, но легко создать «невидимый» branch в логике.terminate на built-in сервере. php artisan serve не поддерживает fastcgi_finish_request — terminate работает синхронно, удлиняя ответ.throttle перед auth — IP-throttle; после auth — user-throttle.$request. $request может быть Symfony\HttpFoundation\Request или Illuminate\Http\Request. Типизируйте Request для доступа к Laravel-методам.X-CSRF-TOKEN или X-XSRF-TOKEN. Без него POST/PUT/DELETE возвращают 419.StartSession в API-группе — лишняя нагрузка. По умолчанию в группе api session отключена; если включили — выключите.Что такое глобальные middleware. Глобальные middleware применяются к каждому HTTP-запросу, независимо от маршрута. Они выполняются до групповых (web, api) и маршрутных (->middleware('...')). В Laravel 11+ регистрируются в bootstrap/app.php через ->withMiddleware(...), в более старых версиях — в свойстве $middleware класса app/Http/Kernel.php.
Стандартный набор, включённый по умолчанию, состоит из семи middleware. Ниже — назначение каждого из них.
TrustProxies. Настраивает доверенные прокси-серверы (например, Nginx, Cloudflare, AWS ELB). Исправляет заголовки X-Forwarded-*, чтобы Laravel правильно определял реальный IP-адрес клиента, протокол (https/http) и хост, если приложение работает за обратным прокси.
HandleCors. Обрабатывает CORS-заголовки (Cross-Origin Resource Sharing). Добавляет в ответы Access-Control-Allow-Origin, Access-Control-Allow-Methods и др., а также автоматически отвечает на OPTIONS-запросы (preflight). Конфигурация — в config/cors.php.
PreventRequestsDuringMaintenance. Проверяет, включён ли режим обслуживания (флаг down). Если да — возвращает страницу «503 Service Unavailable» (или кастомное представление) для всех запросов, кроме IP-адресов, разрешённых в php artisan down --allow=....
ValidatePostSize. Проверяет, не превышает ли размер входящего POST-запроса максимально допустимый лимит, заданный в php.ini (post_max_size и upload_max_filesize). Если превышает — прерывает запрос с ошибкой.
TrimStrings. Автоматически обрезает пробелы в начале и конце всех входящих строковых полей ($request->all()), кроме полей, перечисленных в свойстве $except (например, пароли, чтобы не обрезать пробелы, если они значимы).
ConvertEmptyStringsToNull. Преобразует пустые строки ('') в null во всех входящих данных запроса. Это помогает единообразно работать с отсутствующими значениями в БД (особенно для полей, которые могут быть NULL).
TrustHosts (обычно закомментирован). Ограничивает допустимые значения заголовка Host, чтобы предотвратить атаки подделки хоста. По умолчанию отключён, но может быть активирован для продакшена.
Порядок выполнения. Глобальные middleware выполняются в том порядке, в котором они перечислены в массиве. Это важно, потому что, например, TrimStrings и ConvertEmptyStringsToNull должны отработать до того, как данные попадут в контроллер.
Полный пример bootstrap/app.php со всеми стандартными глобальными middleware (для Laravel 11+):
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\HandleCors;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustProxies;
use App\Http\Middleware\PreventRequestsDuringMaintenance;
// use Illuminate\Foundation\Http\Middleware\TrustHosts; // обычно закомментирован
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
// Глобальные middleware — применяются к каждому запросу.
// Порядок важен: они выполняются сверху вниз.
$middleware->append([
TrustProxies::class, // доверенные прокси (IP, протокол)
HandleCors::class, // CORS-заголовки и OPTIONS
PreventRequestsDuringMaintenance::class, // проверка режима обслуживания
ValidatePostSize::class, // проверка размера POST-запроса
TrimStrings::class, // обрезка пробелов (кроме исключённых)
ConvertEmptyStringsToNull::class, // пустые строки → null
]);
// TrustHosts — ограничение допустимых хостов (по умолчанию отключён):
// $middleware->trustHosts(at: ['example.com', '*.example.com']);
// Свой глобальный middleware:
// $middleware->append(MyCustomGlobalMiddleware::class);
})
->withExceptions(function ($exceptions) {
//
})
->create();
| Класс | Назначение |
|---|---|
TrustProxies | Корректирует IP, протокол и хост, если приложение за прокси (Nginx, Cloudflare). |
HandleCors | Добавляет CORS-заголовки и отвечает на OPTIONS-запросы. Настройки в config/cors.php. |
PreventRequestsDuringMaintenance | Если сайт в режиме обслуживания (php artisan down), возвращает 503 для всех, кроме разрешённых IP. |
ValidatePostSize | Проверяет, что размер POST-данных не превышает лимиты PHP (post_max_size, upload_max_filesize). |
TrimStrings | Обрезает пробелы с начала и конца всех строковых полей ввода (кроме перечисленных в $except). |
ConvertEmptyStringsToNull | Превращает пустые строки ('') в null — удобно для БД с nullable-полями. |
TrustHosts | Ограничивает допустимые значения заголовка Host — защита от атак подделки хоста. |
Если нужно изменить порядок или список:
$middleware->append(...)— добавляет в конец списка$middleware->prepend(...)— добавляет в начало$middleware->use([...])— полностью заменяет список глобальных middleware. Используйте осторожно: можно случайно отключить стандартную обработку CORS, trim, maintenance mode и других критичных вещей.
Что такое группы. Middleware-группы — это наборы middleware, которые применяются к маршрутам, объединённым в группы (обычно через Route::group(['middleware' => 'web']) или автоматически в routes/web.php и routes/api.php). Группы позволяют не перечислять каждый middleware в каждом маршруте, а применять их оптом.
Где регистрируются. В Laravel 11+ — в файле bootstrap/app.php с помощью метода ->withMiddleware(). В Laravel 10 и ниже — в классе App\Http\Kernel в свойстве $middlewareGroups.
| Группа | Назначение | Входящие middleware (стандарт) |
|---|---|---|
web |
Для веб-интерфейса (с сессиями, CSRF, куками) | EncryptCookies, AddQueuedCookiesToResponse, StartSession, ShareErrorsFromSession, VerifyCsrfToken, SubstituteBindings |
api |
Для API-маршрутов (без сессий, с ограничениями) | throttle:api, SubstituteBindings (иногда добавляют EnsureFrontendRequestsAreStateful для Sanctum) |
Кастомизация в bootstrap/app.php (Laravel 11+). В bootstrap/app.php вы можете добавлять или удалять middleware из существующих групп, а также создавать свои.
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;
use App\Http\Middleware\LogRequest;
use App\Http\Middleware\CheckApiVersion;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
// 1. Добавление своих middleware в группу web (в конец)
$middleware->web(append: [
LogRequest::class,
]);
// 2. Добавление в группу api (в начало)
$middleware->api(prepend: [
CheckApiVersion::class, // проверка Accept-Version
]);
// 3. Полностью переопределить группу — заменить стандартные:
// $middleware->web(use: [ /* новый список */ ]);
// 4. Создание собственной группы
$middleware->group('custom', [
App\Http\Middleware\CustomMiddleware::class,
]);
// 5. Удаление из группы напрямую не поддерживается,
// но можно переопределить через use, например убрать throttle из api:
// $middleware->api(use: [
// Illuminate\Routing\Middleware\SubstituteBindings::class,
// ]);
})
->withExceptions(function ($exceptions) {
//
})
->create();
Аналогично для Laravel 10 и ниже. В app/Http/Kernel.php редактируется свойство $middlewareGroups:
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
// ... стандартные ...
\App\Http\Middleware\LogRequest::class, // добавляем свой
],
'api' => [
\App\Http\Middleware\CheckApiVersion::class, // в начало
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
Важные нюансы:
- Порядок важен — middleware выполняются в том порядке, в котором перечислены в массиве. Например,
StartSessionдолжен быть доVerifyCsrfToken, иначе CSRF не сможет прочитать токен из сессии. - Группы в маршрутах —
Route::middleware('web')->group(...)илиRoute::group(['middleware' => ['web', 'auth']], ...). useудаляет стандартные — если вы переопределяете группу черезuse, вы теряете все дефолтные middleware. Обычно лучшеappend/prepend.- API-специфика — часто добавляют
throttleс лимитами, а такжеEnsureFrontendRequestsAreStatefulдля SPA-авторизации через Sanctum.
bootstrap/app.php (Laravel 11+) или в Kernel.php (Laravel ≤10). Используйте методы web(), api() и group() для добавления своих middleware в существующие или новые группы. Изменения в группах применяются ко всем маршрутам, которые используют эту группу.
Что это. Middleware Aliases — это короткие имена для классов middleware. Они нужны, чтобы в роутах писать не полное имя класса, а краткий alias — например 'auth' вместо \App\Http\Middleware\Authenticate::class.
Где объявляются. В зависимости от версии Laravel место отличается:
| Версия | Файл | Свойство / метод |
|---|---|---|
| Laravel 11+ | bootstrap/app.php |
$middleware->alias([...]) внутри withMiddleware() |
| Laravel 10 и ниже | app/Http/Kernel.php |
Свойство protected $middlewareAliases = [...] |
Как объявляются в Laravel 11+ (в bootstrap/app.php):
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;
use App\Http\Middleware\RoleMiddleware;
use App\Http\Middleware\CheckAge;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
// ...
)
->withMiddleware(function (Middleware $middleware) {
// Регистрируем алиасы
$middleware->alias([
'role' => RoleMiddleware::class,
'age' => CheckAge::class,
]);
})
->withExceptions(function ($exceptions) { // })
->create();
Как объявляются в Laravel 10 и ниже (в app/Http/Kernel.php):
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
use App\Http\Middleware\RoleMiddleware;
use App\Http\Middleware\CheckAge;
class Kernel extends HttpKernel
{
// ...
protected $middlewareAliases = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
// Свои алиасы — сюда:
'role' => RoleMiddleware::class,
'age' => CheckAge::class,
];
}
Как использовать алиас в роуте. После регистрации алиаса просто указываете его в ->middleware():
// routes/web.php или routes/api.php
Route::get('/admin', [AdminController::class, 'index'])
->middleware(['auth', 'role:admin']); // auth и role — это алиасы
Route::get('/restricted', function () {
return 'Только для взрослых';
})->middleware('age:18'); // алиас age с параметром
Важные нюансы:
- Алиасы с параметрами работают так же, как и с полными именами классов:
'role:admin'передаст параметрadminв методhandle()middleware. - Встроенные алиасы (
auth,guest,verified,throttleи др.) уже зарегистрированы по умолчанию — их не нужно объявлять вручную. - Если алиас не зарегистрирован, Laravel выдаст ошибку
Target class [alias] does not exist. - В Laravel 11 файла
Kernel.phpбольше нет — вся конфигурация middleware перенесена вbootstrap/app.php.
| Действие | Laravel 11+ | Laravel ≤10 |
|---|---|---|
| Где | bootstrap/app.php |
app/Http/Kernel.php |
| Как | $middleware->alias(['alias' => Class::class]) |
protected $middlewareAliases = ['alias' => Class::class] |
| Использование | ->middleware('alias') |
->middleware('alias') |
Что это. Параметризованный middleware — это middleware, которое принимает дополнительные параметры в строке вызова, например 'throttle:60,1'. Эти параметры передаются в метод handle() после $next как переменное число аргументов (...$params).
Как это работает внутри. В классе middleware метод handle имеет такую сигнатуру:
public function handle($request, Closure $next, ...$params)
{
// $params — массив переданных параметров
// для 'throttle:60,1' это будет [60, 1]
}
При вызове ->middleware('throttle:60,1') Laravel парсит строку: разделяет её по двоеточию (отделяя имя middleware от параметров) и запятым (между параметрами), после чего передаёт значения как отдельные аргументы.
Как использовать в роутах:
Route::get('/api/data', [DataController::class, 'index'])
->middleware('throttle:60,1'); // 60 запросов в минуту
// Несколько middleware сразу — через массив
Route::get('/admin/dashboard', DashboardController::class)
->middleware(['auth:sanctum', 'throttle:60,1', 'verified']);
Где настраивается throttle. Стандартный ThrottleRequests уже зарегистрирован в алиасах под именем throttle. Его параметры можно задавать двумя способами:
- Напрямую в роуте — как в примере выше (
throttle:60,1). - Через именованные лимиты — в файле
app/Http/Kernel.php(для Laravel ≤10) или черезbootstrap/app.php(Laravel 11+) можно определить глобальные лимиты для групп API и использовать их какthrottle:api.
Именованные лимиты в Laravel ≤10 — в App\Providers\RouteServiceProvider:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
protected function configureRateLimiting()
{
RateLimiter::for('api', function ($request) {
return Limit::perMinute(60)
->by($request->user()?->id ?: $request->ip());
});
}
Именованные лимиты в Laravel 11+ — через bootstrap/app.php:
->withRouting(
api: __DIR__.'/../routes/api.php',
// ...
using: function () {
RateLimiter::for('api', fn ($request) =>
Limit::perMinute(60)->by($request->user()?->id ?: $request->ip())
);
}
)
Тогда в роуте достаточно писать 'throttle:api' — будет использоваться заданный лимит.
Создание своего параметризованного middleware. Класс CheckAge с двумя параметрами ($minAge и опциональный $maxAge):
namespace App\Http\Middleware;
use Closure;
class CheckAge
{
public function handle($request, Closure $next, $minAge, $maxAge = null)
{
$age = $request->input('age');
if ($age < $minAge || ($maxAge && $age > $maxAge)) {
abort(403, 'Возраст не подходит.');
}
return $next($request);
}
}
Зарегистрируйте алиас — в bootstrap/app.php (Laravel 11+) или в Kernel.php (Laravel ≤10):
// Laravel 11+
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'age' => \App\Http\Middleware\CheckAge::class,
]);
})
// Laravel ≤10
protected $middlewareAliases = [
// ...
'age' => \App\Http\Middleware\CheckAge::class,
];
Используйте в роуте — параметры идут после двоеточия через запятую, в порядке аргументов метода handle:
Route::get('/adults-only', function () {
return 'Добро пожаловать!';
})->middleware('age:18,30'); // minAge=18, maxAge=30
Особенности throttle:
- Стандартный
throttleиспользует кеш (по умолчаниюfile) для подсчёта количества запросов. - Можно передавать два параметра:
throttle:attempts,decayMinutes. - Если опустить параметры, будет использоваться значение по умолчанию (обычно 60 и 1), но лучше явно указывать.
- Для API-роутов часто используют
throttle:api, где лимит настраивается централизованно, чтобы не дублировать число в каждом роуте.
| Действие | Где регистрируется | Как использовать |
|---|---|---|
| Параметризованный middleware | В алиасах ($middlewareAliases или bootstrap/app.php) |
->middleware('alias:param1,param2') |
| Стандартный throttle | Уже зарегистрирован | 'throttle:60,1' или 'throttle:api' (с настройкой через RateLimiter) |
| Кастомный параметризованный | Регистрируете свой класс и алиас | 'age:18,30' — параметры придут в handle() как $minAge, $maxAge |
handle в том порядке, в котором они перечислены через запятую после двоеточия.
Что такое CORS. CORS (Cross-Origin Resource Sharing) — механизм, который позволяет веб-страницам, загруженным с одного домена (origin), запрашивать ресурсы с другого домена, отличного от того, с которого была загружена страница. Без CORS браузеры блокируют такие запросы из соображений безопасности.
Почему он нужен. Браузеры реализуют политику одного источника (Same-Origin Policy). Она запрещает скриптам на странице выполнять HTTP-запросы к другому источнику (домену, порту или протоколу), если только сервер явно не разрешит это с помощью CORS-заголовков. Пример: ваш фронтенд работает на https://myfrontend.com, а API — на https://api.myapp.com. Если фронтенд попытается отправить AJAX-запрос на API, браузер заблокирует его, потому что источники разные. Чтобы разрешить, API должен вернуть заголовок Access-Control-Allow-Origin: https://myfrontend.com.
Как работает CORS. Для простого запроса (GET, POST с некоторыми Content-Type) браузер сам добавляет заголовок Origin: https://myfrontend.com. Если сервер в ответе вернёт Access-Control-Allow-Origin: https://myfrontend.com (или *), браузер разрешит запрос.
Для сложных запросов (PATCH, DELETE, с кастомными заголовками, с Content-Type: application/json) браузер сначала отправляет предварительный запрос (Preflight) методом OPTIONS, чтобы узнать, разрешён ли основной запрос. Сервер должен ответить заголовками:
Access-Control-Allow-OriginAccess-Control-Allow-Methods— какие HTTP-методы разрешеныAccess-Control-Allow-Headers— какие заголовки можно передаватьAccess-Control-Max-Age— сколько кешировать ответ preflight
Если сервер не отвечает на OPTIONS или отвечает без нужных заголовков, браузер блокирует основной запрос.
Настройка в Laravel. В Laravel управление CORS вынесено в конфигурацию и middleware. Файл конфигурации config/cors.php (по умолчанию есть в Laravel 7+, в более ранних нужно установить пакет fruitcake/laravel-cors):
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'], // пути, для которых применяется CORS
'allowed_methods' => ['*'], // или ['GET', 'POST', ...]
'allowed_origins' => ['https://myfrontend.com', 'http://localhost:3000'],
'allowed_origins_patterns'=> [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => false,
];
Далее middleware — в глобальный стек App\Http\Kernel (или в bootstrap/app.php для Laravel 11+) добавляется \App\Http\Middleware\HandleCors::class (или из пакета). Он автоматически обрабатывает OPTIONS-запросы и добавляет нужные заголовки в ответы. Важно понимать: CORS настраивается на стороне сервера. Если ваш API обслуживается Laravel, вам нужно правильно сконфигурировать его, чтобы браузер разрешал кросс-доменные запросы.
| Ошибка в консоли браузера | Причина | Решение |
|---|---|---|
No 'Access-Control-Allow-Origin' header is present |
Сервер не вернул разрешающий заголовок | Добавить allowed_origins с доменом фронтенда |
Request header field X-Requested-With is not allowed by Access-Control-Allow-Headers |
В preflight сервер не разрешил кастомный заголовок | Указать его в allowed_headers |
Method DELETE is not allowed by Access-Control-Allow-Methods |
Метод не разрешён | Добавить DELETE в allowed_methods |
allowed_origins = ['*'], но это небезопасно для API, работающих с авторизацией — с credentials-режимом такую настройку использовать нельзя. При включении credentials: true (например, для отправки cookies) нельзя ставить * в allowed_origins — нужно указывать конкретный домен и добавить supports_credentials => true.
Итог. CORS — это механизм, который даёт веб-приложениям возможность безопасно запрашивать данные с других доменов, при этом сервер должен явно разрешить такие запросы через специальные HTTP-заголовки. В Laravel настройка CORS сводится к редактированию config/cors.php и подключению соответствующего middleware.
Все объекты Laravel типа RedirectResponse, Response, Request, JsonResponse — это не просто «шаблоны», а стандартизированные объекты, которые Laravel использует для представления всего, что связано с HTTP-запросом и ответом. Они живут в пространстве имён Illuminate\Http\* и построены на фундаменте Symfony HttpFoundation Component. Laravel берёт этот компонент, расширяет его и добавляет свои удобные методы.
Ключевые объекты, с которыми работают постоянно:
| Класс | Что представляет |
|---|---|
Illuminate\Http\Request | Входящий HTTP-запрос. Содержит $_GET, $_POST, $_COOKIE, $_FILES, заголовки. Получаете в контроллере: store(Request $request). |
Illuminate\Http\Response | Базовый HTTP-ответ. Контент + статус + заголовки. Возвращается из контроллера: return response('Hello', 200); |
Illuminate\Http\JsonResponse | Специализированный ответ для API. Автопреобразование в JSON и Content-Type: application/json. Пример: return response()->json(['user' => $user]); |
Illuminate\Http\RedirectResponse | Перенаправление на другой URL. Генерирует HTTP-заголовок Location. Имеет цепочку методов для работы с сессией: withErrors, withInput, with. |
Illuminate\Support\Collection | Не HTTP-объект, но используется повсеместно. Обёртка над массивом с фильтрацией, трансформацией, агрегацией. |
Illuminate\Http\Client\Response | Ответ при исходящих HTTP-запросах через Http::get(...). Тело, заголовки, методы ->json(), ->body(). |
Illuminate\Foundation\Application | Главный объект приложения (контейнер). Регистрация сервисов, разрешение зависимостей, доступ к конфигурации. |
Illuminate\Http\Request — входящий запросПредставляет входящий HTTP-запрос. Именно его вы получаете в контроллере: public function store(Request $request).
| Метод | Что делает | Пример |
|---|---|---|
input($key, $default) | Получить значение поля из GET, POST или JSON | $request->input('name', 'guest') |
all() | Получить все входные данные как массив | $request->all() |
only($keys) | Только указанные поля | $request->only(['name', 'email']) |
except($keys) | Все, кроме указанных | $request->except(['_token']) |
has($key) | Существует ли поле | if ($request->has('file')) |
filled($key) | Существует и не пустое | if ($request->filled('name')) |
file($key) | Получить загруженный файл | $request->file('avatar') |
header($key, $default) | Получить заголовок | $request->header('Authorization') |
ip() | IP-адрес клиента | $request->ip() |
url() | Текущий URL без query | — |
fullUrl() | Полный URL с query | — |
method() | HTTP-метод (GET, POST...) | — |
route($key) | Параметры маршрута | $request->route('user') |
user() | Аутентифицированный пользователь | $request->user() |
validate($rules) | Запустить валидацию, вернуть массив проверенных данных | $request->validate([...]) |
merge($data) | Добавить/перезаписать данные (используется в prepareForValidation) | $request->merge(['foo' => 'bar']) |
replace($data) | Полностью заменить входные данные | — |
Illuminate\Http\Response — базовый ответСоздаётся через хелпер response(). Обычный HTTP-ответ с контентом, статусом и заголовками.
| Метод | Что делает | Пример |
|---|---|---|
content() | Получить тело ответа | — |
status() | Получить статус-код | — |
header($key, $value) | Установить один заголовок | response('Hello')->header('X-Foo', 'Bar') |
withHeaders($headers) | Установить несколько заголовков | ->withHeaders(['X-A' => '1', 'X-B' => '2']) |
cookie($cookie) | Добавить cookie | ->cookie('name', 'value', 60) |
setStatusCode($code) | Установить статус-код | — |
throwResponse() | Выбросить исключение с этим ответом (редко) | — |
Illuminate\Http\JsonResponseСоздаётся через response()->json($data, $status, $headers). Автоматически ставит Content-Type: application/json.
| Метод | Что делает |
|---|---|
setData($data) | Установить данные, которые будут преобразованы в JSON |
getData($assoc = false) | Получить данные (как объект или массив) |
setStatusCode($code) | Установить HTTP-код |
header($key, $value) | Добавить заголовок |
withHeader($key, $value) | Алиас для header |
setEncodingOptions($options) | Опции json_encode (например, JSON_PRETTY_PRINT, JSON_UNESCAPED_UNICODE) |
Illuminate\Http\RedirectResponse + withErrors / withInputЭтот объект отвечает за перенаправление пользователя на другой URL. Вместо контента он генерирует HTTP-заголовок Location, который говорит браузеру перейти на новый адрес. Класс предоставляет цепочку удобных методов для работы с сессией во время редиректа.
Что такое withErrors() и withInput(). Это не хелперы (глобальные функции), а методы объекта RedirectResponse. Они используются для управления данными, которые сохраняются в сессии при редиректе:
withErrors($validator)— сохраняет ошибки валидации в сессию, чтобы они были доступны на странице, на которую происходит редирект, через переменную$errorsв Blade.withInput()— сохраняет в сессию введённые пользователем данные из запроса. Позволяет отобразить их обратно в полях формы через хелперold(), чтобы пользователь не вводил всё заново.
Классический паттерн ручной валидации:
if ($validator->fails()) {
return redirect()->back()
->withErrors($validator) // 1. Сохраняем ошибки в сессию
->withInput(); // 2. Сохраняем введённые данные
}
Что происходит: перенаправляете пользователя обратно на предыдущую страницу, передаёте в сессию ошибки валидации, передаёте в сессию введённые данные. На форме Blade автоматически покажет $errors и old('field').
Отличие от хелперов. Хелперы — это глобальные функции: old(), session(), redirect(). withErrors() и withInput() — это методы, которые вызываются у объекта редиректа, возвращённого хелпером redirect(). Метод withErrors() может принимать не только валидатор, но и массив с сообщениями или строку.
Все ключевые методы RedirectResponse:
| Метод | Что делает |
|---|---|
withErrors($provider) | Сохраняет ошибки в сессию. Принимает валидатор, MessageBag или массив. |
withInput() | Сохраняет текущие входные данные в сессию — читаются через old(). |
with($key, $value) | Сохраняет произвольные flash-данные в сессию. |
withCookie($cookie) | Добавляет cookie к ответу. |
header($key, $value) | Добавляет произвольный заголовок к редиректу. |
route($route, $parameters, $status) | Редирект на именованный маршрут. |
action($action, $parameters, $status) | Редирект на экшен контроллера. |
away($url, $status) | Редирект на внешний URL. |
back($status, $headers) | Редирект на предыдущую страницу. |
to($path, $status, $headers) | Редирект на произвольный путь. |
Illuminate\Support\CollectionСоздаётся через collect($array). Обёртка над массивом с fluent-интерфейсом для фильтрации, трансформации и агрегации.
| Метод | Что делает | Пример |
|---|---|---|
all() | Все элементы как массив | — |
map($cb) | Преобразовать каждый элемент | ->map(fn($x) => $x * 2) |
filter($cb) | Отфильтровать элементы | ->filter(fn($x) => $x > 10) |
pluck($key) | Извлечь колонку из вложенных массивов/объектов | ->pluck('name') |
first($cb) / last($cb) | Первый / последний элемент | — |
count() | Количество элементов | — |
isEmpty() / isNotEmpty() | Проверка пустоты | — |
toArray() / toJson() | Преобразовать в массив / JSON | — |
keys() / values() | Получить ключи / сбросить ключи и получить значения | — |
unique($key) | Убрать дубликаты | — |
sortBy($cb) | Сортировать | — |
groupBy($key) | Сгруппировать по ключу | — |
reduce($cb, $init) | Свернуть в одно значение | — |
sum($key) / avg($key) | Сумма / среднее | — |
chunk($size) | Разбить на части | — |
Illuminate\Http\Client\ResponseВозвращается при исходящих HTTP-запросах через Http::get(...), Http::post(...) и т.д. Не путать с Illuminate\Http\Response (там — входящие).
| Метод | Что делает | Пример |
|---|---|---|
body() | Тело ответа как строка | — |
json($key, $default) | Разобранный JSON (опционально по ключу) | $response->json('data') |
object() | Вернуть JSON как stdClass | — |
collect($key) | Вернуть JSON как Collection | $response->collect('items')->pluck('id') |
status() | HTTP-статус код | — |
ok() / successful() | true, если 200–299 | — |
redirect() | true, если статус 30x | — |
clientError() / serverError() | true, если 4xx / 5xx | — |
header($header) / headers() | Заголовок / все заголовки | — |
cookies() | Объект с куками | — |
transferStats() | Статистика передачи (время, TLS и т.д.) | — |
Illuminate\Foundation\Application (контейнер)Главный объект приложения. Доступен через app(). Отвечает за разрешение зависимостей и регистрацию сервисов.
| Метод | Что делает | Пример |
|---|---|---|
make($abstract, $parameters) | Разрешить класс из контейнера | app()->make(SomeService::class) |
bind($abstract, $concrete, $shared) | Зарегистрировать привязку | app()->bind(Interface::class, Implementation::class) |
singleton($abstract, $concrete) | Зарегистрировать синглтон | — |
instance($abstract, $instance) | Зарегистрировать готовый объект | — |
call($callback, $parameters) | Вызвать функцию/метод с разрешёнными зависимостями | — |
environment() | Имя окружения (local, production...) | — |
runningInConsole() | true, если запущено из консоли | — |
isDownForMaintenance() | Проверить, включён ли режим обслуживания | — |
configure($name) | Загрузить конфигурационный файл | — |
Подробно про контейнер и DI — см. отдельный раздел KB_13 Service Container.
Валидация — это процесс проверки данных, которые приходят от клиента (браузер, мобильное приложение, внешнее API), на соответствие определённым правилам. Это защитный барьер между внешним миром и вашей бизнес-логикой.
Основные цели валидации:
- Безопасность. Защита от вредоносных данных (SQL-инъекции, XSS, подделка параметров). Проверяя тип, длину и формат, вы снижаете риск атак.
- Целостность данных. Гарантия, что в базу попадут только корректные значения: email действительно является email-адресом, а
age— целым числом в разумном диапазоне. - Удобство пользователя. Если пользователь ошибся в форме, вы можете сразу показать понятное сообщение, а не отдавать непонятный сбой.
- Соблюдение бизнес-правил. Например, заказ должен содержать хотя бы один товар, скидка не может быть больше 50%. Это логика приложения, которую тоже проверяют на уровне валидации.
Laravel предлагает три уровня валидации: inline ($request->validate(...)), Validator::make(...), и FormRequest как отдельный класс. FormRequest — предпочтительный путь для контроллеров: правила, кастомные сообщения, авторизация и подготовка данных собраны в одном месте, контроллер остаётся тонким.
В веб-приложениях чаще всего валидируют:
- Входящие данные запроса — параметры URL (
/users/{id}), query-строку (?page=2), тело POST/PUT/PATCH-запроса (JSON, form-data, x-www-form-urlencoded). - Загруженные файлы — размер, тип MIME, наличие вирусов, размерности изображения.
- Данные сессии/куки — реже, обычно уже проверены middleware и не требуют явной валидации.
- Внешние данные — от сторонних API. Иногда их тоже проверяют перед использованием, если формат может измениться.
Валидация выполняется до контроллера (через FormRequest), либо внутри контроллера через $request->validate(). Вы определяете правила для каждого поля, и если данные не проходят проверку, Laravel автоматически возвращает ответ с ошибками: редирект назад для веб-форм или JSON со статусом 422 Unprocessable Entity для API-запросов.
FormRequest подключается к контроллеру через внедрение зависимости (type-hint) в методе контроллера. Валидация происходит до вызова метода контроллера, на этапе разрешения зависимостей, сразу после того, как запрос прошёл все middleware.
Полная цепочка запроса до момента валидации:
- Входящий запрос →
public/index.php→ Ядро (Kernel). - Глобальные middleware (
TrustProxies,HandleCors,TrimStringsи др.). - Групповые middleware (
webилиapi) — сессии, CSRF, throttle и т.д. - Маршрутные middleware (указанные в
->middleware(...)). - Диспетчер роутера определяет контроллер и метод.
- Контейнер DI начинает разрешать зависимости для метода контроллера.
- Если среди параметров есть FormRequest — контейнер создаёт его экземпляр.
- В процессе создания автоматически запускается валидация (через интерфейс
ValidatesWhenResolved). - Если валидация провалена — выбрасывается
ValidationException, которое перехватывается и превращается в ответ 422 Unprocessable Entity (или редирект с ошибками для веб-запросов). - Если валидация успешна — экземпляр FormRequest передаётся в контроллер, выполнение продолжается.
- Контроллер получает уже провалидированные данные и выполняет бизнес-логику.
FormRequest реализует интерфейс ValidatesWhenResolved, и контейнер после создания вызывает у него метод validateResolved().
Частое недопонимание: кажется, что FormRequest перечисляет поля, которые окажутся в базе. Это не так. Он описывает контракт входа — что клиенту вообще разрешено прислать, — и проверяет это до того, как запустится контроллер. То, что пользователь выбирает и заполняет, действительно в нём; то, что сохраняется в строку таблицы, — нет.
| Часть | На какой вопрос отвечает | Пример для заказа |
|---|---|---|
FormRequest | Что приходит от клиента? | sku и quantity, больше ничего |
| Action | Что сохраняем в базу? | user_id из токена, цена из products, посчитанный total, status со значением по умолчанию |
| Resource | Что отдаём клиенту в ответ? | id, позиции, total, status |
На коде это выглядит так. В запросе — только то, что выбирает человек:
// app/Http/Requests/StoreOrderRequest.php
public function rules(): array
{
return [
'items' => ['required', 'array', 'min:1'],
'items.*.sku' => ['required', 'string', 'exists:products,sku'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:100'],
];
}
Остальное появляется уже в Action — из источников, до которых клиент не дотягивается:
// app/Actions/PlaceOrder.php
public function handle(User $user, array $items): Order
{
// цену берём из базы, а не из запроса
$products = Product::whereIn('sku', array_column($items, 'sku'))
->get()->keyBy('sku');
$total = collect($items)->sum(
fn ($line) => $products[$line['sku']]->price * $line['quantity']
);
return Order::create([
'user_id' => $user->id, // из токена, не из тела
'total' => $total, // посчитали сами
'status' => OrderStatus::PendingPayment, // задаёт код
]);
}
rules(). Спросите: может ли клиент соврать в этом поле без последствий для нас? Количество и артикул — пусть присылает, их всё равно проверят правила и база. Цену, идентификатор пользователя и статус клиент присылать не должен вовсе: подделав их, он купил бы товар за рубль или выставил заказу «оплачено». Поэтому таких полей в правилах нет, и попасть в приложение они не могут.validated() возвращает только те поля, для которых правило написано. Пришёл "price": 1 или "status": "delivered", а в rules() их нет — в validated() их не будет, и до Action они не дойдут. Это не побочный эффект, а основная защита от mass assignment на границе HTTP: обратите внимание, что $request->all() отдал бы их как есть, поэтому передавать в модель нужно именно validated().$fillable, а значение не сохраняется — почти всегда забыто правило в rules(). Поле просто отсекается на входе, причём молча: ошибки не будет, потому что лишних полей валидация не считает ошибкой, она их отбрасывает.validated() гарантирует только то, что поле описано правилом и правило прошло. Что sku существует в каталоге, проверяет exists; что цена именно та, не проверяет никто — её поэтому и читают из базы. Валидация отвечает за форму данных, а за их смысл отвечает код, который с ними работает.В authorize() действительно возвращают булево значение, и срабатывает он до rules(). Но проверка там должна быть ровно одна: имеет ли этот пользователь право выполнять это действие. Всё остальное проверяется на других уровнях, и каждый отвечает своим кодом ответа.
| Где | На какой вопрос отвечает | Код при отказе |
|---|---|---|
Middleware auth:sanctum | Пользователь вообще вошёл? | 401 |
authorize() | Этому пользователю можно делать это? | 403 |
rules() | Он прислал корректные данные? | 422 |
401 отдельно. Этот код приходит, когда запрос ожидает JSON: в ответе будет {"message": "Unauthenticated."}. Обычный запрос из браузера вместо кода получит переадресацию на страницу входа — фреймворк смотрит на заголовки и решает, что уместнее.Что писать в authorize() на практике:
// Создание заказа: заказывать может любой вошедший,
// а это уже проверил middleware — проверять нечего
public function authorize(): bool
{
return true;
}
// Если заказывать разрешено только с подтверждённой почтой —
// вот это как раз сюда
public function authorize(): bool
{
return $this->user()->hasVerifiedEmail();
}
// Отмена заказа: отменить можно только свой
public function authorize(): bool
{
return $this->user()->id === $this->route('order')->user_id;
}
authorize(); $this->route('order') вернёт уже загруженную модель, если в маршруте используется привязка {order}.hasVerifiedEmail() появляется не сам по себе. Метод даёт трейт Illuminate\Auth\MustVerifyEmail, а работает он, когда модель User объявлена как implements MustVerifyEmail. В стандартной заготовке Laravel эта строка закомментирована, поэтому вызов на нетронутой модели упадёт с «метод не найден».| Проверка | Где ей место | Почему |
|---|---|---|
| Пользователь вошёл? | Middleware | Иначе $this->user() окажется null и упадёт сам метод, а клиент получит 500 вместо 401 |
| Артикул существует, количество больше нуля | rules() | Это форма данных. Клиенту нужен 422 с указанием поля, а не глухое «запрещено» |
| Товар есть на складе, хватает денег | Action | Это бизнес-правило, а не право доступа. Оно меняется вместе с логикой и должно быть покрыто тестами Action |
authorize() остаётся одна строка: return $this->user()->can('cancel', $this->route('order'));. Ради одного создания заказа политику заводить рано — это как раз тот случай, когда абстракция появляется раньше, чем повторение.authorize()
Возвращает bool. Если false — 403 без обращения к контроллеру. Сюда — авторизация на уровне действия (не аутентификация: для неё middleware).
rules()
Массив правил: ключ — имя поля, значение — pipe-string или массив правил. 'email' => ['required', 'email', Rule::unique('users')->ignore($this->user)]. Доступ к маршрутным параметрам — через $this->route('user').
messages() и attributes()
Кастомизация: messages() — переопределение текста; attributes() — человекочитаемые имена полей в сообщениях.
prepareForValidation()
Хук перед валидацией: можно изменить данные запроса ($this->merge([...])). Полезно для нормализации (trim, lowercase, конвертация типов).
passedValidation()
Хук после успешной валидации, перед возвратом в контроллер. Здесь — побочные действия, требующие валидных данных (логирование, dispatch event).
Доступ к валидным данным
$request->validated() — только валидные поля. $request->safe() — то же, но с методами only/except/collect. Не используйте $request->all() в контроллере после валидации — попадут невалидные поля.
prepareForValidation() без return, а остальные с returnВсё просто: разные методы выполняют разные задачи, и их сигнатуры (возвращаемые типы) отражают их предназначение.
Методы с return — это геттеры. Они возвращают данные, которые Laravel использует, но ничего не меняют — просто отдают информацию:
authorize()— возвращаетbool, чтобы фреймворк понял: разрешено или запрещено действие.rules()— возвращаетarrayправил, которые нужно применить к данным.messages()— возвращаетarrayкастомных сообщений об ошибках.attributes()— возвращаетarrayчеловекочитаемых имён полей.
Метод prepareForValidation() без return — это действие (mutator). Он мутирует (изменяет) данные запроса до запуска валидации. Задача — привести данные к нужному виду: обрезать пробелы, привести email к нижнему регистру, преобразовать типы, удалить лишние символы.
Ему не нужно ничего возвращать, потому что он работает с объектом $this напрямую — изменяет его свойства через $this->merge() или $this->replace(). Laravel после вызова prepareForValidation() продолжит работать с тем же объектом $request, но уже с изменёнными данными. Поэтому метод объявлен как void — он не возвращает значение, а выполняет побочное действие.
return array/bool. Если меняет состояние объекта — void. Это правило работает не только в FormRequest, но и в целом в объектно-ориентированном дизайне.
can() в authorize()В типичном примере authorize() часто встречается такая проверка:
public function authorize(): bool
{
return $this->user()->can('update', $this->route('user'));
}
Это проверка, может ли текущий аутентифицированный пользователь выполнить действие update над конкретным объектом User. Разбор по частям:
$this->user()— возвращает текущего аутентифицированного пользователя (эквивалентauth()->user()).can('update', $user)— вызывает механизм авторизации Laravel, который проверяет, есть ли у этого пользователя право на действиеupdateнад переданной моделью.$this->route('user')— получает модельUser, автоматически подставленную в маршрут через Route Model Binding. Для маршрута/users/{user}это объект пользователя, найденный по ID.
Откуда берётся логика проверки. Laravel ищет определение разрешения update для модели User в двух местах:
- Политики (Policies) — классы, группирующие разрешения для конкретной модели. Создаются командой
php artisan make:policy UserPolicy. Внутри определяются методыview,create,update,deleteи др., каждый возвращаетbool. - Gates — замыкания, зарегистрированные в
App\Providers\AuthServiceProvider(или вbootstrap/app.phpв L11+).
Пример политики:
class UserPolicy
{
public function update(User $currentUser, User $targetUser)
{
return $currentUser->id === $targetUser->id
|| $currentUser->isAdmin();
}
}
Если такой политики нет — Laravel вернёт false (по умолчанию запрещено).
Зачем это нужно в authorize(). Метод authorize() в FormRequest вызывается до валидации. Если он возвращает false — Laravel сразу возвращает ответ 403 Forbidden, даже не запуская проверку правил. Это даёт:
- Разделение ответственности: авторизация (кто может делать) и валидация (что можно отправлять) живут отдельно.
- Не дублировать логику: проверка прав вынесена в политики, и её можно использовать в контроллерах, Blade-представлениях, middleware.
- Чистый код: в контроллере вы уже уверены, что пользователь имеет право на действие, и не пишете лишних
if.
authorize() — это авторизация (разрешение делать), а не аутентификация (вход в систему). За аутентификацию отвечает middleware auth. В FormRequest $this->user() вернёт объект только если пользователь уже аутентифицирован — иначе будет ошибка, поэтому ставьте middleware auth на маршрут. Если метод authorize() отсутствует или всегда возвращает true — проверка прав не выполняется вовсе, будьте осторожны.
can() — встроенный метод Laravel для проверки прав, использующий политики или gates. Вместе с authorize() в FormRequest он даёт удобный способ централизованно управлять доступом к действиям, не засоряя контроллер.
passedValidation() — пост-валидационный хукЭтот хук вызывается после успешной валидации, но до того, как контроллер получит запрос. Полезен для действий, которые должны произойти только если данные прошли проверку, но не относятся к бизнес-логике самого контроллера — логирование, аудит, отправка событий.
Пример: логирование успешной валидации.
class UpdateUserRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('update', $this->route('user'));
}
public function rules(): array
{
return [
'email' => 'required|email|unique:users,email,' . $this->route('user')->id,
'name' => 'required|string|max:255',
];
}
// Этот метод вызывается, если валидация прошла успешно
protected function passedValidation(): void
{
// Логируем событие (кто и когда пытался обновить профиль)
Log::info('User profile update validated', [
'user_id' => $this->user()->id,
'target_user' => $this->route('user')->id,
'data' => $this->validated(), // доступны только валидные поля
]);
// Можно отправить событие (например, админу о важном изменении)
// event(new UserProfileValidated($this->user(), $this->validated()));
}
}
Что здесь происходит. После того как правила проверены и ошибок нет, Laravel автоматически вызывает passedValidation(). Внутри вы имеете доступ ко всем валидным данным через $this->validated() или $this->safe(). Выполняются побочные действия — логирование, отправка событий, запись в кеш, уведомления. Эти действия не влияют на ответ контроллера, но выполняются в том же запросе.
Важные нюансы:
passedValidation()не должен возвращать значение (void) — он просто выполняет действия, как иprepareForValidation().- Если нужно модифицировать данные после валидации (например, добавить поле) — можно использовать
$this->merge()и здесь, хотя обычно это делают вprepareForValidation(). - В контроллере вы всё равно получите тот же объект
$request, который уже содержит все изменения, сделанные в хуках.
Альтернативы. Вместо passedValidation() можно делать аналогичные действия прямо в контроллере после $request->validated(). Но вынос их в FormRequest делает контроллер тоньше и централизует логику, связанную именно с этим запросом. Если действия требуют асинхронности (отправка email, тяжёлые вычисления) — лучше использовать события или очереди, а не делать это синхронно в HTTP-запросе.
passedValidation() — пост-валидационный хук, который выполняется только при успешной проверке. Идеально подходит для логирования, отправки уведомлений, побочных действий, зависящих от валидных данных. Для изменения данных перед валидацией — используйте prepareForValidation(), для реакции на валидные данные — passedValidation().
user() и validated()Частый вопрос: «в моём StorePostRequest нигде не написан user(), а он работает — почему?». Ответ — иерархия наследования. FormRequest расширяет обычный Request, поэтому получает все его методы, плюс добавляет свои — валидационные.
Разложение по методам:
| Метод | Откуда унаследован | Что делает |
|---|---|---|
$request->user() |
Illuminate\Http\Request (базовый) |
Возвращает аутентифицированного пользователя из guard, применённого middleware (обычно auth). Работает в любом Request и его наследниках. |
$request->input('key'), ->all(), ->only() |
Illuminate\Http\Request |
Доступ к сырым данным запроса (до валидации). |
$request->route('post') |
Illuminate\Http\Request |
Route Model Binding — параметр из URL. |
$request->validated() |
FormRequest (только там) |
Возвращает только валидные поля — те, что прошли rules(). Всё остальное игнорируется. |
$request->safe() |
FormRequest |
То же самое + удобные обёртки ->only(...), ->except(...), ->collect(). |
Illuminate\Http\Request. Всё что про валидацию (validated, safe, rules, authorize) — в FormRequest.
validated() — безопасный способ получить данныеvalidated() возвращает только те ключи, для которых определены правила в rules(). Всё лишнее — отсеивается автоматически. Это критично для защиты от mass-assignment:
// Правила
public function rules(): array
{
return [
'title' => 'required|string',
'content' => 'required|string',
];
}
// Атакующий шлёт: {"title": "X", "content": "Y", "is_admin": true, "user_id": 999}
$request->all(); // вернёт ВСЁ, включая is_admin и user_id
$request->validated(); // вернёт только title + content
// Поэтому в контроллере:
Post::create($request->validated()); // безопасно
Post::create($request->all()); // потенциальная дыра
validated() доступен только ПОСЛЕ прохождения валидации. Если валидация не прошла — контроллер даже не будет вызван. Laravel сам поймает ValidationException, вернёт 422 (для API) или redirect back с ошибками (для веб). Поэтому в теле контроллера ты гарантированно работаешь с валидными данными.user()/validated() используются в контроллере, а не в ActionВ рефакторинге ты видел паттерн:
// Контроллер извлекает данные из HTTP-контекста
public function store(StoreOrderRequest $request, PlaceOrderAction $action)
{
$order = $action->handle(
$request->user(), // вытащили user
$request->validated()['items'], // вытащили items
);
return redirect()->route('orders.show', $order);
}
// Action принимает уже готовые примитивы — про HTTP ничего не знает
class PlaceOrderAction
{
public function handle(User $user, array $items): Order
{
// ...
}
}
Почему Action не принимает $request:
- Переиспользование. Тот же Action вызывается из Artisan-команды (
PlaceOrderActionизSyncOldOrdersCommand) — там HTTP-запроса нет вообще, только массивы. - Тестируемость. Юнит-тест:
$action->handle($user, ['items' => [...]])без моков Request/Session/Auth. Быстро и просто. - Разделение слоёв. HTTP-слой (Request/Response) — забота контроллера. Бизнес-логика — забота Action. Смешивать = сложнее поддерживать.
$request->user()— унаследован отIlluminate\Http\Request, работает в любом Request. Возвращает аутентифицированного пользователя из middlewareauth.$request->validated()— добавлен вFormRequest. Возвращает только валидные поля изrules()— защита от mass-assignment.- Оба доступны потому, что
FormRequest extends Request— стандартное PHP-наследование. - В Action их не используют — контроллер извлекает данные, Action работает с примитивами. Так Action переиспользуемый и тестируемый.
Звёздочка — подстановка вместо индекса элемента. Запись items.*.sku читается как «поле sku внутри каждого элемента массива items». Правило применяется к каждому элементу по отдельности, а сколько их придёт, заранее знать не нужно: валидатор сам раскрывает звёздочку по фактическому количеству.
Уровней получается три, и у каждого своя задача:
| Ключ правила | К чему относится | Типичные правила |
|---|---|---|
items | Сам массив целиком | required, array, min:1, max:50 |
items.* | Каждый элемент как единое значение | array, string, distinct |
items.*.sku | Поле внутри каждого элемента | required, exists, integer |
min и max на самом массиве считают элементы, а не значение. У 'items' => 'max:3' смысл «не больше трёх позиций», и в ответе придёт The items field must not have more than 3 items. То же правило на items.*.quantity означало бы уже «не больше трёх штук» — одно и то же слово про разные вещи, и перепутать их легко.Звёздочка раскрывается и в ключах ошибок — вместо неё подставляется индекс. Это и нужно клиенту, чтобы подсветить конкретную строку формы, а не весь список.
// пришло: вторая позиция с нулевым количеством
[
['sku' => 'A', 'quantity' => 1],
['sku' => 'B', 'quantity' => 0],
]
# что в ответе 422
items.1.quantity: The items.1.quantity field must be at least 1.
Свои формулировки и человекочитаемые имена задаются с той же звёздочкой:
public function messages(): array
{
return [
'items.*.sku.required' => 'Артикул обязателен в каждой позиции.',
'items.*.sku.distinct' => 'Этот товар уже добавлен в заказ.',
];
}
public function attributes(): array
{
return ['items.*.sku' => 'артикул позиции'];
}
items.*.sku.required даст ошибку под ключом items.0.sku. Одно сообщение покрывает все позиции сразу.distinct проверяет значение не само по себе, а относительно остальных элементов того же массива: такого значения не должно встретиться больше одного раза. На items.*.sku это читается как «один и тот же артикул не может попасть в заказ дважды».
'items.*.sku' => ['required', 'string', 'exists:products,sku', 'distinct'],
# пришли две позиции с одинаковым артикулом — ошибка на обеих
items.0.sku: The items.0.sku field has a duplicate value.
items.1.sku: The items.1.sku field has a duplicate value.
| Запись | Что считает повтором |
|---|---|
distinct | Нестрогое сравнение: '1' и 1 — повтор, 'KEY-A' и 'key-a' — нет |
distinct:strict | Строгое сравнение с учётом типа: '1' и 1 уже не повтор |
distinct:ignore_case | Регистр не учитывается: 'KEY-A' и 'key-a' — повтор |
'tags.*' => 'distinct' запретит один и тот же тег дважды, ошибка придёт под ключом tags.0.price, а правила на него нет, валидация пройдёт, и validated() вернёт [['sku' => 'A', 'quantity' => 1]] — без цены. Это то же поведение, что и на верхнем уровне, просто внутри массива его замечают позже.'items.*' => 'array:sku,quantity' — элемент может содержать только перечисленные ключи. Но сообщение при отказе приходит обманчивое: The items.0 field must be an array, хотя массивом элемент как раз является, а дело в постороннем ключе. Без своего текста в messages() этот ответ уведёт отладку не туда.| Способ | Где происходит | Преимущества | Недостатки |
|---|---|---|---|
| FormRequest | До контроллера (на этапе DI) | Всё в одном классе, переиспользуемость, чистота контроллера, авторизация и подготовка данных встроены | Требуется создавать отдельные классы под каждый endpoint |
Inline $request->validate() |
Внутри контроллера | Быстро, не требует отдельного класса — удобно для мелких endpoint'ов | Загромождает контроллер, сложно переиспользовать между экшенами |
Validator::make() |
Где угодно (вручную) | Полный контроль, можно использовать не только в контроллерах (jobs, commands, сервисы) | Много ручного кода, нужно самому обрабатывать ошибки и решать что делать при провале |
Важный нюанс. FormRequest наследуется от Illuminate\Http\Request, поэтому в контроллере вы также можете получать данные через $request->input(), $request->file() и т.д. После успешной валидации отфильтрованные данные доступны через validated(). Есть хуки prepareForValidation() для модификации данных перед валидацией и passedValidation() для действий после успешной проверки.
Validator::make() — когда валидация нужна вне контроллера.
1. Inline — $request->validate()
Где: внутри метода контроллера. Паттерн: вызываешь validate() у объекта $request, передаёшь массив правил. Что на выходе: если ошибок нет — возвращает массив (array) проверенных данных. Если есть ошибки — автоматически выбрасывает ValidationException (редирект назад для web, JSON 422 для API).
use Illuminate\Http\Request;
class PostController extends Controller
{
public function store(Request $request)
{
// 1. Валидация
$validated = $request->validate([
'title' => 'required|string|max:255',
'body' => 'required|string',
]);
// 2. Использование данных (массив)
Post::create($validated); // ['title' => '...', 'body' => '...']
return redirect()->route('posts.index');
}
}
Где брать данные: переменная $validated (массив).
2. Вручную — Validator::make()
Где: где угодно — контроллер, сервисный класс, команда Artisan, очередь. Паттерн: создаёшь экземпляр валидатора через фасад Validator, передаёшь данные и правила. Ошибки не выбрасываются автоматически — их нужно проверять вручную через fails() или validate().
Полная сигнатура — принимает 4 аргумента: данные, правила, сообщения, атрибуты:
$validator = Validator::make($data, $rules, $messages, $attributes);
if ($validator->fails()) { /* ... */ }
$validated = $validator->validated();
Пример 1 — ручная проверка ошибок:
use Illuminate\Support\Facades\Validator;
use Illuminate\Http\Request;
class UserController extends Controller
{
public function import(Request $request)
{
$validator = Validator::make($request->all(), [
'file' => 'required|file|mimes:csv',
'columns' => 'required|array|min:1',
]);
// Проверяем вручную
if ($validator->fails()) {
return redirect()->back()
->withErrors($validator)
->withInput();
}
// Данные — через validated()
$validated = $validator->validated(); // массив
// Логика импорта...
}
}
Пример 2 — принудительный выброс исключения (если хочешь чтобы вело себя как $request->validate()):
$validated = Validator::make($data, $rules)->validate();
// Теперь $validated — массив, либо исключение при ошибке
Где брать данные: $validator->validated() (массив).
3. Отдельный класс — FormRequest
Где: создаётся отдельный класс (например, app/Http/Requests/StorePostRequest.php), подключается в контроллере через type-hint. Паттерн: валидация и авторизация происходят ДО контроллера (на этапе DI). Если ошибка — контроллер даже не вызывается (редирект или 422). Что на выходе: в контроллере вызывается validated() или safe(), которые возвращают массив или объект.
// 1. Класс запроса
class StorePostRequest extends FormRequest
{
public function authorize() { return true; }
public function rules() {
return [
'title' => 'required|string|max:255',
'body' => 'required|string',
];
}
}
// 2. Контроллер
class PostController extends Controller
{
// Внедряем FormRequest, а не Request
public function store(StorePostRequest $request)
{
// Валидация уже пройдена!
// Вариант 1: массив проверенных данных
$data = $request->validated(); // array
// Вариант 2: объект с методами only/except
$safe = $request->safe(); // Collection-подобный объект
$only = $request->safe()->only(['title']); // ['title' => '...']
Post::create($data);
}
}
Где брать данные:
$request->validated()— массив.$request->safe()— объект (позволяет цепочки->only(),->except(),->all()).
| Способ | Сбор данных | Обработка ошибок |
|---|---|---|
Inline ($request->validate()) |
Возвращает массив напрямую | Автоматически (редирект / JSON 422) |
Validator::make() |
$validator->validated() — массив |
Вручную через fails() или принудительно через ->validate() |
| FormRequest | $request->validated() — массив;$request->safe() — объект |
Автоматически (до контроллера) |
$request->all() в контроллере после валидации — в all() могут попасть лишние (невалидные) поля, которые злоумышленник подсунул в запрос. Это открытая mass-assignment уязвимость. Всегда берите только те данные, которые прошли проверку — через validated() или safe().
Validator::make() использованы ->withErrors($validator)->withInput() — это не хелперы, а методы объекта RedirectResponse. Подробный разбор в разделе HTTP-объекты Laravel → RedirectResponse (см. sidebar).
Саму валидацию (проверку данных по правилам) можно использовать почти везде — в контроллерах, сервисных классах, jobs, консольных командах, даже в моделях (хотя в моделях не рекомендуется — там место бизнес-логики, а не проверки входа).
А вот специфические методы — это эксклюзивные возможности класса FormRequest. Они не работают в $request->validate() или Validator::make():
authorize()— авторизация до валидации, возврат 403 если falserules()— массив правилmessages()— кастомные сообщения об ошибкахattributes()— человекочитаемые имена полейprepareForValidation()— хук перед валидацией (нормализация данных)passedValidation()— хук после успешной валидации
Если нужны эти хуки — только FormRequest. Если нужна просто проверка правил — подойдёт любой из трёх способов.
final class UpdateUserRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('update', $this->route('user'));
}
public function prepareForValidation(): void
{
$this->merge([
'email' => strtolower(trim($this->input('email', ''))),
]);
}
public function rules(): array
{
return [
'email' => ['required', 'email',
Rule::unique('users')->ignore($this->route('user'))],
'name' => ['required', 'string', 'max:255'],
];
}
public function messages(): array
{
return [
'email.unique' => 'Этот email уже занят',
];
}
}
// Контроллер — тонкий
public function update(UpdateUserRequest $request, User $user): JsonResponse
{
$user->update($request->validated());
return response()->json($user);
}
Типичный паттерн для CRUD-моделей — два отдельных FormRequest: один на создание, второй на обновление. Правила почти всегда отличаются (уникальность, обязательность, проверка связанных записей), и разделение позволяет держать оба класса плоскими без if внутри rules().
StorePostRequest — созданиеПравила применяются только к новым записям. unique без всяких исключений — новой записи ни с чем сравниваться не нужно.
class StorePostRequest extends FormRequest
{
public function rules(): array
{
return [
'title' => 'required|string|max:255',
'slug' => 'required|unique:posts,slug', // уникален в таблице
'content' => 'required',
];
}
}
UpdatePostRequest — обновлениеКлючевое отличие — при проверке unique нужно игнорировать текущую запись, иначе пользователь получит ошибку «slug уже занят», редактируя тот же самый пост со старым slug'ом.
class UpdatePostRequest extends FormRequest
{
public function rules(): array
{
// Пост берётся через Route Model Binding
$postId = $this->route('post')->id;
return [
'title' => 'required|string|max:255',
// Уникальность, но исключаем текущий пост по ID
'slug' => 'required|unique:posts,slug,' . $postId,
'content' => 'required',
];
}
}
Синтаксис unique:posts,slug,{id}:
unique:posts— проверить в таблицеposts,slug— по колонкеslug,{id}— исключить запись с этим ID из проверки (третий параметр = ignore id)
Rule::unique():
use Illuminate\Validation\Rule;
'slug' => [
'required',
Rule::unique('posts', 'slug')->ignore($this->route('post')),
],
Такая запись читаемее, лучше типизирована и меньше шансов ошибиться со строковой конкатенацией. Второй аргумент ignore() — колонка первичного ключа (по умолчанию id).
if- Правила store и update почти всегда различаются: уникальность, обязательные поля (при PATCH часть полей опциональна), проверка связанных записей.
- Один
PostRequestсif ($this->isMethod('POST'))внутриrules()— быстро превращается в лапшу. - Два класса — явнее: сразу видно в сигнатуре контроллера, какие правила применяются в данном методе.
- Тесты пишутся отдельно для каждого сценария без ветвлений.
class PostController extends Controller
{
public function store(StorePostRequest $request)
{
$post = Post::create($request->validated());
return redirect()->route('posts.show', $post);
}
public function update(UpdatePostRequest $request, Post $post)
{
$post->update($request->validated());
return redirect()->route('posts.show', $post);
}
}
unique/exists/условной обязательностью → два FormRequest'а. Тонкие, плоские, без if. Для unique при update — обязательно ignore() с ID текущей модели.
$request->all() вместо validated(). Получите всё, включая невалидные поля. Mass-assignment на модель — потенциальная дыра безопасности.required и пустые строки. required считает пустую строку валидной. Для строгости — required|string|min:1.nullable + другие правила. nullable отключает все последующие правила, если значение null. Без nullable — null приведёт к ошибке валидации.sometimes правило. 'name' => 'sometimes|required|string' — проверять только если поле присутствует. Полезно для PATCH-запросов.unique без ignore при update. Без Rule::unique('users')->ignore($this->user) валидация падает на собственном email пользователя.$fail(). В Laravel 10+ кастомное правило через invokable использует $fail($message); забывание этого вызова делает правило всегда проходящим.'items.*.sku' => 'required' валидирует каждый элемент массива. Без * — только наличие массива как поля.FormRequest::after(). Laravel 11+ ввёл after() для дополнительных проверок после правил. Полезно для меж-полевой логики (например, start_date < end_date), которую неудобно выразить простыми правилами.API Resource — трансформационный слой между моделью Eloquent и JSON-ответом клиенту. Задача: единообразно форматировать данные, скрывать чувствительные поля, включать связанные ресурсы по необходимости, добавлять meta-информацию.
Проблема без ресурсов. Возврат User::find($id) напрямую отдаёт все атрибуты как есть — включая password, remember_token, timestamps, служебные поля. Форматирование дат, преобразование enum-ов, вложенные связи — всё это придётся делать вручную в контроллере или в модели через toArray(). Быстро превращается в кашу.
// Без ресурса — контроллер отдаёт всё
return response()->json(User::with('posts')->find($id));
// В JSON попадут: password, remember_token, email_verified_at,
// created_at/updated_at, все поля постов включая user_id...
// Через ресурс — контроль над форматом
return new UserResource(User::with('posts')->find($id));
Что дают API Resources:
- Явно контролируешь какие поля попадут в JSON.
- Единообразный формат ответов для всего API.
- Скрытие чувствительных полей (пароль, токены, внутренние ID).
- Форматирование дат, enum, sums (валюты, размеры).
- Условные поля — включаются только если явно загружены (защита от N+1).
- Meta-информация — пагинация, версия API, permissions.
- Легкое версионирование API (
V1\UserResourcevsV2\UserResource).
Создание:
php artisan make:resource UserResource # один ресурс
php artisan make:resource UserCollection # коллекция ресурсов
php artisan make:resource Api/V1/PostResource # в подпапке
Файлы создаются в app/Http/Resources/.
Короткий ответ: нет, не всегда. Resource — это один из способов преобразовать Eloquent-модель в JSON, но не единственный. Laravel сам умеет сериализовать модели, и для простых кейсов посредник не нужен.
| Способ | Что делает | Когда |
|---|---|---|
return $model; |
Laravel сам вызовет toArray() → toJson(). Автоматическая сериализация. |
CRUD, структура БД = структура ответа |
return $model->toArray(); / toJson() |
Явное преобразование в массив или строку. | Нужно подкрутить перед отдачей (напр. toArray() + array_merge) |
return response()->json($data); |
Обёртка с контролем статуса/заголовков. | Нужен явный HTTP-статус (201, 204, 422) |
return new UserResource($user); |
Класс-трансформер: полный контроль структуры, отношений, условных полей. | Сложные API, версионирование, отличная от БД структура |
- Кастомизация JSON — добавить
full_name, отформатировать дату (toIso8601String), скрытьpassword/remember_token, переименовать ключи. - Вложенные отношения —
PostResourceсодержитauthor => new UserResource($this->user), изолируя контроль поляuserв одном месте. - Единообразие — все эндпоинты используют одинаковую структуру ответа (
data,meta,links). - Пагинация —
UserResource::collection($users)с paginator'ом сам подцепитmeta.total,meta.current_page,links.first,links.next. - API-версионирование —
App\Http\Resources\V1\UserResourceиV2\UserResourceживут рядом, каждая версия своя.
- Простой CRUD — если возвращаешь модель как есть,
return $user;достаточно (Laravel сам сериализует). - Быстрый прототип / MVP — где структура ещё не устоялась, ставить Resource преждевременно.
- Минимальная трансформация — например, вернуть массив ID:
return $users->pluck('id');. - Внутренний API между микросервисами — где контракт задаётся отдельно, а Laravel — внутренняя реализация.
Скрытие полей в модели: $hidden / $visible
Если единственная задача — скрыть чувствительные поля при сериализации, Resource не обязателен. Достаточно protected $hidden = ['password', 'remember_token'] в модели — они автоматом уйдут из toArray()/toJson(). Подробнее — в Eloquent → Mass Assignment.
->map() на коллекции
return $users->map(fn ($u) => ['id' => $u->id, 'label' => $u->name]); — быстрый ad-hoc трансформер. Проблема: повторяющийся код разбросан по контроллерам, менять формат — искать по всему проекту.
Presenter / DTO
Ручной класс с методом present() или readonly-DTO из php 8.1. Не встроенное в Laravel — но иногда используют для типизации и IDE-подсказок.
--resource-контроллер. php artisan make:controller PostController --resource создаёт контроллер с 7 CRUD-методами (см. ctrl-resource). php artisan make:resource UserResource создаёт трансформер JSON. Слово «resource» — два разных инструмента.return $model; + $hidden в модели. Не насаждай Resource ради Resource — добавляй когда появляется реальная потребность в трансформации.
JsonResource — один ресурсНаследуемся от Illuminate\Http\Resources\Json\JsonResource, метод toArray() возвращает массив, который станет JSON.
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'role' => $this->role->value, // enum
'created_at' => $this->created_at->toIso8601String(),
];
}
}
Использование в контроллере:
use App\Http\Resources\UserResource;
public function show(User $user)
{
return new UserResource($user);
}
Результирующий JSON (автоматически оборачивается в data):
{
"data": {
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"role": "admin",
"created_at": "2026-01-15T10:30:00+00:00"
}
}
toArray обращение к $this проксируется к самой модели — $this->id, $this->email и т.д. Это работает через магический __get: ресурс делегирует доступ к атрибутам исходной модели.
Для массива объектов есть 2 способа.
Способ 1 — ::collection() (простой, для 90% случаев):
public function index()
{
return UserResource::collection(User::all());
}
Каждый User прогонится через UserResource::toArray(), получится массив в data:
{
"data": [
{"id": 1, "name": "Alice", ...},
{"id": 2, "name": "Bob", ...}
]
}
Способ 2 — отдельный ResourceCollection (когда нужна кастомизация коллекции):
php artisan make:resource UserCollection
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\ResourceCollection;
class UserCollection extends ResourceCollection
{
public function toArray($request): array
{
return [
'data' => $this->collection,
'meta' => [
'total' => $this->collection->count(),
'active_count' => $this->collection->where('active', true)->count(),
],
];
}
}
// Использование
return new UserCollection(User::all());
::collection() достаточно. ResourceCollection нужен только когда хотите добавить кастомные meta-поля или изменить структуру ответа для списка.
when / whenLoadedНе все поля нужны всегда. Ресурсы дают методы для условного включения полей.
when() — включить поле если условие истинно:
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
// email виден только владельцу или админу
'email' => $this->when(
$request->user()?->can('view-email', $this->resource),
$this->email
),
// admin_notes только если запросчик — админ
'admin_notes' => $this->when(
$request->user()?->isAdmin(),
$this->admin_notes
),
];
}
Если условие false — поле не попадает в JSON вообще (нет ключа).
whenLoaded() — включить связь только если она загружена (защита от N+1):
public function toArray($request): array
{
return [
'id' => $this->id,
'title' => $this->title,
// author включается ТОЛЬКО если ->with('author') был в query
'author' => new UserResource($this->whenLoaded('author')),
// comments — тоже
'comments' => CommentResource::collection($this->whenLoaded('comments')),
// pivot-атрибуты BelongsToMany
'assigned_at' => $this->whenPivotLoaded('role_user', fn () => $this->pivot->created_at),
];
}
Как это работает:
// Запрос БЕЗ загруженной связи
return new PostResource(Post::find(1));
// JSON: { "id": 1, "title": "..." } — author НЕ включён
// Запрос С загруженной связью
return new PostResource(Post::with('author')->find(1));
// JSON: { "id": 1, "title": "...", "author": {...} } — author включён
'author' => new UserResource($this->author) — на каждый Post будет отдельный SELECT к users (проблема N+1). whenLoaded исключает случай — если разработчик забыл ->with('author'), поле просто не попадёт в JSON, а не сделает N запросов.
Связь даёт не два исхода, а три, и путают обычно второй с третьим. Ниже — настоящий вывод для заказа с двумя связями: items и coupon.
// в ресурсе
'items' => ItemResource::collection($this->whenLoaded('items')),
'coupon' => new CouponResource($this->whenLoaded('coupon')),
# 1. связи не загружены — ключей нет вообще
{"id":1,"total":500}
# 2. загружен только items
{"id":1,"total":500,"items":[{"sku":"KEY-GTA5","qty":2}]}
# 3. загружены обе, но купона у заказа нет
{"id":1,"total":500,"items":[{"sku":"KEY-GTA5","qty":2}],"coupon":null}
null содержательная. Отсутствие ключа означает «мы эти данные не запрашивали», а null — «запрашивали, у объекта их нет». Для клиента это разные вещи: в первом случае нужно сходить за связью отдельным запросом, во втором ходить незачем. Поэтому нельзя считать, что поле всегда присутствует: на фронтенде проверка должна быть на наличие ключа, а не только на пустоту значения.whenLoaded не используют: $order->loadMissing('items') в контроллере либо with('items') в запросе. Выбор тут не между «правильно и неправильно», а между гибким ответом и предсказуемой формой.Полезно знать механику, потому что из неё следует одна ловушка в тестах. whenLoaded для незагруженной связи возвращает не null, а особый объект-метку MissingValue. Дальше ItemResource::collection() оборачивает эту метку как есть, а убирает ключ уже внешний ресурс — на шаге, который идёт после toArray().
toArray(). Этот метод возвращает сырой массив, где вместо отсутствующей связи лежит метка, и очистку он не выполняет. На незагруженной связи прямой вызов (new OrderResource($order))->toArray($request) не просто вернёт лишнее, а упадёт с Call to a member function first() on null. В тестах проверяйте либо ответ целиком через $this->getJson(...)->assertJson(...), либо resolve($request) — он и выполняет фильтрацию.Другие условные методы:
whenNotNull($this->phone)— включить если не null.whenLoaded('posts', fn() => $this->posts->count(), 0)— with default при не-загруженном.mergeWhen($condition, ['field1' => ..., 'field2' => ...])— включить несколько полей одновременно.whenCounted('posts', $this->posts_count)— если использовалиwithCount('posts').
Часто нужно вернуть не только основной ресурс, но и дополнительную мета-информацию: версия API, пагинация, права пользователя, ссылки.
Способ 1 — метод with():
class UserResource extends JsonResource
{
public function toArray($request): array
{
return ['id' => $this->id, 'name' => $this->name];
}
// Добавляется на верхний уровень JSON (рядом с data)
public function with($request): array
{
return [
'meta' => [
'api_version' => '1.0',
'server_time' => now()->toIso8601String(),
],
'links' => [
'self' => route('users.show', $this->id),
'posts' => route('users.posts.index', $this->id),
],
];
}
}
Результат:
{
"data": { "id": 1, "name": "Alice" },
"meta": { "api_version": "1.0", ... },
"links": { "self": "/api/users/1", ... }
}
Способ 2 — additional() при возврате:
return (new UserResource($user))->additional([
'meta' => ['request_id' => $request->header('X-Request-Id')],
]);
Разница: with() — постоянные мета для этого ресурса. additional() — точечно для конкретного запроса.
Если модель пагинируется через paginate() — ресурс автоматически добавит поля meta и links:
public function index()
{
return UserResource::collection(User::paginate(15));
}
Ответ:
{
"data": [
{ "id": 1, "name": "Alice" },
...15 юзеров
],
"links": {
"first": "http://.../users?page=1",
"last": "http://.../users?page=10",
"prev": null,
"next": "http://.../users?page=2"
},
"meta": {
"current_page": 1,
"from": 1,
"last_page": 10,
"path": "http://.../users",
"per_page": 15,
"to": 15,
"total": 150
}
}
Работает и с simplePaginate() (без общего total) и с cursorPaginate() (курсорная пагинация — быстрее для больших таблиц).
dataПо умолчанию все ресурсы оборачиваются в { "data": ... }. Иногда это не нужно.
Отключить обёртку для конкретного ресурса:
class UserResource extends JsonResource
{
public static $wrap = null; // убрать обёртку
}
// или переименовать
public static $wrap = 'user'; // вместо data — user
Глобально отключить для всех ресурсов — в AppServiceProvider::boot():
use Illuminate\Http\Resources\Json\JsonResource;
public function boot(): void
{
JsonResource::withoutWrapping();
}
meta, links, errors на верхнем уровне. Убирать оправдано если API имитирует контракт третьей стороны или используется старым клиентом.
Ресурс автоматически имеет HTTP-статус 200. Для 201 Created / 202 Accepted / других — используйте ->response():
public function store(StoreUserRequest $request)
{
$user = User::create($request->validated());
return (new UserResource($user))
->response()
->setStatusCode(201)
->header('Location', route('users.show', $user));
}
Или через хук withResponse в самом ресурсе (для стандартного изменения):
class UserResource extends JsonResource
{
public function toArray($request): array { /* ... */ }
public function withResponse(Request $request, JsonResponse $response): void
{
$response->header('X-API-Version', '1.0');
}
}
JsonResource— трансформация одной модели в JSON (методtoArray).::collection()— для массива объектов, автопагинация через->paginate().ResourceCollection— когда нужна кастомная структура коллекции с meta.when()/whenLoaded()— условные поля, критично для защиты от N+1.with()иadditional()— метаданные на верхнем уровне JSON.- Пагинация работает автоматически (
links+meta). - Wrap в
data— стандарт, отключайте только с причиной. ->response()->setStatusCode(201)— для custom HTTP-статуса.
Одна команда может создать сразу несколько файлов. Флаги комбинируются, поэтому обычно пишут не make:model, а сразу нужный набор:
| Команда | Что создаст |
|---|---|
make:model Product | Только модель |
make:model Product -m | Модель и миграцию |
make:model Product -mf | Плюс фабрику |
make:model Product -ms | Плюс сидер |
make:model Product -mc | Плюс контроллер |
make:model Product -mcr | Контроллер сразу ресурсный |
make:model Product -a | Всё сразу: миграция, фабрика, сидер, контроллер, запрос, политика |
make:migration add_sku_to_products_table | Отдельная миграция для изменения существующей таблицы |
create_products_table Laravel создаёт заготовку с Schema::create, а по add_sku_to_products_table — с Schema::table для изменения существующей. Поэтому имя пишут по-английски и по правилам, даже если проект на русском.| Метод | Что получится | Когда |
|---|---|---|
id() | Автоинкремент, первичный ключ | Почти всегда первая строка |
string('name', 255) | VARCHAR | Короткий текст |
text('body') | TEXT | Длинный текст без ограничения |
integer, bigInteger | Целые | Счётчики, количества |
decimal('price', 12, 2) | Точное число | Деньги — только так |
boolean('is_active') | Флаг | Да или нет |
json('meta') | JSON | Произвольная структура |
timestamps() | created_at и updated_at | Почти всегда |
softDeletes() | deleted_at | Мягкое удаление |
foreignId('user_id') | Колонка под внешний ключ | Связь с другой таблицей |
float — ошибка, которая всплывает не сразу. Числа с плавающей точкой не представляют десятичные дроби точно, поэтому копейки начинают теряться при суммировании. Для сумм берут decimal с явной точностью, а в модели добавляют приведение 'price' => 'decimal:2'.Когда у колонки заранее известен набор значений — draft и published, new и paid, — возникает выбор: закрепить список в схеме базы через enum() или хранить обычную строку, а список держать в коде. В современном Laravel почти всегда выбирают второе, и ниже разобрано почему.
| Критерий | enum в миграции | string плюс PHP-перечисление |
|---|---|---|
| Как объявляют | $table->enum('status', ['draft', 'published']) | $table->string('status') |
| Добавить новое значение | Миграция с ALTER TABLE по всей таблице | Одна строка в PHP-файле, базу не трогаем |
| Контроль на уровне базы | Есть: постороннее значение не запишется | Нет: база примет любую строку, следит приложение |
| Перенос между СУБД | Работает везде, но реализуется по-разному | Одинаково везде |
| Место и размер индекса | На MySQL чуть компактнее | Чуть больше, на практике незаметно |
| Где описан список значений | В миграции, то есть в истории изменений схемы | В одном файле перечисления, видно из любого места кода |
enum() создаёт на самом деле. Настоящий тип ENUM получается только на MySQL и MariaDB. На PostgreSQL и SQL Server Laravel создаёт обычную строковую колонку с ограничением CHECK, на SQLite — то же самое: varchar check ("status" in ('draft', 'published')). То есть запрет на постороннее значение работает во всех базах, просто механизм разный, а выигрыш по месту остаётся только у MySQL. Распространённое утверждение «в SQLite enum не поддерживается» неточно: поддерживается, но через проверку, а не через отдельный тип.Рекомендуемый подход: в базе строка, в коде перечисление PHP (появились в версии 8.1), между ними — приведение типа в модели.
// 1. Миграция: обычная строковая колонка
$table->string('status')->default('draft');
$table->index('status'); // если по статусу фильтруют
// 2. app/Enums/PostStatus.php
namespace App\Enums;
// : string — это backed enum, у каждого случая есть строковое значение,
// именно оно и лежит в базе
enum PostStatus: string
{
case Draft = 'draft';
case Published = 'published';
// перечисление может нести и поведение
public function label(): string
{
return match($this) {
self::Draft => 'Черновик',
self::Published => 'Опубликован',
};
}
}
// 3. app/Models/Post.php — приведение типа
use App\Enums\PostStatus;
protected function casts(): array
{
return [
'status' => PostStatus::class,
];
}
После этого $post->status возвращает не строку, а объект перечисления: доступны ->value, ->name, собственные методы вроде label(), и сравнение идёт по случаю, а не по тексту. Про приведение типов подробнее — в разделе Eloquent ORM, подраздел про $casts.
// сравнение вместо сверки строк — опечатку поймает IDE, а не прод
if ($post->status === PostStatus::Published) { /* ... */ }
// в запрос можно передать сам случай: Laravel подставит его value
Post::where('status', PostStatus::Published)->get();
// запись — тоже случаем, не строкой
$post->update(['status' => PostStatus::Published]);
// валидация формы: допустимы только случаи перечисления
use Illuminate\Validation\Rule;
'status' => ['required', Rule::enum(PostStatus::class)],
// можно сузить набор для конкретной формы:
// Rule::enum(PostStatus::class)->only([PostStatus::Draft])
// в фабрике
'status' => fake()->randomElement(PostStatus::cases()),
archived — добавляете один case в PostStatus.php, и всё. С enum в миграции пришлось бы писать миграцию с ALTER TABLE, которая на большой таблице выполняется долго и может держать блокировку, то есть деплой превращается в отдельное мероприятие. Контроль значений при этом не теряется: его обеспечивают правило валидации на входе и само перечисление в коде — просто он переехал из базы в приложение.PostStatus::from($value), а этот метод на неизвестной строке выбрасывает ValueError. Значит, статус, вписанный в базу руками, или case, удалённый из перечисления при живых строках в таблице, обрушат не запись, а именно чтение — причём в произвольном месте приложения. Удаляя или переименовывая случай, обновляйте существующие строки миграцией.ENUM в MySQL идёт не по алфавиту. Внутри MySQL хранит номер значения в объявленном списке, и ORDER BY status сортирует по этому номеру. Иногда это удобно, но результат неочевиден и меняется, если список переобъявить в другом порядке. У строковой колонки сортировка обычная, по тексту.PostStatus::tryFrom($value), который на неизвестной строке вернёт null, а не исключение.Когда enum в миграции всё-таки уместен:
| Ситуация | Почему |
|---|---|
| Список значений не изменится никогда | ['male', 'female'], тип документа, две валюты — менять нечего, и жёсткий запрет в схеме только на пользу |
| В ту же базу пишут приложения не на Laravel | Проверка нужна там, где её нельзя обойти, — на стороне СУБД, потому что на чужой код правила валидации Laravel не распространяются |
| Прямой доступ к базе у аналитиков или администраторов | UPDATE руками не пройдёт мимо проверки и не оставит значение, на котором потом упадёт чтение модели |
string в миграции и перечисление PHP с приведением в модели: набор значений меняется правкой одного файла, код читается по именам случаев, работает одинаково на любой базе. enum в схеме берут, когда гарантия нужна именно на уровне базы: список окончательный или в базу пишет кто-то кроме вашего приложения.Перечисление — такой же тип, как класс, поэтому живёт в собственном файле: одно перечисление на файл, имя файла совпадает с именем типа. Отдельной настройки для этого не нужно, достаточно положить файл туда, где его найдёт автозагрузка.
Здесь есть неочевидное поведение, из-за которого файл нередко оказывается не там, где ожидали. Папку команда выбирает сама, и зависит это от того, существует ли она на момент вызова:
| Что вызвали | Куда попадёт файл | Пространство имён |
|---|---|---|
make:enum OrderStatus, папки app/Enums нет | app/OrderStatus.php | App |
make:enum OrderStatus, папка app/Enums есть | app/Enums/OrderStatus.php | App\Enums |
make:enum OrderStatus, есть app/Enumerations | app/Enumerations/OrderStatus.php | App\Enumerations |
make:enum Enums/OrderStatus | app/Enums/OrderStatus.php всегда | App\Enums |
php artisan make:enum Enums/OrderStatus --string. Папка появится, и дальше команда будет класть туда всё сама, уже без префикса. Проверить, куда именно лёг файл, проще всего по выводу команды: она печатает полный путь вида Enum [app/Enums/OrderStatus.php] created successfully.php artisan make:enum Enums/OrderStatus --string # значения-строки
php artisan make:enum Enums/HttpCode --int # значения-числа
php artisan make:enum Enums/Direction # без значений, спросит тип
php artisan make:enum Enums/OrderStatus --force # перезаписать существующий
Заготовка создаётся пустой — случаи дописываете сами:
// app/Enums/OrderStatus.php — то, что создала команда
namespace App\Enums;
enum OrderStatus: string
{
//
}
В composer.json прописано соответствие пространства имён и папки по стандарту PSR-4:
"autoload": {
"psr-4": {
"App\\": "app/"
}
}
Автозагрузчик не держит список файлов — он вычисляет путь из имени класса. Встретив App\Enums\OrderStatus, он отбрасывает префикс App\, подставляет app/ и ищет app/Enums/OrderStatus.php. Поэтому новый файл работает сразу, без composer dump-autoload: эта команда нужна только когда классы ищутся по списку, то есть для classmap и files.
app/Enums/OrderStatus.php с объявлением namespace App\Models; автозагрузчик не найдёт никогда — он будет искать его в app/Models. Ошибка выглядит как Class "App\Enums\OrderStatus" not found и чаще всего возникает после ручного переноса файла в другую папку, когда строку namespace забыли поправить. То же касается регистра: на macOS файловая система регистр обычно игнорирует, и orderstatus.php будет работать локально, но на сервере с Linux — нет.Вложенная папка — это вложенное пространство имён, один к одному. Когда перечислений становится много, их раскладывают по доменам:
Enum в имени не нужен: OrderStatus читается лучше, чем OrderStatusEnum, а тип и так виден по объявлению. Имя пишется как у класса, в единственном числе — перечисление описывает одно значение, а не их набор. Главное — выбрать одно соглашение в начале проекта: переименование позже затрагивает все места, где стоит use.// в модели
use App\Enums\OrderStatus;
protected function casts(): array
{
return ['status' => OrderStatus::class];
}
// в FormRequest
'status' => ['required', Rule::enum(OrderStatus::class)],
// в миграции, как значение по умолчанию
$table->string('status')->default(OrderStatus::PendingPayment->value);
// как тип параметра — подсказка IDE и проверка на входе
public function handle(Order $order, OrderStatus $next): Order
enum Direction { case Up; case Down; } значений нет, поэтому в базу уходит имя случая — 'Up'. У перечисления с типом (enum OrderStatus: string) уходит значение — 'pending_payment'. Для колонок в базе почти всегда берут вариант со значением: имя случая привязано к коду и меняется при переименовании, а значение — часть данных и должно пережить правку кода.Когда у каждого перечисления появляются одни и те же вспомогательные методы, их выносят в трейт, а общий контракт — в интерфейс. Перечисления умеют и то, и другое, как обычные классы.
// app/Enums/Concerns/HasValues.php
namespace App\Enums\Concerns;
trait HasValues
{
// ['pending_payment', 'paid', ...] — для правил валидации и выпадающих списков
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
use App\Enums\Concerns\HasValues;
enum OrderStatus: string
{
use HasValues;
case PendingPayment = 'pending_payment';
// ...
}
private $something внутри enum вызовет ошибку разбора: случаи существуют в единственном экземпляре, и хранить в них изменяемое состояние негде. Всё, что выглядит как свойство, делается методом с match по $this.enum и строкой и про статусы заказа.Магазин внутриигровых предметов и аккаунтов — хороший пример того, где статусов много и каждый что-то значит для денег. Отличие от обычной розницы в том, что деньги удерживаются до подтверждения покупателем, поэтому статус определяет не только надпись в интерфейсе, но и момент выплаты продавцу.
// app/Enums/OrderStatus.php
namespace App\Enums;
enum OrderStatus: string
{
case PendingPayment = 'pending_payment'; // ждём оплату
case Paid = 'paid'; // оплачено, проверка на фрод
case Processing = 'processing'; // продавец уведомлён
case Delivering = 'delivering'; // товар передаётся
case Verifying = 'verifying'; // проверка, деньги на холде
case Completed = 'completed'; // закрыто, выплата продавцу
case Cancelled = 'cancelled'; // отменено до оплаты
case Refunded = 'refunded'; // деньги вернулись покупателю
case Disputed = 'disputed'; // арбитраж поддержки
}
PendingPayment) живёт только в коде и пишется как класс. Значение ('pending_payment') уходит в базу и в JSON ответа API, поэтому его меняют уже миграцией с UPDATE. В PHP-проектах значения принято писать строчными через подчёркивание; верхний регистр тоже работает, но важно выбрать одно написание в начале и не смешивать — иначе в таблице окажутся и PAID, и paid, а это для базы два разных значения.Перечисление само по себе не мешает перевести отменённый заказ сразу в «завершён». Чтобы статус не менялся как попало, рядом описывают разрешённые переходы. Основной путь линейный:
Остальные статусы — ответвления от него:
| Из какого статуса | Куда можно | Что происходит |
|---|---|---|
pending_payment | paid, cancelled | Оплатили либо истёк срок брони |
paid | processing, refunded | Фрод-проверка пропустила или отклонила |
processing | delivering, refunded | Продавец взялся или не вышел на связь |
delivering | verifying, disputed | Передали товар либо что-то пошло не так |
verifying | completed, disputed | Покупатель принял товар или открыл спор |
disputed | completed, refunded | Поддержка решила в чью-то пользу |
completed, cancelled, refunded | Никуда | Конечные состояния, заказ закрыт |
// перечисление может хранить и правила переходов
public function allowedNext(): array
{
return match($this) {
self::PendingPayment => [self::Paid, self::Cancelled],
self::Paid => [self::Processing, self::Refunded],
self::Processing => [self::Delivering, self::Refunded],
self::Delivering => [self::Verifying, self::Disputed],
self::Verifying => [self::Completed, self::Disputed],
self::Disputed => [self::Completed, self::Refunded],
// конечные состояния никуда не ведут
self::Completed,
self::Cancelled,
self::Refunded => [],
};
}
public function canMoveTo(self $next): bool
{
return in_array($next, $this->allowedNext(), true);
}
public function isFinal(): bool
{
return $this->allowedNext() === [];
}
public function label(): string
{
return match($this) {
self::PendingPayment => 'Ожидает оплаты',
self::Paid => 'Оплачен',
self::Processing => 'В обработке',
self::Delivering => 'Передаётся',
self::Verifying => 'На проверке',
self::Completed => 'Завершён',
self::Cancelled => 'Отменён',
self::Refunded => 'Возврат',
self::Disputed => 'Спор',
};
}
match, а не массив-константа. match проверяет полноту на этапе выполнения: добавили новый случай и забыли дописать ветку — получите UnhandledMatchError сразу, на первом же заказе с этим статусом. Массив промолчит и вернёт null, а ошибка всплывёт где-то дальше, уже без следов причины.Смена статуса обычно тянет за собой деньги, уведомления и запись в историю. Если менять поле напрямую из разных мест, рано или поздно какой-то путь пройдёт мимо проверки. Поэтому переход делают одной точкой входа.
// app/Actions/ChangeOrderStatus.php
class ChangeOrderStatus
{
public function handle(Order $order, OrderStatus $next): Order
{
if (! $order->status->canMoveTo($next)) {
throw new DomainException(
"Переход {$order->status->value} -> {$next->value} запрещён"
);
}
return DB::transaction(function () use ($order, $next) {
// перечитываем строку с блокировкой: lockForUpdate — метод запроса,
// не модели, поэтому заказ достаём заново
$order = Order::whereKey($order->getKey())
->lockForUpdate()
->firstOrFail();
// проверяем ещё раз: за время ожидания статус мог измениться
if (! $order->status->canMoveTo($next)) {
throw new DomainException('Статус уже изменён другим запросом');
}
$order->update(['status' => $next]);
$order->statusChanges()->create(['to' => $next, 'at' => now()]);
if ($next === OrderStatus::Completed) {
PayoutSeller::dispatch($order); // снимаем холд
}
return $order;
});
}
}
lockForUpdate внутри транзакции: второй запрос подождёт и увидит уже новый статус, на котором переход не пройдёт.$table->string('status')->default(OrderStatus::PendingPayment->value);
$table->index(['status', 'created_at']); // списки всегда фильтруют и сортируют
// активные сделки — всё, что не закрыто
Order::whereNotIn('status', [
OrderStatus::Completed,
OrderStatus::Cancelled,
OrderStatus::Refunded,
])->get();
// требуют внимания поддержки
Order::where('status', OrderStatus::Disputed)->get();
// просроченная бронь — кандидаты на автоотмену
Order::where('status', OrderStatus::PendingPayment)
->where('created_at', '<', now()->subMinutes(15))
->get();
// в запросе на смену статуса допустимы только переходы из текущего
'status' => ['required', Rule::enum(OrderStatus::class)
->only($order->status->allowedNext())],
'proccessing' не пройдёт дальше IDE, набор допустимых переходов описан в одном файле рядом со статусами, а выплата продавцу привязана к конкретному случаю, а не к совпадению строк. Сравнение enum в миграции со строкой, ловушки приведения и поведение при неизвестном значении — выше, в подразделе про выбор между ними.$table->string('sku')->unique(); // уникальность на уровне базы
$table->string('image')->nullable(); // разрешить NULL
$table->string('status')->default('new'); // значение по умолчанию
$table->index(['status', 'created_at']); // составной индекс
Внешний ключ описывают одной цепочкой. constrained() сам догадывается о таблице по имени колонки: user_id означает users.
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained()->restrictOnDelete();
$table->foreignId('author_id')->constrained('users'); // имя не угадывается — указываем
| Поведение при удалении родителя | Что произойдёт | Пример |
|---|---|---|
cascadeOnDelete() | Удалить и дочерние строки | Удалили пользователя — ушли его заказы |
restrictOnDelete() | Запретить удаление родителя | Товар нельзя удалить, пока он в заказах |
nullOnDelete() | Обнулить ссылку | Удалили категорию — товар остался без неё |
Модификатор — это вызов, который дописывается к колонке и уточняет её поведение. Полный список взят из ColumnDefinition исходников фреймворка; в скобках указана база, если модификатор работает не везде.
| Модификатор | Что делает |
|---|---|
->nullable() | Колонка может быть NULL |
->default($value) | Значение по умолчанию |
->unique() | Уникальный индекс, повторы запрещены |
->index() | Обычный индекс для быстрого поиска |
->primary() | Первичный ключ |
->unsigned() | Только неотрицательные числа, для целых (MySQL) |
->useCurrent() | По умолчанию CURRENT_TIMESTAMP, для timestamp |
->useCurrentOnUpdate() | Обновляет время при каждом UPDATE (MySQL) |
->comment('...') | Комментарий к колонке (MySQL, PostgreSQL) |
->change() | Изменить существующую колонку, внутри Schema::table |
change() переписывает определение целиком. При изменении колонки нужно заново перечислить все модификаторы, которые должны остаться. Чего в новом определении нет — то будет снято: забыли повторить nullable(), и колонка станет NOT NULL; забыли default() — значение по умолчанию исчезнет. Это самая частая причина, по которой «безобидная» миграция изменения типа ломает данные.| Модификатор | Что делает |
|---|---|
->after('column') | Поставить колонку после указанной (MySQL) |
->first() | Поставить колонку первой (MySQL) |
->charset('utf8mb4') | Кодировка колонки (MySQL) |
->collation('...') | Правила сравнения и сортировки строк |
->invisible() | Колонка не попадает в SELECT * (MySQL) |
->type('...') | Задать тип колонки явно |
| Модификатор | Что делает |
|---|---|
->autoIncrement() | Автоинкремент для целых |
->from(1000), ->startingValue(1000) | С какого числа начинать нумерацию (MySQL, PostgreSQL) |
->storedAs('expr') | Вычисляемая колонка, значение хранится на диске |
->virtualAs('expr') | Вычисляемая колонка, значение считается при чтении |
->generatedAs(), ->always() | Identity-колонка (PostgreSQL) |
->persisted() | Сохранять вычисляемую колонку (SQL Server) |
storedAs считает значение один раз при записи и занимает место, зато по нему можно построить индекс. virtualAs не занимает места, но считается при каждом чтении. Типичный пример — total, выведенный из количества и цены: вместо того чтобы поддерживать его в коде и рисковать расхождением, его выводит сама база.| Модификатор | Что делает |
|---|---|
->fulltext() | Полнотекстовый индекс для поиска по тексту |
->spatialIndex() | Индекс для геоданных |
->vectorIndex() | Индекс для векторов |
->instant() | ALGORITHM=INSTANT для операции с колонкой (MySQL) |
->lock('none') | Режим блокировки при изменении схемы (MySQL) |
->using('expr') | Выражение приведения типа при change() (PostgreSQL) |
instant() и lock('none') просят MySQL выполнить операцию, не блокируя запись, — работает не для всех изменений, но когда работает, экономит окно простоя. Учтите, что using() появился не в самой первой версии ветки 13: в 13.6 его ещё нет, в 13.38 уже есть.Для беззнаковых целых отдельный модификатор не нужен — есть готовые методы. Результат совпадает дословно, но читается короче.
$table->integer('price')->unsigned(); // можно и так
$table->unsignedInteger('price'); // но принято так
# обе строки дают одно и то же
`price` int unsigned not null
| Вместо этого | Пишут так |
|---|---|
tinyInteger(...)->unsigned() | unsignedTinyInteger(...) |
smallInteger(...)->unsigned() | unsignedSmallInteger(...) |
mediumInteger(...)->unsigned() | unsignedMediumInteger(...) |
integer(...)->unsigned() | unsignedInteger(...) |
bigInteger(...)->unsigned() | unsignedBigInteger(...) |
unsignedInteger допустимо и даже распространено: целые складываются точно. Но тогда всё приложение обязано помнить про множитель сто, иначе где-нибудь выведется цена в сто раз меньше. Альтернатива — decimal(12, 2) с приведением 'price' => 'decimal:2'. Недопустим только float.Между собой модификаторы колонки порядком не связаны: грамматика собирает их в фиксированной последовательности независимо от того, как они записаны. А вот constrained() обрывает цепочку — он возвращает уже не колонку, а описание внешнего ключа. Всё, что вызвано после него, относится к ключу, и модификатор колонки там просто теряется.
// правильно: сначала колонка, потом ключ
$table->foreignId('category_id')->nullable()->constrained()->nullOnDelete();
// ошибка: nullable после constrained молча не сработает
$table->foreignId('category_id')->constrained()->nullable()->nullOnDelete();
# первый вариант
`category_id` bigint unsigned null
... foreign key (`category_id`) references `categories` (`id`) on delete set null
# второй вариант — колонка осталась NOT NULL, а ключ требует set null
`category_id` bigint unsigned not null
... foreign key (`category_id`) references `categories` (`id`) on delete set null
ForeignKeyDefinition наследуется от Fluent, поэтому любой неизвестный вызов на нём просто записывается как атрибут и игнорируется. Миграция соберётся, SQL сгенерируется, и упадёт уже база — на попытке навесить SET NULL на колонку NOT NULL. Правило простое: всё про колонку — до constrained(), всё про ключ — после.nullable() вместе с cascadeOnDelete() противоречат друг другу: колонка допускает пустоту, но удаление родителя не обнуляет ссылку, а сносит строку — почти всегда имелся в виду nullOnDelete(). Отдельный ->index() рядом с constrained() на MySQL избыточен, потому что InnoDB заводит индекс под внешний ключ сам, — но на PostgreSQL он как раз нужен. И constrained('category') в единственном числе сошлётся на таблицу category, которой нет: по соглашению таблица модели Category называется categories, и тогда аргумент не нужен вовсе.constrained() — это сокращение. Полная запись внешнего ключа состоит из трёх частей: какую колонку связываем, на какую колонку чужой таблицы она ссылается и что это за таблица. Метод берёт на себя две последние, догадываясь о них по имени колонки.
// полная форма
$table->unsignedBigInteger('user_id');
$table->foreign('user_id')->references('id')->on('users');
// то же самое одной строкой
$table->foreignId('user_id')->constrained();
У метода три необязательных аргумента — таблица, колонка и имя ограничения:
constrained($table = null, $column = null, $indexName = null)
$table->foreignId('user_id')->constrained(); // угадает users и id
$table->foreignId('author_id')->constrained('users'); // таблица задана явно
$table->foreignId('post_id')->constrained('posts', 'id'); // и таблица, и колонка
$table->foreignId('post_id')->constrained(indexName: 'fk_comments_post');
Правило простое: от имени колонки отбрасывается хвост _id, а остаток переводится во множественное число. Именно остаток, а не смысл — метод не знает, что автор это пользователь.
| Колонка | На какую таблицу сошлётся | Верно ли это |
|---|---|---|
user_id | users | Да |
post_id | posts | Да |
category_id | categories | Да, множественное число берётся по правилам английского |
author_id | authors | Нет, если авторы лежат в users: таблицу надо указать явно |
parent_id | parents | Нет, для ссылки на себя таблицу указывают: constrained('categories') |
_id. Отсекается подчёркивание вместе с именем колонки-ссылки, а она по умолчанию id. Если вторым аргументом передать другую колонку, правило изменится: у author_uuid с constrained(column: 'uuid') останется author, и ссылка уйдёт на authors. Проверять догадку в уме не нужно — достаточно посмотреть на SQL через php artisan migrate --pretend.Цепочка превращается в два выражения: колонка создаётся вместе с таблицей, а ограничение навешивается отдельной командой.
// миграция
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
});
# что уйдёт в MySQL
create table `posts` (
`id` bigint unsigned not null auto_increment primary key,
`title` varchar(255) not null,
`user_id` bigint unsigned not null
)
alter table `posts`
add constraint `posts_user_id_foreign`
foreign key (`user_id`) references `users` (`id`) on delete cascade
foreign через подчёркивание, всё строчными: posts_user_id_foreign. Знать это нужно, когда читаешь сообщение об ошибке целостности — там фигурирует именно это имя. Третий аргумент indexName нужен редко, в основном когда сгенерированное имя не влезает в ограничение MySQL на 64 символа, а такое случается у длинных имён таблиц.Про cascadeOnDelete, restrictOnDelete и nullOnDelete сказано выше. Такая же четвёрка есть и на обновление ключа: cascadeOnUpdate, restrictOnUpdate, nullOnUpdate, noActionOnUpdate. Применяют их редко, потому что первичные ключи обычно не меняют, но если ключ составной или натуральный — пригождается.
$table->foreignId('user_id')->constrained()
->cascadeOnUpdate() // поменялся id родителя — поменяется и ссылка
->restrictOnDelete(); // а удалить родителя со строками нельзя
// то же через общий метод, если нужно значение, которого нет среди сокращений
$table->foreignId('user_id')->constrained()->onDelete('set null');
Без указания действия база использует no action: удалить родителя, у которого есть потомки, не получится — придёт ошибка целостности.
// колонку и таблицу возьмёт у модели: user_id и users
$table->foreignIdFor(User::class)->constrained();
// если у модели ключ не целый, а UUID или ULID,
// колонка создастся нужного типа сама
$table->foreignUuid('user_id')->constrained();
$table->foreignUlid('user_id')->constrained();
foreignIdFor() удобен тем, что переименование таблицы в модели не придётся повторять в миграции. Обратная сторона — миграция начинает зависеть от кода модели, который со временем меняется, а миграция должна описывать схему на момент своего написания. Поэтому в новых проектах его берут, а в долгоживущих чаще пишут имя колонки строкой.
public function up(): void
{
Schema::table('posts', function (Blueprint $table) {
// nullable здесь не для красоты — см. ловушку ниже
$table->foreignId('category_id')->nullable()->constrained();
});
}
public function down(): void
{
Schema::table('posts', function (Blueprint $table) {
// снимает ограничение и удаляет колонку — две операции одним вызовом
$table->dropConstrainedForeignId('category_id');
});
}
# что уйдёт в базу при откате
alter table `posts` drop foreign key `posts_category_id_foreign`
alter table `posts` drop `category_id`
dropConstrainedForeignId. В руководствах обычно пишут пару вызовов — dropForeign(['category_id']) и следом dropColumn('category_id'). Это работает и делает ровно то же самое, но один метод короче и не даёт забыть первую половину: удалить колонку, не сняв ограничение, база не позволит. Порядок важен именно такой — сначала ключ, потом колонка.nullable не встанет. Новая колонка NOT NULL получает в существующих строках значение по умолчанию — для целого это ноль. Строки с родителем 0 в таблице categories нет, и база откажется создавать ограничение. Поэтому колонку добавляют либо nullable, либо в три приёма: добавить колонку, заполнить её данными, и только потом отдельной миграцией навесить ключ.make:model -m, порядок получается верным сам собой, но при ручном переименовании файлов или переносе миграции между ветками он ломается, и migrate падает на создании ключа.dropForeign('posts_category_id_foreign') со строкой выбросит RuntimeException: This database driver does not support dropping foreign keys by name. Передавайте массив колонок — dropForeign(['category_id']), — тогда Laravel пересоберёт таблицу.->index() явно, иначе такие запросы и каскадное удаление идут полным перебором.php artisan migrate в выводе. В руководствах по этой теме часто показывают результат вроде "Foreign key 'posts_user_id_foreign' created on table 'posts'" — такой строки artisan не печатает. На самом деле выводится Running migrations. и по строке на файл: имя миграции, время выполнения и DONE. Увидеть конкретный SQL можно только через migrate --pretend, а проверить созданные ключи — через php artisan db:table posts.Оба варианта решают одну задачу — не оставить в базе ссылку на строку, которой больше нет. Разница в том, что происходит с дочерними строками: каскад их удаляет, обнуление оставляет, стирая только ссылку. Выбор определяется одним вопросом: имеет ли дочерняя строка смысл без родителя.
| Поведение | ON DELETE CASCADE | ON DELETE SET NULL |
|---|---|---|
| Что с дочерними строками | Удаляются вместе с родителем | Остаются, во внешнем ключе становится NULL |
| Запись в Laravel | ->cascadeOnDelete() | ->nullOnDelete() |
| Требование к колонке | Любая | Обязательно nullable |
| Как обеспечивается целостность | Висячих ссылок нет, потому что нет самих строк | Висячих ссылок нет, потому что ссылка пустая |
| Что усложняется в коде | Ничего в выборках, но данные уходят безвозвратно | Каждая выборка и каждый join должны учитывать NULL |
| Когда уместно | Дочерняя строка без родителя бессмысленна | Дочерняя строка самоценна и живёт дальше |
| Примеры | Комментарии у поста, позиции заказа, токены пользователя | Товар без категории, заказ без менеджера, статья без рубрики |
Синтаксис отличается только хвостом ограничения — его и путают в таблицах сравнения:
// миграция
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->foreignId('author_id')->nullable()->constrained('users')->nullOnDelete();
# что уходит в базу
alter table `comments` add constraint `comments_post_id_foreign`
foreign key (`post_id`) references `posts` (`id`) on delete cascade
alter table `comments` add constraint `comments_author_id_foreign`
foreign key (`author_id`) references `users` (`id`) on delete set null
SET NULL на колонке NOT NULL не создастся. База не может выполнить действие, которое сама же запрещает, и отвечает ошибкой вида Column 'author_id' cannot be NOT NULL: needed in a foreign key constraint. Поэтому nullable() в цепочке с nullOnDelete() обязателен — и в модели эту колонку тоже придётся считать необязательной.Это не особенность фреймворка, а следствие того, где выполняется действие. ON DELETE CASCADE отрабатывает внутри базы, в рамках того же оператора DELETE. Приложение о нём не знает: PHP-код для дочерних строк не выполняется вообще.
| Что не произойдёт при каскаде в базе | Чем это грозит |
|---|---|
События deleting и deleted у дочерней модели | Наблюдатели и слушатели молчат, логика очистки не запускается |
| Удаление связанных файлов в хранилище | Записи нет, а картинки и вложения остались занимать место |
| Чистка кеша и поискового индекса | Поиск продолжает отдавать удалённые записи |
| Срабатывание триггеров в MySQL | Каскадные действия внешнего ключа триггеры не активируют |
SoftDeletes каскад не сработает никогда. Мягкое удаление — это не DELETE, а UPDATE, который проставляет deleted_at. Оператора удаления база не видит, ограничение не срабатывает, и дочерние строки остаются полностью живыми и видимыми. Получается расхождение: родитель в приложении «удалён», а его потомки продолжают отдаваться в списках. Если в проекте мягкое удаление, каскад делают в коде, а не в схеме.Вариант на уровне приложения — удалять потомков в событии родителя. Тогда события, наблюдатели и очистка файлов отработают:
// app/Models/Post.php
protected static function booted(): void
{
static::deleting(function (Post $post) {
// each, а не delete() на запросе: события нужны у каждой строки
$post->comments()->each(fn ($comment) => $comment->delete());
});
}
Post::where('created_at', '<', $date)->delete() уходит в базу одним запросом, минуя модели: ни deleting, ни наблюдатели не сработают, и обработчик выше не выполнится. Тот же эффект у truncate(). Если логика очистки живёт в событиях, удалять нужно по строкам — через each() или cursor(), — а тяжёлую чистку выносить в очередь.Кроме «удалить» и «обнулить» есть «запретить». Часто это и есть правильный ответ: удаление товара, на который ссылаются заказы, должно не проходить молча, а падать с ошибкой, которую обработает приложение.
| Метод | SQL | Смысл |
|---|---|---|
cascadeOnDelete() | on delete cascade | Удалить потомков |
nullOnDelete() | on delete set null | Обнулить ссылку, строки оставить |
restrictOnDelete() | on delete restrict | Запретить удаление родителя немедленно |
noActionOnDelete() | on delete no action | Тоже запретить; значение по умолчанию, если действие не указано |
restrict отличается от no action. В MySQL ничем: обе проверки срабатывают сразу. В PostgreSQL разница есть — NO ACTION можно отложить до конца транзакции через DEFERRABLE INITIALLY DEFERRED, и тогда внутри транзакции разрешено временно нарушить связь, лишь бы к коммиту всё сошлось. С RESTRICT так нельзя, проверка всегда немедленная.cascadeOnDelete. Данные самоценны и должны пережить родителя — nullOnDelete плюс nullable. Родителя с потомками вообще нельзя удалять — restrictOnDelete. Сомневаетесь — берите restrict: запрет всегда можно ослабить, а удалённые строки не вернуть.Классическая задача: у категории могут быть подкатегории любой вложенности. Решается ссылкой таблицы на саму себя — колонкой parent_id, где лежит идентификатор родителя.
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('title');
// nullable обязателен: у корневых категорий родителя нет
$table->foreignId('parent_id')
->nullable()
->constrained('categories') // ссылка на свою же таблицу
->cascadeOnDelete(); // удалили родителя — ушли и потомки
$table->timestamps();
});
parent_id равен её id, то есть значение вычисляемое. Если дополнительно хранить их перечень в отдельном поле, одни и те же данные окажутся в двух местах и рано или поздно разойдутся: ребёнка перенесли в другую категорию, parent_id обновился, а список в родителе остался старым. В базе хранят только ссылку вверх, вниз ходят запросом.parent_id обязан совпадать с типом id. Метод id() создаёт BIGINT UNSIGNED, поэтому и ссылка должна быть такой же. Если написать $table->integer('parent_id'), база откажется создавать внешний ключ: типы по обе стороны связи не совпадают. Метод foreignId() как раз и создаёт BIGINT UNSIGNED, поэтому с ним эта ошибка не возникает.В модели связь описывают двумя методами — вверх и вниз. Это не колонки, а отношения: они выполняют запрос в момент обращения.
// app/Models/Category.php
class Category extends Model
{
protected $fillable = ['title', 'parent_id'];
// родитель: одна категория или null у корневой
public function parent()
{
return $this->belongsTo(Category::class, 'parent_id');
}
// дети: по-английски children, не childs
public function children()
{
return $this->hasMany(Category::class, 'parent_id');
}
}
child — children, формы childs в английском нет. Имя метода попадает в код по всему проекту и в ключи JSON-ответов API, поэтому опечатку потом правят везде сразу. В Laravel имя отношения ничем не ограничено, фреймворк не подскажет — следит за этим только сам разработчик.Как этим пользоваться:
// только корневые категории
Category::whereNull('parent_id')->get();
// корневые сразу с детьми — один дополнительный запрос вместо N
Category::whereNull('parent_id')->with('children')->get();
// два уровня вложенности
Category::whereNull('parent_id')->with('children.children')->get();
// есть ли вообще потомки, без их загрузки
Category::withCount('children')->get();
children.children задаёт ровно два уровня: для произвольной вложенности пришлось бы продолжать цепочку бесконечно. Если дерево глубокое, берут рекурсивное отношение или материализованный путь — колонку вида 1/4/17 с цепочкой предков, по которой поддерево достаётся одним запросом с LIKE.parent_id не должен совпадать с собственным id и не должен быть среди потомков.Вопрос «не сделать ли из этого отдельную модель» возникает почти к каждому полю. Ответ даёт одно правило: значение остаётся колонкой, пока у строки оно одно и у него нет собственных свойств. Как только значений становится несколько или у каждого появляются свои атрибуты — нужна таблица.
| Признак | Колонка | Отдельная таблица |
|---|---|---|
| Сколько значений у строки | Ровно одно | Может быть несколько |
| Есть ли у значения свои поля | Нет, это просто строка или число | Да: остаток, цена, срок, статус |
| Нужно ли искать по значению отдельно | Хватает индекса на колонке | Нужны свои выборки и связи |
| Что даёт вынос | Ничего, кроме лишнего join | Убирает дублирование общих данных |
Артикул — классический случай, когда выносить не надо. В каталоге, где каждая строка это отдельный товар со своим названием, ценой и картинкой, у товара ровно один артикул, и наружу он уходит той же строкой:
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('sku')->unique(); // STEAM-TOPUP-500, KEY-GTA5
$table->string('name');
$table->decimal('price', 12, 2);
$table->timestamps();
});
unique() здесь работает и как ограничение, и как индекс. Повтор артикула база не пропустит, а поиск по нему пойдёт по индексу — отдельный index() добавлять не нужно. Имя ограничения собирается так же, как у внешних ключей: products_sku_unique.Когда у одного товара появляются варианты. Футболка одна, а продаются NIKE-RED-M, NIKE-RED-L, NIKE-BLACK-M: у каждого свой цвет, размер и остаток, иногда и своя цена. Держать это строками в products нельзя — описание и фотографии продублируются трижды и со временем разойдутся.
// общее у всех вариантов
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description');
$table->timestamps();
});
// своё у каждого варианта
Schema::create('product_variants', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->string('sku')->unique();
$table->string('color');
$table->string('size');
$table->decimal('price', 12, 2);
$table->unsignedInteger('stock')->default(0);
// одинаковой пары цвет-размер у одного товара быть не должно
$table->unique(['product_id', 'color', 'size']);
$table->timestamps();
});
price и в products, и в product_variants, появятся два источника правды: одна часть кода возьмёт цену товара, другая — цену варианта, и расхождение всплывёт в чеке. Поле, которое у вариантов различается, в родительской таблице остаться не может.unique() на sku запрещает повтор артикула во всей таблице — это и нужно, артикул уникален глобально. А составной unique(['product_id', 'color', 'size']) запрещает повтор сочетания внутри одного товара: два красных M у одной футболки. В базе это два отдельных индекса, product_variants_sku_unique и product_variants_product_id_color_size_unique.| Что видно в схеме | О чём это говорит |
|---|---|
Колонки с номерами: phone_1, phone_2, phone_3 | Значений у строки несколько, и третьего всегда не хватает |
| Список значений в одной колонке через запятую | Искать по нему можно только через LIKE, связать нельзя |
| У большинства строк колонка пустая | Признак относится не ко всем, а к подвиду — его и выносят |
| Одни и те же описания и картинки дублируются в нескольких строках | Строки на самом деле варианты одной сущности |
with() в каждой выборке, ещё одна фабрика, ещё один ресурс для API и лишнее соединение в каждом запросе списка. Пока у строки одно значение без своих свойств, таблица не даёт ничего. Обратный переход, если варианты всё-таки появятся, — обычная миграция: создать таблицу, перенести данные запросом, удалить переехавшие колонки. Это делается за один раз и не требует переписывать проект.string('sku')->unique() закрывает задачу полностью.| Команда | Что делает | Безопасна на проде |
|---|---|---|
migrate | Применить неприменённые | Да |
migrate --pretend | Показать SQL, ничего не выполняя | Да |
migrate:status | Что применено, что нет | Да |
migrate:rollback | Откатить последнюю партию | С осторожностью |
migrate:rollback --step=1 | Откатить одну миграцию | С осторожностью |
migrate:fresh | Удалить все таблицы и создать заново | Нет, потеря данных |
migrate:refresh | Откатить всё и применить снова | Нет |
down(). В заготовке он есть, но при ручной правке про него забывают — и rollback либо падает, либо оставляет базу в половинчатом состоянии. На рабочем сервере откат вообще редко применяют: безопаснее написать новую миграцию, которая исправляет предыдущую.Фабрика описывает, как выглядит один правдоподобный объект. Сидер решает, сколько их создать и в каком сочетании. Вместе они дают наполненную базу одной командой — это нужно и для разработки, и для тестов.
php artisan make:factory ProductFactory --model=Product
php artisan make:seeder ProductSeeder
// связанные данные создаются цепочкой
User::factory()
->count(10)
->has(Order::factory()->count(3))
->create(); // 10 пользователей, у каждого 3 заказа
php artisan db:seed # запустить DatabaseSeeder
php artisan db:seed --class=ProductSeeder # конкретный
php artisan migrate:fresh --seed # пересоздать и наполнить
Product::factory()->create(['price' => '100.00']) создаёт именно тот объект, который нужен конкретной проверке.Eloquent — реализация Active Record. Модель = таблица; экземпляр модели = строка. Удобство — мгновенный CRUD без boilerplate. Цена — лёгкость написания неоптимальных запросов (N+1, mass assignment) и сильная связанность бизнес-логики с инфраструктурой. Глубокий разбор (полиморфизм, observers, race conditions, chunk vs cursor) — в KB_12. Здесь — основа и базовые паттерны.
Relations: hasOne / hasMany / belongsTo / belongsToMany
Decларация связей в модели. Eloquent создаёт магические геттеры ($user->orders) и query-методы ($user->orders()). Первое — collection после lazy load, второе — query builder для дальнейшей фильтрации.
Mass assignment: $fillable / $guarded
$fillable — whitelist полей для create/update через массив. $guarded = [] — открытый список (опасно). Без правильного списка User::create($request->all()) пропустит is_admin в insert.
Casts
Автоматическое приведение типов: 'is_active' => 'boolean', 'meta' => 'array', 'paid_at' => 'datetime'. Кастомные касты — реализация интерфейса CastsAttributes.
Scopes
scopeActive($q) { $q->where('status', 'active'); } — переиспользуемые куски запроса. Использование: User::active()->get(). Глобальные scopes автоматически применяются ко всем запросам модели.
Accessors / Mutators
Laravel 9+: protected function name(): Attribute { return Attribute::make(get: ..., set: ...); } — преобразование при чтении и записи. Кеш через shouldCache().
Observers
Хуки жизненного цикла: created, updated, deleted, retrieved. Регистрация в Service Provider. Подробно — в KB_12.
$fillable vs $guardedЧто это. Mass assignment (массовое присваивание) — это возможность заполнить модель данными из массива одним вызовом:
User::create($request->all()); // все поля из запроса
$user->update($request->all());
Это удобно, но опасно, если злоумышленник добавит в запрос лишние поля (например, is_admin, role, password). Без защиты он сможет изменить поля, которые не должен был трогать. Для контроля Laravel предоставляет два свойства модели.
$fillable — белый список (whitelist)
Указывает, какие поля разрешено заполнять массово. Всё остальное игнорируется.
class User extends Model
{
protected $fillable = ['name', 'email', 'password'];
}
Вызов User::create($request->all()) заполнит только эти три поля. Если в запросе есть is_admin — он будет проигнорирован.
$guarded — чёрный список (blacklist)
Указывает, какие поля запрещено заполнять массово. Все остальные разрешены.
class User extends Model
{
protected $guarded = ['is_admin', 'id'];
}
User::create($request->all()) заполнит все поля, кроме is_admin и id.
$guarded = []. Если оставить пустой guarded (или не указать ничего в некоторых старых версиях) — все поля становятся разрешёнными. Тогда User::create($request->all()) позволит злоумышленнику передать is_admin=1 и стать администратором.
$fillable с явным перечислением разрешённых полей. $guarded лучше не использовать, чтобы случайно не забыть защитить новое поле при добавлении колонки в таблицу.
Пример безопасного использования — валидация + $fillable:
public function store(Request $request)
{
// 1. Валидация
$validated = $request->validate([
'name' => 'required|string',
'email' => 'required|email|unique:users',
'password' => 'required|min:8',
'is_admin' => 'sometimes|boolean', // не дадим через mass assignment
]);
// 2. Создание — только поля из $fillable
$user = User::create($validated);
// Или явно ограничить массив:
// $user = User::create($request->only(['name', 'email', 'password']));
return response()->json($user);
}
// Update — то же правило
$user->update($request->only(['name', 'email']));
Итог правил безопасности:
$fillable— явное разрешение (рекомендуется).$guarded— явное запрещение (используйте с осторожностью, не оставлять пустым).- Всегда ограничивайте массив данных, передаваемых в
create/update: используйте$request->validated()(из FormRequest) или$request->only(...), а не$request->all(). - Никогда не доверяйте входящим данным без контроля — это фундаментальное правило безопасности.
- В Laravel 11+ и во всех современных версиях рекомендуется явно указывать
$fillable, чтобы избежать случайных уязвимостей.
$hidden/$visible — скрытие при выводе (совсем другая задача)Частая путаница: $hidden/$visible — не защита от mass assignment. Это контроль того, какие поля попадают в JSON/массив при сериализации (при выводе, не при записи). Обе пары свойств живут в модели, но решают разные задачи:
$fillable / $guarded | $hidden / $visible | |
|---|---|---|
| Что защищает | Массовое присваивание при create/update |
Утечку полей в JSON/массив при выводе |
| Направление | Вход: клиент → БД | Выход: БД → клиент |
| Классический пример | is_admin — не даём подставить в create |
password, remember_token — не показываем в API |
| Когда срабатывает | Model::create($data), ->update($data), ->fill($data) |
->toArray(), ->toJson(), return $model |
$hidden/$visibleclass User extends Model
{
// Скрытие при выводе (blacklist)
protected $hidden = ['password', 'remember_token'];
// Или — явно указать что показывать (whitelist, встречается реже)
// protected $visible = ['id', 'name', 'email'];
}
// В контроллере — password и remember_token не попадут в JSON автоматически
return User::find(1);
// { "id": 1, "name": "Alice", "email": "..." } ← password ушёл
// Скрыть дополнительно (поверх $hidden)
$user->makeHidden(['created_at', 'updated_at']);
// Показать поле, скрытое глобально в $hidden (для отладки, для админки)
$user->makeVisible('remember_token');
// Работает и на коллекциях
$users->makeHidden(['email', 'phone']);
class User extends Model
{
// ЗАЩИТА ВХОДА — что можно массово заполнять
protected $fillable = ['name', 'email', 'password'];
// ЗАЩИТА ВЫХОДА — что не показывать в JSON
protected $hidden = ['password', 'remember_token'];
}
// Контроллер
$user = User::create($request->all());
// is_admin из запроса не попадёт в БД — не в $fillable
return $user;
// password и remember_token не будут в JSON-ответе — они в $hidden
$fillable— всегда. Явный whitelist, безопаснее чем$guarded.$hidden— для постоянных правил скрытия (password, токены).$visible— редко, когда полей мало и хочется явно перечислить.makeHidden()/makeVisible()— для временных изменений в конкретном контексте.- API Resource — если структура ответа сильно отличается от структуры БД (доп. поля, вложенные ресурсы, переименование).
$hidden "не сохранять в БД". $hidden НЕ влияет на запись — только на вывод. Поле password в БД сохранится (и должно!), $hidden просто не даёт ему попасть в JSON-ответ. Для «не сохранять» — не добавляй поле в $fillable либо не передавай в create().fillable = что можно налить (into) БД. hidden = что прячем (from) при выводе. Разные направления, разные задачи.
Если не определить ни $hidden, ни Resource, ни явный select() — модель уйдёт в JSON со всеми полями таблицы. Это не «тупизм фреймворка», а осознанное архитектурное решение: Laravel не может угадать, какие поля секретные для твоего домена.
class User extends Model
{
// $hidden не определён — забыли
}
// В контроллере:
return $user; // либо response()->json($user)
// В JSON-ответе будут ВСЕ поля таблицы:
// id, name, email, password (хеш!), remember_token, email_verified_at,
// created_at, updated_at, ... — всё что есть в SELECT *
| Поле | Что даёт злоумышленнику | Уровень риска |
|---|---|---|
password |
Bcrypt/argon2-хеш. Для входа напрямую бесполезен, но если пароль слабый — можно попытаться расшифровать перебором (hashcat, rainbow tables). Плюс если один и тот же хеш всплывёт в утечках других сервисов — линк аккаунтов. | Средний — прямого входа нет, но материал для атаки |
remember_token |
Токен «запомнить меня». Пока он активен, злоумышленник может восстановить сессию и войти под этим пользователем без пароля — просто подставив токен в куку. | Высокий — прямая компрометация аккаунта |
email_verified_at |
Раскрывает статус верификации — используется в phishing/social engineering. | Низкий |
api_token / *_secret |
Прямой доступ к API от имени пользователя. | Критический |
two_factor_recovery_codes |
Обход 2FA. | Критический |
GET /api/user уже видит remember_token в HTML/DevTools/Network — а с ним и любой посмотревший на его экран.Три причины архитектурного решения:
- Не у всех моделей есть чувствительные поля.
Category,Tag,Country— там всё можно показывать. Автоскрытие «магических» имён (типа*_token,*_secret) — false positive: скроет то, что не надо. - Иногда токен нужен. Отладка, админ-панель, миграция сессий — там
remember_tokenможет быть частью ответа. Автоскрытие мешало бы. - Осознанность. Разработчик должен один раз явно указать «эти — не показывать», а не полагаться на неявную «безопасность фреймворка». Такой явный контракт лучше — его видно в коде, легко ревьюить.
User из Laravel уже защищена. В стартовом шаблоне (app/Models/User.php) есть:
protected $hidden = [
'password',
'remember_token',
];
Именно поэтому «пользователи хранятся правильно из коробки». Но свои модели ты обязан защищать сам — Laravel не поставит $hidden за тебя автоматически.
- Прошёлся по всем моделям — есть любые чувствительные поля (токены, секреты, приватные метаданные)? → в
$hidden. - Для новой модели, где точно нет секретов, —
$hiddenвсё равно можно оставить пустым или не определять. Никакой «магии не случится», но это осознанное решение. - Если API-структура сложнее одной модели — переходи на API Resource. Там ты явно перечисляешь что отдавать — забыть невозможно.
- Не полагайся на
select()в запросе как единственную защиту: другой разработчик добавитUser::all()в другом контроллере — токен утечёт.
Что это. Casts — простое указание типов для атрибутов модели. В массиве $casts перечисляете, к какому типу приводить каждое поле. Laravel автоматически преобразует данные при получении из БД и обратно при сохранении.
Это разные вещи, которые легко спутать. Миграция определяет, как значение хранится в базе. Каст определяет, каким типом PHP оно придёт в код. База про типы PHP ничего не знает и отдаёт то, что умеет отдавать драйвер, — в основном строки и целые числа.
Вот что реально приходит из MySQL без каста. Таблица с колонками decimal(12,2), int, tinyint(1), json и datetime:
# типы PHP в значениях, полученных запросом
id integer 1
price string '100.50'
qty integer 7
is_active integer 1
meta string '{"a": 1}'
paid_at string '2026-10-24 09:54:18'
| Колонка в базе | Без каста в PHP | С кастом |
|---|---|---|
datetime | Строка '2026-10-24 09:54:18' | Carbon: diffForHumans(), addDay() — каст datetime |
tinyint(1) | Число 1 или 0 | true или false — каст boolean |
json | Строка '{"a": 1}' | Массив — каст array, или Collection |
varchar со статусом | Строка 'paid' | OrderStatus::Paid — каст классом перечисления |
varchar с паролем | Сохранится как есть, в открытом виде | Хешируется при записи — каст hashed |
decimal(12,2) | Строка '100.50', а не число | Строка с гарантированной точностью — каст decimal:2 |
int, varchar с текстом | Уже int и string | Каст не нужен, он ничего не добавит |
decimal приходит строкой, и это не ошибка драйвера. У DECIMAL нет точного соответствия среди типов PHP: float потерял бы копейки, поэтому MySQL отдаёт значение строкой как есть. Отсюда два следствия. Первое: $product->price без каста — это '100.50', и строгое сравнение === 100.50 не сработает. Второе: каст 'price' => 'decimal:2' тоже возвращает строку, а не число, — он приводит значение к нужному числу знаков, не теряя точности. Каст float на деньгах — ошибка: он как раз возвращает тип, который для денег не годится.User стоят именно datetime и hashed. Оба каста действительно что-то делают: первый превращает строку в Carbon, второй хеширует пароль при записи. Причём hashed сначала проверяет Hash::isHashed() — уже хешированное значение не хешируется второй раз, иначе пароль ломался бы при каждом повторном сохранении модели. Для полей вроде sku или qty каст не нужен: строка и так строка, целое и так целое.Как это выглядит:
class User extends Model
{
protected $casts = [
'is_active' => 'boolean', // в БД tinyint(1) → PHP bool
'meta' => 'array', // JSON в БД → PHP массив
'paid_at' => 'datetime', // строка в БД → Carbon
'settings' => 'object', // JSON → stdClass
'age' => 'integer', // всегда int
'rating' => 'float', // всегда float
'price' => 'decimal:2', // деньги — не float
'tags' => 'collection', // JSON → Collection
'created_at' => 'timestamp', // дата → Unix timestamp
];
}
Теперь при обращении к этим полям — они уже будут нужного типа:
$user = User::find(1);
$isActive = $user->is_active; // bool (true/false)
$meta = $user->meta; // массив (не строка JSON)
$paidAt = $user->paid_at; // Carbon (можно ->diffForHumans())
$settings = $user->settings; // stdClass
А при сохранении они сами преобразуются обратно в формат БД.
Зачем это нужно:
- Удобство — вместо ручного приведения типов в каждом месте, делаете один раз в модели.
- Безопасность — всегда знаете, с каким типом работаете.
- Работа с датами — автоматически получаете объект
Carbon, можно сразу форматировать, сравнивать, вычитать. - JSON-поля — если храните данные в JSON, работаете с массивами/объектами напрямую, без ручного
json_decode.
Касты в Laravel — это не приведение типов в смысле PHP-оператора (int) или (bool). Это преобразование значения при чтении из БД и при записи в БД. Они не меняют тип переменной в момент присвоения — они меняют то, как значение интерпретируется моделью при доступе к атрибуту.
Как это работает на практике. Допустим, в модели указано:
protected $casts = [
'is_active' => 'boolean',
'meta' => 'array',
];
При чтении из БД: поле is_active хранится как tinyint(1) (0 или 1). Когда получаете модель через User::find(1) — Laravel приводит это значение к bool. Поэтому $user->is_active будет true/false, а не 0/1.
При записи в БД: присваиваете $user->is_active = 'yes' (строка). При $user->save() Laravel преобразует её в 1 или 0 в зависимости от boolean-каста. В БД сохранится 1.
Пример:
$user = new User();
$user->is_active = 'anything'; // это строка, но каст есть
$user->save(); // в БД сохранится 1, потому что 'anything' → true
$user = User::find(1);
var_dump($user->is_active); // bool(true)
Тонкий момент. Каст применяется только при обращении к атрибуту модели, а не к переменной. Если сделать $value = $user->is_active, то $value получит уже bool (потому что доступ к атрибуту вернул bool). Если присвоил значение и сразу прочитал до сохранения — каст уже применится:
$user->is_active = 0;
var_dump($user->is_active); // bool(false) — каст сработал сразу
PHP-приведение (например, (int) $var) | Laravel Casts |
|---|---|
| Меняет тип переменной в момент выполнения | Меняет значение при доступе к атрибуту модели |
| Глобально для всей программы | Ограничено моделью Eloquent |
| Не сохраняется в БД автоматически | Применяется при сохранении в БД |
Для преобразований со сложной логикой реализуйте интерфейс CastsAttributes:
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class PasswordHashCast implements CastsAttributes
{
public function get($model, $key, $value, $attributes)
{
// из БД → значение для модели
return $value;
}
public function set($model, $key, $value, $attributes)
{
// перед сохранением → хешируем
return bcrypt($value);
}
}
В модели:
protected $casts = [
'password' => PasswordHashCast::class,
];
Теперь при присвоении $user->password = 'secret' значение автоматически хешируется при сохранении.
- Не путайте
$castsс$fillable— это разные вещи. Первое — про типы, второе — про mass assignment. - Для
array/object/collectionполей в БД должен быть типjson(илиtext). Иначе данные не сохранятся корректно.
$casts — простой способ задать типы для атрибутов модели. Поддерживает множество стандартных типов (boolean, integer, float, string, datetime, date, timestamp, array, object, collection, encrypted, enum). Для сложной логики — кастомные касты через CastsAttributes. Делает код чище, а работу с данными — предсказуемой и безопасной.
Что это. Scopes — методы в модели, которые позволяют выносить часто используемые условия в отдельные, переиспользуемые куски запросов. Делают код чище, а запросы — более читаемыми.
Локальные скоупы (Local Scopes)
Определяются методом с префиксом scope. Например, scopeActive($query). При вызове возвращают $query с добавленными условиями.
class User extends Model
{
public function scopeActive($query)
{
return $query->where('status', 'active');
}
public function scopeRecentlyUpdated($query)
{
return $query->where('updated_at', '>=', now()->subDays(7));
}
}
Использование — префикс scope при вызове опускается, скоупы можно цеплять цепочкой:
$activeUsers = User::active()->get(); // все активные
$recentActive = User::active()->recentlyUpdated()->get(); // цепочка
Внутри Laravel просто вызывает метод и добавляет условия в Query Builder.
Глобальные скоупы (Global Scopes)
Применяются автоматически к каждому запросу модели, без явного указания. Например, всегда исключать удалённые записи (soft delete) или фильтровать по текущему пользователю / тенанту.
Регистрация глобального скоупа:
// В методе booted() модели
protected static function booted()
{
static::addGlobalScope('active', function ($query) {
$query->where('status', 'active');
});
}
Автоматически применяется ко ВСЕМ типам запросов — all(), find(), where(), любые цепочки. Не нужно указывать его явно, он добавляется сам:
// Все эти запросы автоматически получат WHERE status = 'active'
User::all();
// SQL: SELECT * FROM users WHERE status = 'active'
User::find(5);
// SQL: SELECT * FROM users WHERE status = 'active' AND id = 5
User::where('age', 30)->get();
// SQL: SELECT * FROM users WHERE status = 'active' AND age = 30
То есть ты даже не указываешь глобальный скоуп явно — он применяется сам, во всех запросах модели.
Отключение глобального скоупа:
User::withoutGlobalScope('status')->get();
// или
User::withoutGlobalScopes()->get();
Зачем это нужно:
- Сухость (DRY): условия не дублируются в каждом запросе.
- Читаемость:
User::active()->get()гораздо понятнее, чемUser::where('status', 'active')->get(). - Глобальные скоупы полезны для многопользовательских систем (например, всегда фильтровать по текущей компании/тенанту).
Важные нюансы:
- Локальные скоупы не изменяют существующий экземпляр
$query, а возвращают новый с добавленными условиями. - Глобальные скоупы могут мешать при выполнении сложных запросов — поэтому их можно отключать через
withoutGlobalScopeилиwithoutGlobalScopes. - При использовании
SoftDeletesуже есть встроенный глобальный скоуп, который отфильтровывает удалённые записи. Обход —withTrashed(),onlyTrashed().
User::active()->...). Глобальный — применяется автоматически ко всем запросам модели. Используйте скоупы для выноса повторяющейся логики фильтрации, чтобы запросы были лаконичнее, а код — поддерживаемее.
Builder $query в параметре скоупаВ методе скоупа вроде scopePaid(Builder $q) параметр $q — это экземпляр класса Illuminate\Database\Eloquent\Builder. Это тот самый объект-построитель запросов, который позволяет собирать SQL через цепочки методов:
$q->where(...)->orderBy(...)->limit(...);
Когда вы пишете User::where(...)->get(), каждый метод возвращает всё тот же Builder (или его модифицированную копию), что позволяет строить запрос пошагово.
Зачем он в скоупе. Скоуп получает этот билдер, чтобы добавить к нему дополнительные условия. После этого билдер возвращается в основной запрос, и выполнение продолжается.
Аналогия. Builder — это чертёж SQL-запроса. Постепенно добавляете детали: WHERE, ORDER BY, LIMIT. Скоуп добавляет свои детали, не создавая новый чертёж, а дополняя существующий.
: void. В новых версиях Laravel принято указывать возвращаемый тип void у методов-скоупов — метод ничего не возвращает, а только изменяет переданный объект. Объект $q передаётся по ссылке, поэтому все изменения применяются к тому же экземпляру билдера. Писать return $q не нужно.
public function scopePaid(Builder $q): void
{
$q->where('status', 'paid')->whereNotNull('paid_at');
// return не нужен — $q уже модифицирован
}
Локальные скоупы (scopeActive, scopePaid и т.п.) не используются для установки или изменения данных. Они предназначены исключительно для фильтрации при выборке данных из БД.
Что делают скоупы:
- Добавляют условия (
where,orderBy,limitи т.д.) в запрос. - Возвращают построитель запросов (
Builder) с добавленными условиями. - Вызываются в цепочке перед финальными методами (
get(),first(),paginate()).
// Скоуп добавляет условие WHERE status = 'active'
public function scopeActive($query)
{
return $query->where('status', 'active');
}
// Использование — только для выборки
$activeUsers = User::active()->get();
Для изменения данных при записи используются мутаторы или касты:
- Мутатор (setter) — преобразует значение перед сохранением в БД.
- Каст (
$casts) — тоже влияет на сохранение (приводит к нужному типу).
// Мутатор: перед сохранением приводит статус к нижнему регистру
public function setStatusAttribute($value)
{
$this->attributes['status'] = strtolower($value);
}
// Каст: гарантирует, что поле status всегда будет строкой
protected $casts = [
'status' => 'string',
];
- Не вызываются автоматически при
save()илиcreate(). - Не влияют на мутаторы или касты.
- Их задача — только формировать SQL-запросы для чтения.
Что это. Accessors и Mutators — методы в модели, которые позволяют преобразовывать атрибуты при чтении (accessor) и при записи (mutator). Работают на уровне модели, автоматически применяются при обращении к атрибуту через $user->name.
Accessor (геттер) — преобразование при чтении
Позволяет изменить значение поля, когда вы получаете его из модели. Синтаксис Laravel 9+ через Attribute::make():
use Illuminate\Database\Eloquent\Casts\Attribute;
class User extends Model
{
// Получаем полное имя: first_name + last_name
protected function fullName(): Attribute
{
return Attribute::make(
get: fn ($value, $attributes)
=> $attributes['first_name'] . ' ' . $attributes['last_name']
);
}
// Приводим поле status к человекочитаемому виду
protected function statusText(): Attribute
{
return Attribute::make(
get: fn ($value, $attributes) => match ($attributes['status']) {
0 => 'Неактивен',
1 => 'Активен',
default => 'Неизвестно',
}
);
}
}
Использование — обращение по имени метода в snake_case:
$user = User::find(1);
echo $user->full_name; // "Иван Петров"
echo $user->status_text; // "Активен"
Mutator (сеттер) — преобразование при записи
Позволяет изменить значение перед сохранением в БД:
class User extends Model
{
// Перед сохранением хешируем пароль
protected function password(): Attribute
{
return Attribute::make(
set: fn ($value) => bcrypt($value)
);
}
// Перед сохранением обрезаем пробелы и приводим к нижнему регистру
protected function email(): Attribute
{
return Attribute::make(
set: fn ($value) => strtolower(trim($value))
);
}
}
Использование:
$user = new User();
$user->password = 'secret123'; // будет захеширован при сохранении
$user->email = ' TEST@MAIL.COM '; // станет 'test@mail.com'
$user->save();
Совмещение get и set в одном методе
Можно одновременно определить геттер и сеттер:
protected function name(): Attribute
{
return Attribute::make(
get: fn ($value) => ucfirst($value), // при чтении — с большой буквы
set: fn ($value) => trim($value) // при записи — убираем пробелы
);
}
Кеширование через shouldCache()
Если аксессор выполняет тяжёлые вычисления (обращение к API, сложная обработка), можно закешировать результат на время жизни запроса:
protected function expensiveComputation(): Attribute
{
return Attribute::make(
get: fn () => $this->runHeavyCalculation(),
)->shouldCache(); // результат сохраняется в памяти на время запроса
}
При повторном обращении к $user->expensive_computation результат не будет вычисляться заново.
Раньше использовались методы getXxxAttribute() и setXxxAttribute():
// Accessor
public function getFullNameAttribute()
{
return $this->first_name . ' ' . $this->last_name;
}
// Mutator
public function setPasswordAttribute($value)
{
$this->attributes['password'] = bcrypt($value);
}
Это всё ещё работает, но новый синтаксис через Attribute::make() предпочтительнее — даёт больше гибкости (кеширование, доступ ко всем атрибутам, объединение get + set в одном методе).
Когда использовать:
- Accessor — когда нужно отдавать данные в другом формате, чем в БД (дата в другом формате, объединение полей, статус в виде текста).
- Mutator — когда нужно подготовить данные перед сохранением (хеширование, нормализация, очистка).
- Кеширование — когда вычисления дорогие или повторяются в течение одного запроса.
- Accessors и mutators работают только при обращении к атрибуту через объект модели (
$user->name). Если использовать$user->getAttributes()или напрямую массивattributes— они не применяются. - При массовом присваивании (
create,update) мутаторы срабатывают автоматически, если поля переданы через$fillable. - Для производительности не используйте аксессоры внутри циклов без кеширования — это может породить много запросов или вычислений.
Attribute::make() — основной способ в Laravel 9+. shouldCache() — кеширование результата аксессора на время запроса.
Accessor и Mutator удобнее всего представлять как «перехватчики» данных на границе модели:
- Accessor берёт сырое значение из БД и возвращает преобразованное — форматирование дат, объединение полей, перевод статусов в текст, приведение типов.
- Mutator берёт входящее значение и преобразует его перед сохранением — хеширование, очистка, нормализация, приведение к нужному формату.
Они позволяют централизованно управлять тем, как данные выглядят на выходе и входе, без размазывания логики по контроллерам и представлениям. Типичные применения — объединение полей (first_name + last_name → full_name), хеширование паролей, нормализация email/телефонов, преобразование JSON/массивов, форматирование дат и валют.
Attribute::make() — статический фабричный методAttribute::make() — это статический метод класса Illuminate\Database\Eloquent\Casts\Attribute. Он создаёт и возвращает экземпляр объекта Attribute, который используется для определения аксессора и/или мутатора.
Как это работает под капотом. Когда вы пишете:
return Attribute::make(
get: fn ($value, $attributes) => ...,
set: fn ($value) => ...
);
на самом деле происходит вызов статического метода make, который принимает параметры с именами get и set (named arguments, доступные с PHP 8.0). Внутри метод создаёт объект Attribute и возвращает его. Этот объект Laravel сохраняет в кэш модели, и при обращении к атрибуту вызывает соответствующий колбэк.
Вы не вызываете аксессоры и мутаторы отдельно в контроллере. Они срабатывают сами, когда вы обращаетесь к свойству модели или присваиваете значение. Контроллер просто работает с объектом модели, а аксессоры и мутаторы действуют как перехватчики.
Как это выглядит в контроллере:
// Модель User с аксессором fullName и мутатором password
class UserController extends Controller
{
public function show($id)
{
$user = User::find($id);
// Аксессор срабатывает автоматически при чтении поля full_name
return view('profile', [
'fullName' => $user->full_name, // вернёт "Иван Петров"
]);
}
public function store(Request $request)
{
$user = new User();
$user->password = $request->input('password'); // мутатор хеширует его автоматически
$user->save();
// $user->password уже захеширован в БД
}
}
User, Post и т.д.). Контроллер просто использует модель — ему не нужно знать о преобразованиях. Всё происходит прозрачно: вы читаете $user->full_name — а там уже готовая строка.
Что это. Observers (наблюдатели) — это классы, которые позволяют централизованно обрабатывать события жизненного цикла модели Eloquent (создание, обновление, удаление, восстановление). Вместо того чтобы писать логику в самой модели или размазывать её по контроллерам, выносите её в отдельный класс-наблюдатель.
| Метод | Когда срабатывает |
|---|---|
retrieved | После того как модель извлечена из БД (find, get, first...) |
creating | Перед созданием новой записи (до INSERT) |
created | После создания записи (после INSERT) |
updating | Перед обновлением (до UPDATE) |
updated | После обновления |
saving | Перед сохранением — и для создания, и для обновления |
saved | После сохранения — и для создания, и для обновления |
deleting | Перед удалением |
deleted | После удаления |
restoring | Перед восстановлением (если используется SoftDeletes) |
restored | После восстановления |
1. Создать класс через artisan:
php artisan make:observer UserObserver --model=User
2. Определить логику в методах:
// app/Observers/UserObserver.php
class UserObserver
{
public function created(User $user)
{
// Отправляем приветственное письмо
Mail::to($user->email)->send(new WelcomeMail($user));
}
public function deleted(User $user)
{
// Удаляем аватарку пользователя из файловой системы
Storage::delete($user->avatar_path);
}
}
3. Зарегистрировать в сервис-провайдере (например, App\Providers\AppServiceProvider):
use App\Models\User;
use App\Observers\UserObserver;
public function boot()
{
User::observe(UserObserver::class);
}
Зачем это нужно:
- Разделение ответственности: модель занимается только данными, а побочные действия (отправка писем, логирование, очистка кеша) вынесены в отдельный класс.
- Переиспользуемость: логика не дублируется в контроллерах.
- Тестируемость: Observer легко тестировать отдельно от контроллеров.
- Чистота модели: модель не перегружена событиями.
Альтернативы:
- Model Events — можно объявить через свойство
$dispatchesEventsв модели. - Event Listeners — можно связать с моделью через события, но Observer — более высокоуровневый и удобный подход, когда все хуки для одной модели.
User::observe(UserObserver::class) в AppServiceProvider::boot().
Observers нужно регистрировать в Service Provider (например, App\Providers\AppServiceProvider), потому что:
- Централизованное управление — все подписки на события моделей собираются в одном месте, что упрощает поддержку и поиск.
- Гарантированное выполнение при старте — метод
boot()провайдера вызывается после того, как все сервисы зарегистрированы, и модели уже доступны. Если зарегистрировать Observer прямо в модели или в контроллере, это может привести к дублированию или выполнению в неподходящий момент. - Единая точка входа — при смене логики или добавлении новых наблюдателей вы правите только провайдер, не разыскивая вызовы по всему коду.
Пример регистрации в AppServiceProvider:
public function boot()
{
User::observe(UserObserver::class);
Post::observe(PostObserver::class);
}
Observers идеально подходят для побочных действий (side-effects), которые не являются основной бизнес-логикой, но должны происходить при определённых событиях модели.
| Событие | Типичные действия |
|---|---|
created |
Отправка приветственного письма или SMS · создание связанных записей (например, профиля) · уведомление администратору · запись в лог регистрации · генерация реферальной ссылки · отправка события в очередь для аналитики |
updated |
Уведомление об изменении профиля (email/push) · инвалидация кеша (сброс списка пользователей) · логирование изменений (аудит) · синхронизация с CRM / внешним API · пересчёт агрегированных данных (рейтинг) |
deleted |
Удаление связанных файлов (аватары, документы) · очистка кеша · логирование удаления · уведомление о удалении (админу) · удаление зависимых записей (если нет каскадного удаления в БД) |
restored (SoftDeletes) |
Восстановление файлов, если они были удалены · уведомление о восстановлении · обновление индексов поиска |
retrieved |
Логирование доступа к данным (редко, обычно для аудита чтения — GDPR, ISO) |
creating / updating |
Автоматическая установка значений (например, slug) · валидация, не охваченная стандартной · преобразование данных перед записью |
saving |
Общая обработка перед любым сохранением. Осторожно: срабатывает и при создании, и при обновлении. |
Mail::send(...) в контроллере каждый раз при создании пользователя — выносите это в Observer. Контроллеры становятся тоньше, код — переиспользуемым, тестирование — проще. Основные применения: уведомления, логирование, кеширование, очистка данных, синхронизация с внешними системами, генерация данных, аудит.
Eloquent позволяет описывать связи между моделями с помощью простых методов. Ниже — примеры для каждого из четырёх основных типов.
1. hasOne — один к одному
Модель User имеет один Profile.
// app/Models/User.php
class User extends Model
{
public function profile(): HasOne
{
return $this->hasOne(Profile::class);
}
}
Использование:
$user = User::find(1);
$profile = $user->profile; // динамическое свойство — экземпляр Profile
$profile = $user->profile()->first(); // через метод — Query Builder
2. belongsTo — обратная сторона hasOne / hasMany
Модель Profile принадлежит User.
// app/Models/Profile.php
class Profile extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
Использование:
$profile = Profile::find(1);
$user = $profile->user; // объект User
$user = $profile->user()->first(); // Query Builder
3. hasMany — один ко многим
Модель User имеет много Post.
// app/Models/User.php
class User extends Model
{
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}
Использование:
$user = User::find(1);
$posts = $user->posts; // Collection (все посты)
$recent = $user->posts()->latest()->limit(5)->get(); // с условиями
4. belongsToMany — многие ко многим
Модель User может иметь много Role, и наоборот. Используется промежуточная таблица role_user.
// app/Models/User.php
class User extends Model
{
public function roles(): BelongsToMany
{
// второй параметр — имя pivot-таблицы
return $this->belongsToMany(Role::class, 'role_user');
}
}
// app/Models/Role.php
class Role extends Model
{
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'role_user');
}
}
Использование:
$user = User::find(1);
$roles = $user->roles; // Collection моделей Role
$roles = $user->roles()->where('name', 'admin')->get(); // Query Builder
Динамическое свойство ($user->posts) | Query-метод ($user->posts()) |
|---|---|
| Возвращает Collection — все записи уже загружены | Возвращает Query Builder — можно дальше добавлять условия |
| Выполняет запрос сразу при обращении к свойству (lazy loading) | Запрос выполняется только при вызове get(), first(), paginate() |
| Удобно для вывода данных в представлениях | Удобно для фильтрации, сортировки, пагинации |
| Может привести к N+1 проблеме | Позволяет контролировать запросы, использовать with() для eager-загрузки |
Пример метода для дополнительной фильтрации:
$user = User::find(1);
$recentPosts = $user->posts()
->where('published_at', '>=', now()->subMonth())
->get();
belongsToManyДля belongsToMany можно получить промежуточные данные через свойство pivot:
$user = User::find(1);
foreach ($user->roles as $role) {
echo $role->pivot->created_at; // если в pivot есть поле created_at
}
Если нужно достать дополнительные атрибуты pivot — укажите их через withPivot():
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class)->withPivot('expires_at');
}
hasOne— один к одномуhasMany— один ко многимbelongsTo— обратная сторонаhasOneилиhasManybelongsToMany— многие ко многим (с pivot-таблицей)
hasManyThrough, morphTo, morphMany) — в KB_12 Eloquent Advanced.
final class Order extends Model
{
protected $fillable = ['user_id', 'status', 'total_minor', 'currency'];
protected function casts(): array
{
return [
'total_minor' => 'integer',
'paid_at' => 'datetime',
'meta' => AsArrayObject::class,
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function scopePaid(Builder $q): void
{
$q->where('status', 'paid')->whereNotNull('paid_at');
}
protected function totalMajor(): Attribute
{
return Attribute::get(fn () => $this->total_minor / 100);
}
}
// Использование
Order::paid()->where('currency', 'USD')->with('user')->get();
$guarded = [] в проде. Открывает все колонки для mass assignment. User::create($request->all()) пропустит is_admin в insert. Используйте явный $fillable.save() на свежей коллекции. $orders = $user->orders; $orders->each(fn($o) => $o->save()) — N запросов вместо одного. Используйте $user->orders()->update([...]).relation vs relation(). $user->orders — collection (lazy-loaded). $user->orders() — query builder. Путаница приводит к лишним запросам или ошибкам.updated_at при update массивом. Laravel автоматически обновит updated_at. Если нужно избежать (миграция данных) — $model->timestamps = false или DB::table().$model->meta['key'] = 'x' не пометит модель dirty. Используйте AsArrayObject или AsCollection.find с массивом не возвращает ошибку. User::find([1, 2, 99]) — вернёт только существующих, без warning'а. Если важно полное соответствие — проверяйте count.save() — изменения теряются.chunk с update. User::chunk(100, fn($users) => $users->each->update(...)) — после первого chunk PK сдвигается, следующий chunk пропускает строки. Используйте chunkById.Кеш — способ обменять память на CPU/IO. Laravel предлагает единый интерфейс над несколькими бэкендами: file, database, redis, memcached, array (in-memory, для тестов). Понимание различий между бэкендами, паттернов инвалидации и потенциальных race conditions — обязательное знание для middle.
User::find(1), старая закешированная версия становится «недействительной» — её надо удалить (Cache::forget('user:1')) или перезаписать, иначе пользователь будет видеть устаревшие данные. Инвалидация — одна из самых сложных задач в кешировании («There are only two hard things in Computer Science: cache invalidation and naming things»).
Redis — это не единственное и даже не стандартное кеширование в Laravel. Это один из многих вариантов (драйверов), которые фреймворк поддерживает «из коробки». Laravel предоставляет единый API для работы с разными системами хранения — код одинаковый, меняется только конфигурация.
| Драйвер | Где хранит | Когда использовать |
|---|---|---|
file |
Файлы на сервере в storage/framework/cache/data |
Самый простой вариант. Локальная разработка, маленькие проекты без внешних сервисов. |
database |
Таблица в БД (создаётся через php artisan cache:table + migrate) |
Когда нельзя поднять Redis/Memcached. В актуальных версиях Laravel (11.x) — по умолчанию. |
redis |
In-memory хранилище Redis | Продакшен, высокая нагрузка. Богатый функционал: структуры данных, pub/sub, atomic ops. |
memcached |
In-memory хранилище Memcached | Тоже продакшен. Проще Redis, только ключ-значение — но очень быстрый. |
dynamodb |
AWS DynamoDB | Проекты в экосистеме Amazon, serverless на Lambda. |
array |
Массив PHP в памяти запроса | Только для тестов — данные не сохраняются между запросами. |
null |
Ничего не сохраняет | Драйвер-заглушка. Также для тестов, когда нужно отключить кеширование. |
apc / apcu |
PHP APCu (in-memory на процесс) | Устаревшие драйверы. Иногда упоминаются в старой документации. |
Драйвер по умолчанию определяется в файле config/cache.php через переменную окружения CACHE_DRIVER. В разных версиях Laravel настройки по умолчанию могут отличаться, но в актуальных версиях (например, 11.x) по умолчанию используется database.
# .env
CACHE_DRIVER=redis
Для высоконагруженных проектов рекомендуется использовать in-memory драйверы — redis или memcached. Они хранят данные в оперативной памяти и дают максимальную скорость.
Выбор между Redis и Memcached обычно сводится к тому, что Redis предлагает более богатый функционал: поддержку структур данных (списки, хеши, отсортированные множества), pub/sub, atomic locks, persistence — оставаясь при этом очень быстрым. Memcached — минималистичнее, чуть быстрее на простом ключ-значении, но не умеет ничего кроме этого.
Cache::tags(). Метод для групповой работы с кешем через теги работает только с драйверами redis и memcached. При использовании file, database или других драйверов вызов tags() выбросит исключение.
CACHE_DRIVER. Единый API Cache::put()/Cache::get() работает одинаково независимо от драйвера — можно менять хранилище без переписывания кода.
Что это. Multiple stores — это возможность объявить несколько независимых конфигураций кеша в config/cache.php и использовать их по имени через Cache::store('название'). Позволяет гибко управлять хранением, распределяя разные типы данных по разным драйверам или серверам.
Как это выглядит в конфиге:
// config/cache.php
'stores' => [
'redis' => [
'driver' => 'redis',
'connection' => 'default',
],
'redis_fast' => [
'driver' => 'redis',
'connection' => 'fast', // отдельное соединение Redis (другой сервер)
],
'file_slow' => [
'driver' => 'file',
'path' => storage_path('framework/cache/slow'),
],
'database' => [
'driver' => 'database',
'table' => 'cache',
],
],
Как использовать:
// Стандартное хранилище (указанное в CACHE_DRIVER)
Cache::put('key', 'value', 60);
// Конкретное хранилище по имени
Cache::store('redis_fast')->put('hot', 'data', 60);
Cache::store('file_slow')->put('big_report', $report, 3600);
Зачем это нужно:
- Разделение горячего и холодного кеша. Горячий — часто запрашиваемые данные (Redis, высокопроизводительный). Холодный — редко используемые или большие данные (файлы, БД), чтобы не засорять оперативную память.
- Изоляция по функциональности. Например, кеш для сессий отдельно, для API-ответов отдельно, для отчётов отдельно. Упрощает мониторинг и очистку.
- Разные драйверы для разных задач. Можно комбинировать Redis для быстрых операций и файлы для огромных кешей, где скорость не критична.
Если нужно временно переключить стандартное хранилище для всех вызовов Cache::, есть shouldUse():
Cache::shouldUse('redis_fast');
// теперь все вызовы без store() идут в redis_fast
Обычно так делать не рекомендуется — усложняет код и делает поведение неявным. Явный store('name') в каждом месте предсказуемее.
- Каждое хранилище использует свои настройки (драйвер, соединение, таблицу, путь).
- Хранилища не пересекаются — ключи в одном не видны в другом.
- Для
Cache::tags()нужно чтобы драйвер поддерживал теги (Redis/Memcached) —store('redis')->tags(...)работает, ноstore('file')->tags(...)— нет.
Cache::store('name'). Полезно в крупных проектах, где разные части системы имеют разные требования к скорости и объёму кеширования.
Базовые операции
Cache::put(key, value, ttl), get, has, forget, increment, decrement. remember(key, ttl, fn() => ...) — get или compute.
Tags (только Redis/Memcached)
Cache::tags(['users', 'orders'])->put(...) — групповая инвалидация: Cache::tags(['users'])->flush(). Не работает с file/database бэкендами.
Atomic locks
Cache::lock('process-order', 10)->get(fn() => ...) — распределённая блокировка. Защищает от двух одновременных обработчиков одной задачи. Lock автоматически освобождается через 10 сек.
Multiple stores
В config/cache.php можно объявить несколько стораджей и использовать по имени: Cache::store('redis-fast')->put(...). Полезно для разделения горячего и холодного кеша.
Laravel предоставляет единый интерфейс для работы с кешем через фасад Cache. Ниже основные методы с примерами.
1. put() — сохранить значение
Cache::put('key', 'value', $ttl); // $ttl в секундах (или DateTime)
Cache::put('user_1', $user, 3600); // на 60 минут
2. get() — получить значение
$value = Cache::get('key');
$value = Cache::get('key', 'default'); // со значением по умолчанию
$value = Cache::get('key', fn () => 'default'); // lazy default
3. has() — проверить существование
if (Cache::has('key')) { /* ... */ }
4. forget() — удалить
Cache::forget('key');
5. increment() / decrement() — для числовых значений
Cache::increment('counter'); // +1
Cache::increment('counter', 5); // +5
Cache::decrement('counter', 3); // -3
Атомарные операции — безопасны в конкурентной среде.
6. remember() — получить или вычислить и сохранить
// Если ключ есть — вернуть.
// Если нет — выполнить callback, сохранить результат и вернуть.
$users = Cache::remember('active_users', 3600, function () {
return User::active()->get(); // тяжёлый запрос
});
Есть вариант rememberForever() — без TTL, хранится до явного удаления.
Откуда remember берёт данные. Источник данных определяется внутри колбэка, который вы передаёте вторым параметром. Колбэк выполняется только тогда, когда данных по ключу нет в кеше или они истекли, и его результат сохраняется в кеш. Внутри колбэка вы сами пишете логику — Eloquent-запрос, вызов внешнего API, сложные вычисления, чтение из файла. Никакой магии.
$products = Cache::remember('popular_products', 300, function () {
return Product::with('category')
->where('views', '>', 1000)
->orderBy('sales', 'desc')
->limit(10)
->get();
});
// При первом запросе — тяжёлый SQL, результат в кеш на 5 минут.
// Следующие 5 минут все запросы получают данные из кеша без обращения к БД.
Переменная $users/$products — обычная PHP-переменная, содержит либо результат из кеша, либо результат выполнения колбэка. Никакой дополнительной регистрации не требуется. Если колбэк возвращает null или пустой массив — они тоже кешируются (в зависимости от драйвера).
7. pull() — получить и удалить за один вызов
$value = Cache::pull('key'); // вернёт значение и удалит ключ
Подробнее — в отдельной подсекции ниже.
8. store() — выбрать другой драйвер
Cache::store('redis')->put(...);
put— записьget— чтениеhas— проверкаforget— удалениеincrement/decrement— атомарный счётчикremember— «верни, если есть, а если нет — вычисли и сохрани» (самый удобный)pull— прочитать + удалить за один вызов
В API кеша Laravel есть методы, которые возвращают значение (его можно присвоить переменной), и методы, которые ничего не возвращают или возвращают bool. Правило простое: смотрите на возвращаемый тип метода — если mixed или bool, значение можно сохранить в переменную; если void — нельзя.
| Метод | Что возвращает | Пример |
|---|---|---|
get($key, $default = null) | значение или $default | $value = Cache::get('key', 'default'); |
remember($key, $ttl, $cb) | значение из кеша или результат колбэка | $users = Cache::remember('users', 60, fn() => User::all()); |
rememberForever($key, $cb) | то же, но без TTL | $config = Cache::rememberForever('cfg', fn() => ...); |
pull($key) | значение и удаляет ключ | $token = Cache::pull('token'); |
many(array $keys) | массив значений для нескольких ключей | $values = Cache::many(['key1', 'key2']); |
increment($key, $amount = 1) | новое значение счётчика | $newCount = Cache::increment('counter', 5); |
decrement($key, $amount = 1) | новое значение счётчика | $newCount = Cache::decrement('counter'); |
| Метод | Что возвращает | Пример |
|---|---|---|
put($key, $value, $ttl) | ничего (или true) — обычно результат игнорируют | Cache::put('key', 'value', 60); |
add($key, $value, $ttl) | bool — было ли добавление успешным | $added = Cache::add('key', 'value', 60); |
forget($key) | bool — удалён ли ключ | $deleted = Cache::forget('key'); |
flush() | bool — очистка всего | $cleared = Cache::flush(); |
get, pull, remember, increment) — присваиваете переменной. Если ничего не возвращает (put, forget, flush) — просто выполняете действие. Методы возвращающие bool (add, forget, flush) можно присвоить, но обычно результат используют только для проверки успешности операции.
Что это. Теги позволяют объединять несколько ключей кеша в логические группы и очищать всю группу разом. Удобно, когда у вас много связанных данных (например, все кеши, зависящие от пользователя или заказов).
Как использовать:
// Сохраняем значение с тегами
Cache::tags(['users', 'profile'])->put('user_123', $userData, 3600);
// Несколько ключей в одной группе
Cache::tags(['orders'])->put('order_456', $orderData, 3600);
Cache::tags(['orders', 'invoices'])->put('invoice_789', $invoiceData, 3600);
// Получаем значение по ключу с тегами
$user = Cache::tags(['users'])->get('user_123');
// Очищаем все ключи с тегом 'users'
Cache::tags(['users'])->flush();
// Очищаем несколько тегов сразу
Cache::tags(['orders', 'invoices'])->flush();
Зачем это нужно:
- Групповая инвалидация — если обновили данные пользователя, можно очистить весь кеш связанный с этим пользователем, не думая о том, какие ключи были созданы.
- Упрощение управления — вместо запоминания всех ключей вы просто помечаете их тегами и сбрасываете группу.
- Повышение производительности — избавление от устаревших данных без перебора всех ключей по отдельности.
- Работает только с драйверами Redis и Memcached. Для
file,database,arrayтеги не поддерживаются. - При использовании
Cache::tags()нужен правильный порядок вызовов: сначалаtags(), затемput()/get()/flush(). - Теги хранятся отдельно от ключей, поэтому инвалидация группы происходит быстро и атомарно.
Разберём вызов Cache::tags(['users', 'profile'])->put('user_123', $userData, 3600):
'user_123'— строковый ключ, по которому будете получать данные. Вы сами его придумываете (например, для пользователя с ID = 123 удобно использовать"user_123").$userData— сами данные (массив, объект, строка — что угодно).['users', 'profile']— метки, которые присваиваются ключу. Хранятся отдельно и позволяют группировать ключи.
Как Laravel понимает связь тегов и ключей. Под капотом (для Redis/Memcached) Laravel создаёт отдельные структуры:
- Сохраняет основное значение по ключу
'user_123'— как обычно. - Для каждого тега (
usersиprofile) создаёт список ключей, которые относятся к этому тегу. Например, в Redis есть отдельный ключ"tag:users:keys", внутри которого хранится список['user_123', 'user_456', ...]. - При
Cache::tags(['users'])->flush()Laravel проходит по списку ключей тегаusersи удаляет их один за другим.
Таким образом, теги не встраиваются в основной ключ — они хранятся отдельно в индексах. Поэтому один и тот же ключ можно привязать к нескольким тегам, и он будет удалён при очистке любого из них.
Пример с несколькими тегами:
// Сохраняем профиль пользователя с двумя тегами
Cache::tags(['users', 'profile'])->put('user_123', $userData, 3600);
// Сохраняем настройки пользователя только с тегом 'users'
Cache::tags(['users'])->put('user_settings_123', $settings, 3600);
// Получаем данные (нужно указывать теги, если они были при сохранении)
$profile = Cache::tags(['users'])->get('user_123'); // работает
$settings = Cache::tags(['users'])->get('user_settings_123'); // работает
// А вот так — вернёт null, ключ хранится в отдельном пространстве:
// Cache::get('user_123'); // null!
// Очищаем всё с тегом 'users' — удалится и user_123, и user_settings_123
Cache::tags(['users'])->flush();
// Если очистить только 'profile' — удалится только user_123 (у него есть тег profile)
Cache::tags(['profile'])->flush();
Практический сценарий — управление кешем пользователя:
// При сохранении разных частей данных пользователя
Cache::tags(['user_' . $userId])->put('profile', $profile, 3600);
Cache::tags(['user_' . $userId])->put('settings', $settings, 3600);
// При обновлении профиля — одним вызовом очищаем всё связанное
Cache::tags(['user_' . $userId])->flush();
Cache::tags(...)->get(...). Обычный Cache::get() без тегов вернёт null, потому что ключ физически хранится в отдельном пространстве.
- Ключ (
user_123) — произвольная строка, которую вы придумываете. - Теги — отдельные индексы, хранящие списки ключей.
- При
put()Laravel добавляет ключ в индексы тегов. - При
flush()удаляет все ключи из индекса тега. - При
get()проверяет наличие ключа в указанном индексе и возвращает данные. - Работает только с Redis/Memcached — для file/database теги недоступны.
Что это. Atomic lock — это распределённая блокировка, которая гарантирует, что определённый участок кода или задача выполняется только одним процессом или сервером одновременно. Критично важно в распределённых системах, где несколько экземпляров приложения могут попытаться выполнить одну и ту же работу параллельно — обработать один заказ, запустить крон-задачу, обновить баланс.
Как работает Cache::lock():
Cache::lock('process-order', 10)->get(function () {
// выполняем критическую операцию
});
'process-order'— уникальный идентификатор блокировки (например, ID заказа).10— максимальное время удержания в секундах (TTL). Если колбэк займёт больше 10 секунд, блокировка автоматически освободится — защита от зависших задач.get(callback)— пытается захватить блокировку. Если успешно — выполняет колбэк и автоматически освобождает. Если не удалось (уже занята) — сразу вернётfalse/null, колбэк не выполнится.
Проблема. В веб-приложении два параллельных запроса от одного пользователя могут одновременно попытаться оплатить один и тот же заказ. Без блокировки они дважды спишут деньги, дважды обновят статус — несогласованность данных.
Решение. Блокировка на уровне заказа — первый запрос захватывает блокировку, выполняет обработку, освобождает. Второй запрос видит, что блокировка занята, и либо завершается с ошибкой, либо ждёт.
Иногда нужно больше контроля — используются методы acquire() и release():
$lock = Cache::lock('process-order', 10);
if ($lock->acquire()) {
try {
// выполняем работу
} finally {
$lock->release(); // обязательно освободить!
}
} else {
// блокировка уже занята
}
Метод get() делает это автоматически — удобнее и безопаснее (не забыть release).
- Если колбэк внутри
get()выполнился успешно — блокировка освобождается после выхода из колбэка. - Если колбэк выбросил исключение — блокировка всё равно освободится (гарантируется механизмом).
- Если выполнение превысило TTL (10 секунд) — блокировка автоматически удаляется драйвером кеша, чтобы не блокировать другие процессы. При этом ваш код продолжит выполняться — это может привести к одновременной работе двух процессов, если первый ещё не завершился, а второй уже захватил освободившуюся блокировку. Поэтому TTL должен быть больше ожидаемого времени выполнения.
redis, memcached, database (начиная с определённых версий), dynamodb. Для file и array — не работают или с ограничениями. Laravel использует атомарные операции (например, Redis SET NX EX) для реализации блокировок.
- Обработка платежей — блокировка по ID заказа, чтобы не списать дважды.
- Крон-задача — блокировка по имени задачи, чтобы не запустилась повторно если предыдущий запуск ещё не завершился.
- Обновление ресурса — генерация отчёта, кеширование тяжёлого набора данных: несколько запросов не запустят одну и ту же операцию.
- Rate-limiting на уровне ресурса — ограничение одновременных запросов к внешнему API.
Cache::lock() — распределённая блокировка с автоматическим управлением. get(callback) — пытается захватить, выполняет колбэк, освобождает. TTL — защита от зависаний. Критична для согласованности данных в конкурентных средах. Поддерживается Redis, Memcached, Database. Используйте когда нужно гарантировать, что операция выполняется только одним экземпляром приложения одновременно.
pull() — get + forget за один вызовМетод pull() делает две вещи одновременно: возвращает значение по ключу (если оно существует и не истекло) и удаляет этот ключ из кеша сразу после извлечения. Значение при этом сохраняется в переменную и используется в коде — оно не «теряется», просто больше не хранится в кеше.
Время жизни (TTL) не имеет значения для самого удаления — pull удаляет ключ немедленно, независимо от того, сколько времени оставалось до истечения. Если ключ уже истёк, то pull вернёт null (или значение по умолчанию) и не станет ничего удалять — ключ уже удалён драйвером автоматически.
Простой пример:
// Сохраняем значение на 10 минут
Cache::put('temp', 'data', 600);
// Через 2 минуты делаем pull
$value = Cache::pull('temp'); // 'data' — вернулось, и ключ удалён
// Повторная попытка
$value = Cache::get('temp'); // null — ключа больше нет
Когда использовать:
- Одноразовые данные — например, токен для сброса пароля, код подтверждения. После использования хранить не нужно, чтобы никто не использовал его повторно.
- Флаги выполнения — например,
Cache::pull('import_in_progress'): если существует, значит импорт идёт; после проверки удаляем, чтобы не блокировать следующий запуск. - Счётчики-индикаторы — прочитать, использовать для логики, удалить чтобы сбросить.
- Атомарное чтение с очисткой — гарантирует, что значение будет прочитано и удалено за одну операцию, без гонок между несколькими запросами.
Практический пример — одноразовая ссылка активации:
// Сохраняем одноразовую ссылку для активации
Cache::put('activation_123', 'user@mail.com', 3600);
// Когда пользователь переходит по ссылке
$email = Cache::pull('activation_123'); // 'user@mail.com'
if ($email) {
// активируем пользователя
User::where('email', $email)->update(['active' => true]);
} else {
// ссылка уже использована или истекла
}
После pull() ключ удалён, и ту же ссылку нельзя использовать повторно.
- Если ключ не существует —
pull()вернётnullили переданное значение по умолчанию. pull()удаляет ключ только если он был найден и активен. Если истёк — считается отсутствующим, удаления не происходит (ключа уже нет).- Значение сохраняется в переменную и используется в коде. Удаление не мешает использованию — значение уже скопировано.
pull() = get() + forget() за один вызов. Не зависит от оставшегося TTL — удаляет сразу после чтения. Полезен для извлечения и очистки одноразовых данных (токены, коды, флаги), чтобы они не могли быть прочитаны повторно.
make:cache-tableЕсли вы используете драйвер database — Laravel хранит кеш в таблице БД, и её нужно создать. Для этого есть Artisan-команда:
php artisan make:cache-table
Команда создаёт файл миграции в database/migrations/. Далее её нужно применить:
php artisan migrate
php artisan migrate. Если по каким-то причинам её нет — команда make:cache-table создаст.
php artisan cache:table. В актуальных версиях Laravel (11.x и выше) используется именно make:cache-table. Обе команды делают одно и то же — создают миграцию для таблицы кеша.
Cache stampede — ситуация, когда ключ устарел и сразу 100 запросов пытаются его пересоздать одновременно, перегружая БД. Решение — атомарный lock.
// Под нагрузкой — N запросов одновременно регенерируют один ключ
return Cache::remember('dashboard:stats', 300, fn () =>
Stats::expensive());
// Только один запрос регенерирует, остальные ждут
return Cache::remember('dashboard:stats', 300, function () {
return Cache::lock('lock:dashboard:stats', 10)->block(5, fn () =>
Stats::expensive());
});
Cache::remember и null. Если callable вернул null, Laravel не кеширует. Каждый запрос пересоздаёт. Используйте rememberForever с sentinel, либо проверяйте на null отдельно.Cache::tags(...) на file/database driver выбросит исключение. Проверяйте драйвер или используйте только tags-capable бэкенды.$user->with('orders')->get() в Cache::put — relations сериализуются. При десериализации связи могут не работать корректно. Кешируйте plain данные, не Eloquent-модели.CACHE_PREFIX в .env.tags()->flush().session, queue, cache на одном Redis. Дефолт. Если flush — сбрасываются и сессии, и очереди. Разделяйте по DB index'ам.Очереди — механизм отложенного выполнения задач. Web-запрос ставит job в очередь и возвращает ответ; воркер забирает job и исполняет. Это разгружает HTTP-обработчики, обеспечивает retry при сбоях, позволяет масштабировать воркеры независимо. Понимание драйверов, retry-логики, batching и race conditions — обязательно для всего, что выходит за рамки «отправить письмо».
Очереди в Laravel позволяют выполнять ресурсоёмкие задачи (отправка писем, обработка изображений, вызов API) асинхронно, чтобы не тормозить ответ пользователю. Драйвер определяет, где и как хранятся и обрабатываются задания (jobs). Настраивается через QUEUE_CONNECTION в .env.
1. sync — синхронное выполнение (отладка / тесты)
Задание выполняется немедленно в том же процессе, что и запрос — не попадает в очередь вообще.
- Используется для локальной разработки и тестов, чтобы видеть ошибки сразу.
- Не подходит для продакшена — блокирует ответ клиенту.
QUEUE_CONNECTION=sync
2. database — таблица в БД (просто, надёжно)
Задания сохраняются в таблицу jobs в вашей БД (MySQL/PostgreSQL). Работник (php artisan queue:work) периодически забирает новые задания.
- Плюсы: не требует внешних сервисов, легко настраивается.
- Минусы: медленнее in-memory решений, создаёт нагрузку на БД.
QUEUE_CONNECTION=database
# Создать таблицу jobs (в L11+ обычно уже есть по умолчанию)
php artisan queue:table
php artisan migrate
3. redis — Redis (быстро, для продакшена)
Использует структуру данных list в Redis для хранения заданий.
- Очень быстрый, хорошо масштабируется, подходит для высоких нагрузок.
- Требует установленного и настроенного Redis-сервера.
- Совместим с Laravel Horizon — дашборд + мониторинг очередей.
QUEUE_CONNECTION=redis
4. sqs — Amazon SQS (облачный)
Использует очередь AWS Simple Queue Service. Подходит для приложений, работающих в облаке AWS — высокая надёжность, автомасштабирование.
Настройка требует ключей доступа и региона (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, SQS_QUEUE, AWS_DEFAULT_REGION).
5. beanstalkd — легковесный внешний сервис
Использует протокол Beanstalkd — очень простой и быстрый. Требует установки и запуска beanstalkd-сервера.
Хорош для небольших и средних проектов, где Redis избыточен, но database слишком медленный.
| Сценарий | Драйвер |
|---|---|
| Разработка / тестирование (видеть ошибки сразу) | sync |
| Проект без внешних сервисов, малая нагрузка | database |
| Проект с высокой нагрузкой, нужна скорость | redis (+ Horizon) |
| Проект в облаке AWS | sqs |
| Минимальные требования к инфраструктуре, средний трафик | beanstalkd |
- Драйвер указывается в
.env:QUEUE_CONNECTION=.... - Запуск воркера:
php artisan queue:work(илиqueue:listenдля разработки — перезапускается при изменении кода). - Для каждого драйвера настройки в
config/queue.php(соединение, имя очереди, retry, block_for и т.д.). - Можно объявить несколько соединений и обращаться по имени:
Job::dispatch()->onConnection('redis-fast').
Что это. Job — класс, который инкапсулирует работу, которую нужно выполнить асинхронно (в очереди). Он содержит всю логику в методе handle() и настраивается через свойства.
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ProcessPodcast implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $podcast;
public function __construct($podcast)
{
$this->podcast = $podcast;
}
public function handle()
{
// основная логика: обработка подкаста, конвертация, уведомление и т.д.
}
}
Что каждая часть делает:
handle()— вызывается когда задание извлекается из очереди и выполняется работником. Здесь вся бизнес-логика.ShouldQueue— интерфейс-маркер, указывающий, что задание должно быть поставлено в очередь, а не выполняться синхронно.Dispatchable— трейт, позволяет вызыватьProcessPodcast::dispatch(...).InteractsWithQueue— методы работы с очередью изнутри job (удалить, освободить, delete, release).Queueable— задать очередь, соединение, задержку черезonQueue(),onConnection(),delay().SerializesModels— автоматически превращает Eloquent-модели в ID при сериализации, при десериализации — восстанавливает черезfindOrFail.
| Свойство | Назначение | Пример |
|---|---|---|
$tries |
Максимальное количество попыток выполнения при ошибках | public $tries = 3; |
$timeout |
Максимальное время выполнения в секундах. Если превышено — задание провалено | public $timeout = 120; |
$maxExceptions |
Максимальное число исключений, после которого задание провалено. Позволяет делать несколько повторных попыток до лимита | public $maxExceptions = 3; |
$backoff |
Задержка между повторными попытками (сек). Может быть числом или массивом (экспоненциальная задержка) | public $backoff = [10, 30, 60]; |
Эти свойства задаются прямо в классе или через метод retryUntil() для динамической задержки.
class SendOrderConfirmation implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $order;
public $tries = 5;
public $timeout = 60;
public $backoff = [15, 30, 60]; // после 1-й ошибки — 15с, 2-й — 30с, 3-й — 60с
public function __construct(Order $order)
{
$this->order = $order;
}
public function handle()
{
// Отправляем подтверждение заказа
Mail::to($this->order->user->email)
->send(new OrderConfirmation($this->order));
}
}
Запуск job — в контроллере или где угодно:
SendOrderConfirmation::dispatch($order);
- Если задание выбрасывает исключение, количество попыток уменьшается. После исчерпания
$tries— задание помещается в таблицуfailed_jobs. $timeoutдолжен быть меньше, чем таймаут воркераqueue:work(по умолчанию 60 секунд). Иначе воркер может убить задание раньше срабатывания собственного timeout.- Для динамического таймаута — метод
retryUntil(), возвращающийCarbon-объект «до этого момента ретраить». $backoffпринимает массив — каждый элемент это задержка перед N+1 попыткой. Если указано одно число — задержка одинаковая для всех попыток.
- Надёжность — автоматические повторные попытки при временных сбоях (сеть, внешний API).
- Контроль времени — не даём заданию зависнуть навсегда.
- Гибкость — разные задачи могут иметь разные стратегии повторных попыток.
handle()), а свойства ($tries, $timeout, $backoff) управляют поведением при ошибках, делая систему устойчивой и предсказуемой.
Job-классы с интерфейсом ShouldQueue — это асинхронные задачи, которые выполняются параллельно, не блокируя основной процесс (поток выполнения, который обрабатывает запрос пользователя).
Полный поток выполнения:
- Основной процесс (контроллер, команда) создаёт задачу и вызывает
dispatch(). - Задача немедленно помещается в очередь (в драйвер: БД, Redis, SQS и т.д.).
- Основной процесс продолжает выполнение — отправляет ответ пользователю, не дожидаясь завершения задачи.
- Отдельный воркер (
php artisan queue:work) в фоновом режиме забирает задачи из очереди и выполняет их одновременно с другими запросами и другими воркерами.
Таким образом, пока пользователь получает мгновенный ответ, тяжёлая обработка (отправка писем, генерация PDF, импорт данных) происходит за кадром, не замедляя работу приложения.
Про параллельность:
- Один воркер обрабатывает только одну задачу за раз.
- Чтобы обрабатывать задачи параллельно, нужно запустить несколько воркеров (или использовать
supervisorдля управления процессами). - Воркеры могут быть запущены на разных серверах — это распределённая обработка.
Пример:
// В контроллере
public function store(Request $request)
{
$order = Order::create($request->validated());
// Отправляем задачу в очередь — займёт миллисекунды
SendOrderConfirmation::dispatch($order);
// Ответ пользователю возвращается сразу, без ожидания письма
return response()->json(['message' => 'Order placed, confirmation will be sent'], 201);
}
Пользователь получает ответ сразу, а письмо отправляется параллельно в фоне.
- Не все Job-классы асинхронны. Если не добавить
implements ShouldQueue, задача выполняется синхронно прямо в моментdispatch(). - Параллельность ограничена количеством запущенных воркеров и настройками очереди (например,
php artisan queue:work --queue=high,default). - Даже с одним воркером задачи выполняются последовательно, но всё равно асинхронно — не блокируют основной процесс. Параллельность — это уже вопрос масштабирования.
ShouldQueue — способ выполнять тяжёлые операции фоново, не блокируя пользовательские запросы. Обрабатываются параллельно при наличии нескольких воркеров и значительно повышают отзывчивость приложения. Формула: больше воркеров → выше пропускная способность обработки очереди.
Chains и Batches — это способы организовать выполнение нескольких задач (jobs) скоординированно. Отличаются порядком, поведением при ошибках и возможностью отслеживать прогресс.
Chain (Цепочка) — последовательное выполнение
Задачи выполняются одна за другой, строго по порядку. Если какая-то задача в цепочке завершается с ошибкой (исключение), выполнение всей цепочки прерывается — остальные задачи не запускаются.
use Illuminate\Support\Facades\Bus;
Bus::chain([
new DownloadFileJob($fileId),
new ProcessFileJob($fileId),
new NotifyUserJob($userId),
])->dispatch();
Применение: когда следующая задача зависит от результата предыдущей (скачать → обработать → уведомить). В таком случае нельзя запускать их одновременно.
Batch (Пакет) — параллельное выполнение
Задачи выполняются параллельно (одновременно), независимо друг от друга. Можно добавить финальные колбэки:
then— вызывается, когда все задачи успешно завершены.catch— вызывается, если хотя бы одна задача упала (исключение).finally— вызывается в любом случае, после выполнения всех задач.
use Illuminate\Support\Facades\Bus;
use Illuminate\Bus\Batch;
use Throwable;
$batch = Bus::batch([
new SendEmailToUser($user1),
new SendEmailToUser($user2),
new SendEmailToUser($user3),
])->then(function (Batch $batch) {
// все успешно
})->catch(function (Batch $batch, Throwable $e) {
// что-то упало
})->finally(function (Batch $batch) {
// всегда
})->dispatch();
Применение: когда задачи независимы и их можно выполнять одновременно для ускорения (отправка нескольких писем, обработка независимых файлов, параллельные API-запросы).
- Для Batches нужна таблица
job_batchesдля хранения состояния пакетов. Создаётся командой:php artisan queue:batches-table php artisan migrate - В цепочках нельзя получить доступ к состоянию промежуточных задач (только через передачу данных, например, через общий объект или кеш).
- В батчах можно отслеживать прогресс через
$batch->progress()(0–100), или отменить через$batch->cancel(). - Цепочки не требуют дополнительной таблицы и поддерживаются даже в драйвере
sync.
| Характеристика | Chain | Batch |
|---|---|---|
| Порядок выполнения | Строго последовательный | Параллельный |
| При ошибке | Прерывается вся цепочка | Продолжается выполнение остальных задач |
| Финальные колбэки | Нет (можно добавить свою задачу в конце) | then, catch, finally |
| Отслеживание прогресса | Нет | Есть — $batch->progress() |
| Отмена | Нет | $batch->cancel() |
| Дополнительная таблица | Не нужна | Нужна (job_batches) |
Работает с драйвером sync |
Да | Только с БД-совместимыми (нужен доступ к job_batches) |
- Chain — для последовательных задач, где важен порядок и зависимость (скачать → обработать → уведомить).
- Batch — для параллельных независимых задач, с возможностью реагировать на успех/ошибку всех или одной из них (массовая рассылка писем, параллельная обработка файлов).
В Laravel можно ограничивать частоту выполнения заданий очереди (jobs), чтобы не перегружать внешние сервисы (API, БД, сторонние сервисы) слишком большим количеством запросов.
Если приложение отправляет тысячи писем через внешний SMTP, вызывает API (Telegram, Google Maps, платёжки) или выполняет действия с ограничениями по частоте — легко превысить лимиты, получить 429 Too Many Requests или быть заблокированным. Очереди позволяют замедлить выполнение таких задач, соблюдая квоты.
1. Middleware RateLimited (рекомендуемый способ)
Применяем специальный middleware к job-классу — он проверяет лимит и при необходимости откладывает выполнение.
use Illuminate\Queue\Middleware\RateLimited;
class ProcessExternalApiJob implements ShouldQueue
{
public function middleware(): array
{
return [
new RateLimited('api-calls'), // идентификатор лимита
];
}
public function handle()
{
// Вызов внешнего API
}
}
Лимит 'api-calls' определяется в App\Providers\AppServiceProvider (в boot()) через фасад RateLimiter:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('api-calls', function ($job) {
return Limit::perMinute(100); // 100 вызовов в минуту
});
Когда лимит превышен, Laravel автоматически release()-ит задание с задержкой (по умолчанию — до следующего окна). Задание повторно попытается выполниться позже, когда лимит будет доступен.
middleware() возвращает new RateLimited('api-calls'), а не сам лимитКлючевой момент, который вызывает путаницу: middleware() — это не обычный метод для обработки запроса, а метод для регистрации промежуточного слоя, который будет применён к job перед выполнением.
Как это работает на самом деле. В классе Job вы определяете метод middleware(), который возвращает массив объектов middleware. Этот метод вызывается при сериализации/десериализации Job для определения, какие middleware применить.
public function middleware(): array
{
return [
new RateLimited('api-calls'),
];
}
Здесь 'api-calls' — это идентификатор (имя) лимита, который связывает job с соответствующей конфигурацией в RateLimiter.
Где задаётся сам лимит. В App\Providers\AppServiceProvider (или другом сервис-провайдере) определяете правила для этого идентификатора через RateLimiter::for():
RateLimiter::for('api-calls', function ($job) {
return Limit::perMinute(100);
});
Лимит настраивается отдельно от job и может быть легко изменён без правки самого Job-класса.
Что делает Laravel при выполнении Job:
- Берёт задание из очереди.
- Проверяет, есть ли зарегистрированные middleware через метод
middleware(). - Если есть, оборачивает выполнение задания в стек этих middleware.
- Middleware
RateLimitedпроверяет черезRateLimiter, превышен ли лимит для'api-calls'. Если да — вызывает$this->release($delay): задание освобождается обратно в очередь с задержкой (по умолчанию — до восстановления лимита черезavailableIn()), воркер переходит к следующему заданию.
Почему так сделано:
- Разделение ответственности: Job содержит бизнес-логику, а ограничения частоты вынесены в отдельную настройку.
- Гибкость: лимиты (100 → 200 запросов в минуту) меняются без пересборки Job-классов — правится только провайдер.
- Переиспользование: один
RateLimitedmiddleware применяется к разным Job-классам с разными идентификаторами, и для каждого задан свой лимит.
middleware() не вызывается в момент выполнения бизнес-логики. Он вызывается при сериализации/десериализации Job для определения списка применяемых middleware. Поэтому он должен быть лёгким и не содержать логики, зависящей от текущего состояния приложения (запросов к БД, чтения кеша и т.д.).
Полный цикл в одном примере:
// app/Jobs/ProcessExternalApiJob.php
class ProcessExternalApiJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function middleware(): array
{
return [new RateLimited('api-calls')];
}
public function handle()
{
// внешний API запрос
}
}
// App\Providers\AppServiceProvider::boot()
RateLimiter::for('api-calls', function ($job) {
return Limit::perMinute(100)->by($job->user?->id ?? 'global');
});
Порядок работы: dispatch() → job в очереди → воркер берёт → middleware RateLimited проверяет лимит для 'api-calls' (с учётом by(...)) → если превышен: release() с задержкой (повтор позже); если не превышен: выполняется handle().
middleware() возвращает объекты middleware для последующей обработки задания. Сам лимит настраивается вне Job — через RateLimiter::for(). RateLimited — это инструмент, который проверяет лимит и при необходимости откладывает выполнение (не возвращает HTTP-ответ, как в web-middleware). Такой подход централизованно управляет нагрузкой на внешние ресурсы, сохраняя код чистым и гибким.
2. Через RateLimiter вручную внутри handle()
Более гранулярный контроль — сами проверяем лимит и вызываем release().
public function handle()
{
$key = 'api-calls:' . $this->id;
$limiter = app(RateLimiter::class);
if ($limiter->tooManyAttempts($key, 100)) {
$this->release($limiter->availableIn($key));
return;
}
// выполняем запрос
$limiter->hit($key, 60); // учитываем попытку (60 сек окно)
// ... сам вызов API
}
Limit::perMinute(),perHour(),perSecond()— задают временные окна.->by(...)— ключ для дифференциации лимита (по ID пользователя, IP, внешнему ключу).->releaseAfter(N)— время задержки при освобождении задания.->response()— кастомный ответ при превышении (для HTTP throttle, не для Job).
Пример с разными лимитами для разных пользователей:
RateLimiter::for('api-calls', function ($job) {
return $job->user
? Limit::perMinute(100)->by($job->user->id)
: Limit::perMinute(10)->by($job->ip);
});
- Отправка email через внешние SMTP (SendGrid, Mailgun) — у них часто жёсткие лимиты.
- API соцсетей (Twitter/X, Facebook, Instagram) — строгие ограничения по OAuth-токену.
- Платёжные системы (Stripe, PayPal) — избегаем ошибок из-за rate limit.
- Парсинг сайтов — чтобы не заблокировали за слишком частые запросы.
- Пуш-уведомления (FCM, APNs) — Google и Apple тоже ограничивают.
RateLimited (рекомендуемый способ) или вручную с фасадом RateLimiter. При превышении job освобождается с задержкой и повторяется позже — очередь сама регулирует нагрузку. Обязательный инструмент для надёжных интеграций с внешними API.
Иногда одно и то же задание может быть поставлено в очередь несколько раз — из-за повторных кликов пользователя, повторных вызовов API или ошибок в коде. Это приводит к двойной обработке одного заказа, повторной отправке письма или другим проблемам.
Unique Jobs гарантируют, что в очереди одновременно находится не более одного задания с заданным уникальным идентификатором. Если попытаться поставить такое же задание повторно — оно будет проигнорировано.
Добавьте интерфейс ShouldBeUnique к Job-классу и определите метод uniqueId(), возвращающий строковый идентификатор уникальности:
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
class ProcessOrder implements ShouldQueue, ShouldBeUnique
{
public $orderId;
public function __construct($orderId)
{
$this->orderId = $orderId;
}
public function handle()
{
// обработка заказа
}
// Уникальный идентификатор — ID заказа
public function uniqueId()
{
return $this->orderId;
}
}
Теперь, если вызвать ProcessOrder::dispatch($orderId) дважды, пока первое задание ещё не обработано — второе будет проигнорировано (не попадёт в очередь).
$uniqueFor— время (в секундах), в течение которого блокировка считается активной. По умолчанию — время удержания задания (обычно 24 часа).public $uniqueFor = 3600; // блокировка на 1 часuniqueVia()— метод, определяющий драйвер для хранения блокировок (по умолчанию — кеш):public function uniqueVia() { return Cache::driver('redis'); }
При попытке поставить задание в очередь Laravel:
- Вычисляет уникальный идентификатор через
uniqueId(). - Пытается создать блокировку в хранилище (по умолчанию — кеш) с этим идентификатором.
- Если блокировка успешно создана — задание ставится в очередь.
- Если блокировка уже существует — задание не ставится, при
dispatch()возвращаетсяnull/false. - По завершению задания (или его неудаче) блокировка автоматически снимается.
- Обработка заказов — не обработать один заказ дважды.
- Отправка email — не отправить письмо повторно.
- Синхронизация с внешним API — не слать дублирующиеся запросы.
- Импорт данных — не запускать импорт повторно, пока идёт предыдущий.
- Если задание выполняется и падает с ошибкой, оно может быть повторно поставлено в очередь (в зависимости от
$triesи$backoff). Уникальность действует на протяжении всего времени, пока задание в очереди или выполняется. - Для работы Unique Jobs нужен драйвер кеша, поддерживающий блокировки:
redis,memcached,database. Драйверыfileиarrayне подходят. - Если задание по какой-то причине не было удалено из очереди (воркер упал), блокировка может остаться — в этом случае поможет
$uniqueFor.
class UpdateUserCache implements ShouldQueue, ShouldBeUnique
{
public $userId;
public $uniqueFor = 600; // блокировка на 10 минут
public function __construct($userId)
{
$this->userId = $userId;
}
public function uniqueId()
{
return 'user-cache:' . $this->userId;
}
public function handle()
{
Cache::put('user_' . $this->userId, User::find($this->userId));
}
}
Если за 10 минут поступит два вызова UpdateUserCache::dispatch($userId) для одного пользователя, второй будет проигнорирован — первое задание ещё в очереди или выполняется.
ShouldBeUnique— интерфейс для предотвращения дублирования заданий.uniqueId()— метод, возвращающий строку, по которой определяется уникальность.- Блокировка работает через кеш и автоматически снимается после выполнения или сбоя.
- Защищает от двойной постановки, дублирующей обработки и избыточной нагрузки.
Драйверы
sync — исполнение в текущем процессе (тесты). database — таблица jobs в БД (просто, медленно). redis — Redis-list, быстрый. sqs, beanstalkd — внешние. Подробнее — в подразделе Драйверы выше.
Job-классы
Класс с handle() методом. Имплементирует ShouldQueue. Свойства: $tries, $timeout, $maxExceptions, $backoff.
Retry и backoff
При исключении job ставится обратно в очередь до $tries раз. $backoff = [10, 30, 60] — задержки между попытками. После исчерпания — таблица failed_jobs.
Chains, Batches
Chain — последовательное исполнение нескольких jobs (если один упал — остальные не идут). Batch — параллельное с финальным callback, когда все завершились (или хотя бы один упал).
Rate limiting
RateLimited::for('api-calls') middleware на job. Откладывает исполнение, если лимит превышен. Защищает от перегрузки внешних API.
Unique jobs
ShouldBeUnique — гарантия, что в очереди не более одного job этого типа с тем же $uniqueId. Защита от двойной постановки.
final class SendInvoice implements ShouldQueue
{
use Queueable;
public int $tries = 5;
public int $timeout = 120;
public array $backoff = [10, 30, 60, 300];
public function __construct(public int $orderId) {}
public function handle(InvoiceMailer $mailer): void
{
$order = Order::findOrFail($this->orderId);
// Идемпотентность: если invoice уже отправлен — выходим
if ($order->invoice_sent_at) return;
$mailer->send($order);
$order->update(['invoice_sent_at' => now()]);
}
public function failed(Throwable $e): void
{
// Финальный обработчик после исчерпания retries
Log::error('invoice.failed', ['order_id' => $this->orderId, 'error' => $e->getMessage()]);
}
}
SerializesModels — в БД хранится ['id' => 42], а в handle снова загружается через findOrFail. Если строка удалена между постановкой и исполнением — ModelNotFoundException.DB::transaction(fn () => { ... ; SendInvoice::dispatch($order); }) — job отправлен до commit'а; воркер пытается найти order, которого ещё нет в БД. Используйте dispatch()->afterCommit() или $afterCommit = true в job.$timeout сек, job остаётся в БД как «в обработке». В таком случае увеличьте timeout или разбейте на несколько jobs.$tries = 0. Означает «бесконечные попытки». Если job всегда падает, воркер крутится в цикле. Лучше явное число.maxExceptions vs tries. tries — общее число запусков (включая успешные). maxExceptions — только неуспешных. Для jobs, которые часто timeout-ят без exception, нужно следить за обоими.failed_jobs копится молча. Без алерта или дашборда (Horizon) проблемы обнаруживаются через жалобы пользователей.--max-time. Долгоживущий воркер копит память (особенно в Laravel без Octane). Перезапуск через --max-time=3600 освобождает память.События — механизм слабого связывания: код «эмитит» событие, не зная, кто на него подпишется. Несколько listeners могут реагировать независимо. Это основной способ организации side-effects (отправка письма после регистрации, audit-лог, обновление аналитики) без раздувания основного кода.
Прежде чем плодить OrderCreated, SendOrderConfirmation и т.д. — спроси себя: а нужно ли тут вообще событие? Для простого CRUD (сохранил заказ → показал в списке) Events — оверинжиниринг. Они нужны, только когда есть побочные эффекты, которые надо отделить от основного действия.
Есть таблица orders, ты создаёшь запись через Order::create() и в панели выводишь список через Order::all() с пагинацией — никакие события не требуются. Это базовый CRUD, вся логика в контроллере + модели.
// Контроллер — тонкий, никаких event()
public function store(StoreOrderRequest $request)
{
$order = Order::create($request->validated());
return redirect()->route('orders.index');
}
// В blade — простой вывод
@foreach ($orders as $order)
<tr><td>{{ $order->id }}</td><td>{{ $order->total }}</td></tr>
@endforeach
Заказ уже в БД — панель его видит через SELECT. Никакого event(new OrderShown(...)) для «отображения истории платежей» — данные и так есть.
Если при создании заказа надо дополнительно:
- Записать событие в отдельную audit-таблицу (
order_logs) - Отправить уведомление администратору (email/SMS/Slack)
- Обновить агрегированные данные (статистика продаж, счётчик заказов дня)
- Вызвать внешнее API (регистрация платежа в CRM, синхронизация с 1С)
- Выполнить тяжёлые операции, которые не должны тормозить ответ пользователю
В этих случаях Event + Listener идеален: событие эмитится один раз, а слушатели реагируют независимо — синхронно или асинхронно через очередь.
// Событие — DTO с данными
class OrderCreated
{
public function __construct(public Order $order) {}
}
// Listener 1 — синхронно, audit-лог
class LogOrderCreation
{
public function handle(OrderCreated $event): void
{
OrderLog::create([
'order_id' => $event->order->id,
'action' => 'created',
]);
}
}
// Listener 2 — асинхронно, тяжёлая почта
class SendOrderConfirmation implements ShouldQueue
{
public function handle(OrderCreated $event): void
{
Mail::to($event->order->email)->send(new OrderPlacedMail($event->order));
}
}
// В контроллере/Action — одна строка
event(new OrderCreated($order));
Ключевое правило:
| Действие | Куда |
|---|---|
| Часть транзакции, влияет на целостность (резервирование товара, списание средств, изменение основного статуса) | → Action / Service, внутри DB::transaction |
| Побочный эффект, не влияет на основной процесс (логи, уведомления, интеграции, аналитика) | → Event + Listener, желательно ->afterCommit() |
Пример: «резервировать товар на складе» — часть транзакции заказа (если не удалось — заказ не создать), значит в Action. «Отправить письмо о заказе» — побочка (если письмо не ушло — заказ всё равно создан), значит в Event.
| Ситуация | Нужен ли Event? |
|---|---|
| Просто создать заказ и показать в списке (CRUD) | Нет |
| Дополнительно записать в лог / отправить уведомление / синхронизировать | Да |
| Тяжёлые операции, которые могут тормозить ответ (почта, внешние API) | Да, с ShouldQueue |
| Действие — часть транзакции, влияет на целостность | Нет, вынести в Action/Service |
UserViewedProfile, OrderListRendered, PageOpened «чтобы потом что-нибудь на них подписать» — путь к неотлаживаемому коду. Плодить события следует, когда уже есть конкретный слушатель, которому это событие нужно.ShouldQueue, либо (в) явно хочешь развязать слои.Action, побочные эффекты — в Event + Listener. «Отображение в панели» — просто чтение через Eloquent, никаких событий не надо. Событие уместно только когда есть реальный побочный эффект.
Event class
DTO с публичными свойствами. Имплементирует ничего особого (или ShouldBroadcast, если транслируется). Создание: php artisan make:event OrderPaid.
Listener class
Класс с handle(Event $event). Может имплементировать ShouldQueue — тогда исполнение асинхронно через очередь. Регистрация — в EventServiceProvider::$listen или через Event::listen(...).
Auto-discovery
С Laravel 8+ Listeners автоматически обнаруживаются по типу аргумента handle. Включается через $shouldDiscoverEvents. Удобно, но усложняет grep — найти всех слушателей события через явный массив проще.
Subscriber
Класс, объявляющий несколько листенеров через subscribe(Dispatcher $events). Полезно для группировки логически связанных слушателей.
Eloquent events
created, updated, deleted, retrieved — события моделей. Подписка через observers или static::created(fn ($model) => ...) в boot модели.
Что это. Event — это класс-контейнер (DTO), который инкапсулирует информацию о произошедшем действии («заказ оплачен», «пользователь зарегистрирован»). Он не содержит бизнес-логики — только передаёт данные слушателям (Listeners).
- Публичные свойства — данные, доступные слушателям (модель заказа, ID пользователя, доп. параметры).
- Конструктор — принимает данные и инициализирует свойства.
- Интерфейсы — обычно ничего не имплементирует; если событие транслируется через WebSockets — реализует
ShouldBroadcast.
Создание через artisan:
php artisan make:event OrderPaid
Создаётся класс в app/Events/OrderPaid.php:
namespace App\Events;
use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class OrderPaid
{
use Dispatchable, SerializesModels;
public Order $order;
public function __construct(Order $order)
{
$this->order = $order;
}
}
Разбор трейтов:
Dispatchable— даёт методdispatch()для вызова события (OrderPaid::dispatch($order)вместоevent(new OrderPaid($order))).SerializesModels— позволяет корректно сериализовать модели Eloquent при передаче в очередь (если listener асинхронный). Сохраняется толькоid, при выполнении происходитfindOrFail.
// 2 равнозначных способа
event(new OrderPaid($order));
OrderPaid::dispatch($order);
После диспатча все зарегистрированные listeners получают экземпляр события и выполняют свои действия (синхронно или через очередь если реализуют ShouldQueue).
- Разделение ответственности — код, который инициирует событие, не знает о том, что с ним будет сделано.
- Гибкость — можно добавлять новые listeners без изменения исходного кода (например, добавить отправку SMS при оплате, не трогая контроллер).
- Асинхронность — listeners могут реализовать
ShouldQueueи выполняться в очереди, не замедляя основной запрос.
Если событие реализует ShouldBroadcast — оно автоматически транслируется через WebSockets (Pusher, Laravel Reverb, Ably). Тогда фронтенд (через Laravel Echo) может получать уведомления о событиях без перезагрузки страницы.
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Broadcasting\Channel;
class OrderPaid implements ShouldBroadcast
{
use Dispatchable, SerializesModels;
public Order $order;
public function __construct(Order $order)
{
$this->order = $order;
}
// На какой канал транслировать
public function broadcastOn(): Channel
{
return new Channel('orders');
}
// (опционально) — данные для WebSocket-подписчиков
public function broadcastWith(): array
{
return ['id' => $this->order->id, 'total' => $this->order->total];
}
}
Типы каналов:
Channel('orders')— публичный, слушать может кто угодно.PrivateChannel('user.'.$userId)— приватный, требует авторизации.PresenceChannel('room.42')— приватный + отслеживает кто онлайн.
ShouldBroadcast ставит трансляцию в очередь (это правильно — не блокирует основной поток). ShouldBroadcastNow — синхронная трансляция, только когда критично.
Важно понимать: broadcastOn() — не метод, который ты вызываешь напрямую. Это метод-описание. Он нужен, чтобы сообщить Laravel по какому каналу транслировать событие. Ты вызываешь OrderPaid::dispatch($order), а фреймворк сам «спрашивает» у события — какой канал вернёт broadcastOn().
Полный процесс по шагам:
1. Создаёшь событие с ShouldBroadcast
Класс реализует интерфейс — маркер для Laravel, что это событие нужно не только обработать внутри, но и отправить на фронтенд.
2. Определяешь канал в broadcastOn()
Канал — как «комната» или «тема», на которую можно подписаться. Метод возвращает Channel / PrivateChannel / PresenceChannel.
public function broadcastOn(): array
{
return [
new PrivateChannel('orders.' . $this->order->id),
];
}
3. Инициируешь событие в коде
В контроллере / Action / где угодно:
OrderPaid::dispatch($order);
// или event(new OrderPaid($order));
В этот момент Laravel понимает: событие нужно транслировать через WebSocket.
4. Laravel кладёт трансляцию в очередь
Трансляция происходит асинхронно, чтобы не тормозить основной ответ сервера. Событие попадает в отдельный job на трансляцию.
5. Воркер транслирует событие
Воркер очереди (queue:work) забирает job и отправляет данные события (все public свойства) в твой WebSocket-сервис — Pusher, Ably или Laravel Reverb.
6. Фронтенд получает событие через Laravel Echo
Laravel Echo — клиентская JS-библиотека для WebSockets. Настроена на фронтенде, постоянно слушает указанные каналы. Получив событие — выполняет твой JS-колбэк:
// resources/js/app.js
Echo.private(`orders.${orderId}`)
.listen('OrderPaid', (e) => {
console.log(e.order); // данные события
// Обновляем UI: показать уведомление, изменить статус, ...
});
- Сервер: ты создаёшь и диспатчишь событие (
OrderPaid::dispatch($order)). - Laravel: использует
broadcastOn()твоего события, чтобы узнать куда отправлять. - Очередь / Воркер: отправляет данные события в WebSocket-сервис.
- Фронтенд: Laravel Echo, подписанный на этот канал, получает событие и выполняет колбэк.
broadcastOn() — не прямой вызов, а инструкция для фреймворка, часть автоматического механизма доставки событий на клиент.
- Event — DTO, описывающий факт в прошедшем времени.
- Создаётся через
php artisan make:event. - Диспатчится через
event()или::dispatch(). - Трейты
Dispatchable+SerializesModels— стандарт. - Listeners реагируют — синхронно или асинхронно.
- Через
ShouldBroadcastтранслируется в real-time (Pusher/Reverb + Echo). - Класс события не содержит логики — только данные.
Что это. Listener — класс, который содержит логику, реагирующую на определённое событие. Получает экземпляр события в методе handle() и выполняет нужные действия (отправить письмо, обновить кеш, залогировать).
namespace App\Listeners;
use App\Events\OrderPaid;
class SendOrderConfirmation
{
public function handle(OrderPaid $event)
{
// отправка письма
Mail::to($event->order->user->email)
->send(new OrderConfirmation($event->order));
}
}
- Метод
handleвызывается автоматически, когда событие диспатчится. - В параметре указывается конкретный класс события, на которое подписан слушатель.
- Внутри доступны все публичные свойства события (
$event->order). - Создание через artisan:
php artisan make:listener SendOrderConfirmation --event=OrderPaid
Связь между событием и слушателем регистрируется в App\Providers\EventServiceProvider, в свойстве $listen:
protected $listen = [
OrderPaid::class => [
SendOrderConfirmation::class,
UpdateInventory::class,
LogPayment::class,
],
];
Одно событие может иметь несколько слушателей — они выполняются в порядке перечисления. Альтернативно через фасад Event в boot() провайдера:
Event::listen(OrderPaid::class, SendOrderConfirmation::class);
Подробнее про все 3 способа регистрации — в подсекции Регистрация: $listen формат.
Если слушатель должен выполняться асинхронно, не блокируя основной поток — он реализует интерфейс ShouldQueue:
use Illuminate\Contracts\Queue\ShouldQueue;
class SendOrderConfirmation implements ShouldQueue
{
public function handle(OrderPaid $event)
{
// ...
}
}
После реализации интерфейса слушатель автоматически помещается в очередь при диспатче события. Воркер очереди выполняет его независимо от основного запроса.
Можно настраивать очередь, задержку, попытки через свойства класса:
public string $queue = 'emails';
public int $delay = 60;
public int $tries = 3;
Полный список свойств ($connection, $queue, $delay, $tries, $maxExceptions, $timeout, $backoff, $deleteWhenMissingModels) и метод failed() — в подсекции Queue-config для Listeners.
- Событие диспатчится через
event()или::dispatch(). EventServiceProviderопределяет список слушателей для этого события.- Для каждого слушателя:
- Если имплементирует
ShouldQueue— ставится в очередь. - Если нет — вызывается синхронно, прямо в текущем процессе.
- Если имплементирует
- Асинхронные слушатели выполняются воркером позже.
- При очереди данные события должны быть сериализуемы. Модели Eloquent сериализуются через ID благодаря трейту
SerializesModels. - Если слушатель выбрасывает исключение — оно обрабатывается по настройкам очереди (retry,
failed(), таблицаfailed_jobs). - Можно отменить дальнейшую обработку слушателей возвратом
falseизhandle(). В современных версиях не рекомендуется — лучше бросать исключение или использовать conditional-логику.
Событие:
class OrderPaid
{
use Dispatchable, SerializesModels;
public Order $order;
public function __construct(Order $order)
{
$this->order = $order;
}
}
Слушатель:
class SendOrderConfirmation implements ShouldQueue
{
public function handle(OrderPaid $event)
{
/* отправка письма */
}
}
Регистрация:
// EventServiceProvider
protected $listen = [
OrderPaid::class => [SendOrderConfirmation::class],
];
Диспатч:
OrderPaid::dispatch($order);
Теперь при оплате заказа слушатель ставится в очередь и отправляет письмо в фоне, не задерживая ответ пользователю.
- Класс с методом
handle(EventClass $event)— вызывается автоматически. - Регистрируется в
EventServiceProvider::$listenили черезEvent::listen(). - Один event → 0..N listeners, порядок выполнения = порядок в массиве.
ShouldQueue→ асинхронно через очередь. Свойства$queue/$delay/$triesнастраивают поведение.- Синхронный без
ShouldQueue— выполняется в том же процессе что иdispatch(). - Данные event должны быть сериализуемы для очереди — модели через
SerializesModels.
Что это. Subscriber (подписчик) — класс, который позволяет объединить несколько слушателей (методов-обработчиков) для различных событий в одном месте. В отличие от обычного класса-слушателя (одно событие = один класс), подписчик содержит несколько методов, каждый из которых реагирует на своё событие.
Класс должен содержать метод subscribe(), получающий экземпляр Dispatcher. Внутри регистрируешь слушателей через $events->listen() для каждого события.
namespace App\Listeners;
use Illuminate\Events\Dispatcher;
class UserEventSubscriber
{
// Обработчик события регистрации
public function handleUserRegistered($event)
{
// логика
}
// Обработчик события обновления профиля
public function handleUserUpdated($event)
{
// логика
}
// Метод регистрации listeners
public function subscribe(Dispatcher $events): void
{
$events->listen(
'App\Events\UserRegistered',
[UserEventSubscriber::class, 'handleUserRegistered']
);
$events->listen(
'App\Events\UserUpdated',
[UserEventSubscriber::class, 'handleUserUpdated']
);
}
}
Вместо строковых имён можно использовать Event::class — синтаксис [класс, метод] остаётся.
2 способа — как и с обычными listeners:
// 1. Через свойство $subscribe в EventServiceProvider
protected $subscribe = [
UserEventSubscriber::class,
];
// 2. Программно в boot()
Event::subscribe(UserEventSubscriber::class);
После регистрации диспетчер вызывает subscribe() у подписчика — тот сам регистрирует все связи.
- Группировка по смыслу — все обработчики одной сущности (например, пользователя) в одном классе.
- Снижение дублирования — если несколько listeners используют общие зависимости, их можно внедрить через конструктор подписчика.
- Упрощение регистрации — не перечислять каждую пару событие → слушатель в
$listen; всё объявлено внутри подписчика. - Удобство для большой логики — при большом числе listeners подписчик уменьшает количество файлов.
| Характеристика | Обычный Listener | Subscriber |
|---|---|---|
| Количество событий | Одно | Множество |
| Метод обработки | handle(Event $event) |
Любой пользовательский метод |
| Регистрация | В $listen или Event::listen() |
В $subscribe или Event::subscribe() |
| Структура | Простая, один класс = одно событие | Группирует связанные обработчики |
| Поддержка очереди | Да (ShouldQueue) |
Да, но с оговоркой (см. ниже) |
ShouldQueue на уровне подписчика, но это повлияет на все методы. Более гибко — использовать InteractsWithQueue и устанавливать очередь для отдельных методов через свойства. На практике подписчики чаще используют синхронно, а для асинхронных задач — отдельные listeners с ShouldQueue.
События:
namespace App\Events;
class UserRegistered { public $user; /* ... */ }
class UserUpdated { public $user; /* ... */ }
Подписчик:
namespace App\Listeners;
use Illuminate\Events\Dispatcher;
use App\Events\{UserRegistered, UserUpdated};
class UserEventSubscriber
{
public function sendWelcomeEmail($event) { /* ... */ }
public function clearCache($event) { /* ... */ }
public function subscribe(Dispatcher $events): void
{
$events->listen(UserRegistered::class, [self::class, 'sendWelcomeEmail']);
$events->listen(UserUpdated::class, [self::class, 'clearCache']);
}
}
Регистрация в EventServiceProvider:
protected $subscribe = [
UserEventSubscriber::class,
];
Диспатч:
event(new UserRegistered($user));
При диспатче сработает метод sendWelcomeEmail подписчика.
- Используй подписчиков, когда есть несколько событий, связанных с одной моделью или модулем.
- Для простых проектов достаточно обычных listeners — проще для поиска.
- Для критически важных событий, требующих асинхронности — отдельные listeners с
ShouldQueue, явно регистрируемые в$listen. - Подписчики удобны для аудита, логирования, очистки кеша — действий, выполняемых синхронно при разных событиях.
subscribe().
$listen форматЧтобы Laravel знал, какой listener подписан на какое событие, есть 3 способа:
1. Массив $listen в EventServiceProvider (классика L10 и ниже)
В app/Providers/EventServiceProvider.php — свойство $listen: ключ = класс события, значение = массив классов listener'ов.
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
// Один event → несколько listeners
OrderPaid::class => [
SendOrderConfirmation::class,
UpdateSalesStats::class,
NotifyWarehouse::class,
],
// Одно событие → один listener
UserRegistered::class => [
SendWelcomeEmail::class,
],
// Ключ можно как строку — для стандартных Laravel-событий
'Illuminate\Auth\Events\Login' => [
LogSuccessfulLogin::class,
],
];
}
Каждый listener получит один и тот же экземпляр события. Порядок выполнения — как в массиве (сверху вниз), но не гарантируется, если listeners асинхронные (ShouldQueue).
2. Event::listen() в boot() провайдера
Программная регистрация — полезно если listener определяется в runtime:
use Illuminate\Support\Facades\Event;
public function boot(): void
{
// Класс + метод
Event::listen(
OrderPaid::class,
[SendOrderConfirmation::class, 'handle']
);
// Замыкание (inline listener)
Event::listen(OrderPaid::class, function (OrderPaid $event) {
Log::info('Order paid: ' . $event->order->id);
});
// Wildcard — слушать все события с префиксом
Event::listen('order.*', function (string $eventName, array $data) {
// $eventName = 'order.paid', 'order.cancelled' и т.п.
});
}
3. Auto-discovery (Laravel 8+)
Laravel может сам находить listeners сканируя app/Listeners — по типу аргумента handle(). Включается в EventServiceProvider:
public function shouldDiscoverEvents(): bool
{
return true;
}
Теперь SendOrderConfirmation::handle(OrderPaid $event) будет автоматически подписан на OrderPaid — регистрация в $listen не нужна.
$listen проще для больших проектов.
Кеширование: в production для скорости — php artisan event:cache. Compiles массив в один файл. Сброс — event:clear. Auto-discovery без кеша — дорого.
Механизм работает на основе сигнатуры метода handle() в классе слушателя: Laravel анализирует тип параметра и сопоставляет его с соответствующим событием.
Включение — 2 способа:
// 1. Свойство $shouldDiscoverEvents (классика, Laravel 8+)
protected $shouldDiscoverEvents = true;
// 2. Метод в boot() (иногда используется в Laravel 11)
public function boot(): void
{
Event::shouldDiscoverEvents();
}
После включения Laravel сканирует app/Listeners (по умолчанию) и автоматически подписывает каждый слушатель на событие, указанное в типе параметра handle().
Пример:
// app/Events/OrderShipped.php
class OrderShipped
{
public $order;
public function __construct($order) { $this->order = $order; }
}
// app/Listeners/SendShipmentNotification.php
// автоматически подпишется на OrderShipped — тип параметра
class SendShipmentNotification
{
public function handle(OrderShipped $event)
{
// ...
}
}
Не нужно ничего добавлять в $listen.
Плюсы:
- Меньше шаблонного кода — не дублируешь имена событий и слушателей.
- Удобно для быстрой разработки: создал слушатель — и он уже работает.
- Меньше шанс забыть зарегистрировать слушатель при рефакторинге.
Минусы:
- Усложняет поиск — grep'ом найти всех слушателей события труднее, связь неявная.
- Меньше контроля над порядком — порядок не гарантируется без свойства
$priority. - Для больших проектов сканирование папки замедляет загрузку (только при обновлении кеша).
$listen. Комбинировать можно, но вносит путаницу. Для критических мест (аудит, логирование) — явно.
Event::listen() — глубжеПрограммная регистрация выполняется в коде, обычно в методе boot() любого сервис-провайдера (чаще всего EventServiceProvider). Называется «программной», потому что явно вызываешь Event::listen() в рантайме (на этапе загрузки приложения) для каждой связи событие → слушатель.
Когда использовать программную регистрацию:
- Связь должна определяться динамически, в зависимости от условий (feature flag, env-переменная).
- Слушатель — замыкание (анонимная функция), а не отдельный класс.
- Нужен wildcard-обработчик для группы событий (общий префикс).
- Регистрация в провайдере, отличном от
EventServiceProvider(например, доменный провайдер модуля).
Все варианты вызова:
use Illuminate\Support\Facades\Event;
public function boot()
{
// 1. Класс-слушатель с указанием метода (обычно 'handle')
Event::listen(
OrderPaid::class,
[SendOrderConfirmation::class, 'handle']
);
// 2. Замыкание (inline-слушатель)
Event::listen(OrderPaid::class, function (OrderPaid $event) {
Log::info('Order paid: ' . $event->order->id);
});
// 3. Wildcard-обработчик для всех событий с префиксом 'order.'
Event::listen('order.*', function (string $eventName, array $data) {
// $eventName = 'order.paid', 'order.cancelled' и т.д.
// $data — массив данных (для событий без объекта)
});
// 4. Условная регистрация
if (config('features.audit_log')) {
Event::listen(OrderPaid::class, LogPaymentToAudit::class);
}
}
$listen массив vs Event::listen()| Характеристика | $listen массив | Event::listen() |
|---|---|---|
| Место | Свойство класса EventServiceProvider | Вызов в boot() провайдера |
| Гибкость | Статичен, не зависит от условий | Условная логика (if/else, конфиги) |
| Тип слушателя | Только классы | Классы, замыкания, wildcard-обработчики |
| Скорость при загрузке | Быстрее (кешируется через event:cache) |
Медленнее, но разница незначительна |
| Поддерживаемость | Все связи в одном месте — легко найти | Может быть разбросано по разным провайдерам |
Что значит «в runtime». Регистрация происходит во время выполнения загрузочного кода приложения, а не на уровне статической конфигурации. Но это всё равно происходит один раз при старте приложения, а не на каждый запрос. Метод boot() выполняется на старте приложения, и Event::listen() — это просто добавление слушателя в глобальный реестр. Термин «runtime» здесь означает: связь создаётся кодом, который может использовать логику, доступную только во время выполнения (значение env-переменной, конфига, результат вызова метода).
В Laravel 11+. Структура упростилась, но EventServiceProvider и метод boot() по-прежнему доступны и работают одинаково. Многие переехали в декларативные замыкания в bootstrap/app.php, но Event::listen() — по-прежнему рабочий подход. Работает одинаково во всех версиях Laravel, начиная с 5.0.
Event::listen() — гибкий способ, полезный когда:
- Не хочешь создавать отдельный класс-слушатель (замыкания).
- Нужно привязать слушателя только при определённых условиях (feature flag).
- Нужен wildcard для группы событий.
- Предпочитаешь явный код, а не статический массив.
$listen, а дополняет его.
Если listener реализует ShouldQueue — исполнение переносится в очередь и всё поведение job'а становится доступно. Свойства и методы — те же что у Job.
Свойства настройки:
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class SendOrderConfirmation implements ShouldQueue
{
use InteractsWithQueue;
// Куда положить job
public string $connection = 'redis'; // какое соединение
public string $queue = 'emails'; // какая очередь
public int $delay = 60; // задержка в секундах
// Retry-логика
public int $tries = 3;
public int $maxExceptions = 2;
public int $timeout = 120;
public array $backoff = [10, 30, 60]; // экспоненциальная задержка
// Автоудаление при отсутствии модели
public bool $deleteWhenMissingModels = true;
public function handle(OrderPaid $event): void
{
Mail::to($event->order->user)->send(new OrderConfirmationMail($event->order));
}
// Хук после исчерпания $tries
public function failed(OrderPaid $event, Throwable $e): void
{
Log::error('Order confirmation failed', [
'order_id' => $event->order->id,
'error' => $e->getMessage(),
]);
// Уведомить админа что письмо не отправилось после всех попыток
AdminAlert::dispatch('Confirmation email failed');
}
}
Разбор свойств:
| Свойство | Что делает |
|---|---|
$connection | Соединение очереди (из config/queue.php): redis / database / sqs... |
$queue | Имя очереди внутри соединения (для приоритизации: queue:work --queue=high,default) |
$delay | Задержка перед первой попыткой выполнения (секунды) |
$tries | Максимальное количество попыток (после — job попадает в failed_jobs) |
$maxExceptions | Максимум исключений (не то же самое что tries — timeout не считается исключением) |
$timeout | Таймаут выполнения одной попытки (секунды) |
$backoff | Задержка между попытками — [10, 30, 60] экспоненциально или число для константной |
$deleteWhenMissingModels | Если модель в SerializesModels не найдена (удалена) — удалить job вместо ошибки |
Динамическая конфигурация — методы вместо свойств:
public function viaConnection(): string
{
return app()->environment('production') ? 'redis' : 'sync';
}
public function viaQueue(): string
{
return $this->event->isUrgent() ? 'high' : 'default';
}
public function backoff(): array
{
return [rand(10, 30), rand(60, 120)];
}
Метод failed() — хук после исчерпания попыток. Вызывается ровно один раз. Полезно для:
- Логирования в БД (запись в audit-таблицу вместо только
failed_jobs) - Уведомления админа / Slack / Sentry
- Компенсирующего действия (откатить статус, вернуть на модерацию)
- Метрики (increment failure counter)
__construct, у Listener'а — handle(EventClass $event). Свойства ($tries, $backoff, $queue, $connection) — идентичны.
Внешне похожи: и Event, и Job можно поставить в очередь, оба выполняют бизнес-действие асинхронно. Разница — в семантике связи отправителя с получателем.
| Event | Job | |
|---|---|---|
| Отношение | 1 → N (отправитель, N слушателей) | 1 → 1 (отправитель, один обработчик) |
| Отправитель знает получателя | Нет. Просто эмитит «случилось X» | Да. Явно: «сделай Y» |
| Смысл | «Что случилось» (факт в прошедшем времени: OrderPaid, UserRegistered) |
«Что сделать» (императив: SendConfirmation, GenerateReport) |
| Кол-во обработчиков | Любое (0..N listeners) | Ровно один (handle()) |
| Добавить нового получателя | Просто зарегистрировать новый listener в $listen |
Нужно вызвать OtherJob::dispatch() в коде |
| Хорошо когда | Много независимых side-effects (письмо, аналитика, лог, уведомление) | Одно конкретное действие (отправить письмо, конвертировать видео) |
Пример на одной задаче — «пользователь оплатил заказ»:
Вариант A — через Event (правильно, когда действий несколько):
// В контроллере / Action
OrderPaid::dispatch($order);
// Отправитель не знает, что произойдёт дальше.
// В EventServiceProvider
protected $listen = [
OrderPaid::class => [
SendOrderConfirmation::class, // письмо клиенту
UpdateSalesStats::class, // аналитика
NotifyWarehouse::class, // уведомить склад
SyncToCrm::class, // CRM
],
];
// Через полгода добавили ещё SendSlackNotification —
// зарегистрировали в $listen, код Order-логики не тронули.
Вариант B — напрямую через Jobs (правильно, когда действие одно и конкретное):
// В контроллере / Action
SendOrderConfirmation::dispatch($order);
// Явно: «отправь письмо-подтверждение».
Плохой Job (замаскированный Event):
// Такой job слишком много берёт на себя
class ProcessOrderPayment implements ShouldQueue
{
public function handle()
{
$this->sendConfirmation();
$this->updateStats();
$this->notifyWarehouse();
$this->syncCrm();
}
}
// Если один шаг упал — упадёт весь job. Retry прогонит все шаги снова.
// Добавить новое действие = править этот класс. Тестировать сложнее.
Хороший подход — Event + отдельные Listeners:
// Каждое действие — независимый listener
OrderPaid::dispatch($order);
// Каждый listener retry-ится независимо.
// Один упал — остальные отработали.
// Добавить новое — просто новый listener.
- Одно конкретное действие —
Job. Пример: сгенерировать PDF по ID заказа. - Факт «что-то случилось» → много реакций —
Event. Пример: OrderPaid → 5 разных listener'ов. - Один listener сегодня, но может быть больше завтра —
Event. Гибкость без переписывания. - Тонкий контроллер хорош оба варианта — главное вынести из
__invokeконтроллера.
Правильно ли, что Job — для расписания, а Events/Listeners — реакция на событие?
В целом да, но с нюансами:
- Jobs — не только для расписания. Для расписания есть
Scheduler. Jobs — общий механизм для выноса тяжёлых операций из основного потока в очередь. - Events/Listeners — механизм реакции на факт: объявляешь событие (
OrderPlaced), один или несколько listeners реагируют. - Могут пересекаться: listener может задиспатчить Job, Job может выбросить event.
Мнемоника: Jobs = «работа», Events/Listeners = «реакция на событие».
Почему у Jobs нет «времени выполнения»?
Ни у Jobs, ни у Events нет встроенного времени. Оба выполняются в момент когда их вызывают. Но есть разница:
- Event — выбрасывается и сразу вызывает listeners (синхронно; асинхронно если
ShouldQueue). Время выполнения = время вызова. - Job — ставится в очередь и выполняется позже, когда воркер её заберёт. Точное время неизвестно — зависит от загрузки очереди, количества воркеров, задержек.
Как задать конкретное время Job'у:
// Задержка на 5 минут
ProcessOrder::dispatch($order)->delay(now()->addMinutes(5));
// Точное время
ProcessOrder::dispatch($order)->delay(now()->setTime(15, 0));
// Или через $delay свойство в самом job
public int $delay = 60; // секунд
Для регулярного запуска Job (крон-задача) — используется Scheduler, который вызывает dispatch() по расписанию.
Job вызывается в контроллере после действия?
Да. Типичный поток: контроллер выполнил основное действие (сохранил заказ) → диспатчит Job → возвращает ответ пользователю. Job выполняется в фоне.
public function store(OrderRequest $request)
{
$order = Order::create($request->validated());
// Отправляем задачу в очередь после сохранения
ProcessOrder::dispatch($order);
return response()->json([
'message' => 'Order placed, processing in background',
], 201);
}
Что происходит:
- Заказ сохраняется в БД (синхронно).
- Job
ProcessOrderставится в очередь (миллисекунды). - Ответ пользователю уходит сразу, без ожидания обработки.
- Воркер в фоне забирает job и выполняет — независимо от HTTP-запроса.
Где ещё можно вызывать:
- Из контроллера (самый частый случай)
- Из Action / Service (тонкий контроллер, вся логика включая dispatch — в Action)
- Из другого Job (chain / batch)
- Из Event Listener'а
- Из artisan-команды
- Из Scheduler'а
- Если Job не имплементирует
ShouldQueue— выполняется синхронно прямо в моментdispatch(). dispatchSync()— принудительно синхронно, даже сShouldQueue(для тестов).dispatch()->afterCommit()— job уйдёт в очередь только после commit транзакции (защита от гонки, когда воркер ищет ещё несохранённый заказ).
// app/Events/OrderPaid.php
final class OrderPaid
{
public function __construct(public Order $order) {}
}
// app/Listeners/SendOrderConfirmation.php
final class SendOrderConfirmation implements ShouldQueue
{
public string $queue = 'mail';
public int $tries = 3;
public function handle(OrderPaid $event): void
{
Mail::to($event->order->user)
->send(new OrderConfirmationMail($event->order));
}
}
// Эмит из кода
OrderPaid::dispatch($order);
ShouldQueue listener сериализуется через SerializesModels — сохраняется только id. Между dispatch и handle строка может измениться или удалиться.Event::fake() в тестах не вызывает listeners. Если тесту нужно проверить side-effect listener'а — не используйте fake, либо отдельно тестируйте listener.app/Listeners и регистрирует всё. Лишние классы (helpers, тесты) могут случайно зарегистрироваться как listeners.created, listener на OrderCreated делает то же — два лога. Выберите один уровень.Event::dispatch() в job. Job сам делает SerializesModels. Если event тоже имеет модель — двойная сериализация. Передавайте ids явно.ShouldBroadcast по умолчанию транслируется синхронно. На большом объёме событий это блокирует. Имплементируйте ShouldBroadcastNow только когда уверены; иначе — оставляйте через очередь.Laravel scheduler — встроенный планировщик задач, заменяющий ручное редактирование crontab. Одна cron-запись (* * * * * php artisan schedule:run) запускает Laravel, который сам решает, что запустить в эту минуту. Это убирает разрыв между приложением и cron-конфигурацией: расписание лежит в коде, версионируется, тестируется.
Позволяет декларативно определять периодически выполняемые задачи (отправка отчётов, очистка временных файлов, синхронизация с внешними API) без необходимости писать отдельные cron-записи для каждой задачи.
До Laravel 11 (включая 10, 9 и раньше)
Расписание определяется в классе App\Console\Kernel в методе schedule(). Класс находится в app/Console/Kernel.php.
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
protected function schedule(Schedule $schedule)
{
// Ежедневный отчёт в 8:00 утра
$schedule->command('reports:send')->dailyAt('08:00');
// Каждые 5 минут очистка кеша
$schedule->command('cache:clear')->everyFiveMinutes();
// Замыкание каждый час
$schedule->call(function () {
// некоторая логика
})->hourly();
// exec — запуск shell-команды
$schedule->exec('php artisan queue:work --stop-when-empty')->daily();
}
}
В этом же классе можно определить Artisan-команды, вызываемые из расписания или вручную.
Laravel 11 и новее
Начиная с Laravel 11, файл App\Console\Kernel больше не создаётся по умолчанию. Всё расписание планируется в файле routes/console.php. Этот файл, ранее используемый только для регистрации консольных маршрутов (Artisan-команд), теперь также содержит методы для определения расписания через фасад Schedule.
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('reports:send')->dailyAt('08:00');
Schedule::command('cache:clear')->everyFiveMinutes();
Schedule::call(function () {
// некоторая логика
})->hourly();
Структура стала более плоской и унифицированной: все маршруты (HTTP и консольные) и расписания — в директории routes.
Если обновляете проект с L10 → L11:
- Удалить класс
App\Console\Kernel(или оставить, но он не будет использоваться). - Перенести всё расписание из
schedule()вroutes/console.php. - Убедиться, что
routes/console.phpесть и в нём используется фасадSchedule.
Пример переноса:
// Было в Kernel::schedule()
$schedule->command('backup:run')->daily();
// Стало в routes/console.php
Schedule::command('backup:run')->daily();
Замыкания в расписании переносятся без изменений.
Декларация в routes/console.php
С Laravel 11+ расписание лежит в routes/console.php через Schedule::command(...)->daily(). До 11 — в App\Console\Kernel::schedule().
Периоды и фильтры
->everyMinute(), ->hourly(), ->dailyAt('14:30'), ->weekly(), ->cron('*/5 * * * *'). Фильтры: ->weekdays(), ->between('9:00', '17:00'), ->when(fn () => ...).
withoutOverlapping
->withoutOverlapping(10) — не запускать, если предыдущий ещё не завершился. TTL 10 минут — защита от случая, когда процесс упал и lock не освобождён.
onOneServer
При горизонтальном масштабировании (несколько серверов) job выполняется ровно на одном. Требует драйвер кеша, поддерживающий atomic lock (Redis, Memcached, DB).
Output handling
->sendOutputTo($file), ->emailOutputOnFailure($email). Удобно для разовых отчётов; для регулярного логирования — внешние агрегаторы.
withoutOverlapping() — предотвращение параллельного выполненияМетод withoutOverlapping() гарантирует, что одновременно выполняется только один экземпляр задачи. Если новый запуск задачи происходит, пока предыдущий ещё выполняется — новый запуск будет пропущен.
Полезно для задач, которые могут работать дольше интервала запуска — резервное копирование, обработка больших файлов, синхронизация с внешним API — чтобы избежать конфликтов, дублирования данных или перегрузки ресурсов.
Schedule::command('backup:run')->daily()->withoutOverlapping(10);
// или для замыкания
Schedule::call(function () {
// длительная операция
})->everyFiveMinutes()->withoutOverlapping(5);
$expiresAtМетод принимает один необязательный параметр — $expiresAt в минутах. Это TTL блокировки: через сколько минут она автоматически истекает. По умолчанию — 1440 минут (24 часа).
- Если передать
0или отрицательное число — блокировка никогда не истекает автоматически. Крайне не рекомендуется: при падении процесса блокировка останется навсегда и задача никогда не запустится снова. - Без параметра (
->withoutOverlapping()) — TTL 24 часа. - Рекомендуемое значение — время, заведомо превышающее максимальное ожидаемое время выполнения задачи, но не слишком большое, чтобы разблокировка произошла в случае сбоя.
Laravel использует драйвер кеша (по умолчанию — тот же, что настроен в config/cache.php) для хранения блокировки (lock). При старте задачи создаётся ключ кеша, обычно содержащий имя задачи. Если ключ уже существует (задача выполняется) — текущий запуск отменяется.
- После успешного завершения задачи блокировка снимается автоматически (
release()вfinally). - Если задача упала с ошибкой или процесс принудительно остановлен — блокировка может остаться. Для этого и нужен
$expiresAt: по истечении срока блокировка автоматически удаляется.
- Имя задачи для блокировки — по умолчанию формируется из командной строки или замыкания. Через
->name('unique-name')можно явно задать имя, что удобно если несколько задач могут иметь одинаковый идентификатор. - С
->runInBackground()— блокировка всё равно действует, так как основана на кеше. - Если задача выполняется дольше
$expiresAt— блокировка освободится автоматически, и запустится вторая копия задачи, даже если первая ещё работает. Поэтому$expiresAtдолжно быть строго больше ожидаемого времени выполнения.
Schedule::command('emails:send-bulk')
->everyThirtyMinutes()
->withoutOverlapping(15); // блокировка истекает через 15 минут
Если задача выполняется в среднем 10 минут — 15 минут безопасный запас. Если задача зависнет на 20 минут, блокировка освободится через 15, и запустится новый экземпляр (может привести к дублированию). Оценивайте время выполнения адекватно.
- Операции с внешними API, которые могут долго отвечать.
- Пакетная обработка данных, которая может занять больше времени чем интервал расписания.
- Генерация больших отчётов или экспорт данных.
- Резервное копирование.
Cache::lock() внутри самой задачи (см. Cache → Atomic Locks). withoutOverlapping — встроенное простое решение для типовых сценариев в планировщике.
withoutOverlapping() — удобный и надёжный способ предотвратить параллельное выполнение периодических задач. Главное — правильно подобрать TTL: чтобы блокировка не зависала навсегда, но и не сбрасывалась слишком рано. Правило: TTL > максимальное ожидаемое время выполнения задачи с запасом.
onOneServer() — выполнение только на одном сервере в кластереПри горизонтальном масштабировании (несколько экземпляров приложения, работающих параллельно) каждая запланированная задача будет запускаться на каждом сервере — если не принять меры. Это приводит к дублированию: повторная отправка писем, двойная обработка данных, N бэкапов вместо одного.
Метод onOneServer() гарантирует, что задача выполняется ровно на одном узле кластера, независимо от количества запущенных экземпляров.
Schedule::command('reports:generate')->daily()->onOneServer();
// Комбинация с withoutOverlapping — максимальная защита
Schedule::command('backup:run')
->daily()
->onOneServer()
->withoutOverlapping();
Использует атомарные блокировки в общем хранилище кеша. При старте задачи:
- Планировщик пытается создать lock с уникальным именем для этой задачи.
- Если блокировку удалось захватить — задача выполняется на текущем сервере.
- Если блокировка уже занята другим сервером — задача пропускается на этом узле.
- Блокировка автоматически снимается после завершения задачи или в случае ошибки.
Атомарная операция — операция, которая выполняется как единое целое: либо полностью успешно, либо не выполняется вообще, без промежуточных состояний. В контексте распределённых блокировок это означает, что проверка наличия блокировки и её создание (если её нет) происходит за один неделимый шаг — исключая ситуацию, когда два сервера одновременно попытаются создать блокировку и оба её получат.
Как реализовано в каждом драйвере:
- Redis: команда
SET key value NX EX seconds— устанавливает ключ только если его нет, с истечением через заданное время. - Memcached: метод
add()— добавляет элемент только если его нет. - Database: используется
INSERTс уникальным индексом или блокировка строки черезSELECT ... FOR UPDATEв транзакции.
Эти механизмы гарантируют, что только один процесс сможет захватить блокировку, остальные получат отказ. Без атомарности два сервера могли бы одновременно проверить, что блокировки нет, и оба создать её — что привело бы к дублированию задачи (гонка данных).
Нужен драйвер кеша, поддерживающий атомарные операции захвата/освобождения блокировок:
redis— предпочтительный вариант (встроенные атомарные командыSET NX EX).memcached— тоже поддерживает атомарные операции.database— использует таблицу кеша с атомарными блокировками (начиная с определённых версий Laravel).
file, array, null — они не обеспечивают распределённую блокировку. С такими драйверами onOneServer() либо выбросит исключение, либо просто не сработает (задача будет выполняться на всех серверах).
По умолчанию используется драйвер из CACHE_DRIVER. Можно явно указать другой драйвер для блокировок:
Schedule::command('backup:run')
->daily()
->onOneServer()
->useCacheDriver('redis');
Полезно, если основной кеш используется для других целей (например, file для данных, redis для блокировок).
- TTL блокировки — по умолчанию 24 часа. Если задача выполняется дольше, блокировка истечёт, и другой сервер может её захватить — параллельное выполнение. В стандартном API
onOneServer()не принимает параметры — TTL фиксирован (24 часа). - Падение сервера — если процесс упал без освобождения блокировки, она автоматически истечёт через TTL, задача снова станет доступной другим узлам.
- Имя блокировки — строится по идентификатору задачи. Можно задать своё через
->name('unique-name'). - Совместимость с
withoutOverlapping()— использовать вместе.withoutOverlappingпредотвращает параллельный запуск на одном сервере,onOneServer— на разных. Вместе = максимальная защита. - Скорость — проверка блокировки добавляет незначительную задержку.
- Резервное копирование — не нужно делать одно и то же на каждом сервере.
- Генерация глобальных отчётов — достаточно одного экземпляра.
- Очистка временных файлов — не удалять одновременно с разных серверов.
- Отправка массовых уведомлений — избежать дублирования.
- Уникальные jobs (
ShouldBeUnique) через централизованную очередь (Redis/SQS) — но только для асинхронных задач, не для планировщика. - Ручная
Cache::lock()внутри самой задачи — больше гибкости, сложнее. - Kubernetes CronJob — переносит управление на уровень инфраструктуры.
onOneServer() — простой и эффективный способ избежать дублирования периодических задач в кластерной среде. Опирается на общий кеш с атомарными блокировками (Redis/Memcached/DB). Для большинства сценариев где критично, чтобы задача выполнялась только один раз глобально. Комбинация ->onOneServer()->withoutOverlapping() даёт максимальную защиту: один сервер + один экземпляр.
Полный набор методов для задания частоты и фильтров:
Периоды (интервалы запуска) — методы задания частоты. Все они возвращают тот же экземпляр Schedule для цепочки вызовов.
| Метод | Описание |
|---|---|
->cron('* * * * *') | Произвольное cron-выражение, напр. ->cron('*/5 * * * *') |
->everyMinute() | Каждую минуту |
->everyTwoMinutes() | Каждые 2 минуты |
->everyFiveMinutes() | Каждые 5 минут |
->everyTenMinutes() | Каждые 10 минут |
->everyFifteenMinutes() | Каждые 15 минут |
->everyThirtyMinutes() | Каждые 30 минут |
->hourly() | Каждый час (в 00 минут) |
->hourlyAt(17) | Каждый час в 17 минут |
->everyTwoHours() | Каждые 2 часа |
->everyThreeHours() | Каждые 3 часа |
->everyFourHours() | Каждые 4 часа |
->everySixHours() | Каждые 6 часов |
->daily() | Ежедневно в полночь (00:00) |
->dailyAt('13:30') | Ежедневно в 13:30 |
->twiceDaily(1, 13) | Дважды в день: в 1:00 и 13:00 |
->twiceDailyAt(3, 15, 30) | Дважды в день в 3:30 и 15:30 (часы, минуты) |
->weekly() | Еженедельно в полночь воскресенья |
->weeklyOn(1, '8:00') | Еженедельно в понедельник (1 = Monday) в 8:00 |
->monthly() | Ежемесячно 1-го числа в полночь |
->monthlyOn(4, '15:00') | 4-го числа каждого месяца в 15:00 |
->twiceMonthly(1, 16, '13:00') | 1-го и 16-го числа каждого месяца в 13:00 |
->quarterly() | Каждый квартал: 1 января, 1 апреля, 1 июля, 1 октября в полночь |
->yearly() | Ежегодно 1 января в полночь |
->days([1, 3, 5]) | Запускать в определённые дни недели (0=воскресенье) |
Фильтры задают дополнительные условия. Если условие не выполнено — задача пропускается.
| Метод | Описание |
|---|---|
->weekdays() | Только по рабочим дням (пн–пт) |
->weekends() | Только по выходным (сб–вс) |
->sundays() | Только по воскресеньям |
->mondays() | Только по понедельникам |
->tuesdays() | Только по вторникам |
->wednesdays() | Только по средам |
->thursdays() | Только по четвергам |
->fridays() | Только по пятницам |
->saturdays() | Только по субботам |
->between('9:00', '17:00') | Запускать только в указанном интервале (включительно) |
->unlessBetween('22:00', '6:00') | Не запускать между указанными временами |
->when(fn () => ...) | Запускать, если замыкание вернуло true |
->skip(fn () => ...) | Пропустить, если замыкание вернуло true (обратное when) |
->environments(['production']) | Запускать только в указанных окружениях |
->evenInMaintenanceMode() | Выполнять даже в режиме обслуживания (по умолчанию задачи не запускаются) |
->timezone('Asia/Almaty') | Таймзона для расписания |
Методы можно комбинировать — все условия применяются одновременно (AND-логика). Например:
Schedule::command('reports:generate')
->dailyAt('02:00')
->weekdays()
->between('01:00', '04:00')
->when(fn() => $this->shouldRun())
->onOneServer();
Задача выполнится каждый будний день в 2:00 ночи, но только если сервер захватит блокировку и замыкание shouldRun() вернёт true. Все фильтры проверяются в момент, когда наступает время запуска.
- Фильтры
weekdays(),between(),when()проверяются непосредственно перед запуском задачи. - Для точного контроля используйте
->cron()и комбинируйте с->when()для сложной логики. - При
->onOneServer()фильтры всё равно применяются на каждом сервере, но задача выполнится только на том, кто успешно захватит блокировку.
При выполнении запланированных задач (команд, замыканий, исполняемых файлов) часто нужно сохранить вывод (stdout/stderr) в файл, отправить результат по email или иначе обработать. Laravel даёт цепочку методов для управления выводом задачи после её выполнения.
->sendOutputTo($filePath) — перезапись файлаПеренаправляет весь вывод (stdout + stderr) в указанный файл. Файл перезаписывается при каждом запуске задачи.
Schedule::command('reports:generate')
->daily()
->sendOutputTo(storage_path('logs/report.log'));
->appendOutputTo($filePath) — дописываниеТо же, что sendOutputTo, но вывод дописывается в конец файла. Полезно для накопления логов за длительное время.
Schedule::command('backup:run')
->daily()
->appendOutputTo(storage_path('logs/backup.log'));
->emailOutputTo($email, $subject = null) — всегда на emailОтправляет вывод задачи на email после каждого выполнения. Если вывод пустой — письмо не отправляется.
Schedule::command('reports:generate')
->weekly()
->emailOutputTo('admin@example.com', 'Weekly Report Output');
Можно комбинировать с sendOutputTo() — тогда вывод одновременно и сохранён в файл, и отправлен по почте. Также поддерживает массив адресов:
->emailOutputTo(['admin@example.com', 'manager@example.com']);
->emailOutputOnFailure($email, $subject = null) — только при ошибкеОтправляет вывод только если задача завершилась с ошибкой (ненулевой код возврата). При успехе письмо не приходит — удобно для мониторинга без спама.
Schedule::command('critical:task')
->everyFiveMinutes()
->emailOutputOnFailure('devops@example.com', 'Critical Task Failed');
->onSuccess() / ->onFailure() — колбэкиЗамыкания для произвольного кода при успехе или неудаче задачи. Даёт максимальную гибкость: Slack-нотификация, отдельный лог, обновление метрик и т.д.
Schedule::command('backup:run')
->daily()
->onSuccess(function () {
// Отправить уведомление в Slack
})
->onFailure(function () {
// Записать в отдельный лог ошибку
});
| Метод | Что делает | Когда срабатывает |
|---|---|---|
->sendOutputTo(path) | Записать вывод в файл (перезапись) | После каждого запуска |
->appendOutputTo(path) | Дописать вывод в конец файла | После каждого запуска |
->emailOutputTo(email) | Отправить вывод на email (можно массив) | После каждого запуска, если вывод не пустой |
->emailOutputOnFailure(email) | Отправить вывод на email | Только при exit code ≠ 0 |
->onSuccess(Closure) | Произвольный код | При exit code = 0 |
->onFailure(Closure) | Произвольный код | При exit code ≠ 0 |
- Регулярные отчёты —
sendOutputTo()+ ротация черезlogrotateлибо перенаправление в агрегатор (ELK, Graylog, Datadog). - Критичные задачи —
emailOutputOnFailure()илиonFailure()для оперативного оповещения без «шума» при успехе. - Большие объёмы вывода — не отправляйте email'ом, сохраняйте в файл и обрабатывайте внешними инструментами.
- Парсинг/анализ — сохраняйте в файл и обрабатывайте другой запланированной задачей.
Schedule::command('sales:aggregate')
->dailyAt('01:00')
->sendOutputTo(storage_path('logs/sales.log'))
->emailOutputOnFailure('team@example.com', 'Sales Aggregation Failed')
->onSuccess(fn() => Log::info('Sales aggregation succeeded'));
В сумме: вывод сохранён локально, при ошибке команда получает email с логом, при успехе — запись в лог Laravel. Классическая схема для «серьёзной» задачи без внешних инструментов.
echo, print, $this->info() в Artisan). Вывод внутренних исключений тоже перехватывается.Log::info() не попадает в sendOutputTo. Стандартный Log-фасад пишет напрямую в storage/logs/laravel.log через файловые каналы — этот вывод не захватывается планировщиком. Для захвата логов задачи — используйте отдельный канал (напр. stderr) или пишите в консоль явно.emailOutputTo() ничего не отправит, если команда не произвела вывод. Это часто желаемое поведение, но иногда путает — «почему письмо не пришло, задача же выполнилась?».sendOutputTo перезаписывает. Если нужно накопление истории — appendOutputTo, иначе увидите только последний запуск.www-data или тот, кто запускает schedule:run). Директория должна быть доступна на запись.MAIL_MAILER=log на dev. Локально письмо «уйдёт» в лог, а не на реальный адрес — легко подумать, что фича не работает.- Нужно сохранить один лог →
sendOutputTo() - Нужна история запусков →
appendOutputTo() - Отчёт каждый раз в почту →
emailOutputTo() - Оповещение только при падении →
emailOutputOnFailure() - Кастомная логика (Slack/метрики) →
onSuccess()/onFailure()
Чтобы планировщик работал, нужно добавить одну запись в системный cron, которая будет вызывать Artisan-команду schedule:run каждую минуту. Эта команда проверяет, какие задачи должны быть запущены в текущую минуту, и выполняет их.
Запись в cron (обычно через crontab -e):
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
Что делает каждая часть:
* * * * *— cron-выражение «каждую минуту» (min hour day month day_of_week).cd /path-to-your-project— переход в директорию проекта.php artisan schedule:run— запуск планировщика Laravel.>> /dev/null 2>&1— подавить вывод и ошибки (иначе cron пришлёт email на локального юзера).
php artisan schedule:list— показать все зарегистрированные задачи и когда они запустятся.php artisan schedule:test(Laravel 9+) — интерактивно выбрать задачу и запустить её сейчас.php artisan schedule:run— запустить то, что должно запуститься прямо сейчас согласно расписанию.php artisan schedule:work— запустить schedule:run каждую минуту в фоне (для локальной разработки, замена cron).
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('reports:daily')
->dailyAt('08:00')
->timezone('Asia/Almaty')
->withoutOverlapping(30)
->onOneServer()
->emailOutputOnFailure('devops@example.com');
Schedule::job(new CleanupOldExports())
->weekly()
->sundays()
->at('03:00');
* * * * * php artisan schedule:run в crontab расписание не работает. Заверьте на проде первой проверкой.withoutOverlapping без TTL. Дефолтный TTL — 24 часа. Если процесс упал, lock держится сутки. Указывайте явный таймаут.onOneServer на file cache. File cache локален для сервера — atomic lock не работает между серверами. Используйте Redis.->daily() в UTC. Дефолтная таймзона — app.timezone. Если она UTC, а ожидание «Asia/Almaty 8:00» — расписание сработает в 13:00 по Алматы. Указывайте timezone().schedule:run сам по себе быстрый, но ->command(...) может занять минуты. Используйте ->runInBackground() или ->job(...) для постановки в очередь.->thenPing('https://hc.example.com/ok') — pingback после успеха. Если URL мёртв (Healthchecks.io не обновлён) — false alarm.php artisan schedule:run запустит то, что должно запуститься сейчас. Чтобы протестировать без ожидания — schedule:test (Laravel 9+).schedule:run в crontab. Две записи cron — два процесса каждую минуту. Tasks с withoutOverlapping справятся, без — выполнятся дважды.Готовой «авторизации» в Laravel из коробки нет — есть несколько пакетов, каждый под свою задачу. Чаще всего ставят два: Sanctum для токенов API и Socialite для входа через внешние сервисы.
# Токены API и аутентификация SPA
composer require laravel/sanctum
# Вход через Google, GitHub, VK и другие провайдеры
composer require laravel/socialite
# Ссылка из public/storage на storage/app/public —
# без неё загруженные файлы не отдаются по URL
php artisan storage:link
# Создать таблицы, включая personal_access_tokens из Sanctum
php artisan migrate
php artisan install:api: он ставит сам пакет, создаёт файл routes/api.php, подключает его и публикует миграции — то есть заменяет связку composer require плюс ручная правка маршрутов. Отдельный composer require laravel/sanctum нужен, когда маршруты API у вас уже настроены и нужен только пакет.storage:link делают один раз и забывают. Это символическая ссылка, и в репозиторий она не попадает. После клонирования проекта или первого деплоя её нужно создать заново, иначе все загруженные картинки отдают 404. На сервере с запретом символических ссылок используют php artisan storage:link --relative.Минимальный порядок для свежего проекта, где нужен API с токенами и загрузка файлов:
php artisan install:api # Sanctum плюс routes/api.php
composer require laravel/socialite # если нужен вход через соцсети
php artisan storage:link # публичный доступ к загрузкам
php artisan migrate # таблицы, включая токены
php artisan serve
Это самая частая путаница: названия стоят рядом в документации, но решают разные задачи. Разделить их проще всего по вопросу «что именно даёт пакет».
| Пакет | Что даёт | Когда брать |
|---|---|---|
| Стартовый набор раньше Breeze | Готовые страницы, маршруты и контроллеры: регистрация, вход, сброс пароля, подтверждение почты. Код копируется в проект и становится вашим | Начинаете новый проект и нужен рабочий вход «прямо сейчас» с интерфейсом |
| Fortify | Та же логика входа и регистрации, но без интерфейса — только маршруты и обработчики. Интерфейс пишете сами | Нужна стандартная логика аутентификации, но свой дизайн или свой фронтенд |
| Sanctum | Токены API и аутентификация SPA через сессионные cookie. Страниц входа не создаёт | Мобильное приложение, сторонний клиент по токену или SPA на том же домене |
| Socialite | Вход через внешних провайдеров: Google, GitHub и прочие. Обмен кода на профиль пользователя | Кнопка «Войти через Google» вместо пароля |
| Passport | Полноценный сервер OAuth 2.0: выдаёт токены сторонним приложениям, поддерживает гранты и scope | Вы сами — провайдер авторизации для чужих приложений |
laravel new.Главное различие, которое и путают: стартовый набор отвечает за вход человека через браузер, Sanctum — за доступ программы к вашему API. Они не заменяют друг друга и спокойно работают вместе: пользователь заходит на сайт через форму из набора, а мобильное приложение того же сервиса ходит по токену Sanctum.
| Задача | Что ставить |
|---|---|
| Классический сайт с формой входа | Стартовый набор при создании проекта |
| API для мобильного приложения | Sanctum, токены |
| SPA на том же домене | Sanctum в режиме cookie-аутентификации |
| Вход через Google или GitHub | Socialite, обычно вместе с набором |
| Своя вёрстка, но стандартная логика входа | Fortify |
| Выдавать токены чужим приложениям | Passport |
localStorage, достаётся любым XSS, тогда как cookie с флагом HttpOnly недоступна скриптам и дополнительно защищена проверкой CSRF.Это сравнение постоянно встречается в статьях и постах, и само по себе оно сбивает с толку: пакеты сопоставляют так, будто надо выбрать один из двух. На деле они отвечают на разные вопросы, и выбор «или-или» возникает редко.
| Breeze и пришедшие ему на смену наборы | Sanctum | |
|---|---|---|
| Что это | Стартовый набор: готовый код страниц и маршрутов | Пакет аутентификации без интерфейса |
| Что появляется в проекте | Страницы входа, регистрации, сброса пароля и подтверждения почты | Таблица токенов и способ проверять запросы |
| Кого аутентифицирует | Человека в браузере | Программу: мобильное приложение, сторонний клиент, SPA |
| Чем подтверждается личность | Сессия в cookie | Токен в заголовке либо та же сессия — для своего SPA |
| Нужен ли фронтенд | Идёт в комплекте | Пишете сами или его вообще нет |
| Код после установки | Ваш: можно править как угодно | Остаётся в vendor, вы его не трогаете |
localStorage забирает любой XSS, cookie с HttpOnly скриптам недоступна.| Проект | Набор инструментов | Почему так |
|---|---|---|
| Админка или сайт на Blade | Стартовый набор Livewire | Страницы входа готовы, фронтенд на PHP, отдельное приложение не нужно |
| Бэкенд для мобильного приложения | Sanctum, токены | Клиент хранит токен и шлёт его в заголовке. Сессий и cookie нет |
| React или Vue на том же домене | Sanctum в режиме cookie | Личность подтверждается сессией, работает защита от CSRF |
| React на отдельном поддомене | Sanctum в режиме cookie плюс настройка CORS | Домен верхнего уровня должен совпадать, cookie настраивается на .example.com |
| Сайт плюс мобильное приложение | Набор и Sanctum вместе | Человек заходит через форму, приложение — по токену. Пользователь один и тот же |
| Вход через Google вместо пароля | Socialite плюс набор | Socialite отдаёт профиль, дальше вход обычными средствами фреймворка |
| Своя вёрстка, стандартная логика | Fortify | Маршруты и обработчики есть, интерфейс делаете сами |
| Выдаёте доступ чужим приложениям | Passport | Нужен полноценный OAuth 2.0 с грантами и scope |
| Вопрос | Короткий ответ |
|---|---|
| Чем аутентификация отличается от авторизации? | Первая отвечает «кто это», вторая — «что ему можно». В Laravel за первое отвечают guards и providers, за второе — gates и policies |
| Breeze или Sanctum? | Вопрос поставлен неверно: первый даёт страницы входа для человека, второй — проверку запросов от программы. В реальных проектах часто оба |
| Как аутентифицировать своё SPA? | Sanctum в режиме сессионных cookie, не токенами. Токены — для сторонних и мобильных клиентов |
| Чем Sanctum отличается от Passport? | Sanctum — простые токены и SPA. Passport — полноценный сервер OAuth 2.0, нужен, когда вы выдаёте доступ чужим приложениям |
| Где хранить токен на клиенте? | Для своего приложения токен лучше не использовать вовсе. Если он нужен — в памяти приложения, а не в localStorage, который читается при XSS |
Аутентификация (auth) и авторизация (authorization) — два разных слоя. Auth отвечает «кто этот пользователь». Authorization — «может ли он сделать это действие». Laravel разделяет их явно: guards/providers для аутентификации, gates и policies — для авторизации. Понимание разницы и взаимодействия — основа безопасной архитектуры.
Guards
Способ аутентификации: session, token, sanctum, passport, кастомный. Один guard на один способ. auth('api')->user() — пользователь из guard api.
Providers
Источник пользователей: eloquent (модель User), database (таблица напрямую). Гибкость — кастомный провайдер для LDAP, SSO.
Gates
Глобальная функция авторизации: Gate::define('admin-area', fn ($user) => $user->is_admin). Использование: Gate::allows('admin-area'). Для «сквозных» проверок, не привязанных к модели.
Policies
Класс с методами авторизации по модели: UserPolicy::update(User $admin, User $target). Регистрация — AuthServiceProvider::$policies. Использование: $user->can('update', $target).
Gate::before / after
Глобальные хуки: Gate::before(fn ($user) => $user->is_root ? true : null) — root проходит везде. Возврат null — продолжать проверку обычным путём.
Guard — механизм, определяющий, как аутентифицировать пользователя для конкретного запроса: через сессии, токены, API-ключи, JWT и т.д. Каждый guard реализует один способ проверки подлинности. В Laravel можно использовать несколько guards одновременно для разных частей приложения (например, web для веб-интерфейса и api для API).
Все guards настраиваются в config/auth.php в массиве guards. По умолчанию:
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'token',
'provider' => 'users',
'hash' => false,
],
],
Каждый guard содержит два ключа:
driver— механизм аутентификации (session, token, sanctum, passport, custom)provider— источник пользователей (обычноusers= модельApp\Models\User; можно определить свой для LDAP/SSO)
| Драйвер | Описание | Типичное использование |
|---|---|---|
session |
Хранит состояние в сессии (куки). Требует middleware StartSession. |
Веб-интерфейс, стандартный вход через форму. |
token |
Проверяет API-токен в заголовке Authorization или параметре запроса. Токен хранится в поле users.api_token. Устарел. |
Простые API без сложной логики. Не рекомендуется для новых проектов. |
sanctum |
Гибкий: поддерживает и сессии (через куки), и токены (через заголовок). | Современные SPA (Vue/React) и мобильные клиенты. |
passport |
Полноценный OAuth2-сервер: access-токены, refresh-токены, scopes. | API для сторонних приложений, микросервисы, сложные системы с разделением прав. |
jwt (кастомный) |
JWT-токены. Не в ядре — требует tymon/jwt-auth. |
Безсессионная аутентификация с компактными токенами. |
custom |
Собственный драйвер под специфические нужды (сертификаты, внешний сервис). | Нестандартные протоколы, интеграции. |
// Пользователь из guard 'web' (по умолчанию)
$user = auth()->user();
// Пользователь из guard 'api'
$user = auth('api')->user();
// Через фасад — явно указываем guard
$user = Auth::guard('api')->user();
// Проверка аутентификации в определённом guard
if (auth('api')->check()) {
// ...
}
if (Auth::guard('web')->attempt($credentials)) {
// вход для web — создаётся сессия
}
if (Auth::guard('api')->attempt($credentials)) {
// вход для api-token — обычно не используется,
// т.к. токены не создаются через attempt (нужен createToken)
}
Для sanctum и passport — обычно attempt с последующей выдачей токена ($user->createToken(...)).
// Использовать guard 'api'
Route::get('/user', function () {
return auth('api')->user();
})->middleware('auth:api');
// Несколько guards — проверяются по очереди
Route::get('/profile', function () {
// ...
})->middleware('auth:web,api');
Если guard не указан — используется defaults.guard из config/auth.php (обычно web).
Если стандартные драйверы не подходят — реализуйте Illuminate\Contracts\Auth\Guard и зарегистрируйте через сервис-провайдер:
// AppServiceProvider::boot()
Auth::extend('custom', function ($app, $name, $config) {
return new CustomGuard(
$app->make(YourUserProvider::class),
$app->make(Request::class)
);
});
После этого в config/auth.php добавляйте guard с драйвером custom.
session— сессии + куки, требуетStartSessionв middleware-группеweb.token— устаревший. Вместо него —sanctumилиpassport.sanctum— для SPA использует куки (сессии), для мобильных — токены в заголовке. Может работать полностью без сессий.passport— OAuth2-сервер, требует установки пакета и настройки RSA-ключей.
- Веб-сайт →
webс драйверомsession. - API →
sanctum(простой) илиpassport(OAuth2). - Микросервисы / внешние интеграции →
passportили кастомный JWT. - Несколько способов входа в одном проекте (админы через сессии, пользователи через токены) → отдельные guards, привязанные к нужным маршрутам.
auth('guard')->user() для получения, middleware('auth:guard') для защиты маршрутов.
Provider (провайдер) — компонент, отвечающий за извлечение пользователей из хранилища (БД, LDAP, внешний API и т.д.) на основе переданных данных (email/пароль/токен). Он определяет, откуда берутся пользователи, в то время как Guard определяет, как их проверять. Эти два понятия работают вместе: Guard использует Provider для получения пользователя.
Все провайдеры настраиваются в config/auth.php в массиве providers. По умолчанию:
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\Models\User::class,
],
],
Каждый провайдер содержит:
driver— тип провайдера (способ получения пользователей)model— Eloquent-модель (дляeloquent-драйвера)table— имя таблицы (дляdatabase-драйвера)
| Драйвер | Описание | Когда использовать |
|---|---|---|
eloquent |
Использует Eloquent-модель для поиска пользователя. По умолчанию — App\Models\User. |
Стандартный выбор. Пользователи в БД, нужны отношения Eloquent, mutators, casts. |
database |
Использует Query Builder для прямого обращения к таблице БД (без модели). | Простые системы без Eloquent, либо когда модель не нужна. |
Eloquent-провайдер с двумя моделями (пользователи + администраторы):
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\Models\User::class,
],
'admins' => [
'driver' => 'eloquent',
'model' => App\Models\Admin::class,
],
],
Database-провайдер (без модели):
'providers' => [
'users' => [
'driver' => 'database',
'table' => 'users',
],
],
Каждый Guard ссылается на конкретный Provider через ключ provider:
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'token',
'provider' => 'users',
],
'admin' => [
'driver' => 'session',
'provider' => 'admins',
],
],
Теперь guard admin использует провайдер admins, который ссылается на модель Admin. web и api используют один и тот же провайдер users, но разные способы проверки.
Если стандартные провайдеры не подходят (пользователи в LDAP, Active Directory, внешнем API) — реализуйте интерфейс Illuminate\Contracts\Auth\UserProvider:
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Auth\Authenticatable;
class LdapUserProvider implements UserProvider
{
public function retrieveById($identifier) { /* найти по ID */ }
public function retrieveByToken($identifier, $token) { /* remember-me */ }
public function updateRememberToken(Authenticatable $user, $token) { /* сохранить токен */ }
public function retrieveByCredentials(array $credentials) { /* найти по email/username */ }
public function validateCredentials(Authenticatable $user, array $credentials) { /* проверить пароль */ }
}
Регистрация в сервис-провайдере через Auth::provider():
// AppServiceProvider::boot()
Auth::provider('ldap', function ($app, array $config) {
return new LdapUserProvider();
});
После этого в config/auth.php:
'providers' => [
'ldap_users' => [
'driver' => 'ldap',
// дополнительные настройки (host, base_dn, ...)
],
],
Теперь любой guard может использовать этот провайдер для аутентификации из LDAP.
Класс можно разместить в любом месте, которое соответствует автозагрузке PSR-4 (обычно внутри app/). Для чистоты структуры проекта рекомендуется выделить отдельную директорию под классы, связанные с аутентификацией.
| Путь | Пояснение |
|---|---|
app/Auth/LdapUserProvider.php | Рекомендуется. Логичное место для всех кастомных провайдеров — сразу видно, что относится к аутентификации. |
app/Services/Auth/LdapUserProvider.php | Если в проекте уже есть папка Services/ для внешних интеграций. |
app/Extensions/LdapUserProvider.php | Если принято выделять «расширения фреймворка» в отдельный слой. |
app/LdapUserProvider.php | Простой вариант, но не рекомендуется — засоряет корень app/. |
Пример структуры проекта:
app/
├── Auth/
│ ├── LdapUserProvider.php
│ └── ...
├── Http/
├── Models/
└── ...
Полный пример регистрации с правильным namespace:
// app/Auth/LdapUserProvider.php
namespace App\Auth;
use Illuminate\Contracts\Auth\UserProvider;
class LdapUserProvider implements UserProvider { /* ... */ }
// app/Providers/AppServiceProvider.php
use App\Auth\LdapUserProvider;
use Illuminate\Support\Facades\Auth;
public function boot(): void
{
Auth::provider('ldap', function ($app, array $config) {
return new LdapUserProvider();
});
}
- Класс обязательно реализует интерфейс
Illuminate\Contracts\Auth\UserProvider. - Namespace должен совпадать с путём: файл
app/Auth/LdapUserProvider.php→namespace App\Auth;. - После регистрации в
boot()имяldapможно использовать вconfig/auth.phpв массивеproviders.
app/Auth/ — сразу понятно, что там классы аутентификации, легко найти все кастомные UserProvider/Guard в одном месте.
Разделение данных
Одна таблица — для обычных пользователей, другая — для администраторов. У каждой своя модель, свои поля, свои relations.
Интеграция с внешними системами
Корпоративные пользователи — через Active Directory. Внешние клиенты — через обычную БД. Один Laravel обслуживает обе группы.
Миграция
При переходе с одной системы на другую можно заменить провайдер без изменения логики аутентификации выше по стеку.
Тестирование
Фейковый провайдер, возвращающий предопределённых пользователей — простой способ протестировать flow без реальной БД.
// config/auth.php
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\Models\User::class,
],
'api_clients' => [
'driver' => 'database',
'table' => 'api_clients',
],
],
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'passport',
'provider' => 'api_clients',
],
],
Веб-часть использует модель User, а API — таблицу api_clients. Никаких пересечений: разные модели, разные способы проверки, но одна общая инфраструктура Laravel.
- Provider = источник данных пользователей (Eloquent, БД-таблица, LDAP, внешнее API).
- Драйверы:
eloquent(стандарт) иdatabase(простой без модели). - Кастомные —
Auth::provider(...)вboot()для интеграции с внешними системами. - Связь: Guard (как) использует Provider (откуда) для поиска и проверки учётных данных.
- Гибкость: разные части приложения — разные провайдеры (админы из одной таблицы, пользователи из другой).
SPA (Single Page Application) — одностраничное веб-приложение. В отличие от классических многостраничных сайтов (MPA), где каждый клик по ссылке загружает новую HTML-страницу с сервера, SPA работает иначе.
- Один раз загружает всю «оболочку». При первом посещении браузер получает одну HTML-страницу + все CSS/JS-бандлы.
- Дальше работает в браузере. Вся навигация и отрисовка интерфейса — в браузере. При переходе по ссылке JavaScript перехватывает событие, запрашивает у сервера только данные (обычно JSON) и динамически обновляет уже загруженную страницу.
- Никаких перезагрузок. Нет мерцаний и белых экранов, характерных для перезагрузки. Интерфейс обновляется плавно и мгновенно — по ощущениям близко к нативным приложениям.
Бэкенд (API)
Laravel работает как API-сервер: отдаёт только данные (JSON), не занимается отрисовкой HTML. Роуты в routes/api.php, контроллеры возвращают JsonResource/массивы.
Фронтенд (SPA)
Клиентская часть на JS-фреймворке: React / Vue.js / Angular / Svelte. Развёртывается отдельно (Vite/Webpack) либо инжектится в единственный blade-шаблон Laravel.
Аутентификация: Laravel Sanctum
Для защиты API — Laravel Sanctum. Даёт лёгкий механизм аутентификации: для SPA на том же домене — через куки-сессии (безопасно, без хранения токена в JS), для мобильных клиентов — через токены в заголовке Authorization.
// 1. Установка
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
// 2. config/sanctum.php — указать домены SPA
'stateful' => ['localhost:3000', 'app.example.com'],
// 3. Защищаем routes/api.php
Route::middleware('auth:sanctum')->get('/user', fn(Request $r) => $r->user());
// 4. Со стороны SPA (пример на Axios):
// - сначала GET /sanctum/csrf-cookie (получить XSRF-TOKEN)
// - потом POST /login с credentials (создаётся сессия)
// - все последующие запросы автоматически несут куки
Gates — механизм авторизации, позволяющий определить простые глобальные правила доступа в виде замыканий. Предназначены для сквозных проверок, не привязанных к конкретной модели: пользователь — админ? имеет доступ к отчётам? находится в определённой группе? Это аналог Policies, но для более простых и обобщённых сценариев.
Gates определяются в app/Providers/AuthServiceProvider::boot() через фасад Gate:
use Illuminate\Support\Facades\Gate;
public function boot(): void
{
// Простая проверка
Gate::define('admin-area', function ($user) {
return $user->is_admin === true;
});
// Проверка с дополнительными параметрами
Gate::define('update-post', function ($user, $post) {
return $user->id === $post->user_id;
});
// Проверка на основе нескольких условий
Gate::define('access-reports', function ($user) {
return $user->hasRole('manager') || $user->hasPermission('view_reports');
});
}
- Первый аргумент — строка-идентификатор (имя разрешения, обычно kebab-case).
- Второй аргумент — замыкание, принимающее текущего
$userи, опционально, дополнительные параметры (модель, ID, любые данные).
AuthServiceProvider больше не создаётся автоматически. Gates и policies можно регистрировать прямо в AppServiceProvider::boot(), либо создать AuthServiceProvider вручную для чистоты структуры.1. Через фасад Gate:
if (Gate::allows('admin-area')) {
// пользователь имеет доступ к админ-зоне
}
if (Gate::denies('update-post', $post)) {
abort(403, 'You cannot edit this post.');
}
2. Через методы can() / cannot() на пользователе:
if ($request->user()->can('admin-area')) {
// ...
}
if ($request->user()->cannot('update-post', $post)) {
// ...
}
3. В шаблонах Blade:
@can('admin-area')
<a href="/admin">Панель администратора</a>
@endcan
@cannot('update-post', $post)
<p>Вы не можете редактировать этот пост.</p>
@endcannot
4. В контроллерах через authorize():
public function update(Request $request, int $id)
{
$post = Post::findOrFail($id);
$this->authorize('update-post', $post); // бросит 403 если false
// ...
}
| Характеристика | Gates | Policies |
|---|---|---|
| Назначение | Глобальные, не привязанные к модели | Связаны с моделью (Post, User, Order) |
| Где определяются | AuthServiceProvider::boot() |
Отдельные классы в app/Policies/ |
| Проверка | Gate::allows('ability', $model) |
$user->can('ability', $model) / authorize() |
| Организация | Один файл для всех правил | Каждая модель — свой файл политики |
| Для сложных проверок | Гибко, но менее структурировано | Структурировано, поддерживает auto-discovery |
| Работа с моделями | Можно, но модель передаётся явно | Специализировано для моделей |
Проверка роли / прав
Пользователь — админ? Модератор? Имеет ли доступ к админке? — типичный кейс для Gate, не привязан к конкретной сущности.
Функциональные ограничения
Доступ к отчётам, экспорту данных, определённым разделам меню — «может ли эта функция быть доступна пользователю».
Глобальные условия
Доступ к функциональности только в рабочее время / только на определённом окружении — проверка времени/условий внутри замыкания.
A/B тестирование и feature-флаги
Проверка, включена ли фича для данного пользователя (по ID, группе, эксперименту).
Gate::before — глобальный супер-админПозволяет выполнить проверку до всех остальных Gates — типичный кейс «супер-админ проходит везде»:
Gate::before(function ($user, $ability) {
if ($user->is_super_admin) {
return true; // пропускает все проверки
}
// return null; — продолжить обычную проверку
});
- Вернуть
true→ проверка сразу разрешена, остальные Gates не выполняются. - Вернуть
false→ проверка отклонена (полный запрет). - Вернуть
null→ продолжить обычную проверку (стандартное поведение).
Есть также Gate::after() — вызывается после обычной проверки, может изменить результат (реже используется).
- Gate возвращает
true/false.false→ доступ запрещён. - Если замыкание бросает исключение — оно не перехватывается и приводит к ошибке (не рекомендуется).
- Можно определять Gates с дополнительными параметрами (ID сущности, статус, любые данные).
Gate::before/Gate::after— для глобальных проверок (супер-админ везде проходит).
Gate::define в разных провайдерах превращаются в кошмар — держите все определения в одном месте (AuthServiceProvider).false из замыкания. Внутри Gate не пишите в лог отказ — authorize() сам бросает AuthorizationException (403), лог создаётся глобальным обработчиком.AuthServiceProvider для централизованного управления.
Policies (политики) — классы, группирующие логику авторизации для конкретной модели (Post, User, Order). Каждая политика содержит методы, соответствующие различным действиям (view, create, update, delete...), которые принимают текущего пользователя и, опционально, целевую модель.
Policies — рекомендуемый способ авторизации действий, связанных с моделями: структурируют проверки по сущностям и легко расширяются. Тесно интегрируются с фасадом Gate, но дают более высокоуровневый и удобный API.
Политика создаётся Artisan-командой. Флаг --model сразу генерирует типовые методы для CRUD-операций:
php artisan make:policy PostPolicy --model=Post
Пример полной PostPolicy:
namespace App\Policies;
use App\Models\User;
use App\Models\Post;
class PostPolicy
{
// Может ли просматривать список постов (обычно всегда true)
public function viewAny(User $user): bool
{
return true;
}
// Может ли просматривать конкретный пост
public function view(User $user, Post $post): bool
{
return $user->id === $post->user_id || $user->is_admin;
}
// Может ли создавать посты (модели ещё нет — только $user)
public function create(User $user): bool
{
return $user->hasVerifiedEmail();
}
// Может ли обновлять
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
// Может ли удалять
public function delete(User $user, Post $post): bool
{
return $user->id === $post->user_id || $user->is_admin;
}
// Восстановление (для SoftDeletes)
public function restore(User $user, Post $post): bool
{
return $user->is_admin;
}
// Принудительное удаление из БД
public function forceDelete(User $user, Post $post): bool
{
return $user->is_super_admin;
}
}
Каждый метод возвращает true (разрешено) или false (запрещено). Если метод не определён — по умолчанию false (кроме случаев, когда Gate::before() вернул true).
Политики регистрируются в App\Providers\AuthServiceProvider в свойстве $policies. Ключ — имя модели, значение — класс политики.
namespace App\Providers;
use App\Models\Post;
use App\Models\User;
use App\Policies\PostPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
class AuthServiceProvider extends ServiceProvider
{
protected $policies = [
Post::class => PostPolicy::class,
User::class => UserPolicy::class,
];
public function boot(): void
{
$this->registerPolicies();
// дополнительная регистрация Gate::define(...) при необходимости
}
}
Метод $this->registerPolicies() автоматически загружает указанные политики.
registerPolicies() вызывается по умолчанию — достаточно добавить массив $policies. Более того, сам AuthServiceProvider больше не создаётся автоматически: можно регистрировать через Gate::policy(Post::class, PostPolicy::class) в AppServiceProvider::boot() либо создать AuthServiceProvider вручную.1. Через фасад Gate:
if (Gate::allows('update', $post)) {
// разрешено — Laravel ищет PostPolicy@update
}
if (Gate::denies('update', $post)) {
abort(403);
}
Laravel по классу переданного $post находит зарегистрированную политику и вызывает нужный метод.
2. Через can() / cannot() у пользователя (самый удобный способ):
if ($request->user()->can('update', $post)) {
// разрешено
}
3. В контроллере через authorize():
public function update(Request $request, int $id)
{
$post = Post::findOrFail($id);
$this->authorize('update', $post);
// выполнение обновления
}
Если проверка не пройдена — authorize() бросает AuthorizationException и возвращает ответ 403.
4. В шаблонах Blade:
@can('update', $post)
<a href="{{ route('posts.edit', $post) }}">Редактировать</a>
@endcan
@cannot('delete', $post)
<p>Вы не можете удалить этот пост.</p>
@endcannot
Если соблюдаются соглашения об именовании (модель App\Models\Post ↔ политика App\Policies\PostPolicy) — Laravel автоматически находит политику без явной регистрации в $policies. Достаточно правильных namespace.
Кастомный резолвер auto-discovery — через Gate::guessPolicyNamesUsing(fn ($class) => ...) в boot(). Полезно, если политики лежат вне App\Policies\. Для явности лучше регистрировать вручную в $policies.
before и after в политикеМетод before в самой политике выполняется перед всеми остальными методами. Если вернёт true/false — проверка завершится досрочно. Типичный кейс — разрешить админу всё:
public function before(User $user, string $ability)
{
if ($user->is_super_admin) {
return true;
}
// return null; — продолжить обычную проверку
}
Отличие от Gate::before() — этот before действует только внутри этой политики, а не глобально.
Действие create принимает только пользователя — модели ещё не существует:
public function create(User $user): bool
{
return $user->hasVerifiedEmail();
}
// Вызов в контроллере:
$this->authorize('create', Post::class); // передаём класс, не инстанс
В конструктор политики можно внедрять любые сервисы через DI (Laravel резолвит политику через контейнер):
class PostPolicy
{
public function __construct(
private readonly SubscriptionChecker $subs,
) {}
public function create(User $user): bool
{
return $this->subs->isActive($user);
}
}
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function update(Request $request, Post $post)
{
$this->authorize('update', $post);
// обновляем пост
return redirect()->route('posts.index');
}
}
false. Если политика не имеет метода — доступ запрещён по умолчанию. Исключение — если Gate::before или PostPolicy::before вернули true.view(User $user, Post $post) — Laravel сразу вернёт false для гостя. Чтобы гость мог пройти — сигнатура view(?User $user, Post $post).create. В authorize('create', Post::class) передавайте класс, не инстанс — метод принимает только $user.App\Policies\ или модель вне App\Models\ — регистрируйте вручную в $policies.update обращение к $post->owner->team → на списке из 20 постов будет 40 запросов. Eager-load relations до проверки.- Группировка проверок по сущностям (по одному классу на модель)
- Создание через
php artisan make:policy PostPolicy --model=Post - Регистрация в
AuthServiceProvider::$policies(либо auto-discovery по namespace) - Использование через
$user->can(),authorize(),@can beforeв самой политике — для локальных исключений (админ обходит всё)
Gate::before и Gate::after — механизм, позволяющий перехватывать все проверки авторизации (и Gates, и Policies) до или после их выполнения. Полезны для глобальной логики: супер-администратор проходит всё, логирование попыток доступа, кеширование результатов проверок.
Gate::before — перехват ДО проверкиРегистрирует замыкание, которое выполняется перед любой проверкой авторизации (Gates + Policies).
use Illuminate\Support\Facades\Gate;
Gate::before(function ($user, string $ability, array $arguments) {
// $user — текущий user или null (гость)
// $ability — 'update', 'admin-area', ...
// $arguments — массив аргументов проверки (модель, ID)
});
Правила возврата:
return true→ доступ разрешён, дальнейшая проверка не выполняетсяreturn false→ доступ запрещён, дальнейшая проверка не выполняетсяreturn null→ «ничего не решаю, пусть проверяют дальше» — обычная проверка продолжается
null, если хотите запретить доступ. В PHP null !== false, а Laravel проверяет наличие именно null. Для запрета — только явный return false.Типичный пример — супер-администратор:
Gate::before(function ($user, string $ability, array $arguments) {
if ($user && $user->is_root) {
return true; // root имеет доступ ко всему
}
return null; // остальные — обычная проверка
});
Gate::after — перехват ПОСЛЕ проверкиРегистрирует замыкание, выполняющееся после завершения проверки (как успешной, так и неудачной). Может переопределить результат, но это редко используется.
Gate::after(function ($user, string $ability, array $arguments, bool $result) {
// $result — итог обычной проверки
// return true/false — переопределить
// return null — оставить как есть
});
Пример — логирование всех проверок:
Gate::after(function ($user, string $ability, array $arguments, bool $result) {
Log::info("Auth check: user {{$user->id}} ability {{$ability}} result " . ($result ? 'ALLOW' : 'DENY'));
return null; // не меняем результат
});
Пример — переопределение результата (редкая практика):
Gate::after(function ($user, string $ability, array $arguments, bool $result) {
if ($ability === 'view-sensitive' && $user->is_verified) {
return true; // даже если проверка вернула false — даём доступ
}
return null;
});
Для одной проверки Gate::allows('update', $post) порядок следующий:
- Выполняется глобальный
Gate::before(). Если вернулtrue/false— проверка сразу завершается. Еслиnull— идём дальше. - Если разрешение определено через
Gate::define()— вызывается его замыкание. - Иначе, если существует Policy для класса модели — вызывается соответствующий метод политики.
- Внутри Policy может быть свой
before()(только для этой политики) — выполняется до конкретного метода, но после глобальногоGate::before(). - После получения результата выполняется глобальный
Gate::after(), который может его переопределить.
Таким образом, Gate::before имеет наивысший приоритет — может полностью отменить дальнейшую проверку.
Gate::before vs Policy::before| Характеристика | Gate::before | Policy::before |
|---|---|---|
| Область действия | Глобальная — ко всем проверкам приложения | Локальная — только для этой политики |
| Где регистрируется | AuthServiceProvider::boot() |
Метод before() внутри класса политики |
| Приоритет | Первый — до всего остального | После глобального Gate::before |
| Типичное применение | Root, feature-флаги, maintenance | «Админ может всё с этой моделью» |
Когда использовать Gate::before
- Супер-администраторов (root) — единственная запись, разрешающая всё
- Гостевой доступ — авто-запрет для неаутентифицированных (но лучше через middleware
auth) - Feature-флаги и maintenance — глобальное включение/выключение доступа
Когда использовать Gate::after
- Логирование всех проверок авторизации (аудит)
- Мониторинг — подсчёт отказов, метрики, аномалии
- Кеширование результатов проверок (реже, требует продуманной инвалидации)
Gate::before снижает читаемость и предсказуемость — оставьте туда только глобальные исключения (root, флаги). Конкретные правила — в Gates и Policies.// AuthServiceProvider::boot()
public function boot(): void
{
$this->registerPolicies();
// Глобальный before: root проходит всё
Gate::before(function ($user, string $ability, array $arguments) {
if ($user && $user->is_root) {
return true;
}
return null;
});
// Глобальный after: логирование в отдельный канал
Gate::after(function ($user, string $ability, array $arguments, bool $result) {
Log::channel('auth')->info('Auth check', [
'user' => $user?->id,
'ability' => $ability,
'result' => $result ? 'ALLOW' : 'DENY',
]);
return null;
});
// Обычные Gates
Gate::define('admin-area', fn($user) => $user->is_admin);
}
Gate::before— глобальный перехват до проверки.true=разрешено,false=запрещено,null=продолжить.Gate::after— глобальный перехват после. Может переопределить результат, но обычно используется для побочных эффектов (логирование, метрики).before— для root и глобальных правил. Не для сложной бизнес-логики.after— для аудита и мониторинга.- Приоритет:
Gate::before→Gate::define/Policy::before→ метод Policy →Gate::after.
final class PostPolicy
{
public function viewAny(?User $user): bool
{
return true; // публичный список
}
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id || $user->hasRole('editor');
}
public function delete(User $user, Post $post): bool
{
return $this->update($user, $post) && ! $post->isPublished();
}
}
// Контроллер
public function update(UpdatePostRequest $request, Post $post): RedirectResponse
{
$this->authorize('update', $post); // бросит 403 если не разрешено
$post->update($request->validated());
return redirect($post->url());
}
// Auto-discovery: policy для Post автоматически находится, если
// PostPolicy лежит в app/Policies/. Иначе — регистрация явно.
auth()->user() возвращает null. Если запрос не прошёл через auth middleware, user() вернёт null. Code auth()->user()->id упадёт. Используйте auth()->id() или явные проверки.before и after. Можно объявить методы before/after прямо в policy. before возвращает true/false/null — null означает «продолжить обычную проверку».?User $user — guest пройдёт. Если просто User — Laravel сразу вернёт false для guest без вызова метода.auth()->user() возьмёт default guard. Уточняйте: auth('admin')->user().Gate::define вызван в register() провайдера — Gate ещё не готов. Только в boot().can на Eloquent-модели. $user->can('update', $post) требует, чтобы у User был трейт Authorizable (он есть по умолчанию).PostPolicy::update($user, $post) может обращаться к $post->owner — N+1 на странице со списком постов. Eager load relations перед проверкой policy.actingAs($user) ставит user в default guard. Для API: Sanctum::actingAs($user, ['*']) или actingAs($user, 'api').Laravel Octane — обёртка над Swoole / RoadRunner / FrankenPHP, превращающая Laravel в long-running процесс. Bootstrap выполняется один раз на воркер, запросы обрабатываются без переинициализации фреймворка. Это даёт x5-x10 ускорение. Цена — изменение модели жизненного цикла: то, что в PHP-FPM «само очищается» между запросами, в Octane живёт между ними и приводит к утечкам состояния.
Singleton переживает запросы
В FPM singleton живёт до конца запроса. В Octane — до перезапуска воркера (десятки тысяч запросов). Состояние, накопленное в singleton, утечёт.
Static-свойства
То же: static $cache = [] в классе остаётся между запросами. Не используйте static для request-scope данных.
Scoped bindings
Решение для request-scope: $app->scoped(...) — singleton на запрос, сбрасывается между запросами через Octane::flushApplicationState().
Concurrent tasks
Octane::concurrently([fn () => ..., fn () => ...]) — параллельное выполнение в отдельных воркерах. Полезно для агрегации данных из разных источников.
Ticks
Octane::tick('foo', fn () => ..., 60) — периодическое выполнение раз в N секунд в каждом воркере. Кеш в памяти, прогрев данных.
// Singleton с request-state — утечка
$app->singleton(RequestContext::class);
// Scoped — сбрасывается между запросами
$app->scoped(RequestContext::class);
// Static с per-request данными — утечка
final class UserCache
{
private static array $loaded = []; // растёт между запросами
}
// Зависимость от scoped-кеша или нормального Cache
final class UserCache
{
public function __construct(private CacheRepository $cache) {}
public function get(int $id): User
{
return $this->cache->remember("user:{$id}", 300, fn () => User::findOrFail($id));
}
}
config(['key' => 'value']) в FPM сбросится с концом запроса. В Octane — переживёт. Все вызовы должны быть только чтение.--max-requests=500 освобождает.Event::listen, копятся между запросами. Регистрация — только в Service Provider.Model::addGlobalScope(...) в коде запроса добавит scope для всех воркеров до перезапуска.cursor() или chunk() вместо get().RequestReceived, RequestHandled, RequestTerminated события. Регистрируйте сброс состояния через них, не через middleware terminate.laravel new laravel-ch
cd laravel-ch
# или без установщика
composer create-project laravel/laravel laravel-ch
Установщик сам копирует .env и генерирует ключ приложения. По умолчанию проект настроен на SQLite: файл database/database.sqlite уже создан, базовые миграции прогнаны. Проверяем, что всё поднялось:
php artisan about # версии и драйверы одним экраном
php artisan serve # http://127.0.0.1:8000
Загруженные файлы лежат в storage/app/public, а отдаёт их веб-сервер из public. Связывает эти два места символическая ссылка:
php artisan storage:link
php artisan storage:link --relative.В свежем проекте нет файла routes/api.php — его создаёт команда установки API. Она же ставит Sanctum и публикует миграцию таблицы токенов:
php artisan install:api
# что произойдёт
- Installing laravel/sanctum: Extracting archive
INFO Published API routes file.
INFO Running migrations.
..._create_personal_access_tokens_table .......... DONE
INFO API scaffolding installed. Please add the
[Laravel\Sanctum\HasApiTokens] trait to your User model.
Последняя строка — не пожелание, а обязательный шаг. Без трейта у модели не будет метода createToken:
// app/Models/User.php
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable implements MustVerifyEmail
{
use HasFactory, Notifiable, HasApiTokens;
}
Call to undefined method App\Models\User::createToken(). Ошибка вылезает не при установке, а позже, при первой попытке выдать токен, поэтому связь с пропущенным шагом неочевидна.php artisan make:controller Api/AuthController
Регистрация проверяет данные, создаёт пользователя и поднимает событие Registered — именно оно отправляет письмо с подтверждением адреса:
// app/Http/Controllers/Api/AuthController.php
public function register(Request $request): JsonResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user)); // письмо с подтверждением
return response()->json([
'user' => $user,
'status' => 'registered',
'verified' => false,
], 201);
}
Вход проверяет учётные данные и выдаёт токен. Токен имеет смысл выдавать только подтверждённому пользователю:
public function login(Request $request): JsonResponse
{
$request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (! Auth::attempt($request->only('email', 'password'))) {
// 401, а не 200: клиент должен отличать отказ от успеха по коду
return response()->json(['message' => 'Invalid login details'], 401);
}
$user = User::where('email', $request->email)->firstOrFail();
if ($user->email_verified_at === null) {
return response()->json(['verified' => false], 403);
}
return response()->json([
'user_token' => $user->createToken('auth_token')->plainTextToken,
'token_type' => 'Bearer',
'verified' => true,
]);
}
200 с полем вроде "status": "failed". Так делать не стоит: клиент обязан отличать отказ от успеха по коду, а не по содержимому тела. Неверные учётные данные — это 401, неподтверждённая почта — 403. Отдельно встречается опечатка stus вместо status — она кочует из статьи в статью.// routes/api.php
use App\Http\Controllers\Api\AuthController;
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
// Ссылка из письма: подпись проверяет middleware signed
Route::get('/email/verify/{id}/{hash}', [AuthController::class, 'verifyEmail'])
->middleware(['signed', 'throttle:6,1'])
->name('verification.verify');
// Всё, что требует токена
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', fn (Request $request) => $request->user());
});
routes/api.php автоматически получают префикс /api. То есть Route::post('/login') доступен как POST /api/login. Проверить полный список: php artisan route:list.# Регистрация
curl -sS -X POST http://127.0.0.1:8000/api/register \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-d '{"name":"user","email":"user@example.com","password":"secret123","password_confirmation":"secret123"}' | jq
# Вход: сохраняем токен в переменную
TOKEN=$(curl -sS -X POST http://127.0.0.1:8000/api/login \
-H 'Content-Type: application/json' -H 'Accept: application/json' \
-d '{"email":"user@example.com","password":"secret123"}' | jq -r '.user_token')
# Защищённый маршрут
curl -sS http://127.0.0.1:8000/api/user \
-H "Authorization: Bearer $TOKEN" \
-H 'Accept: application/json' | jq
Accept: application/json обязателен. Без него Laravel при ошибке валидации отдаёт не JSON, а редирект или HTML-страницу — и jq спотыкается на разборе. Это же причина загадочных ответов при проверке API из терминала.Флаг -m создаёт модель вместе с миграцией — два файла одной командой:
php artisan make:model Product -m
php artisan make:model Order -m
INFO Model [app/Models/Product.php] created successfully.
INFO Migration [database/migrations/..._create_products_table.php] created successfully.
Созданная миграция пустая — в ней только идентификатор и метки времени. Колонки описываем сами:
// database/migrations/..._create_products_table.php
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('sku')->unique();
$table->string('name');
$table->string('type')->nullable();
// деньги — только decimal: float теряет копейки
$table->decimal('price', 12, 2);
$table->string('currency', 3)->default('KZT');
$table->string('image')->nullable();
$table->timestamps();
});
}
// database/migrations/..._create_orders_table.php
Schema::create('orders', function (Blueprint $table) {
$table->id();
// внешний ключ: тип колонки и связь одной строкой
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained()->restrictOnDelete();
$table->unsignedInteger('quantity')->default(1);
$table->decimal('total', 12, 2);
$table->string('status')->default('new');
$table->timestamps();
});
Модель по умолчанию пустая. Чтобы массовое заполнение работало, перечисляем разрешённые поля и описываем связи:
// app/Models/Product.php
class Product extends Model
{
protected $fillable = ['sku', 'name', 'type', 'price', 'currency', 'image'];
protected function casts(): array
{
return ['price' => 'decimal:2'];
}
public function orders()
{
return $this->hasMany(Order::class);
}
}
// app/Models/Order.php
class Order extends Model
{
protected $fillable = ['user_id', 'product_id', 'quantity', 'total', 'status'];
public function user() { return $this->belongsTo(User::class); }
public function product() { return $this->belongsTo(Product::class); }
}
Колонка status у заказа — строка, а не enum в миграции, и это осознанный выбор: новый статус добавляется правкой PHP-файла, без ALTER TABLE на рабочей таблице. Чтобы при этом не сверять строки в коде, список статусов описывают перечислением и привязывают его к модели:
php artisan make:enum Enums/OrderStatus --string
// app/Enums/OrderStatus.php
namespace App\Enums;
enum OrderStatus: string
{
case New = 'new';
case Paid = 'paid';
case Shipped = 'shipped';
case Canceled = 'canceled';
}
// app/Models/Order.php — приведение типа
use App\Enums\OrderStatus;
protected function casts(): array
{
return [
'total' => 'decimal:2',
'status' => OrderStatus::class,
];
}
После этого $order->status === OrderStatus::Paid вместо сравнения с 'paid', а в правилах запроса на оплату — Rule::enum(OrderStatus::class) вместо перечисления строк через in:.
enum в миграции и строки с перечислением, что enum() создаёт в разных СУБД и чем грозит значение, которого в перечислении нет, — в разделе Миграции, модели, сидеры.php artisan migrate # применить
php artisan migrate:status # что уже применено
Если в проекте есть категории с вложенностью, таблица ссылается сама на себя через parent_id. Тут два правила, которые нарушают чаще всего: колонка обязана быть nullable, потому что у корневых категорий родителя нет, и её тип должен совпадать с типом id — то есть BIGINT UNSIGNED, что и даёт foreignId(). Отдельной колонки со списком детей быть не должно: дети вычисляются запросом, а в модели описываются отношением children.
php artisan make:model Category -m
$table->foreignId('parent_id')->nullable()
->constrained('categories')->cascadeOnDelete();
orders ссылается на products, таблица продуктов должна создаваться раньше — иначе внешний ключ не на что навесить. При создании моделей подряд порядок получается верным сам собой, но при ручном переименовании файлов его легко нарушить.php artisan make:factory ProductFactory --model=Product
php artisan make:seeder ProductSeeder
// database/factories/ProductFactory.php
public function definition(): array
{
return [
'sku' => $this->faker->unique()->bothify('SKU-####'),
'name' => $this->faker->words(3, true),
'price' => $this->faker->randomFloat(2, 100, 50000),
'currency' => 'KZT',
];
}
// database/seeders/ProductSeeder.php
public function run(): void
{
Product::factory()->count(20)->create();
}
php artisan db:seed --class=ProductSeeder
php artisan migrate:fresh --seed # пересоздать всё и наполнить
migrate:fresh удаляет все таблицы. На рабочем сервере это потеря данных без вопросов и подтверждений. Команда предназначена только для локальной разработки.| Шаг | Команда | Где разобрано |
|---|---|---|
| Контроллер ресурса для товаров | php artisan make:controller Api/ProductController --api --model=Product | Controllers |
| Проверка входных данных | php artisan make:request StoreProductRequest | Validation & FormRequest |
| Формат ответа API | php artisan make:resource ProductResource | API Resources |
| Права на действия | php artisan make:policy OrderPolicy --model=Order | Auth, Gates & Policies |
| Фоновая обработка заказа | php artisan make:job ProcessOrder | Queues |
| Тесты | php artisan make:test ProductApiTest | Тестирование — отдельный модуль |
tinker.Реализуем endpoint POST /api/orders: пользователь оформляет заказ, мы списываем платёж, ставим в очередь отправку invoice, эмитим событие, обновляем счётчики. Покажем интеграцию всех слоёв Laravel и где встречаются knock-on эффекты.
// 1. FormRequest — валидация и авторизация
final class StoreOrderRequest extends FormRequest
{
public function authorize(): bool { return $this->user() !== null; }
public function rules(): array
{
return [
'items' => ['required', 'array', 'min:1'],
'items.*.sku' => ['required', 'exists:products,sku'],
'items.*.quantity'=> ['required', 'integer', 'min:1'],
'currency' => ['required', 'in:USD,KZT,EUR'],
];
}
}
// 2. Controller — тонкий, делегирует
final class OrderController
{
public function store(StoreOrderRequest $request, CreateOrder $action): JsonResponse
{
$order = $action($request->user(), $request->validated());
return response()->json(new OrderResource($order), 201);
}
}
// 3. Action — оркестрация бизнес-логики
final class CreateOrder
{
public function __construct(
private PaymentGateway $gateway,
private RiskEngine $risk,
) {}
public function __invoke(User $user, array $data): Order
{
if ($this->risk->score($data) >= 80) {
throw new HighRiskException();
}
return DB::transaction(function () use ($user, $data) {
$order = Order::create([
'user_id' => $user->id,
'status' => 'pending',
'currency' => $data['currency'],
'total_minor' => OrderTotal::calculate($data['items']),
]);
$order->items()->createMany($data['items']);
$chargeId = $this->gateway->charge($order->total_minor, $order->currency, "ord_{$order->id}");
$order->update(['status' => 'paid', 'charge_id' => $chargeId, 'paid_at' => now()]);
// dispatch'аем после commit транзакции
SendInvoice::dispatch($order->id)->afterCommit();
OrderPaid::dispatch($order);
return $order;
});
}
}
Что демонстрирует пример: FormRequest валидирует и авторизует; Controller остаётся тонким; Action — единица бизнес-логики; транзакция оборачивает все БД-операции; afterCommit гарантирует, что job не отправится раньше commit'а; событие OrderPaid подключает дополнительные слушатели без правок Action. Все слои Laravel работают в паре, каждый отвечает за своё.
config:cache с динамическими значениями. Кеш собирается раз; вызовы env() в коде после кеша вернут null. env() только в конфиге, в коде — config().$fillable. User::create($request->all()) с $guarded = [] пропустит is_admin. Используйте FormRequest и явный whitelist.Model::preventLazyLoading() в локальной разработке.afterCommit для job. Job уйдёт в очередь до commit; воркер не найдёт строку. Используйте ->afterCommit().$app->scoped(...) для request-state.session() в API. API-маршруты обычно без session middleware — session()->get() вернёт null. Для stateless API используйте jwt/Sanctum tokens.->orWhere без скобок. where(...)->orWhere(...)->where(...) — or теряет приоритет. Группируйте: where(fn ($q) => $q->where(...)->orWhere(...)).updated_at при импорте. Mass import через Eloquent обновляет timestamps — теряется исходная дата. Используйте DB::table() или $model->timestamps = false.findOrFail в API без exception handler. Дефолт — 404, но без явного JSON ответа — HTML страница. Кастомизируйте в App\Exceptions\Handler.Mail::send синхронно. Блокирует request на отправку. Используйте Mail::queue или ShouldQueue на Mailable.config('app.url') для генерации ссылок. Без правильной настройки APP_URL ссылки в письмах ведут на http://localhost. Проверяйте в проде.DB::raw с пользовательским вводом. Защита prepared statements теряется. Только параметризованные значения через ? binding.1. Опишите цикл HTTP-запроса в Laravel
(1) public/index.php загружает Composer и bootstrap/app.php. (2) Application создаётся, конфигурируется ядро. (3) Kernel::handle прогоняет bootstrappers (env, config, providers). (4) Провайдеры: все register(), потом все boot(). (5) Pipeline глобальных middleware. (6) Router: подбор маршрута, групповые/маршрутные middleware, route model binding. (7) Controller с DI. (8) Response поднимается через middleware. (9) terminate() после отправки клиенту.
2. Чем register() отличается от boot() в Service Provider?
register() вызывается первым у всех провайдеров — можно только добавлять binding'и в контейнер, нельзя использовать другие сервисы (их ещё нет). boot() — после того как все провайдеры завершили register(), доступны все сервисы и фасады. Регистрация маршрутов, observer'ов, событий — в boot().
3. Что делает scopeBindings() и зачем?
Если в маршруте два параметра-модели через relation (/users/{user}/posts/{post:slug}), без scopeBindings Laravel найдёт пост по slug в любой таблице. С scopeBindings — только среди постов конкретного user. Защита от чтения чужих данных через подмену slug.
4. Когда middleware-метод terminate не работает?
Требует поддержки fastcgi_finish_request или эквивалента в SAPI. PHP-FPM — поддерживает. Built-in artisan serve — нет; на нём terminate работает синхронно, удлиняя ответ. Также в Octane модель другая.
5. Чем $user->orders отличается от $user->orders()?
$user->orders — magic-property, возвращает Collection заказов, lazy-loaded при первом обращении. $user->orders() — метод, возвращает HasMany query builder для дальнейшей фильтрации: $user->orders()->where('status', 'paid')->get().
6. Что такое cache stampede и как с ним бороться?
Когда дорогой ключ устаревает, множество одновременных запросов начинают его регенерировать параллельно, перегружая БД. Решение — atomic lock через Cache::lock(key)->block(timeout, fn() => ...): только один запрос пересоздаёт, остальные ждут или возвращают stale-значение.
7. Что произойдёт, если job в очереди dispatched внутри транзакции, а транзакция откатится?
Без специальных мер job уже в очереди и будет выполнен — обработчик попробует найти строку, которой нет (rollback). Решение: dispatch()->afterCommit() или public bool $afterCommit = true; в job. Job попадает в очередь только после успешного commit.
8. Почему job должен быть идемпотентным?
При retry handle() вызывается N раз при сбоях. Без идемпотентности (например, отправка письма без проверки факта отправки) пользователь получит N писем. Решение: проверка флага в начале handle (if ($order->invoice_sent_at) return;) или использование уникальных идентификаторов на стороне внешнего сервиса (idempotency key).
9. В чём разница между Event и Job?
Event — декларация факта (что-то произошло), Job — задача (что нужно сделать). Event может иметь несколько listeners, каждый — отдельная единица работы. Job — единичная задача. Подписка на Event можно добавить без правок основного кода; новый Job требует явного dispatch. Используйте Events для slojnoy логики; Jobs — для атомарных действий.
10. Чем gate отличается от policy?
Gate — глобальная функция авторизации, не привязанная к модели: Gate::define('admin-area', fn ($user) => ...). Policy — класс с методами авторизации для конкретной модели: UserPolicy::update($admin, $target). Policy лучше масштабируется на CRUD-операции; Gate — для абстрактных разрешений (доступ к админке, право видеть метрики).
11. Что делает Gate::before и когда уместен?
Глобальный хук перед любой проверкой. Возврат true — разрешает всё; false — запрещает всё; null — обычная проверка. Уместен для super-admin: Gate::before(fn ($user) => $user->is_root ? true : null). Опасно использовать для основной логики — скрывает правила.
12. Как разделить аутентификацию для web и для API?
Через guards в config/auth.php. web — session-based (cookies). api — token-based (sanctum/passport). Доступ: auth('api')->user(). Маршруты группируются: Route::middleware('auth:web') или auth:sanctum. Default guard — для запросов без явного указания.
13. Почему singleton может быть опасен в Octane?
В классическом PHP-FPM процесс умирает после запроса — singleton сбрасывается. В Octane процесс живёт между запросами; singleton с per-request state (например, корзина, контекст пользователя) утечёт следующему пользователю. Решение: $app->scoped(...) — singleton на запрос, сбрасывается через Octane::flushApplicationState().
14. Что делает Octane::concurrently()?
Выполняет несколько callable параллельно в отдельных воркерах. Возвращает массив результатов в том же порядке. Идеально для агрегации данных из разных источников: [$users, $orders, $stats] = Octane::concurrently([fn () => User::count(), fn () => Order::sum('total'), fn () => Stats::current()]).
15. Как мониторить производительность Laravel-приложения?
(1) Slow query log БД. (2) Laravel Telescope (только в dev/staging — он сам тяжёлый). (3) Внешние APM: New Relic, Datadog, Tideways — видят time-per-request с разбивкой по уровням (контроллер, БД, внешние API). (4) Horizon для очередей — throughput, failed jobs, wait time. (5) Метрики из приложения через middleware: timing, status codes, размер ответа — в Prometheus/Datadog.