Работа с новой архитектурой в Laravel 11
В одном из прошлых постов было озвучено изучение мидлварей в Laravel 11 до его релиза. Что изменилось с тех пор и с чем мы столкнулись на практике, рассмотрим ниже.
Основная "киллер-фича" фреймворка Laravel версии 11 - "плоский код". Под капот убрано всё, что большинством разработчиков не используется, по сути являющееся "мусором". А также убраны некоторые действительно полезные вещи.
Изменения
То, что сразу бросается в глаза при установке проекта:
Отсутствуют мидлвари;
Отсутствуют некоторые сервис-провайдеры;
Появился файл
bootstrap/providers.php
;Определение роутов, команд, мидлварь, ошибок и другого перенесено в файл
bootstrap/app.php
(раньше мидлвари определялись в файлеapp/Http/Kernel.php
, эксепшены вapp/Exceptions/Handler.php
, крон-команды вapp/Console/Kernel.php
);Опция из настроек окружения для определения хранилища кэша переименована с
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:
Нелогичность размещения объявлений управления;
Непонимание разработчиков при работе с органами управления;
Перегрузка роутов кроном;
Отсутствие специализированного файла, исключённого из репозитория, для хранения консольных команд разработчиков на личных машинах.
Главное, чтобы инструмент помогал решать задачи, а не пытался изменять архитектурное расположение колёс на полном ходу, где одно из них оказывается на заднем сиденье.
Именно эта причина и сподвигла меня написать эту статью на случай, если кому-то она окажется полезной.
Всех благ и удачных проектов!