В одном из прошлых постов было озвучено изучение мидлварей в Laravel 11 до его релиза. Что изменилось с тех пор и с чем мы столкнулись на практике, рассмотрим ниже.

Основная "киллер-фича" фреймворка Laravel версии 11 - "плоский код". Под капот убрано всё, что большинством разработчиков не используется, по сути являющееся "мусором". А также убраны некоторые действительно полезные вещи.

Изменения

То, что сразу бросается в глаза при установке проекта:

  1. Отсутствуют мидлвари;

  2. Отсутствуют некоторые сервис-провайдеры;

  3. Появился файл bootstrap/providers.php;

  4. Определение роутов, команд, мидлварь, ошибок и другого перенесено в файл bootstrap/app.php (раньше мидлвари определялись в файле app/Http/Kernel.php, эксепшены в app/Exceptions/Handler.php, крон-команды в app/Console/Kernel.php);

  5. Опция из настроек окружения для определения хранилища кэша переименована с CACHE_DRIVER на CACHE_STORE.

Теперь рассмотрим более детально и как мы с этим работаем.

bootstrap/app.php

Сказать по-правде, управление всем внутри одного конкретного файла, мягко говоря, очень неудобно, не функционально и не красиво. Поэтому для распределения мы используем invoke классы.

В конечном итоге, наш файл выглядит так:

<?php

use App\Console\Handler as ScheduleHandler;
use App\Exceptions\Handler as ExceptionHandler;
use App\Http\Middleware\Handler as MiddlewareHandler;
use Illuminate\Foundation\Application;

return Application::configure(basePath: dirname(__DIR__))
    ->withMiddleware(new MiddlewareHandler())
    ->withExceptions(new ExceptionHandler())
    ->withSchedule(new ScheduleHandler())
    ->withCommands([__DIR__ . '/../app/Console/Commands'])
    ->withRouting(
        api: __DIR__ . '/../routes/api.php',
        commands: __DIR__ . '/../routes/console.php',
        apiPrefix: ''
    )->create();

Наше приложение работает исключительно как API и в нём нет таких понятий как web, blade, html, ts/js и т.п., за исключением страниц ошибок при попытке открыть их из адресной строки браузера. Вследствие чего и префикс api у всех роутов нам также не нужен, поэтому мы его "обнуляем".

Мидлвари

При чистой установке проекта Laravel предлагает описывать мидлвари в файле bootstrap/app.php, что является неудобным способом. Поэтому, при помощи invoke метода мы создали класс в привычном месте:

<?php

namespace App\Http\Middleware;

use App\Http\Middleware\Authorizations\AuthInternalMiddleware;
use App\Http\Middleware\Authorizations\AuthPrivateMiddleware;
use App\Http\Middleware\Authorizations\AuthPublicMiddleware;
use Illuminate\Foundation\Configuration\Middleware as BaseMiddleware;

class Handler
{
    protected array $aliases = [
        'auth.private'  => AuthPrivateMiddleware::class,
        'auth.public'   => AuthPublicMiddleware::class,
        'auth.internal' => AuthInternalMiddleware::class,
    ];

    public function __invoke(BaseMiddleware $middleware): BaseMiddleware
    {
        if ($this->aliases) {
            $middleware->alias($this->aliases);
        }

        return $middleware;
    }
}

Кроме алиасов, можно также работать с группами и всем, что умеют "обычные" мидлвари. Просто выглядит это немного иначе. В данном случае нужны только алиасы.

Таким образом, всё взаимодействие с мидлварями вынесено во внешний файл и легко читается любым разработчиком, работающим с Laravel.

Объявляем мы этот файл в методе withMiddleware файла bootstrap/app.php:

<?php

use App\Http\Middleware\Handler as MiddlewareHandler;
use Illuminate\Foundation\Application;

return Application::configure(basePath: dirname(__DIR__))
    ->withMiddleware(new MiddlewareHandler())
    // ...
    ->create();

Обработчик ошибок

Также как и с мидлварями, эксепшены лишились файла app/Exceptions/Handler.php, который мы также вернули в логически корректное место добавив свои объявления. В итоге, файл выглядит следующим образом:

<?php

namespace App\Exceptions;

use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Configuration\Exceptions as BaseExceptions;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Sentry\Laravel\Integration;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;

class Handler
{
    protected string $jsonFlags = JSON_UNESCAPED_SLASHES ^ JSON_UNESCAPED_UNICODE;

    public function __invoke(BaseExceptions $exceptions): BaseExceptions
    {
        $this->renderUnauthorized($exceptions);
        $this->renderNotFound($exceptions);

        $this->reportSentry($exceptions);

        return $exceptions;
    }

    protected function renderUnauthorized(BaseExceptions $exceptions): void
    {
        $exceptions->renderable(
            fn (AuthenticationException $e, ?Request $request = null) => $this->response(
                message: __('Unauthorized'),
                code: 401,
                asJson: $request?->expectsJson() ?? false
            )
        );
    }

    protected function renderNotFound(BaseExceptions $exceptions): void
    {
        $exceptions->renderable(
            fn (NotFoundHttpException $e, ?Request $request = null) => $this->response(
                message: __('Not Found'),
                code: 404,
                asJson: $request?->expectsJson() ?? false
            )
        );
    }

    protected function reportSentry(BaseExceptions $exceptions): void
    {
        $exceptions->reportable(
            fn (Throwable $e) => Integration::captureUnhandledException($e)
        );
    }

    protected function response(string $message, int $code, bool $asJson): Response
    {
        if ($asJson) {
            return response()->json(compact('message'), $code, options: $this->jsonFlags);
        }

        $this->registerErrorViewPaths();

        return response()->view($this->view($code), status: $code);
    }

    protected function view(int $code): string
    {
        return view()->exists('errors::' . $code) ? 'errors::' . $code : 'errors::400';
    }

    protected function registerErrorViewPaths(): void
    {
        View::replaceNamespace(
            'errors',
            collect(config('view.paths'))
                ->map(fn (string $path) => "$path/errors")
                ->push($this->vendorViews())
                ->all()
        );
    }

    protected function vendorViews(): string
    {
        return __DIR__ . '/../../vendor/laravel/framework/src/Illuminate/Foundation/Exceptions/views';
    }
}

Пути к view фалам переопределены для того, чтобы мы могли отображать переведённые расшифровки ошибок, например, "404 | Не найдено" вместо "404 | Not Found" для российской локализации.

Например, файл resources/views/errors/404.blade.php содержит следующий код:

@extends('errors::minimal')

@section('title', __('http-statuses.404'))
@section('code',  404)
@section('message', __('http-statuses.404'))

Объявляем мы этот файл в методе withException файла bootstrap/app.php:

<?php

use App\Exceptions\Handler as ExceptionHandler;
use Illuminate\Foundation\Application;

return Application::configure(basePath: dirname(__DIR__))
    ->withExceptions(new ExceptionHandler())
    // ...
    ->create();

Выполнение заданий по расписанию (schedule)

Внезапно в Laravel 11 определение запуска заданий по расписанию, которые принято называть "кроном" (cron), стали... барабанная дробь... РОУТАМИ!

Да, Вы не ослышались. Согласно новым правилам запуск консольных команд определяется в файле routes/console.php.

И нам с этим нужно срочно что-то делать начиная с того, что в нашей команде принято файл routes/console.php исключать из репозитория, так как его цель заключается в хранении личных наборов команд разработчика не беспокоясь о том, что она может случайно попасть в репозиторий. Такое постоянно случалось до введения этой практики, когда мы создавали новые "dev" классы в папке app/Console/Commands.

Поэтому создаём новый старый класс App\Console\Handler:

<?php

namespace App\Console;

use Illuminate\Cache\Console\PruneStaleTagsCommand;
use Illuminate\Console\Scheduling\Schedule;

class Handler
{
    public function __invoke(Schedule $schedule): void
    {
        $schedule->command(PruneStaleTagsCommand::class)->hourly();

        // ...
    }
}

Объявляем мы этот файл в методе withSchedule файла bootstrap/app.php:

<?php

use App\Console\Handler as ScheduleHandler;
use Illuminate\Foundation\Application;

return Application::configure(basePath: dirname(__DIR__))
    ->withSchedule(new ScheduleHandler())
    // ...
    ->create();

Консольные команды

Инициализация консольных команд происходит в методе withCommands файла bootstrap/app.php. Здесь нужно передать массив абсолютных путей к директориям. Таким образом, мы просто передаём параметр на папку app/Console:

<?php

use Illuminate\Foundation\Application;

return Application::configure(basePath: dirname(__DIR__))
    ->withCommands([__DIR__ . '/../app/Console/Commands'])
    // ...
    ->create();

Здесь ничего сложного. Просто объявили и из консоли уже можно вызывать php artisan <name>.

Маршрутизация

Помимо прочего, новая версия Laravel также лишилась сервис-провайдера RouteServiceProvider и теперь объявлять маршруты нужно непосредственно в файлах папки routes. Так как у нас нет web зоны, мы решили оставить файл routes/api.php, добавив в него вызов групп:

<?php

app('router')
    ->middleware('auth.private')
    ->name('private.')
    ->prefix('v1/private')
    ->group(__DIR__ . '/api/private.php');

app('router')
    ->middleware('auth.public')
    ->name('public.')
    ->prefix('v1/public')
    ->group(__DIR__ . '/api/public.php');

app('router')
    ->middleware('auth.internal')
    ->name('internal.')
    ->prefix('v1/internal')
    ->group(__DIR__ . '/api/internal.php');

Сервис-провайдеры

Несмотря на то, что объявление сервис-провайдеров в новой версии фреймворка было вынесено в файл bootstrap/providers.php, старое расположение всё же работает, и работает по принципу array_merge.

Например, наше приложение имеет русскую fallback локализацию, но Laravel не умеет переводить текст из json файлов на неё. То есть условный __('Whoops!') отдаст как Whoops! вместо Упс!. Решает эту проблему пакетное решение JSON Fallback, но речь не столько о нём, сколько о принципе его подключения.

Так вот, для работы этого пакета нужно заменить дефолтный сервис-провайдер Illuminate\Translation\TranslationServiceProvider на другой. В случае с новой структурой приложения сделать это попросту невозможно - файл bootstrap/providers.php возвращает массив строк, где строка - прямая ссылка на сервис-провайдер. А нам мало того, что нужно заменить провайдер, дак ещё и заменить дефолтный, то есть тот, который находится где-то под капотом. И что делать? - спросите. Вот здесь нам на помощь и приходит новая старая опция providers в файле config/app.php. Просто добавляем её, объявляя дефолтные сервис-провайдеры, и всё. Приложение дальше само возьмёт их, сделает нужные подмены и подгрузит содержимое файла bootstrap/providers.php, следствием чего станет корректная работа приложения с заменяемыми сервис-провайдерами.

// config/app.php

<?php

use Illuminate\Support\ServiceProvider;
use Illuminate\Translation\TranslationServiceProvider as BaseTranslationServiceProvider;
use LaravelLang\JsonFallback\TranslationServiceProvider as JsonTranslationServiceProvider;

return [
    'name' => env('APP_NAME', 'Laravel'),

    // ...

    'providers' => ServiceProvider::defaultProviders()->replace([
        BaseTranslationServiceProvider::class => JsonTranslationServiceProvider::class,
    ])->toArray(),
];

Фишка в том, что под капотом Laravel как раз прокидывает дефолтные провайдеры в этот самый путь конфигурации и, объявляя их таким образом, мы их подменяем.

routes/console.php

Данный файл каждый разработчик использует в своих личных целях и он исключён из попадания в репозиторий.

Аргумент commands метода withRouting в файле bootstrap/app.php позволяет принимать в качестве значения путь, который под капотом валидируется на существование. Таким образом, нам не нужно самостоятельно это делать.

В итоге, получаем следующее объявление:

<?php

use Illuminate\Foundation\Application;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        api: __DIR__ . '/../routes/api.php',
        commands: __DIR__ . '/../routes/console.php',
        apiPrefix: ''
    )->create();

И убеждаемся в наличии строчки /routes/console.php в файле .gitignore, а также в отсутствии файла в самом репозитории.

<?php

use Illuminate\Support\Facades\Artisan;

Artisan::command('foo', function () {
    dump(
        'I like it! Move it! Move it!'
    );
});

Если какой-либо файл попал в репозиторий, хотя должен игнорироваться и правило на него описано в файле .gitignore, удалите файл и создайте коммит. Уже записанные в репозиторий файлы игнорируют эти параметры.

Заключение

Вернув старые новые файлы на свои места, мы решаем сразу несколько проблем при переходе на Laravel 11:

  • Нелогичность размещения объявлений управления;

  • Непонимание разработчиков при работе с органами управления;

  • Перегрузка роутов кроном;

  • Отсутствие специализированного файла, исключённого из репозитория, для хранения консольных команд разработчиков на личных машинах.

Главное, чтобы инструмент помогал решать задачи, а не пытался изменять архитектурное расположение колёс на полном ходу, где одно из них оказывается на заднем сиденье.

Именно эта причина и сподвигла меня написать эту статью на случай, если кому-то она окажется полезной.

Всех благ и удачных проектов!