О разделе

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, providersKB_13
Eloquent advanced (полиморфизм, observers, race)KB_12
Хелперы и фасадыKB_10
Наследование Controller/Model/FormRequestKB_9
Тестирование Laravel-приложенийKB_6 + KB_14
Установка и зависимости
Что должно быть на машине

Laravel — это пакет Composer, а не отдельная программа. Поэтому ставят не «Laravel», а связку: интерпретатор PHP нужной версии, менеджер пакетов Composer и, если во фронтенде есть сборка, Node с npm. Всё остальное фреймворк подтянет сам.

ЧтоВерсияЗачем
PHP8.3 и выше для Laravel 13Требование записано в composer.json самого фреймворка: "php": "^8.3"
Composer2.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
Отдельно ставят то, что нужно драйверу базы. Для MySQL это pdo_mysql, для PostgreSQL — pdo_pgsql, для SQLite — pdo_sqlite. Их нет в списке выше, потому что фреймворк не знает заранее, какую базу вы возьмёте. Симптом отсутствия: could not find driver при первой же миграции.
Установка PHP и Composer

Если 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
Для macOS есть третий путь — Herd. Это готовая среда разработки: внутри уже лежат PHP, Nginx и команды 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     # и наполнить тестовыми данными
Базу данных Laravel не создаёт. Миграции наполняют уже существующую схему. Если её нет, вы получите 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 и авторизация SPAcomposer require laravel/sanctum
Laravel HorizonПанель и метрики очередей на Rediscomposer 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 в продакшене — это и лишняя нагрузка, и утечка данных: они пишут содержимое запросов, включая заголовки с токенами.
Пакеты авторизации вынесены отдельно. Команды установки Sanctum и Socialite, разбор стартовых наборов и выбор между ними — в разделе Аутентификация: выбор инструмента.
Проверка, что всё поднялось
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 фреймворка
Architecture Concepts — четыре опоры фреймворка
О чём этот раздел

В официальной документации 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) { /* ... */ });
Когда контейнеру всё-таки нужно объяснять. Документация называет ровно две ситуации. Первая — ты объявляешь в конструкторе интерфейс: контейнер не может угадать, какую из реализаций подставить. Вторая — ты пишешь пакет для других разработчиков и регистрируешь его сервисы. Во всех остальных случаях биндинг не нужен.
1. Request Lifecycle

Путь запроса — всегда один и тот же. Детальный разбор каждого шага в разделе Request Lifecycle, здесь — карта целиком.

public/index.php │ автолоадер Composer │ bootstrap/app.php → создаётся Application (он же контейнер) ▼ handleRequest() → HTTP Kernel (Illuminate\Foundation\Http\Kernel) │ ├─ bootstrappers: окружение, конфиг, логи, обработка ошибок ├─ регистрация service providers: │ сначала register() у ВСЕХ, потом boot() у ВСЕХ │ ▼ глобальный middleware-стек │ ▼ Router: подбор маршрута → middleware маршрута/группы │ ▼ контроллер или замыкание ← здесь контейнер внедряет зависимости │ ▼ Response идёт обратно через middleware │ ▼ $response->send() → браузер
Ключевой момент из документации: сначала выполняется register() у всех провайдеров, и только потом boot() у всех. Именно поэтому в boot() можно рассчитывать, что все биндинги уже зарегистрированы, а в register() — нельзя обращаться к сервисам других провайдеров.
2. Service Container — все способы биндинга
Определение из документации. Service container — инструмент для управления зависимостями классов и выполнения внедрения зависимостей. «Dependency injection — заумная формулировка, которая по сути означает вот что: зависимости класса "внедряются" в него через конструктор или, в некоторых случаях, через сеттеры».

Что это значит на примере

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

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)Нет — автоматически не вызывается
Уточнение про сеттеры. В определении сказано «через конструктор или, в некоторых случаях, через сеттеры» — это описание паттерна DI в целом, а не поведения контейнера Laravel. Контейнер при сборке читает только конструктор: в Container вызывается $reflector->getConstructor(), и разрешаются его параметры. Слова setter в коде контейнера нет вообще. Сеттер-инъекцию делают руками — либо вызовом сеттера в замыкании биндинга, либо через хук:

$this->app->resolving(Podcast::class, fn ($p, $app) => $p->setApple($app->make(AppleMusic::class)));
Документация отдельно отмечает: глубокое понимание контейнера необходимо и для построения больших приложений, и для участия в разработке самого ядра Laravel. Это не внутренняя деталь, которую можно пропустить.

Разбор идёт по структуре официальной документации. Контейнер — это набор ассоциативных массивов; каждый способ биндинга пишет в один из них, и по тому, куда он пишет, сразу понятны время жизни и приоритет.

СпособЧто с чем связываетВремя жизниХранилище
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) — или когда контейнер сам создаёт контроллер — происходит ровно это:

  1. Контейнер смотрит на класс Transistor.
  2. Читает его конструктор через рефлексию.
  3. Для каждого параметра конструктора находит зависимость — рекурсивно, то есть у зависимостей тоже разрешаются их зависимости.
  4. Создаёт объект: new Transistor(...$dependencies).
  5. Возвращает готовый экземпляр.

Весь этот процесс и называется resolve. Результат — собранный объект.

Полный порядок внутри resolve()

Понимать порядок важно: от него зависит, что можно сделать на каком этапе. Это фактическая последовательность из Container::resolve():

make(Transistor::class) │ ▼ 1. fireBeforeResolvingCallbacks() ← beforeResolving: объекта ещё НЕТ │ ▼ 2. if (isset($instances[$abstract])) ──→ return $instances[$abstract] │ ранний выход: НИ ОДНО │ событие не сработает ▼ 3. build($concrete) ← рефлексия + рекурсия по зависимостям │ ▼ 4. foreach (getExtenders() as $extender) $object = $extender($object, $this) ← extend(): МОЖЕТ подменить объект │ ▼ 5. if (isShared()) $instances[...] = $object │ ▼ 6. fireResolvingCallbacks(): a) глобальные resolving (без типа) b) resolving для конкретного типа c) afterResolving ← всё ещё ДО возврата │ ▼ 7. $resolved[$abstract] = true │ ▼ 8. return $object

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()

Три вещи, в которых легко ошибиться

1. В resolving() нельзя подменить объект. Возвращаемое значение колбэка игнорируется — в fireCallbackArray() стоит просто $callback($object, $this); без присваивания. Объект можно мутировать (вызвать сеттер, выставить свойство), но вернуть вместо него другой — нет. Подмена делается через extend(), который выполняется раньше и результат замыкания как раз использует: $object = $extender($object, $this);
2. afterResolving срабатывает до возврата объекта, а не после. Оба хука живут внутри одного fireResolvingCallbacks(), и порядок там жёсткий: глобальные resolving → типизированные resolving → afterResolving. Разница между ними только в этой очерёдности, а не в том, что один «до возврата», а другой «после». К моменту return $object отработали уже оба.
3. Для синглтона события срабатывают только один раз. При повторном 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')
Все эти способы — Service Locator, а не DI. Зависимость не видна в сигнатуре класса, и собрать тест, не читая тело метода, невозможно. Нормальный путь — объявить тип в конструкторе; app() оставить для замыканий роутов, провайдеров и мест, где конструктора просто нет.

5. Где контейнер реализован

Внутри vendor, но знать полезно — я сверялся с этими файлами по всему разделу.

Файл в Illuminate/Роль
Container/Container.phpСам контейнер: bind, make, resolve, build, хуки
Foundation/Application.phpНаследует Container и добавляет всё специфичное для Laravel: пути, окружение, загрузку провайдеров
Container/BoundMethod.phpЛогика вызова методов с внедрением — за этим стоит App::call()
Container/ContextualBindingBuilder.phpFluent-цепочка 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() — это один и тот же объект, и у него доступны и методы контейнера, и методы приложения.

Итог одной картинкой

СОЗДАЁТСЯ bootstrap/app.php (artisan — тот же файл) │ ▼ НАСТРАИВАЕТСЯ bootstrap/providers.php → app/Providers/* bootstrap/app.php: withBindings() / withSingletons() атрибуты #[Bind] / #[Singleton] на классах │ ▼ ИСПОЛЬЗУЕТСЯ неявно (DI): Controllers, Middleware, Listeners, Jobs::handle(), Commands, замыкания роутов явно: $this->app->..., app(), resolve(), App::make(), Container::getInstance() │ ▼ РЕАЛИЗОВАН Illuminate/Container/* + Foundation/Application.php

Прямо руками контейнер трогают в основном в провайдерах. Везде остальное он работает сам — и именно поэтому его легко не замечать.

Практика: контейнер и сервисы

Сначала — сервисы это не контейнеры

Частая путаница: кажется, что «DI-контейнеры» — это папка app/Services. Нет. Контейнер один на всё приложение, сервисов — много.

ПонятиеЧто это
DI-контейнерОдин глобальный объект — экземпляр Illuminate\Foundation\Application. Создаётся один раз в bootstrap/app.php и живёт всю обработку запроса. Хранит биндинги, создаёт объекты, подставляет зависимости
СервисОбычный класс с бизнес-логикой: OrderService, PaymentService. Контейнером не является — он его содержимое
БиндингЗапись в контейнере: «когда просят это — отдавай то»
DIМеханизм, которым контейнер подставляет сервисы в конструкторы

Аналогия: контейнер — склад деталей и сборщик; сервисы — изделия, которые из деталей собираются; контроллеры, джобы и команды — заказчики. Склад один, изделий много, заказчиков ещё больше.

┌─────────────────────────────────────────────────┐ │ DI-контейнер = Application │ │ хранит биндинги, создаёт объекты, │ │ рекурсивно разрешает зависимости │ └─────────────────────────────────────────────────┘ ▲ ▲ ▲ │ регистрируются в контейнере │ │ │ │ ┌─────────┐ ┌──────────┐ ┌──────────┐ │ Order │ │ Payment │ │ Report │ ← сервисы │ Service │ │ Service │ │Generator │ └─────────┘ └──────────┘ └──────────┘ ▲ ▲ ▲ │ внедряются через DI │ │ │ │ ┌──────────────────────────────────────────┐ │ Контроллеры · Джобы · Команды │ ← потребители │ Middleware · Listeners │ └──────────────────────────────────────────┘
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):

OrderService ├── OrderRepository │ └── Logger ← без зависимостей, создаётся первым └── StripeGateway └── string $apiKey ← из биндинга, значение из config

Контейнер спускается по графу вниз, а собирает снизу вверх: 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));
    }
}
Что это даёт. В проде работает реальный Stripe, в тестах фейк и никаких запросов в интернет. Переход на PayPal — новый адаптер плюс одна строка биндинга; контроллер не меняется вообще. Это и есть смысл зависимости от интерфейса, а не от класса.
Про 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);
    }
}
Почему в джобе именно так. Конструктор джоба сериализуется при отправке в очередь — объект превращается в строку и ложится в Redis или таблицу. Сервис сериализовать нельзя: внутри него соединения, клиенты, замыкания. Поэтому в конструктор идут только данные (модели через 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, интерфейс отвязывает код от вендора, биндинг выбирает реализацию по окружению
Свой сервисОдин и тот же сервис получается в контроллере, в джобе и в тесте — разными путями, но из одного контейнера
Главная мысль. Контроллеры, джобы и команды не знают, как создаётся зависимость. Они просят интерфейс, а контейнер решает, что подставить: реальный Stripe, фейк для тестов или свой сервис. Отсюда и гибкость, и тестируемость, и независимость от инфраструктуры.
Атрибут vs параметр — два уровня одной сигнатуры

Атрибут не заменяет параметр, а уточняет, как его разрешить. Это две разные вещи в одной строке объявления.

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);
Аргумент необязателен — но зависит от версии. В актуальной документации 13.x #[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,      // свой атрибут
) {}
Два одинаковых атрибута на одном параметре — фатальная ошибка PHP, а не «перезапись». Без флага 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() исчезают из бизнес-кода. Цена — лишний уровень косвенности: чтобы понять, откуда пришло значение, нужно открыть класс атрибута.
3. Service Providers

Провайдеры — центральное место всего бутстрапа приложения. И твой код, и все сервисы ядра Laravel поднимаются через них.

Что значит «bootstrapping». По документации — регистрация: биндингов в контейнер, слушателей событий, middleware и даже маршрутов. Провайдеры это центральное место, где конфигурируется приложение.

Внутри 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() с событиями, маршрутами или макросами — отложить нельзя: этот код должен отработать на каждом запросе, а отложенный провайдер может не загрузиться вовсе.
4. Facades

Фасады дают «статический» интерфейс к классам, доступным в сервис-контейнере. 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 они при этом легко тестируются.

Главная опасность — «scope creep» класса. Формулировка документации: поскольку фасады настолько просты в использовании и не требуют внедрения, класс легко незаметно разрастается и начинает использовать десяток фасадов. При внедрении зависимостей этого не происходит — большой конструктор визуально сигналит, что класс стал слишком велик. С фасадами такого сигнала нет, поэтому следи за размером класса и дроби его, когда область ответственности расползается.

Фасады против внедрения зависимостей

Главный плюс 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();
}

Справочник фасадов

Каждый фасад и класс, который за ним стоит. Третья колонка — ключ биндинга в контейнере, где он есть.

ФасадКлассКлюч биндинга
AppIlluminate\Foundation\Applicationapp
ArtisanIlluminate\Contracts\Console\Kernelartisan
Auth (Instance)Illuminate\Contracts\Auth\Guardauth.driver
AuthIlluminate\Auth\AuthManagerauth
BladeIlluminate\View\Compilers\BladeCompilerblade.compiler
Broadcast (Instance)Illuminate\Contracts\Broadcasting\Broadcaster—
BroadcastIlluminate\Contracts\Broadcasting\Factory—
BusIlluminate\Contracts\Bus\Dispatcher—
Cache (Instance)Illuminate\Cache\Repositorycache.store
CacheIlluminate\Cache\CacheManagercache
ConfigIlluminate\Config\Repositoryconfig
ContextIlluminate\Log\Context\Repository—
CookieIlluminate\Cookie\CookieJarcookie
CryptIlluminate\Encryption\Encrypterencrypter
DateIlluminate\Support\DateFactorydate
DB (Instance)Illuminate\Database\Connectiondb.connection
DBIlluminate\Database\DatabaseManagerdb
EventIlluminate\Events\Dispatcherevents
Exceptions (Instance)Illuminate\Contracts\Debug\ExceptionHandler—
ExceptionsIlluminate\Foundation\Exceptions\Handler—
FileIlluminate\Filesystem\Filesystemfiles
GateIlluminate\Contracts\Auth\Access\Gate—
HashIlluminate\Contracts\Hashing\Hasherhash
HttpIlluminate\Http\Client\Factory—
LangIlluminate\Translation\Translatortranslator
LogIlluminate\Log\LogManagerlog
MailIlluminate\Mail\Mailermailer
NotificationIlluminate\Notifications\ChannelManager—
Password (Instance)Illuminate\Auth\Passwords\PasswordBrokerauth.password.broker
PasswordIlluminate\Auth\Passwords\PasswordBrokerManagerauth.password
Pipeline (Instance)Illuminate\Pipeline\Pipeline—
ProcessIlluminate\Process\Factory—
Queue (Base Class)Illuminate\Queue\Queue—
Queue (Instance)Illuminate\Contracts\Queue\Queuequeue.connection
QueueIlluminate\Queue\QueueManagerqueue
RateLimiterIlluminate\Cache\RateLimiter—
RedirectIlluminate\Routing\Redirectorredirect
Redis (Instance)Illuminate\Redis\Connections\Connectionredis.connection
RedisIlluminate\Redis\RedisManagerredis
RequestIlluminate\Http\Requestrequest
Response (Instance)Illuminate\Http\Response—
ResponseIlluminate\Contracts\Routing\ResponseFactory—
RouteIlluminate\Routing\Routerrouter
ScheduleIlluminate\Console\Scheduling\Schedule—
SchemaIlluminate\Database\Schema\Builder—
Session (Instance)Illuminate\Session\Storesession.store
SessionIlluminate\Session\SessionManagersession
Storage (Instance)Illuminate\Contracts\Filesystem\Filesystemfilesystem.disk
StorageIlluminate\Filesystem\FilesystemManagerfilesystem
URLIlluminate\Routing\UrlGeneratorurl
Validator (Instance)Illuminate\Validation\Validator—
ValidatorIlluminate\Validation\Factoryvalidator
View (Instance)Illuminate\View\View—
ViewIlluminate\View\Factoryview
ViteIlluminate\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.
Контейнер как Service Locator. app(UserRepository::class) в середине метода вместо объявления в конструкторе прячет зависимость: по сигнатуре класса не видно, что ему нужно, и тест нельзя собрать, не читая тело. Явное внедрение в конструктор — норма, app() — исключение.
Итог
  • Контейнер — это ядро. Объект Application и есть контейнер; всё остальное живёт в нём.
  • Ты не пишешь new для контроллеров, сервисов и джобов — контейнер строит граф объектов по типам в конструкторе через рефлексию.
  • Биндинг нужен в двух случаях: интерфейс в типе и разработка пакета. Конкретные классы разрешаются без конфигурации.
  • Провайдеры — место сборки приложения: register() только биндинги, boot() всё остальное, и строго в этом порядке по всем провайдерам.
  • Фасад — не статика, а __callStatic() поверх биндинга. Отсюда и тестируемость, и риск незаметного разрастания класса.
Request Lifecycle
Назначение

Понимание цикла запроса — основа диагностики любого «магического» поведения Laravel. От ошибки 500 до неожиданного middleware-эффекта: всё лечится тем, что разработчик знает, в какой момент запускается какая часть фреймворка. Цикл одинаков и для классического PHP-FPM, и (с оговорками) для Octane — отличие в том, что в Octane bootstrap-фаза происходит один раз на процесс, а не на запрос.

Что такое «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.
Отличие Laravel 11+ от 10 и ниже: раньше эти настройки лежали в 3 разных файлах (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']);
    }
}
Laravel 11+: список провайдеров переехал в 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 (Ядро)

Ядро (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 KernelConsole 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
Общее у обоих:
  1. Запускают bootstrappers (env, config, facades, service providers)
  2. Регистрируют exception handler
  3. Отдают управление роутеру / командному диспетчеру
  4. После обработки — вызывают 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. Просто скрыто под красивую цепочку.

В обоих случаях результат один и тот же: запрос передаётся в ядро для дальнейшей обработки, а полученный ответ отправляется клиенту. Разница только в уровне абстракции — Laravel 11 прячет boilerplate внутрь фреймворка.

Аналогично для 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);
    }
}
Особые случаи
1. Тяжёлая работа в boot() провайдера. boot() выполняется на каждом запросе. Тяжёлый код (загрузка большого JSON, обращение к API) накапливается. Используйте deferred providers или ленивую инициализацию.
2. Глобальный middleware вместо группового. Если middleware нужен только для web, его регистрация глобально замедлит и API. Используйте middleware groups (web/api).
3. terminate без поддержки. Метод terminate вызывается только если веб-сервер поддерживает fastcgi_finish_request (FPM да; built-in artisan serve — нет). На built-in сервере terminate работает синхронно, удлиняя ответ.
4. Подсчёт SQL-запросов в middleware. Включение DB::enableQueryLog() на проде раздувает память: каждый запрос хранится в массиве до конца запроса.
5. config() без config:cache. Без php artisan config:cache Laravel перечитывает все конфиги с диска на каждом запросе (десятки файлов). На проде config:cache обязателен.
6. Логи в storage/logs/laravel.log при больших объёмах. Один файл, отсутствует ротация по умолчанию — за неделю несколько ГБ. Используйте daily канал или внешний агрегатор.
7. Утечка в обработчике exception. Кастомный Handler::render может вернуть response, но забыть закрыть транзакцию или освободить ресурс. Handler::report и render разделены неспроста: report для логов, render для ответа клиенту.
8. Lifecycle в Octane. Bootstrap-фаза в Octane выполняется один раз на воркер. Состояние, оставленное в singleton'ах между запросами, утечёт. Подробно — в разделе Octane.
Bootstrap: providers.php & app.php — глубоко
Важная поправка про Laravel 11+

Список провайдеров лежит не в 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.

Представь: при старте приложения нужно выполнить, скажем, 200 подготовительных действий. Свалить их в один файл — каша. Поэтому Laravel говорит: разложи по классам, каждый класс = один блок подготовки, и дай мне список этих классов.

Если провайдер не зарегистрирован в 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) — и всё работает. Ноль конфигурации.

Финальный контраст: Сервис vs Провайдер
СервисПровайдер
Что делаетБизнес-логикуНастраивает контейнер
Когда выполняетсяКогда ты его вызвалОдин раз при старте приложения, всегда
Сколько раз в проектеДесятки-сотни классовОбычно 1-5
Где лежитapp/Services, app/Actions, где угодноapp/Providers
РегистрацияНе нужна (autowiring)Обязательна, в bootstrap/providers.php
Нужен, если...Есть логика, которую надо где-то держатьКонтейнер сам не догадается, как собрать объект
Что вообще НЕ надо нигде регистрировать

Большая часть классов подключается сама, и это главное, что стоит понять:

ЧтоКак находится
КонтроллерыПо имени в роуте + autowiring контейнера
Модели EloquentПросто new User / через контейнер
Middleware-классыУказываешь в роуте или в withMiddleware()
Jobs, Events, Listeners, NotificationsАвтодискавери по неймспейсу
Form RequestsType-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 vs Service Provider

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

СущностьКогда выполняетсяЧто делаетПример
Middleware На каждый HTTP-запрос — в цепочке до и после контроллера Обрабатывает входящий запрос и исходящий ответ (модифицирует, проверяет, логирует) Проверка CSRF, CORS, сжатие ответа (gzip), логирование времени, throttle rate limit
Service Provider Один раз при старте приложения (register() + boot() для каждого запроса, но до роутинга) Регистрирует сервисы в контейнере, загружает маршруты, события, observers, конфиги, Blade-директивы, макросы EventServiceProvider, RouteServiceProvider, кастомный провайдер для платёжного шлюза
Быстрый тест: нужно ли что-то делать только для этого запроса? — Middleware. Нужно ли настроить приложение так, чтобы потом все запросы работали правильно? — Provider.
Service Providers: 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');
    }
}
Ключевое правило: «сначала весь register(), потом весь boot()» — гарантирует что к моменту boot() любого провайдера все биндинги от других провайдеров уже доступны. Никогда не обращайтесь к другим сервисам из register().
Типичная ошибка: вызвать Route::get(...) или DB::table(...) в register(). Роутер и БД ещё не зарегистрированы → BindingResolutionException или тихая поломка. Всё что использует другие сервисы — только в boot().
Routing
Назначение

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 проверяет.

Практика: scoped bindings vs ручная проверка
// Уязвимо: без кастомного ключа и без 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() нужен ровно тогда, когда кастомного ключа нет.
Route Binding + виды обработчиков — детально

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 vs Scoped Bindings — что именно ты подменяешь

Оба механизма влияют на то, какая модель окажется в аргументе контроллера, но работают на разных уровнях. Explicit заменяет сам способ поиска, Scoped — лишь область поиска.

Explicit BindingScoped 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);
    }
}
Explicit отменяет scoped. Если для параметра зарегистрирован Route::bind('post', ...), то твой колбэк получает только сырое значение из URL — родительская модель в него не передаётся, и никакого скоупа Laravel не добавит. Совмещать мультитенантность через Route::bind() с вложенными ресурсами нужно осознанно: проверку принадлежности родителю в этом случае пишешь сам, внутри колбэка.
Скоуп — не замена авторизации. Scoped binding отвечает на вопрос «принадлежит ли пост этому пользователю», но не на вопрос «имеет ли текущий пользователь право его смотреть». Маршрут /users/{user}/posts/{post} со скоупом по-прежнему отдаст чужой пост, если в URL подставить чужой {user}. Policy всё так же обязательна.
Кеширование маршрутов: route:cache

php artisan route:cache — Artisan-команда, которая компилирует все маршруты приложения в один PHP-файл bootstrap/cache/routes-v7.php. Если файл существует, Laravel на старте подключает его напрямую и полностью пропускает регистрацию маршрутов.

Что происходит без кеша и с ним

Без кеша — на каждый запросС кешем
Файлы маршрутовПарсятся и исполняются routes/web.php, api.php и все подключённыеНе читаются вообще
Объекты RouteСоздаются заново для каждого маршрутаВосстанавливаются из готового массива
Регулярные выраженияКомпилируются для каждого URI с параметрамиУже скомпилированы и сохранены
СтоимостьРастёт линейно с числом маршрутовОдно подключение файла
Важное уточнение. Кеш экономит на регистрации маршрутов, а не на их выполнении. Контроллеры при регистрации не вызываются — инстанцируются они только у того маршрута, который совпал с URL. Поэтому эффект заметен на приложениях с сотнями маршрутов и почти незаметен на десятке.

Команды

# Собрать кеш маршрутов
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. — сейчас его в коде нет. Держать логику в контроллерах по-прежнему стоит, но ради читаемости и тестируемости, а не из-за кеша.

Подводные камни

Кеш не инвалидируется сам. Это слепок маршрутов на момент сборки. Добавил маршрут, выкатил, забыл пересобрать — на проде 404, а локально всё работает. 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 устарел; актуальные причины писать контроллеры — читаемость, тестируемость и переиспользование, а не возможность собрать кеш.
Signed Routes — как устроена подпись

Подписанный 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.');
Подпись не делает ссылку одноразовой. Это самое частое заблуждение. Подписанный URL можно открыть сколько угодно раз, пока не истёк 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 bindingImplicit binding + дополнительное условие через отношение — чтобы нельзя было достать чужую запись. Активируется ->scopeBindings().
Closure routeРоут с обработчиком в виде анонимной функции. Удобно для health-check и редиректов; в актуальных версиях кешируется наравне с контроллерами.
Invokable classКласс с методом __invoke() — альтернатива контроллеру с одним действием. Кешируется.
Signed routeURL с криптоподписью, защищает параметры от подмены. Проверяется middleware signed.
Особые случаи
1. Забытый route:cache после деплоя. Кеш — слепок маршрутов на момент сборки. Выкатили новый маршрут и не пересобрали кеш — на проде 404, а локально всё работает. route:cache обязан быть шагом деплоя, а не ручной операцией.
2. Конфликт маршрутов. /users/{user} и /users/me — первый поймает me как параметр. Объявляйте конкретные маршруты раньше параметризованных.
3. Implicit binding без findOrFail. Если параметр маршрута не существует, Laravel вернёт 404 автоматически. Но если в кастомной логике написать where(...)->first() — может вернуть null. Используйте firstOrFail.
4. Подписанные ссылки без таймаута. signedRoute без temporarySignedRoute валиден вечно. Для magic-link нужен короткий expiry (15 минут).
5. Throttle без identifier. throttle:60,1 без указания ключа ограничивает по IP. За NAT пользователь может «съесть» лимит для всех. Используйте throttle на user_id для аутентифицированных.
6. Группа maintenance в route файле. Если включить php artisan down, доступ к маршрутам блокируется. Исключения — через --secret и middleware preventDuringMaintenance.
7. Cross-route data leakage. Использование request()->input(...) вместо параметров маршрута: значение тянется из тела, query или route — приоритет неочевиден. Явно типизируйте через FormRequest.
8. fallback ловит всё. Route::fallback(...) срабатывает на любой не-matched URL. Если в нём опечатка в check'е — пользователь может попасть на код, не предназначенный для публичного доступа.
Controllers
Обзор + структура папки

Контроллер — класс, который группирует связанные обработчики 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/).
Тонкий vs толстый контроллер

Ключевой архитектурный принцип: контроллер должен быть тонким. Его задача — принять запрос, делегировать работу, вернуть ответ. Всю бизнес-логику вынести в отдельные классы (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 Controllers — 7 методов CRUD

Resource Controller — стандартный паттерн Laravel для CRUD-ресурса. Одна команда генерирует контроллер с 7 методами, одна строка в роутере — 7 маршрутов.

Генерация:

php artisan make:controller PostController --resource --model=Post

Регистрация в роутах:

Route::resource('posts', PostController::class);

Одна строка создаст следующие 7 маршрутов:

МетодURLДействиеRoute name
GET/postsindex — списокposts.index
GET/posts/createcreate — форма созданияposts.create
POST/postsstore — сохранить новыйposts.store
GET/posts/{post}show — показать одинposts.show
GET/posts/{post}/editedit — форма редактирования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 / Shallow resources

Nested resource — вложенные ресурсы через точку. Полезно когда URL отражает иерархию: комментарии принадлежат посту.

Route::resource('posts.comments', CommentController::class);

Регистрирует маршруты вида:

  • GET /posts/{post}/comments
  • POST /posts/{post}/comments
  • GET /posts/{post}/comments/{comment} — оба параметра в URL
  • PUT /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} — не вложено
Для безопасности с nested resources — ->scopeBindings() (см. раздел Routing), чтобы комментарий искался только среди комментариев конкретного поста, а не глобально.
Middleware в контроллере (Laravel 10 vs 11+)

В 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']),
        ];
    }
}
В новых проектах на Laravel 11+ рекомендуется назначать middleware в роутах (группы или ->middleware()). Это делает конфигурацию явной — открыл routes/web.php и сразу видишь всю картину доступа.
Single Action Controllers — __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);
// Никакого второго параметра — не нужно указывать имя метода
Подробнее — в разделе Routing → Invokable Classes. Такие классы кешируются route:cache и хорошо ложатся на паттерн Action (один класс = одно действие).
Возврат ответов из контроллера

Laravel автоматически превращает возвращаемое значение в HTTP-ответ. Что можно вернуть из метода контроллера:

Что вернутьЧто получитсяПример
СтрокаContent-Type: text/htmlreturn 'Hello';
Массив / коллекцияАвтоматически JSONreturn ['status' => 'ok'];
Eloquent-модельАвтоматически JSON (через toArray)return User::find(1);
view(...)Отрендеренный HTMLreturn view('posts.show', compact('post'));
response()->json(...)Явный JSON с controlreturn response()->json($data, 201);
redirect(...)HTTP 302 + Locationreturn redirect()->route('home');
back()Редирект на предыдущую страницуreturn back()->withErrors($errors);
response()->download(...)Файл с Content-Disposition: attachmentreturn response()->download('/path/file.pdf');
response()->streamDownload(fn())Стрим — файл создаётся на летуДля больших CSV / отчётов
response()->noContent()HTTP 204 без телаПосле destroy в API
JsonResourceФорматированный JSON через API Resourcereturn 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() для передачи данных в view

compact() — встроенная функция 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].

Плюсы: короче, чем массив с ключами вручную, особенно когда переменных 3–5. Критика: имена ключей неочевидны без чтения ближайшего кода — новичок не всегда понимает, откуда взялись переменные во view. В сообществе Laravel применяется широко — вопрос стиля команды.
Опечатка в имени = тихая ошибка. compact('psot') (вместо 'post') в PHP 7.4− молча возвращал []. В PHP 8.0+ бросает Warning, но не Error — во view переменная просто окажется undefined. IDE обычно подсвечивают. Явный массив ['post' => $post] статически проверяем лучше.
Actions & Services — архитектура бизнес-логики
Обзор: почему тонкий контроллер

Контроллер отвечает только за приём HTTP-запроса и формирование HTTP-ответа. Всё что «внутри» — бизнес-логика, транзакции, работа с внешними сервисами, отправка писем — должно жить в отдельных классах. Их называют по-разному: Services, Actions, UseCases, Interactors. Все они делают одно и то же — инкапсулируют операцию, чтобы её можно было переиспользовать, тестировать и менять независимо от HTTP-слоя.

Проблемы толстого контроллера:

  • Дублирование. Если ту же логику нужно вызвать из консольной команды / очереди / другого контроллера — придётся копировать.
  • Сложное тестирование. Юнит-тест логики требует поднимать HTTP-контекст ($request, роутер, middleware).
  • Смешение слоёв. Контроллер знает про SQL, транзакции, отправку email, платёжный шлюз — нарушение SRP.
  • Невозможно переиспользовать между веб/API. Веб-контроллер редиректит, API-контроллер отдаёт JSON, а логика одна и та же — не выносится.

Что даёт вынесение в Actions/Services:

  • Один класс — одна операция, легко читать и найти в коде.
  • Тестируется без HTTP-контекста, зависимости мокаются через DI.
  • Переиспользуется из контроллера, job, artisan-команды, теста, listener.
  • Контроллер становится оркестратором: принял запрос → делегировал → вернул ответ.
Мантра. «Контроллер — тонкий, бизнес-логика — снаружи». Всё что не про HTTP — вон из контроллера. Контроллер не должен знать про транзакции, платёжные шлюзы, email, кеш, внешние API.
Service Layer — сервисный слой

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-команды, теста.
Антипаттерн: «God Service» на 40 методов и 3000 строк. Признак — в конструкторе 8+ зависимостей. Разбивайте на несколько сервисов по под-темам (например, UserRegistrationService, UserProfileService, UserSecurityService) или переходите на Actions (одна операция = один класс).
Action-паттерн — single-purpose класс

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).
  • Атомарен: всё что нужно для завершения одной бизнес-операции — внутри одного класса.
Как invokable action — можно использовать __invoke() вместо handle(), тогда вызов сокращается до $action($user, $items). Также подходит для Route::get(..., PlaceOrder::class) — Laravel сам вызовет __invoke.
Service vs Action vs UseCase — терминология

Разные команды используют разные названия для одной и той же идеи. Ниже — как различаются на практике:

ServiceAction / 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 pattern — абстракция над БД

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);
    }
}
В Laravel Repository часто избыточен. Eloquent сам по себе — Active Record, и уже даёт удобное API. Оборачивать User::find() в UserRepository::find() — часто писать один класс поверх другого без выгоды.

Когда Repository оправдан:

  • Планируется реальная замена БД (не гипотетическая): Postgres → MongoDB, БД → внешний API.
  • Сложные query нужно переиспользовать (можно и через Query Scope в модели).
  • Строгий DDD с полной изоляцией доменной модели от Eloquent.
  • Тесты — но mockаются модели легко и без Repository через Model::factory()->make().
В 90% Laravel-проектов Repository не нужен — Eloquent используется напрямую в Actions/Services.
Правило: добавляй Repository только когда есть конкретная причина. «Может пригодится» — не причина. Начинайте с прямого использования Eloquent в Actions/Services; если станет реально сложно — вынесите в Repository потом.
Куда положить: структура папок

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-команды.
Feature-тесты (через $this->post()) остаются — проверяют интеграцию контроллер + Action + middleware + validation. Но основную бизнес-логику тестируйте юнит-тестами Actions/Services — их проще писать и они на порядок быстрее.
Рефакторинг: толстый контроллер → тонкий (5 шагов)

Пошаговый разбор реального рефакторинга: как жирный OrderController::store «худеет» до 4 строк. Каждый шаг — одно ответственное изменение, каждое улучшение можно смёрджить отдельно.

Исходный код — жирный контроллер

Что делает OrderController::store в исходном виде:

  1. Валидация полей через $request->validate() (без FormRequest)
  2. Бизнес-проверка: сумма корзины < 100 → ошибка
  3. Создание заказа в БД
  4. Резервирование товаров на складе
  5. Оплата через Stripe
  6. Отправка уведомления на email синхронно

Проблемы: валидация бизнес-правила смешана с созданием · БД и внешние сервисы (склад, Stripe) внутри контроллера · синхронная почта тормозит ответ · тестовой изоляции нет.

Шаг 1 — вынести валидацию в FormRequest
// 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.

Шаг 2 — вынести бизнес-логику в Action
// 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'ом.
Шаг 3 — интерфейс и реализация для оплаты
// 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.

Шаг 4 — событие и listener для уведомления
// 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 доставляется параллельно.

Шаг 5 — тонкий контроллер
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(...)
Практика: PlaceOrder Action с транзакцией + Event

Полный пример реальной бизнес-операции: разместить заказ. Валидация в 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
Назначение

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

Каждый 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 всё нормально.
Практика: middleware с проверкой и логированием
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');
Особые случаи
1. Изменение request в handle. $request->merge(['foo' => 'bar']) работает, но следующие middleware и controller получат изменённый объект. Это может ломать тесты, ожидающие исходный запрос.
2. Возврат response из handle без $next. Если middleware вернул response, дальнейшие middleware и controller не выполняются — это используется для редиректов, авторизации. Удобно, но легко создать «невидимый» branch в логике.
3. terminate на built-in сервере. php artisan serve не поддерживает fastcgi_finish_request — terminate работает синхронно, удлиняя ответ.
4. Порядок middleware. Глобальные → групповые → маршрутные. Внутри каждой категории — в порядке объявления. throttle перед auth — IP-throttle; после auth — user-throttle.
5. Middleware без типизированного $request. $request может быть Symfony\HttpFoundation\Request или Illuminate\Http\Request. Типизируйте Request для доступа к Laravel-методам.
6. CSRF и AJAX. JS-фреймворки отправляют CSRF через заголовок X-CSRF-TOKEN или X-XSRF-TOKEN. Без него POST/PUT/DELETE возвращают 419.
7. Middleware ловит session, для API не нужно. StartSession в API-группе — лишняя нагрузка. По умолчанию в группе api session отключена; если включили — выключите.
8. Утечка состояния в Octane. Middleware-инстанс переиспользуется между запросами в Octane. Свойства middleware с per-request состоянием утекут. Не храните состояние; всё — через параметры handle.
Глобальные middleware — стандартный набор Laravel

Что такое глобальные 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-групп (web / api)

Что такое группы. 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 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 — throttle:60,1 и подобные

Что это. Параметризованный 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 — практический разбор для middleware

Что такое 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-Origin
  • Access-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
Важно помнить. CORS — это браузерная защита; она не мешает запросам из Postman, curl или мобильных приложений. Для публичных API часто используют allowed_origins = ['*'], но это небезопасно для API, работающих с авторизацией — с credentials-режимом такую настройку использовать нельзя. При включении credentials: true (например, для отправки cookies) нельзя ставить * в allowed_origins — нужно указывать конкретный домен и добавить supports_credentials => true.

Итог. CORS — это механизм, который даёт веб-приложениям возможность безопасно запрашивать данные с других доменов, при этом сервер должен явно разрешить такие запросы через специальные HTTP-заголовки. В Laravel настройка CORS сводится к редактированию config/cors.php и подключению соответствующего middleware.

HTTP-объекты Laravel
Обзор: экосистема на Symfony HttpFoundation

Все объекты 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Главный объект приложения (контейнер). Регистрация сервисов, разрешение зависимостей, доступ к конфигурации.
Весь этот механизм называется «Объекты HTTP-запроса и ответа» (HTTP Request & Response Objects) — стандартный способ представления HTTP-взаимодействия в современных PHP-фреймворках (Laravel, Symfony, Slim).
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.

Validation и FormRequest
Зачем нужна валидация

Валидация — это процесс проверки данных, которые приходят от клиента (браузер, мобильное приложение, внешнее 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.

Полная цепочка запроса до момента валидации:

  1. Входящий запрос → public/index.php → Ядро (Kernel).
  2. Глобальные middleware (TrustProxies, HandleCors, TrimStrings и др.).
  3. Групповые middleware (web или api) — сессии, CSRF, throttle и т.д.
  4. Маршрутные middleware (указанные в ->middleware(...)).
  5. Диспетчер роутера определяет контроллер и метод.
  6. Контейнер DI начинает разрешать зависимости для метода контроллера.
  7. Если среди параметров есть FormRequest — контейнер создаёт его экземпляр.
  8. В процессе создания автоматически запускается валидация (через интерфейс ValidatesWhenResolved).
  9. Если валидация провалена — выбрасывается ValidationException, которое перехватывается и превращается в ответ 422 Unprocessable Entity (или редирект с ошибками для веб-запросов).
  10. Если валидация успешна — экземпляр FormRequest передаётся в контроллер, выполнение продолжается.
  11. Контроллер получает уже провалидированные данные и выполняет бизнес-логику.
Ключевое: валидация происходит до начала выполнения контроллера, на этапе разрешения зависимостей в контейнере. Это удобно — в контроллере вы уже уверены в корректности данных. Технически это достигается тем, что FormRequest реализует интерфейс ValidatesWhenResolved, и контейнер после создания вызывает у него метод validateResolved().
Что FormRequest описывает, а что нет

Частое недопонимание: кажется, что FormRequest перечисляет поля, которые окажутся в базе. Это не так. Он описывает контракт входа — что клиенту вообще разрешено прислать, — и проверяет это до того, как запустится контроллер. То, что пользователь выбирает и заполняет, действительно в нём; то, что сохраняется в строку таблицы, — нет.

ЧастьНа какой вопрос отвечаетПример для заказа
FormRequestЧто приходит от клиента?sku и quantity, больше ничего
ActionЧто сохраняем в базу?user_id из токена, цена из products, посчитанный total, status со значением по умолчанию
ResourceЧто отдаём клиенту в ответ?id, позиции, total, status
клиент шлёт JSON │ v StoreOrderRequest проверяет данные по rules() │ ├── не прошло -> Laravel сам отвечает 422 со списком ошибок, │ контроллер не запускается вообще │ └── прошло ---> контроллер получает validated(): только проверенные поля, лишнее отброшено

На коде это выглядит так. В запросе — только то, что выбирает человек:

// 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 эта строка закомментирована, поэтому вызов на нетронутой модели упадёт с «метод не найден».
Чего в authorize() быть не должно
ПроверкаГде ей местоПочему
Пользователь вошёл?MiddlewareИначе $this->user() окажется null и упадёт сам метод, а клиент получит 500 вместо 401
Артикул существует, количество больше нуляrules()Это форма данных. Клиенту нужен 422 с указанием поля, а не глухое «запрещено»
Товар есть на складе, хватает денегActionЭто бизнес-правило, а не право доступа. Оно меняется вместе с логикой и должно быть покрыто тестами Action
Когда проверок прав станет много. Отмена, просмотр, возврат — и в каждом запросе повторяется одна и та же логика принадлежности. Тогда её выносят в политику, а в authorize() остаётся одна строка: return $this->user()->can('cancel', $this->route('order'));. Ради одного создания заказа политику заводить рано — это как раз тот случай, когда абстракция появляется раньше, чем повторение.
Компоненты FormRequest

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().
Откуда в FormRequest берутся user() и validated()

Частый вопрос: «в моём StorePostRequest нигде не написан user(), а он работает — почему?». Ответ — иерархия наследования. FormRequest расширяет обычный Request, поэтому получает все его методы, плюс добавляет свои — валидационные.

Illuminate\Http\Request ← базовый HTTP-запрос │ всё что от Symfony\Request + user(), input(), header(), │ file(), cookie(), session(), route(), ip(), method() ... │ ▼ Illuminate\Foundation\Http\FormRequest ← + валидация │ validated(), safe(), rules(), authorize(), │ prepareForValidation(), passedValidation(), messages() ... │ ▼ App\Http\Requests\StorePostRequest ← твой класс (в нём ты пишешь только rules() и authorize())

Разложение по методам:

МетодОткуда унаследованЧто делает
$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().
Правило-мнемоника: всё что про HTTP (user, input, header, route, cookie) — в 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. Возвращает аутентифицированного пользователя из middleware auth.
  • $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: запрет повторов внутри массива

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() для действий после успешной проверки.

Резюме. FormRequest — рекомендуемый способ для контроллеров: отделяет логику валидации и авторизации от бизнес-логики, делает контроллер тонким, а код — чистым и переиспользуемым. Inline подходит для одноразовых endpoint'ov, Validator::make() — когда валидация нужна вне контроллера.
Все 3 способа — код и где брать данные

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).
Что можно использовать везде, а что только в FormRequest

Саму валидацию (проверку данных по правилам) можно использовать почти везде — в контроллерах, сервисных классах, jobs, консольных командах, даже в моделях (хотя в моделях не рекомендуется — там место бизнес-логики, а не проверки входа).

А вот специфические методы — это эксклюзивные возможности класса FormRequest. Они не работают в $request->validate() или Validator::make():

  • authorize() — авторизация до валидации, возврат 403 если false
  • rules() — массив правил
  • messages() — кастомные сообщения об ошибках
  • attributes() — человекочитаемые имена полей
  • prepareForValidation() — хук перед валидацией (нормализация данных)
  • passedValidation() — хук после успешной валидации

Если нужны эти хуки — только FormRequest. Если нужна просто проверка правил — подойдёт любой из трёх способов.

Практика: 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);
}
Store vs Update Request — почему разделяют

Типичный паттерн для CRUD-моделей — два отдельных FormRequest: один на создание, второй на обновление. Правила почти всегда отличаются (уникальность, обязательность, проверка связанных записей), и разделение позволяет держать оба класса плоскими без if внутри rules().

1. StorePostRequest — создание

Правила применяются только к новым записям. unique без всяких исключений — новой записи ни с чем сравниваться не нужно.

class StorePostRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'title'   => 'required|string|max:255',
            'slug'    => 'required|unique:posts,slug', // уникален в таблице
            'content' => 'required',
        ];
    }
}
2. 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);
    }
}
Правило: любой CRUD-ресурс с полями unique/exists/условной обязательностью → два FormRequest'а. Тонкие, плоские, без if. Для unique при update — обязательно ignore() с ID текущей модели.
Особые случаи
1. $request->all() вместо validated(). Получите всё, включая невалидные поля. Mass-assignment на модель — потенциальная дыра безопасности.
2. required и пустые строки. required считает пустую строку валидной. Для строгости — required|string|min:1.
3. nullable + другие правила. nullable отключает все последующие правила, если значение null. Без nullable — null приведёт к ошибке валидации.
4. sometimes правило. 'name' => 'sometimes|required|string' — проверять только если поле присутствует. Полезно для PATCH-запросов.
5. unique без ignore при update. Без Rule::unique('users')->ignore($this->user) валидация падает на собственном email пользователя.
6. Кастомное правило без $fail(). В Laravel 10+ кастомное правило через invokable использует $fail($message); забывание этого вызова делает правило всегда проходящим.
7. Массивы и dot-notation. 'items.*.sku' => 'required' валидирует каждый элемент массива. Без * — только наличие массива как поля.
8. FormRequest::after(). Laravel 11+ ввёл after() для дополнительных проверок после правил. Полезно для меж-полевой логики (например, start_date < end_date), которую неудобно выразить простыми правилами.
API Resources — форматирование JSON-ответов
Зачем нужны API Resources

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\UserResource vs V2\UserResource).

Создание:

php artisan make:resource UserResource                    # один ресурс
php artisan make:resource UserCollection                # коллекция ресурсов
php artisan make:resource Api/V1/PostResource             # в подпапке

Файлы создаются в app/Http/Resources/.

Всегда ли нужен Resource между Eloquent и JSON

Короткий ответ: нет, не всегда. Resource — это один из способов преобразовать Eloquent-модель в JSON, но не единственный. Laravel сам умеет сериализовать модели, и для простых кейсов посредник не нужен.

4 способа отдать модель как JSON
СпособЧто делаетКогда
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, версионирование, отличная от БД структура
Когда Resource обязателен / сильно упрощает жизнь
  • Кастомизация 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 живут рядом, каждая версия своя.
Когда Resource не нужен
  • Простой CRUD — если возвращаешь модель как есть, return $user; достаточно (Laravel сам сериализует).
  • Быстрый прототип / MVP — где структура ещё не устоялась, ставить Resource преждевременно.
  • Минимальная трансформация — например, вернуть массив ID: return $users->pluck('id');.
  • Внутренний API между микросервисами — где контракт задаётся отдельно, а Laravel — внутренняя реализация.
Практическое правило одной фразой: если JSON-ответ отличается от структуры БД (добавляются поля, меняется формат, скрываются данные, вкладываются связи) — используй Resource. Если один-в-один — можно обойтись.
Альтернативы Resource

Скрытие полей в модели: $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 и --resource-контроллер. php artisan make:controller PostController --resource создаёт контроллер с 7 CRUD-методами (см. ctrl-resource). php artisan make:resource UserResource создаёт трансформер JSON. Слово «resource» — два разных инструмента.
Итог: в современных Laravel-проектах для внешних API почти всегда используют Resource — особенно с версионированием и интеграцией с фронтендом. Для внутренних админок и CRUD-эндпоинтов часто обходятся 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: ресурс делегирует доступ к атрибутам исходной модели.
Collection — список ресурсов

Для массива объектов есть 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').
Meta / links / additional

Часто нужно вернуть не только основной ресурс, но и дополнительную мета-информацию: версия 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() (курсорная пагинация — быстрее для больших таблиц).

Wrapping — обёртка 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();
}
Не убирайте data-обёртку без причины. Она — стандарт REST API (JSON:API, JSend). Даёт место для 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');
    }
}
Итог по API Resources:
  • JsonResource — трансформация одной модели в JSON (метод toArray).
  • ::collection() — для массива объектов, автопагинация через ->paginate().
  • ResourceCollection — когда нужна кастомная структура коллекции с meta.
  • when() / whenLoaded() — условные поля, критично для защиты от N+1.
  • with() и additional() — метаданные на верхнем уровне JSON.
  • Пагинация работает автоматически (links + meta).
  • Wrap в data — стандарт, отключайте только с причиной.
  • ->response()->setStatusCode(201) — для custom HTTP-статуса.
Миграции, модели и сидеры
Зачем это нужно. Миграции — это схема базы в виде кода под контролем версий. Вместо «зайди в phpMyAdmin и добавь колонку» в репозитории лежит файл, который применяется одинаково у всех в команде и на сервере. Практический проход по шагам — в разделе Проект: сборка по шагам.
Генерация: какие флаги что создают

Одна команда может создать сразу несколько файлов. Флаги комбинируются, поэтому обычно пишут не 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'.
Статусы: enum в миграции или string плюс PHP-перечисление

Когда у колонки заранее известен набор значений — 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, которая на большой таблице выполняется долго и может держать блокировку, то есть деплой превращается в отдельное мероприятие. Контроль значений при этом не теряется: его обеспечивают правило валидации на входе и само перечисление в коде — просто он переехал из базы в приложение.
Значение, которого нет в перечислении, ломает чтение модели. При приведении Laravel вызывает 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.phpApp
make:enum OrderStatus, папка app/Enums естьapp/Enums/OrderStatus.phpApp\Enums
make:enum OrderStatus, есть app/Enumerationsapp/Enumerations/OrderStatus.phpApp\Enumerations
make:enum Enums/OrderStatusapp/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 — нет.
Вложенные папки и группировка

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

app/Enums/ OrderStatus.php -> App\Enums\OrderStatus PaymentMethod.php -> App\Enums\PaymentMethod Billing/ InvoiceStatus.php -> App\Enums\Billing\InvoiceStatus Currency.php -> App\Enums\Billing\Currency
Про имена. Суффикс 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 -> processing -> delivering -> verifying -> completed

Остальные статусы — ответвления от него:

Из какого статусаКуда можноЧто происходит
pending_paymentpaid, cancelledОплатили либо истёк срок брони
paidprocessing, refundedФрод-проверка пропустила или отклонила
processingdelivering, refundedПродавец взялся или не вышел на связь
deliveringverifying, disputedПередали товар либо что-то пошло не так
verifyingcompleted, disputedПокупатель принял товар или открыл спор
disputedcompleted, 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())],
Не выводите прогресс из порядка случаев. Соблазн посчитать «этап 3 из 6» по позиции случая в перечислении заканчивается тем, что кто-то переставит строки местами или вставит новый статус в середину — и прогресс поедет. Если нужен номер шага, задайте его отдельным методом, где он написан явно.
Что именно даёт перечисление в этой задаче. Девять строк в базе превращаются в тип: опечатка '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()Обнулить ссылкуУдалили категорию — товар остался без неё
В SQLite внешние ключи по умолчанию могут не проверяться. Проект соберётся локально, а на MySQL или PostgreSQL та же вставка упадёт с нарушением целостности. Если база разработки отличается от боевой, проверяйте такие места отдельно.
Модификаторы колонок: полный справочник

Модификатор — это вызов, который дописывается к колонке и уточняет её поведение. Полный список взят из 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 подробно: что он делает за вас

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_idusersДа
post_idpostsДа
category_idcategoriesДа, множественное число берётся по правилам английского
author_idauthorsНет, если авторы лежат в users: таблицу надо указать явно
parent_idparentsНет, для ссылки на себя таблицу указывают: 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 падает на создании ключа.
В SQLite ключ нельзя удалить по имени. Вызов dropForeign('posts_category_id_foreign') со строкой выбросит RuntimeException: This database driver does not support dropping foreign keys by name. Передавайте массив колонок — dropForeign(['category_id']), — тогда Laravel пересоберёт таблицу.
Индекс под внешний ключ создаётся не везде. MySQL с InnoDB заводит его автоматически, а PostgreSQL — нет. Колонка со ссылкой почти всегда участвует в выборках вида «все заказы пользователя», поэтому на PostgreSQL к ней добавляют ->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 против SET NULL: что выбрать

Оба варианта решают одну задачу — не оставить в базе ссылку на строку, которой больше нет. Разница в том, что происходит с дочерними строками: каскад их удаляет, обнуление оставляет, стирая только ссылку. Выбор определяется одним вопросом: имеет ли дочерняя строка смысл без родителя.

ПоведениеON DELETE CASCADEON 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() обязателен — и в модели эту колонку тоже придётся считать необязательной.
Главное для Laravel: каскад проходит мимо Eloquent

Это не особенность фреймворка, а следствие того, где выполняется действие. 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

Классическая задача: у категории могут быть подкатегории любой вложенности. Решается ссылкой таблицы на саму себя — колонкой 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 и не должен быть среди потомков.
Поле или отдельная таблица: на примере SKU

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

ПризнакКолонкаОтдельная таблица
Сколько значений у строкиРовно одноМожет быть несколько
Есть ли у значения свои поляНет, это просто строка или числоДа: остаток, цена, срок, статус
Нужно ли искать по значению отдельноХватает индекса на колонкеНужны свои выборки и связи
Что даёт выносНичего, кроме лишнего 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 — базовое
Назначение

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.

Mass Assignment — $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/$visible
class 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. Критический
Реальная уязвимость. Если API возвращает модели без фильтрации — это не потенциальная проблема, а текущая. Любой авторизованный пользователь через GET /api/user уже видит remember_token в HTML/DevTools/Network — а с ним и любой посмотревший на его экран.
Почему Laravel не скрывает всё это автоматически

Три причины архитектурного решения:

  • Не у всех моделей есть чувствительные поля. 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 — простое указание типов для атрибутов модели. В массиве $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 или 0true или 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.
Важно: касты ≠ PHP-приведение типов

Касты в 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-приведением
PHP-приведение (например, (int) $var)Laravel Casts
Меняет тип переменной в момент выполнения Меняет значение при доступе к атрибуту модели
Глобально для всей программы Ограничено моделью Eloquent
Не сохраняется в БД автоматически Применяется при сохранении в БД
Итог по природе кастов. Касты — это не оператор приведения, а автоматическое преобразование данных на границе между БД и моделью. Они не меняют тип самой переменной в PHP-смысле, а лишь определяют, как значение будет интерпретироваться при доступе к атрибуту. Удобно чтобы всегда работать с данными в нужном формате, не заботясь о ручном преобразовании.
Кастомные касты (если стандартных мало)

Для преобразований со сложной логикой реализуйте интерфейс 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 — переиспользуемые фильтры для запросов

Что это. 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 — преобразование на входе и выходе

Что это. 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 результат не будет вычисляться заново.

Старый синтаксис (до Laravel 9)

Раньше использовались методы 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.
  • Для производительности не используйте аксессоры внутри циклов без кеширования — это может породить много запросов или вычислений.
Итог. Accessor — преобразует значение при чтении. Mutator — преобразует значение при записи. 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 — наблюдатели за событиями модели

Что это. Observers (наблюдатели) — это классы, которые позволяют централизованно обрабатывать события жизненного цикла модели Eloquent (создание, обновление, удаление, восстановление). Вместо того чтобы писать логику в самой модели или размазывать её по контроллерам, выносите её в отдельный класс-наблюдатель.

Какие события можно перехватывать
МетодКогда срабатывает
retrievedПосле того как модель извлечена из БД (find, get, first...)
creatingПеред созданием новой записи (до INSERT)
createdПосле создания записи (после INSERT)
updatingПеред обновлением (до UPDATE)
updatedПосле обновления
savingПеред сохранением — и для создания, и для обновления
savedПосле сохранения — и для создания, и для обновления
deletingПеред удалением
deletedПосле удаления
restoringПеред восстановлением (если используется SoftDeletes)
restoredПосле восстановления
Как создать и использовать Observer

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 — более высокоуровневый и удобный подход, когда все хуки для одной модели.
Итог. Observer — класс, который слушает события модели и выполняет код в ответ. Позволяет держать модель тонкой, а побочную логику — в отдельном классе, что улучшает поддерживаемость и читаемость проекта. Регистрация: User::observe(UserObserver::class) в AppServiceProvider::boot().
Зачем регистрировать Observers в провайдерах

Observers нужно регистрировать в Service Provider (например, App\Providers\AppServiceProvider), потому что:

  • Централизованное управление — все подписки на события моделей собираются в одном месте, что упрощает поддержку и поиск.
  • Гарантированное выполнение при старте — метод boot() провайдера вызывается после того, как все сервисы зарегистрированы, и модели уже доступны. Если зарегистрировать Observer прямо в модели или в контроллере, это может привести к дублированию или выполнению в неподходящий момент.
  • Единая точка входа — при смене логики или добавлении новых наблюдателей вы правите только провайдер, не разыскивая вызовы по всему коду.

Пример регистрации в AppServiceProvider:

public function boot()
{
    User::observe(UserObserver::class);
    Post::observe(PostObserver::class);
}
Частые сценарии использования Observers

Observers идеально подходят для побочных действий (side-effects), которые не являются основной бизнес-логикой, но должны происходить при определённых событиях модели.

СобытиеТипичные действия
created Отправка приветственного письма или SMS · создание связанных записей (например, профиля) · уведомление администратору · запись в лог регистрации · генерация реферальной ссылки · отправка события в очередь для аналитики
updated Уведомление об изменении профиля (email/push) · инвалидация кеша (сброс списка пользователей) · логирование изменений (аудит) · синхронизация с CRM / внешним API · пересчёт агрегированных данных (рейтинг)
deleted Удаление связанных файлов (аватары, документы) · очистка кеша · логирование удаления · уведомление о удалении (админу) · удаление зависимых записей (если нет каскадного удаления в БД)
restored (SoftDeletes) Восстановление файлов, если они были удалены · уведомление о восстановлении · обновление индексов поиска
retrieved Логирование доступа к данным (редко, обычно для аудита чтения — GDPR, ISO)
creating / updating Автоматическая установка значений (например, slug) · валидация, не охваченная стандартной · преобразование данных перед записью
saving Общая обработка перед любым сохранением. Осторожно: срабатывает и при создании, и при обновлении.
Почему это важно. Observers позволяют отделить побочные действия от кода контроллера и модели. Вместо того чтобы писать Mail::send(...) в контроллере каждый раз при создании пользователя — выносите это в Observer. Контроллеры становятся тоньше, код — переиспользуемым, тестирование — проще. Основные применения: уведомления, логирование, кеширование, очистка данных, синхронизация с внешними системами, генерация данных, аудит.
Связи Eloquent — примеры всех типов

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
Динамическое свойство vs Query-метод
Динамическое свойство ($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();
Промежуточные таблицы (pivot) для 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 или hasMany
  • belongsToMany — многие ко многим (с pivot-таблицей)
Связи описываются в моделях и дают удобный доступ к связанным данным через динамические свойства (Collection) или Query Builder для сложных запросов. Более сложные типы (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();
Особые случаи
1. $guarded = [] в проде. Открывает все колонки для mass assignment. User::create($request->all()) пропустит is_admin в insert. Используйте явный $fillable.
2. save() на свежей коллекции. $orders = $user->orders; $orders->each(fn($o) => $o->save()) — N запросов вместо одного. Используйте $user->orders()->update([...]).
3. relation vs relation(). $user->orders — collection (lazy-loaded). $user->orders() — query builder. Путаница приводит к лишним запросам или ошибкам.
4. updated_at при update массивом. Laravel автоматически обновит updated_at. Если нужно избежать (миграция данных) — $model->timestamps = false или DB::table().
5. JSON-каст и dirty detection. Изменение вложенного значения в JSON: $model->meta['key'] = 'x' не пометит модель dirty. Используйте AsArrayObject или AsCollection.
6. find с массивом не возвращает ошибку. User::find([1, 2, 99]) — вернёт только существующих, без warning'а. Если важно полное соответствие — проверяйте count.
7. Tinker и dirty state. В tinker отредактированная модель не сохраняется автоматически; забыли save() — изменения теряются.
8. chunk с update. User::chunk(100, fn($users) => $users->each->update(...)) — после первого chunk PK сдвигается, следующий chunk пропускает строки. Используйте chunkById.
Cache
Назначение

Кеш — способ обменять память на 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

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() выбросит исключение.
Итог. У вас есть полная свобода выбора — от простого файлового кеша для разработки до высокопроизводительных Redis / Memcached в продакшене. Всё настраивается через конфигурационный файл и переменную окружения CACHE_DRIVER. Единый API Cache::put()/Cache::get() работает одинаково независимо от драйвера — можно менять хранилище без переписывания кода.
Multiple Stores — несколько хранилищ в одном проекте

Что это. 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(...) — нет.
Итог. Multiple stores — механизм для создания нескольких независимых кешей с разными конфигурациями. Оптимизация производительности (горячий кеш в Redis, холодный в файлах), разделение по смыслу (сессии/API/отчёты), лёгкое переключение через 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) и разгрузки БД.
Что возвращают методы кеша (для переменной)

В 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');
Ничего не возвращают или возвращают bool
МетодЧто возвращаетПример
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 — групповая инвалидация

Что это. Теги позволяют объединять несколько ключей кеша в логические группы и очищать всю группу разом. Удобно, когда у вас много связанных данных (например, все кеши, зависящие от пользователя или заказов).

Как использовать:

// Сохраняем значение с тегами
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 создаёт отдельные структуры:

  1. Сохраняет основное значение по ключу 'user_123' — как обычно.
  2. Для каждого тега (users и profile) создаёт список ключей, которые относятся к этому тегу. Например, в Redis есть отдельный ключ "tag:users:keys", внутри которого хранится список ['user_123', 'user_456', ...].
  3. При 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 Locks — распределённые блокировки

Что это. 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 — удаляет сразу после чтения. Полезен для извлечения и очистки одноразовых данных (токены, коды, флаги), чтобы они не могли быть прочитаны повторно.
Таблица для database-драйвера — make:cache-table

Если вы используете драйвер database — Laravel хранит кеш в таблице БД, и её нужно создать. Для этого есть Artisan-команда:

php artisan make:cache-table

Команда создаёт файл миграции в database/migrations/. Далее её нужно применить:

php artisan migrate
В новых проектах Laravel (начиная с версии 11) миграция для cache-таблицы обычно уже присутствует по умолчанию. Скорее всего вам не придётся создавать её вручную — достаточно выполнить php artisan migrate. Если по каким-то причинам её нет — команда make:cache-table создаст.
Старая команда. В более старых версиях документации и сторонних источниках можно встретить php artisan cache:table. В актуальных версиях Laravel (11.x и выше) используется именно make:cache-table. Обе команды делают одно и то же — создают миграцию для таблицы кеша.
Практика: cache stampede

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());
});
Особые случаи
1. Cache::remember и null. Если callable вернул null, Laravel не кеширует. Каждый запрос пересоздаёт. Используйте rememberForever с sentinel, либо проверяйте на null отдельно.
2. Tags с file driver. Cache::tags(...) на file/database driver выбросит исключение. Проверяйте драйвер или используйте только tags-capable бэкенды.
3. Lock не освобождён. Если процесс упал между захватом lock и выполнением, lock держится до TTL. Сделайте TTL разумным (5-30 сек), не «на всякий случай 5 минут».
4. Кеш модели с relations. Кеширование $user->with('orders')->get() в Cache::put — relations сериализуются. При десериализации связи могут не работать корректно. Кешируйте plain данные, не Eloquent-модели.
5. Префиксы в Redis. Несколько приложений на одном Redis-инстансе — конфликт ключей. Указывайте CACHE_PREFIX в .env.
6. Cache::flush() в проде. Сбрасывает всё. На общем Redis с другими приложениями — снесёт чужие данные. Используйте tags()->flush().
7. session, queue, cache на одном Redis. Дефолт. Если flush — сбрасываются и сессии, и очереди. Разделяйте по DB index'ам.
8. Stale-while-revalidate. Laravel не имеет встроенного SWR. Реализуется вручную: вернуть устаревший кеш, регенерировать в фоне через job.
Queues
Назначение

Очереди — механизм отложенного выполнения задач. Web-запрос ставит job в очередь и возвращает ответ; воркер забирает job и исполняет. Это разгружает HTTP-обработчики, обеспечивает retry при сбоях, позволяет масштабировать воркеры независимо. Понимание драйверов, retry-логики, batching и race conditions — обязательно для всего, что выходит за рамки «отправить письмо».

Драйверы очередей — где хранятся jobs

Очереди в 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)
Проект в облаке AWSsqs
Минимальные требования к инфраструктуре, средний трафикbeanstalkd
Ключевое:
  • Драйвер указывается в .env: QUEUE_CONNECTION=....
  • Запуск воркера: php artisan queue:work (или queue:listen для разработки — перезапускается при изменении кода).
  • Для каждого драйвера настройки в config/queue.php (соединение, имя очереди, retry, block_for и т.д.).
  • Можно объявить несколько соединений и обращаться по имени: Job::dispatch()->onConnection('redis-fast').
Очереди — важная часть архитектуры Laravel, позволяющая асинхронно обрабатывать задачи и улучшать отзывчивость приложения.
Job-классы — отложенные задачи

Что это. Job — класс, который инкапсулирует работу, которую нужно выполнить асинхронно (в очереди). Он содержит всю логику в методе handle() и настраивается через свойства.

Структура Job-класса
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).
  • Контроль времени — не даём заданию зависнуть навсегда.
  • Гибкость — разные задачи могут иметь разные стратегии повторных попыток.
Итог. Job-классы — основа асинхронной обработки в Laravel. Они определяют работу (handle()), а свойства ($tries, $timeout, $backoff) управляют поведением при ошибках, делая систему устойчивой и предсказуемой.
Как это работает: асинхронность и параллельность

Job-классы с интерфейсом ShouldQueue — это асинхронные задачи, которые выполняются параллельно, не блокируя основной процесс (поток выполнения, который обрабатывает запрос пользователя).

Полный поток выполнения:

  1. Основной процесс (контроллер, команда) создаёт задачу и вызывает dispatch().
  2. Задача немедленно помещается в очередь (в драйвер: БД, Redis, SQS и т.д.).
  3. Основной процесс продолжает выполнение — отправляет ответ пользователю, не дожидаясь завершения задачи.
  4. Отдельный воркер (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).
  • Даже с одним воркером задачи выполняются последовательно, но всё равно асинхронно — не блокируют основной процесс. Параллельность — это уже вопрос масштабирования.
Итог по параллельности. Job-классы с ShouldQueue — способ выполнять тяжёлые операции фоново, не блокируя пользовательские запросы. Обрабатываются параллельно при наличии нескольких воркеров и значительно повышают отзывчивость приложения. Формула: больше воркеров → выше пропускная способность обработки очереди.
Chains и Batches — управление группами задач

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 vs Batch
ХарактеристикаChainBatch
Порядок выполнения Строго последовательный Параллельный
При ошибке Прерывается вся цепочка Продолжается выполнение остальных задач
Финальные колбэки Нет (можно добавить свою задачу в конце) then, catch, finally
Отслеживание прогресса Нет Есть — $batch->progress()
Отмена Нет $batch->cancel()
Дополнительная таблица Не нужна Нужна (job_batches)
Работает с драйвером sync Да Только с БД-совместимыми (нужен доступ к job_batches)
Итог.
  • Chain — для последовательных задач, где важен порядок и зависимость (скачать → обработать → уведомить).
  • Batch — для параллельных независимых задач, с возможностью реагировать на успех/ошибку всех или одной из них (массовая рассылка писем, параллельная обработка файлов).
Rate Limiting для Job — защита от перегрузки внешних API

В Laravel можно ограничивать частоту выполнения заданий очереди (jobs), чтобы не перегружать внешние сервисы (API, БД, сторонние сервисы) слишком большим количеством запросов.

Зачем это нужно

Если приложение отправляет тысячи писем через внешний SMTP, вызывает API (Telegram, Google Maps, платёжки) или выполняет действия с ограничениями по частоте — легко превысить лимиты, получить 429 Too Many Requests или быть заблокированным. Очереди позволяют замедлить выполнение таких задач, соблюдая квоты.

Как работает — 2 подхода

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:

  1. Берёт задание из очереди.
  2. Проверяет, есть ли зарегистрированные middleware через метод middleware().
  3. Если есть, оборачивает выполнение задания в стек этих middleware.
  4. Middleware RateLimited проверяет через RateLimiter, превышен ли лимит для 'api-calls'. Если да — вызывает $this->release($delay): задание освобождается обратно в очередь с задержкой (по умолчанию — до восстановления лимита через availableIn()), воркер переходит к следующему заданию.

Почему так сделано:

  • Разделение ответственности: Job содержит бизнес-логику, а ограничения частоты вынесены в отдельную настройку.
  • Гибкость: лимиты (100 → 200 запросов в минуту) меняются без пересборки Job-классов — правится только провайдер.
  • Переиспользование: один RateLimited middleware применяется к разным 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 тоже ограничивают.
Итог. Rate limiting для Job — механизм контроля частоты выполнения заданий, чтобы не превысить лимиты внешних сервисов. Реализуется через middleware RateLimited (рекомендуемый способ) или вручную с фасадом RateLimiter. При превышении job освобождается с задержкой и повторяется позже — очередь сама регулирует нагрузку. Обязательный инструмент для надёжных интеграций с внешними API.
Unique Jobs — защита от дублирования

Иногда одно и то же задание может быть поставлено в очередь несколько раз — из-за повторных кликов пользователя, повторных вызовов 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:

  1. Вычисляет уникальный идентификатор через uniqueId().
  2. Пытается создать блокировку в хранилище (по умолчанию — кеш) с этим идентификатором.
  3. Если блокировка успешно создана — задание ставится в очередь.
  4. Если блокировка уже существует — задание не ставится, при dispatch() возвращается null/false.
  5. По завершению задания (или его неудаче) блокировка автоматически снимается.
Когда это полезно
  • Обработка заказов — не обработать один заказ дважды.
  • Отправка 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. Защита от двойной постановки.

Практика: job с retry и idempotency
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()]);
    }
}
Особые случаи
1. Job без идемпотентности. Retry означает, что handle вызовется N раз при сбоях. Если job отправляет письмо без проверки факта — пользователь получит N писем. Делайте handle идемпотентным.
2. Передача Eloquent-модели в job. Модель сериализуется через SerializesModels — в БД хранится ['id' => 42], а в handle снова загружается через findOrFail. Если строка удалена между постановкой и исполнением — ModelNotFoundException.
3. Транзакция вокруг dispatch. DB::transaction(fn () => { ... ; SendInvoice::dispatch($order); }) — job отправлен до commit'а; воркер пытается найти order, которого ещё нет в БД. Используйте dispatch()->afterCommit() или $afterCommit = true в job.
4. Долгий handle, превышающий timeout. Воркер убивает процесс через $timeout сек, job остаётся в БД как «в обработке». В таком случае увеличьте timeout или разбейте на несколько jobs.
5. $tries = 0. Означает «бесконечные попытки». Если job всегда падает, воркер крутится в цикле. Лучше явное число.
6. maxExceptions vs tries. tries — общее число запусков (включая успешные). maxExceptions — только неуспешных. Для jobs, которые часто timeout-ят без exception, нужно следить за обоими.
7. Failed jobs без мониторинга. Таблица failed_jobs копится молча. Без алерта или дашборда (Horizon) проблемы обнаруживаются через жалобы пользователей.
8. Воркер без --max-time. Долгоживущий воркер копит память (особенно в Laravel без Octane). Перезапуск через --max-time=3600 освобождает память.
Events & Listeners
Назначение

События — механизм слабого связывания: код «эмитит» событие, не зная, кто на него подпишется. Несколько listeners могут реагировать независимо. Это основной способ организации side-effects (отправка письма после регистрации, audit-лог, обновление аналитики) без раздувания основного кода.

Когда Events нужны, а когда лишние

Прежде чем плодить OrderCreated, SendOrderConfirmation и т.д. — спроси себя: а нужно ли тут вообще событие? Для простого CRUD (сохранил заказ → показал в списке) Events — оверинжиниринг. Они нужны, только когда есть побочные эффекты, которые надо отделить от основного действия.

Правило одной фразой: Events — не про сохранение и не про отображение. Events — про побочные эффекты сохранения (лог, уведомление, интеграция, аналитика).
Когда 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(...)) для «отображения истории платежей» — данные и так есть.

Когда Events полезны

Если при создании заказа надо дополнительно:

  • Записать событие в отдельную 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 и Event

Ключевое правило:

ДействиеКуда
Часть транзакции, влияет на целостность (резервирование товара, списание средств, изменение основного статуса) → Action / Service, внутри DB::transaction
Побочный эффект, не влияет на основной процесс (логи, уведомления, интеграции, аналитика) → Event + Listener, желательно ->afterCommit()

Пример: «резервировать товар на складе» — часть транзакции заказа (если не удалось — заказ не создать), значит в Action. «Отправить письмо о заказе» — побочка (если письмо не ушло — заказ всё равно создан), значит в Event.

Итоговая таблица
СитуацияНужен ли Event?
Просто создать заказ и показать в списке (CRUD)Нет
Дополнительно записать в лог / отправить уведомление / синхронизироватьДа
Тяжёлые операции, которые могут тормозить ответ (почта, внешние API)Да, с ShouldQueue
Действие — часть транзакции, влияет на целостностьНет, вынести в Action/Service
Антипаттерн «событие ради события». Раздувать UserViewedProfile, OrderListRendered, PageOpened «чтобы потом что-нибудь на них подписать» — путь к неотлаживаемому коду. Плодить события следует, когда уже есть конкретный слушатель, которому это событие нужно.
Не путай Event с обычным вызовом. Если у события ровно один listener и никакой асинхронности — это не событие, а замаскированный прямой вызов. Убери прослойку, вызови метод напрямую. Event имеет смысл когда: (а) listeners несколько, либо (б) нужна асинхронность через 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 Class — DTO для сигнализации о событиях

Что это. Event — это класс-контейнер (DTO), который инкапсулирует информацию о произошедшем действии («заказ оплачен», «пользователь зарегистрирован»). Он не содержит бизнес-логики — только передаёт данные слушателям (Listeners).

Структура Event-класса
  • Публичные свойства — данные, доступные слушателям (модель заказа, 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 и выполняться в очереди, не замедляя основной запрос.
Broadcasting — трансляция в реальном времени

Если событие реализует 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 — синхронная трансляция, только когда критично.
Как это работает end-to-end: сервер → WebSocket → фронт

Важно понимать: 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: показать уведомление, изменить статус, ...
    });
Последовательность действий:
  1. Сервер: ты создаёшь и диспатчишь событие (OrderPaid::dispatch($order)).
  2. Laravel: использует broadcastOn() твоего события, чтобы узнать куда отправлять.
  3. Очередь / Воркер: отправляет данные события в WebSocket-сервис.
  4. Фронтенд: Laravel Echo, подписанный на этот канал, получает событие и выполняет колбэк.
Таким образом, broadcastOn() — не прямой вызов, а инструкция для фреймворка, часть автоматического механизма доставки событий на клиент.
Итог по Event Class:
  • Event — DTO, описывающий факт в прошедшем времени.
  • Создаётся через php artisan make:event.
  • Диспатчится через event() или ::dispatch().
  • Трейты Dispatchable + SerializesModels — стандарт.
  • Listeners реагируют — синхронно или асинхронно.
  • Через ShouldBroadcast транслируется в real-time (Pusher/Reverb + Echo).
  • Класс события не содержит логики — только данные.
Listener Class — обработчик события

Что это. 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.

Жизненный цикл
  1. Событие диспатчится через event() или ::dispatch().
  2. EventServiceProvider определяет список слушателей для этого события.
  3. Для каждого слушателя:
    • Если имплементирует ShouldQueue — ставится в очередь.
    • Если нет — вызывается синхронно, прямо в текущем процессе.
  4. Асинхронные слушатели выполняются воркером позже.
Важные нюансы:
  • При очереди данные события должны быть сериализуемы. Модели 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);

Теперь при оплате заказа слушатель ставится в очередь и отправляет письмо в фоне, не задерживая ответ пользователю.

Итог по Listener:
  • Класс с методом handle(EventClass $event) — вызывается автоматически.
  • Регистрируется в EventServiceProvider::$listen или через Event::listen().
  • Один event → 0..N listeners, порядок выполнения = порядок в массиве.
  • ShouldQueue → асинхронно через очередь. Свойства $queue/$delay/$tries настраивают поведение.
  • Синхронный без ShouldQueue — выполняется в том же процессе что и dispatch().
  • Данные event должны быть сериализуемы для очереди — модели через SerializesModels.
Subscriber — группировка логически связанных listeners

Что это. 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 подписчик уменьшает количество файлов.
Отличия от обычных Listeners
ХарактеристикаОбычный ListenerSubscriber
Количество событий Одно Множество
Метод обработки handle(Event $event) Любой пользовательский метод
Регистрация В $listen или Event::listen() В $subscribe или Event::subscribe()
Структура Простая, один класс = одно событие Группирует связанные обработчики
Поддержка очереди Да (ShouldQueue) Да, но с оговоркой (см. ниже)
Subscriber + очередь. Если нужно чтобы методы подписчика выполнялись асинхронно — можно имплементировать 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.
  • Подписчики удобны для аудита, логирования, очистки кеша — действий, выполняемых синхронно при разных событиях.
Итог. Subscriber — инструмент организации кода, а не замена обычным listeners. Улучшает читаемость и поддерживаемость когда количество связанных обработчиков растёт. Один класс → несколько событий → несколько методов + один 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 не нужна.

Осторожно: auto-discovery работает через сканирование папки. Найти всех слушателей события grep'ом уже сложнее — надо смотреть type-hint каждого файла. Явный $listen проще для больших проектов.

Кеширование: в production для скорости — php artisan event:cache. Compiles массив в один файл. Сброс — event:clear. Auto-discovery без кеша — дорого.

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.
  • Для больших проектов сканирование папки замедляет загрузку (только при обновлении кеша).
Практические рекомендации: для небольших/средних проектов — auto-discovery. Для больших проектов, где важна явность — оставить ручную регистрацию в $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, а дополняет его.
Queue-config для Listeners

Если 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)
Всё то же самое работает и в Jobs. Разница только в контексте: у Job аргумент __construct, у Listener'а — handle(EventClass $event). Свойства ($tries, $backoff, $queue, $connection) — идентичны.
Event vs Job — когда что использовать

Внешне похожи: и Event, и Job можно поставить в очередь, оба выполняют бизнес-действие асинхронно. Разница — в семантике связи отправителя с получателем.

EventJob
Отношение 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 контроллера.
Частый вопрос: «Чем отличается Event от Job?». Короткий ответ: Event — «случилось X», может быть 0..N слушателей, слабая связь. Job — «сделай Y», один обработчик, явный вызов. Event хорош когда одна причина → много следствий.
Частые вопросы про Jobs / Events

Правильно ли, что 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);
}

Что происходит:

  1. Заказ сохраняется в БД (синхронно).
  2. Job ProcessOrder ставится в очередь (миллисекунды).
  3. Ответ пользователю уходит сразу, без ожидания обработки.
  4. Воркер в фоне забирает job и выполняет — независимо от HTTP-запроса.

Где ещё можно вызывать:

  • Из контроллера (самый частый случай)
  • Из Action / Service (тонкий контроллер, вся логика включая dispatch — в Action)
  • Из другого Job (chain / batch)
  • Из Event Listener'а
  • Из artisan-команды
  • Из Scheduler'а
Нюансы диспатча:
  • Если Job не имплементирует ShouldQueue — выполняется синхронно прямо в момент dispatch().
  • dispatchSync() — принудительно синхронно, даже с ShouldQueue (для тестов).
  • dispatch()->afterCommit() — job уйдёт в очередь только после commit транзакции (защита от гонки, когда воркер ищет ещё несохранённый заказ).
Практика: событие и асинхронный listener
// 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);
Особые случаи
1. Eloquent-модель в свойстве event. При ShouldQueue listener сериализуется через SerializesModels — сохраняется только id. Между dispatch и handle строка может измениться или удалиться.
2. Event::fake() в тестах не вызывает listeners. Если тесту нужно проверить side-effect listener'а — не используйте fake, либо отдельно тестируйте listener.
3. Sync listener бросает exception. Если listener синхронный и упал — основная операция rollback'нется (если в транзакции). Подумайте, нужна ли такая жёсткая связь.
4. Auto-discovery и shared classes. Auto-discovery сканирует app/Listeners и регистрирует всё. Лишние классы (helpers, тесты) могут случайно зарегистрироваться как listeners.
5. Listeners в EventServiceProvider не отсортированы. Порядок исполнения не гарантирован. Если порядок важен — используйте chain через subscriber.
6. Eloquent observer + Event listener. Дублирование. Если observer пишет audit-лог на created, listener на OrderCreated делает то же — два лога. Выберите один уровень.
7. Event::dispatch() в job. Job сам делает SerializesModels. Если event тоже имеет модель — двойная сериализация. Передавайте ids явно.
8. Broadcast event без queue. ShouldBroadcast по умолчанию транслируется синхронно. На большом объёме событий это блокирует. Имплементируйте ShouldBroadcastNow только когда уверены; иначе — оставляйте через очередь.
Scheduler
Назначение

Laravel scheduler — встроенный планировщик задач, заменяющий ручное редактирование crontab. Одна cron-запись (* * * * * php artisan schedule:run) запускает Laravel, который сам решает, что запустить в эту минуту. Это убирает разрыв между приложением и cron-конфигурацией: расписание лежит в коде, версионируется, тестируется.

Позволяет декларативно определять периодически выполняемые задачи (отправка отчётов, очистка временных файлов, синхронизация с внешними API) без необходимости писать отдельные cron-записи для каждой задачи.

Где определяется расписание — L10 vs L11+

До 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.

Миграция с Laravel 10 на 11

Если обновляете проект с L10 → L11:

  1. Удалить класс App\Console\Kernel (или оставить, но он не будет использоваться).
  2. Перенести всё расписание из schedule() в routes/console.php.
  3. Убедиться, что 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 должно быть строго больше ожидаемого времени выполнения.
Пример с cron и TTL
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();
Принцип работы

Использует атомарные блокировки в общем хранилище кеша. При старте задачи:

  1. Планировщик пытается создать lock с уникальным именем для этой задачи.
  2. Если блокировку удалось захватить — задача выполняется на текущем сервере.
  3. Если блокировка уже занята другим сервером — задача пропускается на этом узле.
  4. Блокировка автоматически снимается после завершения задачи или в случае ошибки.
Что такое «атомарная операция захвата»

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

Как реализовано в каждом драйвере:

  • 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() фильтры всё равно применяются на каждом сервере, но задача выполнится только на том, кто успешно захватит блокировку.
Output Handling — обработка вывода задач

При выполнении запланированных задач (команд, замыканий, исполняемых файлов) часто нужно сохранить вывод (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. Классическая схема для «серьёзной» задачи без внешних инструментов.

1. Захватывается только stdout/stderr задачи. Методы работают с выводом, который команда пишет в консоль (echo, print, $this->info() в Artisan). Вывод внутренних исключений тоже перехватывается.
2. Log::info() не попадает в sendOutputTo. Стандартный Log-фасад пишет напрямую в storage/logs/laravel.log через файловые каналы — этот вывод не захватывается планировщиком. Для захвата логов задачи — используйте отдельный канал (напр. stderr) или пишите в консоль явно.
3. Пустой вывод = нет письма. emailOutputTo() ничего не отправит, если команда не произвела вывод. Это часто желаемое поведение, но иногда путает — «почему письмо не пришло, задача же выполнилась?».
4. sendOutputTo перезаписывает. Если нужно накопление истории — appendOutputTo, иначе увидите только последний запуск.
5. Права на файл. Файл создаётся под юзером cron (обычно www-data или тот, кто запускает schedule:run). Директория должна быть доступна на запись.
6. Email через MAIL_MAILER=log на dev. Локально письмо «уйдёт» в лог, а не на реальный адрес — легко подумать, что фича не работает.
Короткая шпаргалка:
  • Нужно сохранить один лог → sendOutputTo()
  • Нужна история запусков → appendOutputTo()
  • Отчёт каждый раз в почту → emailOutputTo()
  • Оповещение только при падении → emailOutputOnFailure()
  • Кастомная логика (Slack/метрики) → onSuccess() / onFailure()
Запуск планировщика через cron

Чтобы планировщик работал, нужно добавить одну запись в системный 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 на локального юзера).
В некоторых средах (Laravel Forge, Vapor, Envoyer, платформы с поддержкой очередей) cron-запись настраивается автоматически. Проверять в UI платформы.
Тестирование расписания
  • 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');
Особые случаи
1. Cron-запись не настроена. Без * * * * * php artisan schedule:run в crontab расписание не работает. Заверьте на проде первой проверкой.
2. withoutOverlapping без TTL. Дефолтный TTL — 24 часа. Если процесс упал, lock держится сутки. Указывайте явный таймаут.
3. onOneServer на file cache. File cache локален для сервера — atomic lock не работает между серверами. Используйте Redis.
4. ->daily() в UTC. Дефолтная таймзона — app.timezone. Если она UTC, а ожидание «Asia/Almaty 8:00» — расписание сработает в 13:00 по Алматы. Указывайте timezone().
5. Долгие задачи блокируют scheduler. schedule:run сам по себе быстрый, но ->command(...) может занять минуты. Используйте ->runInBackground() или ->job(...) для постановки в очередь.
6. Pingback URL на dead server. ->thenPing('https://hc.example.com/ok') — pingback после успеха. Если URL мёртв (Healthchecks.io не обновлён) — false alarm.
7. Тестирование scheduler. php artisan schedule:run запустит то, что должно запуститься сейчас. Чтобы протестировать без ожидания — schedule:test (Laravel 9+).
8. Несколько schedule:run в crontab. Две записи cron — два процесса каждую минуту. Tasks с withoutOverlapping справятся, без — выполнятся дважды.
Аутентификация: выбор инструмента
О чём раздел. Здесь — какой пакет ставить под задачу и чем они отличаются. Как устроены сами механизмы внутри: guards, providers, gates и policies — в разделе Auth, Gates & Policies.
Пакеты авторизации и быстрая настройка проекта

Готовой «авторизации» в 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
Для Sanctum есть команда короче. В документации основным способом указан 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, Socialite и Passport

Это самая частая путаница: названия стоят рядом в документации, но решают разные задачи. Разделить их проще всего по вопросу «что именно даёт пакет».

ПакетЧто даётКогда брать
Стартовый набор
раньше Breeze
Готовые страницы, маршруты и контроллеры: регистрация, вход, сброс пароля, подтверждение почты. Код копируется в проект и становится вашимНачинаете новый проект и нужен рабочий вход «прямо сейчас» с интерфейсом
FortifyТа же логика входа и регистрации, но без интерфейса — только маршруты и обработчики. Интерфейс пишете самиНужна стандартная логика аутентификации, но свой дизайн или свой фронтенд
SanctumТокены API и аутентификация SPA через сессионные cookie. Страниц входа не создаётМобильное приложение, сторонний клиент по токену или SPA на том же домене
SocialiteВход через внешних провайдеров: Google, GitHub и прочие. Обмен кода на профиль пользователяКнопка «Войти через Google» вместо пароля
PassportПолноценный сервер OAuth 2.0: выдаёт токены сторонним приложениям, поддерживает гранты и scopeВы сами — провайдер авторизации для чужих приложений
Про Breeze отдельно. В документации Laravel 13 его уже нет. Официальные стартовые наборы теперь называются по фронтенду — React, Svelte, Vue и Livewire, — и все они используют Fortify для аутентификации. Breeze остаётся в множестве проектов на Laravel 10 и 11, поэтому название встречается постоянно, но в новый проект на 13 его не ставят: набор выбирается при laravel new.

Главное различие, которое и путают: стартовый набор отвечает за вход человека через браузер, Sanctum — за доступ программы к вашему API. Они не заменяют друг друга и спокойно работают вместе: пользователь заходит на сайт через форму из набора, а мобильное приложение того же сервиса ходит по токену Sanctum.

человек в браузере программа или мобильное приложение │ │ форма входа запрос с токеном из стартового набора Authorization: Bearer ... │ │ сессия в cookie Sanctum проверяет токен │ │ └──────────── один и тот же User ───┘
ЗадачаЧто ставить
Классический сайт с формой входаСтартовый набор при создании проекта
API для мобильного приложенияSanctum, токены
SPA на том же доменеSanctum в режиме cookie-аутентификации
Вход через Google или GitHubSocialite, обычно вместе с набором
Своя вёрстка, но стандартная логика входаFortify
Выдавать токены чужим приложениямPassport
Не используйте токены API для собственного SPA. В документации это сказано прямо: для первого лица предназначен режим с сессионными cookie. Токен, положенный в localStorage, достаётся любым XSS, тогда как cookie с флагом HttpOnly недоступна скриптам и дополнительно защищена проверкой CSRF.
Аналогия для понимания. Стартовый набор — это готовая дверь с замком и табличкой. Fortify — замок без двери: механизм есть, внешний вид делаете сами. Sanctum — пропуск для тех, кто приходит не через дверь, а по служебному входу. Socialite — турникет, который пускает по чужому пропуску, выданному Google. Passport — вы сами печатаете пропуска для других организаций.
Breeze против Sanctum: разбор частого сравнения

Это сравнение постоянно встречается в статьях и постах, и само по себе оно сбивает с толку: пакеты сопоставляют так, будто надо выбрать один из двух. На деле они отвечают на разные вопросы, и выбор «или-или» возникает редко.

Breeze и пришедшие ему на смену наборыSanctum
Что этоСтартовый набор: готовый код страниц и маршрутовПакет аутентификации без интерфейса
Что появляется в проектеСтраницы входа, регистрации, сброса пароля и подтверждения почтыТаблица токенов и способ проверять запросы
Кого аутентифицируетЧеловека в браузереПрограмму: мобильное приложение, сторонний клиент, SPA
Чем подтверждается личностьСессия в cookieТокен в заголовке либо та же сессия — для своего SPA
Нужен ли фронтендИдёт в комплектеПишете сами или его вообще нет
Код после установкиВаш: можно править как угодноОстаётся в vendor, вы его не трогаете
Как выбрать на практике. Обычный сайт, где человек заходит через форму, — стартовый набор. API для мобильного приложения или стороннего клиента — Sanctum. Современная связка «Laravel как бэкенд плюс React или Vue отдельным приложением» — чаще всего оба: набор даёт страницы входа и логику регистрации, Sanctum закрывает запросы к API.
Поправка на версию. Многие такие сравнения написаны во времена Laravel 10 и 11, когда Breeze был основным стартовым набором. В Laravel 13 его в документации нет: наборы называются React, Svelte, Vue и Livewire, а логику входа в них обеспечивает Fortify. Смысл сравнения остался прежним, изменилось только название левой колонки.
Чего в таком сравнении обычно не хватает. Из того, что Sanctum «для API», часто делают вывод, что для своего SPA нужны токены. Документация говорит обратное: для приложения первого лица предназначен режим с сессионными cookie, а токены — для сторонних клиентов и мобильных приложений. Разница существенная: токен в 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, Gates и Policies
Назначение

Аутентификация (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 — продолжать проверку обычным путём.

Guards — глубокий разбор

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 к маршрутам
// Использовать 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).

Кастомные guards

Если стандартные драйверы не подходят — реализуйте 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, привязанные к нужным маршрутам.
Итог: Guard = «как проверяем пользователя». Provider = «где хранятся пользователи». Один guard — один способ проверки, но в одном приложении их может быть много. auth('guard')->user() для получения, middleware('auth:guard') для защиты маршрутов.
Providers — источники пользователей

Provider (провайдер) — компонент, отвечающий за извлечение пользователей из хранилища (БД, LDAP, внешний API и т.д.) на основе переданных данных (email/пароль/токен). Он определяет, откуда берутся пользователи, в то время как Guard определяет, как их проверять. Эти два понятия работают вместе: Guard использует Provider для получения пользователя.

Guard vs Provider — одной фразой: Guard — «как проверяем» (session/token/…), Provider — «откуда достаём» (Eloquent/DB/LDAP). Один 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',
    ],
],
Как Provider связан с Guard

Каждый 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, SSO, внешние API)

Если стандартные провайдеры не подходят (пользователи в 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.

Куда помещать класс кастомного UserProvider

Класс можно разместить в любом месте, которое соответствует автозагрузке 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 в одном месте.
Зачем нужна гибкость providers

Разделение данных

Одна таблица — для обычных пользователей, другая — для администраторов. У каждой своя модель, свои поля, свои 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 и Sanctum

SPA (Single Page Application) — одностраничное веб-приложение. В отличие от классических многостраничных сайтов (MPA), где каждый клик по ссылке загружает новую HTML-страницу с сервера, SPA работает иначе.

Как устроена SPA
  • Один раз загружает всю «оболочку». При первом посещении браузер получает одну HTML-страницу + все CSS/JS-бандлы.
  • Дальше работает в браузере. Вся навигация и отрисовка интерфейса — в браузере. При переходе по ссылке JavaScript перехватывает событие, запрашивает у сервера только данные (обычно JSON) и динамически обновляет уже загруженную страницу.
  • Никаких перезагрузок. Нет мерцаний и белых экранов, характерных для перезагрузки. Интерфейс обновляется плавно и мгновенно — по ощущениям близко к нативным приложениям.
SPA + Laravel: типичная схема

Бэкенд (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.

Sanctum для SPA — коротко
// 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 (создаётся сессия)
// - все последующие запросы автоматически несут куки
Итог: SPA + Laravel — это способ строить веб-приложения, которые по скорости и плавности интерфейса максимально приближены к нативным программам. Стандартная связка — Laravel как JSON API + Vue/React как SPA + Sanctum для auth.
Gates — простые проверки авторизации

Gates — механизм авторизации, позволяющий определить простые глобальные правила доступа в виде замыканий. Предназначены для сквозных проверок, не привязанных к конкретной модели: пользователь — админ? имеет доступ к отчётам? находится в определённой группе? Это аналог Policies, но для более простых и обобщённых сценариев.

Правило выбора: Gate — когда проверка не про конкретную сущность («может ли вообще войти в админку»). Policy — когда проверка про конкретную модель («может ли редактировать этот пост»).
Определение Gates

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, любые данные).
Laravel 11+: AuthServiceProvider больше не создаётся автоматически. Gates и policies можно регистрировать прямо в AppServiceProvider::boot(), либо создать AuthServiceProvider вручную для чистоты структуры.
Использование Gates

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 vs Policies
ХарактеристикаGatesPolicies
Назначение Глобальные, не привязанные к модели Связаны с моделью (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 принимает любые данные как второй аргумент — это не обязательно Eloquent-модель. Полиси — специализированы для моделей, Gate — универсален.
Централизуйте. Разбросанные Gate::define в разных провайдерах превращаются в кошмар — держите все определения в одном месте (AuthServiceProvider).
Не логи́ните false из замыкания. Внутри Gate не пишите в лог отказ — authorize() сам бросает AuthorizationException (403), лог создаётся глобальным обработчиком.
Итог: Gate — простой и быстрый способ определить правила авторизации, не привязанные к модели. Идеально подходит для общих проверок (админ, модератор, разрешения, feature-флаги). Для сложной логики по сущностям — используйте Policies. Определяйте Gates в AuthServiceProvider для централизованного управления.
Policies — авторизация действий с моделями

Policies (политики) — классы, группирующие логику авторизации для конкретной модели (Post, User, Order). Каждая политика содержит методы, соответствующие различным действиям (view, create, update, delete...), которые принимают текущего пользователя и, опционально, целевую модель.

Policies — рекомендуемый способ авторизации действий, связанных с моделями: структурируют проверки по сущностям и легко расширяются. Тесно интегрируются с фасадом Gate, но дают более высокоуровневый и удобный API.

Когда что: Gates — для проверок не про сущность (админ? модератор?). Policies — для проверок про конкретную модель (может ли этот user редактировать этот post?).
Создание политики

Политика создаётся 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() автоматически загружает указанные политики.

Laravel 11+: 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
Auto-discovery политик

Если соблюдаются соглашения об именовании (модель 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);
    }
}
Пример: контроллер с route model binding
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');
    }
}
Особые случаи
1. Метод не определён = false. Если политика не имеет метода — доступ запрещён по умолчанию. Исключение — если Gate::before или PostPolicy::before вернули true.
2. Guest (null user). Если сигнатура view(User $user, Post $post) — Laravel сразу вернёт false для гостя. Чтобы гость мог пройти — сигнатура view(?User $user, Post $post).
3. Правильный аргумент для create. В authorize('create', Post::class) передавайте класс, не инстанс — метод принимает только $user.
4. Auto-discovery не сработал. Если политика лежит вне App\Policies\ или модель вне App\Models\ — регистрируйте вручную в $policies.
5. N+1 в policy. Внутри метода update обращение к $post->owner->team → на списке из 20 постов будет 40 запросов. Eager-load relations до проверки.
Итог — Policies это:
  • Группировка проверок по сущностям (по одному классу на модель)
  • Создание через php artisan make:policy PostPolicy --model=Post
  • Регистрация в AuthServiceProvider::$policies (либо auto-discovery по namespace)
  • Использование через $user->can(), authorize(), @can
  • before в самой политике — для локальных исключений (админ обходит всё)
Policies — для действий с моделями. Gates — для общих проверок. Вместе — чистый, переиспользуемый, тестируемый код авторизации.
Gate::before / Gate::after — глобальные хуки авторизации

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) порядок следующий:

  1. Выполняется глобальный Gate::before(). Если вернул true/false — проверка сразу завершается. Если null — идём дальше.
  2. Если разрешение определено через Gate::define() — вызывается его замыкание.
  3. Иначе, если существует Policy для класса модели — вызывается соответствующий метод политики.
  4. Внутри Policy может быть свой before() (только для этой политики) — выполняется до конкретного метода, но после глобального Gate::before().
  5. После получения результата выполняется глобальный Gate::after(), который может его переопределить.

Таким образом, Gate::before имеет наивысший приоритет — может полностью отменить дальнейшую проверку.

Gate::before vs Policy::before
ХарактеристикаGate::beforePolicy::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.
Практика: policy для модели
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/. Иначе — регистрация явно.
Особые случаи
1. auth()->user() возвращает null. Если запрос не прошёл через auth middleware, user() вернёт null. Code auth()->user()->id упадёт. Используйте auth()->id() или явные проверки.
2. Policy before и after. Можно объявить методы before/after прямо в policy. before возвращает true/false/null — null означает «продолжить обычную проверку».
3. Policy с null user (guest). Если метод принимает ?User $user — guest пройдёт. Если просто User — Laravel сразу вернёт false для guest без вызова метода.
4. Multiple guards и confusion. На странице с двумя guards (admin и user) auth()->user() возьмёт default guard. Уточняйте: auth('admin')->user().
5. Gate::define после регистрации. Если Gate::define вызван в register() провайдера — Gate ещё не готов. Только в boot().
6. can на Eloquent-модели. $user->can('update', $post) требует, чтобы у User был трейт Authorizable (он есть по умолчанию).
7. Policy и lazy loading. PostPolicy::update($user, $post) может обращаться к $post->owner — N+1 на странице со списком постов. Eager load relations перед проверкой policy.
8. Подмена user в тестах. actingAs($user) ставит user в default guard. Для API: Sanctum::actingAs($user, ['*']) или actingAs($user, 'api').
Octane и long-running окружения
Назначение

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 секунд в каждом воркере. Кеш в памяти, прогрев данных.

Практика: безопасный сервис под Octane
// 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));
    }
}
Особые случаи
1. Auth-state в singleton. Если ваш guard кешируется в singleton — следующий запрос увидит предыдущего пользователя. Тестируйте под Octane перед деплоем.
2. Конфиг, изменённый рантайм. config(['key' => 'value']) в FPM сбросится с концом запроса. В Octane — переживёт. Все вызовы должны быть только чтение.
3. PDO-соединение и pool. Постоянное PDO-соединение в воркере дольше, чем в FPM. Если БД рестартанула, воркер падает с broken pipe. Octane reconnect помогает, но проверяйте.
4. Memory leaks. Утечки PHP-память накапливаются. Перезапуск воркера через --max-requests=500 освобождает.
5. Event listeners в singleton. Listeners, регистрируемые на ходу через Event::listen, копятся между запросами. Регистрация — только в Service Provider.
6. Eloquent global scopes. Глобальные scopes на модели — нормально, поскольку определены в коде. Но Model::addGlobalScope(...) в коде запроса добавит scope для всех воркеров до перезапуска.
7. Долгий handle с большим объёмом данных. Загрузка 100MB в память остаётся в воркере. Используйте cursor() или chunk() вместо get().
8. Octane-specific хуки. Octane имеет RequestReceived, RequestHandled, RequestTerminated события. Регистрируйте сброс состояния через них, не через middleware terminate.
Проект: сборка по шагам
О чём раздел. Сквозной путь от пустого каталога до работающего API с регистрацией, входом по токену и своими таблицами. Команды идут подряд, в том порядке, в котором их выполняют. Теория по каждому шагу — в тематических разделах, ссылки по ходу.
Шаг 1. Создание проекта и первый запуск
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
Шаг 2. Ссылка на хранилище

Загруженные файлы лежат в storage/app/public, а отдаёт их веб-сервер из public. Связывает эти два места символическая ссылка:

php artisan storage:link
Делается один раз и не попадает в репозиторий. После клонирования проекта на другой машине или при первом деплое команду выполняют заново — иначе все загруженные картинки отдают 404. Если хостинг запрещает символические ссылки, помогает php artisan storage:link --relative.
Шаг 3. API и Sanctum

В свежем проекте нет файла 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(). Ошибка вылезает не при установке, а позже, при первой попытке выдать токен, поэтому связь с пропущенным шагом неочевидна.
Шаг 4. Контроллер регистрации и входа
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 — она кочует из статьи в статью.
Шаг 5. Маршруты API
// 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.
Шаг 6. Проверка через curl
# Регистрация
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 из терминала.
Шаг 7. Свои таблицы: модели и миграции

Флаг -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, таблица продуктов должна создаваться раньше — иначе внешний ключ не на что навесить. При создании моделей подряд порядок получается верным сам собой, но при ручном переименовании файлов его легко нарушить.
Шаг 8. Тестовые данные
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 удаляет все таблицы. На рабочем сервере это потеря данных без вопросов и подтверждений. Команда предназначена только для локальной разработки.
Шаг 9. Что делать дальше
ШагКомандаГде разобрано
Контроллер ресурса для товаровphp artisan make:controller Api/ProductController --api --model=ProductControllers
Проверка входных данныхphp artisan make:request StoreProductRequestValidation & FormRequest
Формат ответа APIphp artisan make:resource ProductResourceAPI Resources
Права на действияphp artisan make:policy OrderPolicy --model=OrderAuth, Gates & Policies
Фоновая обработка заказаphp artisan make:job ProcessOrderQueues
Тесты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 работают в паре, каждый отвечает за своё.

Сводные подводные камни
Топ ошибок Laravel-приложений
1. config:cache с динамическими значениями. Кеш собирается раз; вызовы env() в коде после кеша вернут null. env() только в конфиге, в коде — config().
2. Mass assignment без $fillable. User::create($request->all()) с $guarded = [] пропустит is_admin. Используйте FormRequest и явный whitelist.
3. N+1 в API Resource. Resource обращается к relations — если не загружены, N+1. Model::preventLazyLoading() в локальной разработке.
4. Транзакция без afterCommit для job. Job уйдёт в очередь до commit; воркер не найдёт строку. Используйте ->afterCommit().
5. Eloquent в Octane без scoped. Singleton хранит state — следующий запрос видит чужие данные. $app->scoped(...) для request-state.
6. Полагание на session() в API. API-маршруты обычно без session middleware — session()->get() вернёт null. Для stateless API используйте jwt/Sanctum tokens.
7. ->orWhere без скобок. where(...)->orWhere(...)->where(...) — or теряет приоритет. Группируйте: where(fn ($q) => $q->where(...)->orWhere(...)).
8. updated_at при импорте. Mass import через Eloquent обновляет timestamps — теряется исходная дата. Используйте DB::table() или $model->timestamps = false.
9. findOrFail в API без exception handler. Дефолт — 404, но без явного JSON ответа — HTML страница. Кастомизируйте в App\Exceptions\Handler.
10. Mail::send синхронно. Блокирует request на отправку. Используйте Mail::queue или ShouldQueue на Mailable.
11. config('app.url') для генерации ссылок. Без правильной настройки APP_URL ссылки в письмах ведут на http://localhost. Проверяйте в проде.
12. DB::raw с пользовательским вводом. Защита prepared statements теряется. Только параметризованные значения через ? binding.
Вопросы для самопроверки (middle / senior)
Lifecycle и Container

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().

Routing и Middleware

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 модель другая.

Eloquent и Cache

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 — для атомарных действий.

Auth и безопасность

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 — для запросов без явного указания.

Octane и производительность

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.