Разбиваем Laravel-монолит на модули: как это работает внутри
Монолит на Laravel разросся до 400 контроллеров в одной папке, и любая правка в биллинге ломает каталог. Разбираем, что происходит внутри фреймворка при модуляризации и где она даёт трещину.

Типичная картина: проект на Laravel 11 живёт четыре года, в app/Http/Controllers лежит 380 файлов, миграции пронумерованы до 2020_-х, а routes/web.php — это 1900 строк. Правка в модуле биллинга требует прогнать весь тестовый набор, потому что никто не уверен, что не заденет каталог. Команда решает резать монолит на модули. Ниже — что при этом реально происходит внутри фреймворка: как загружаются сервис-провайдеры модулей, что делает автозагрузчик Composer, где ломается кеш конфигурации и почему изоляция модулей на уровне папок ничего не изолирует.
Речь именно о разборе механики, а не о выборе пакета. Сравнение nwidart/laravel-modules с самописной структурой — отдельная тема; здесь смотрим, что общего у всех подходов на уровне контейнера и загрузки.
Что такое «модуль» с точки зрения Laravel
Фреймворк не знает слова «модуль». Для него существуют сервис-провайдеры, биндинги в контейнере и загруженные файлы. Любая модульная структура — это соглашение поверх стандартного механизма загрузки. Когда вы кладёте код в Modules/Billing/, для Laravel не меняется ничего, пока вы не сделаете три вещи:
- зарегистрируете namespace модуля в автозагрузчике Composer (PSR-4);
- зарегистрируете сервис-провайдер модуля в контейнере приложения;
- внутри провайдера подключите маршруты, миграции, конфиги и вьюхи модуля.
Всё остальное — папки, неймспейсы, разделение — это организация для людей. Изоляция на уровне рантайма появляется только там, где вы её явно строите: через контейнер, события и явные контракты между модулями.
Автозагрузка: почему добавления папки недостаточно
Composer читает секцию autoload.psr-4 в composer.json и строит карту неймспейс → путь. Если вы добавили модули как отдельный корень:
"autoload": {
"psr-4": {
"App\\": "app/",
"Modules\\": "Modules/"
}
}
то класс Modules\Billing\Providers\BillingServiceProvider будет искаться по пути Modules/Billing/Providers/BillingServiceProvider.php. Пока вы не выполните composer dump-autoload, автозагрузчик о новых классах не знает — вы получите Class not found даже при корректных путях. Это первая частая ловушка при миграции: файл на месте, а класс не грузится.
Как модуль встраивается в жизненный цикл приложения
При каждом запросе Laravel проходит фазу bootstrap: создаёт контейнер, читает список провайдеров, вызывает у каждого register(), затем у каждого boot(). Провайдер модуля должен попасть в этот список. В Laravel 11 приложения конфигурируются через bootstrap/app.php, а провайдеры перечисляются в bootstrap/providers.php (в 10.x — в config/app.php, ключ providers). Разница между версиями важна: инструкция из статьи для 10.x про config/app.php в свежем проекте на 11 просто не сработает.
Внутри провайдера модуль подключает свои ресурсы:
<?php
// Modules/Billing/Providers/BillingServiceProvider.php — Laravel 11.x
namespace Modules\Billing\Providers;
use Illuminate\Support\ServiceProvider;
class BillingServiceProvider extends ServiceProvider
{
// register() вызывается раньше boot() у всех провайдеров
public function register(): void
{
// Слияние конфига модуля с общим репозиторием конфигурации
$this->mergeConfigFrom(
__DIR__ . '/../config/billing.php',
'billing'
);
}
public function boot(): void
{
// Маршруты модуля — отдельный файл, а не общий routes/web.php
$this->loadRoutesFrom(__DIR__ . '/../routes/web.php');
// Миграции модуля лежат внутри его папки
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
// Вьюхи под своим namespace: view('billing::invoice')
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'billing');
}
}
Ключевой момент — порядок. Все register() отрабатывают до всех boot(). Если в boot() модуля A вы обращаетесь к сервису, который биндит модуль B, — это работает, потому что register() модуля B уже выполнился. Но если вы разрешаете зависимость прямо в register(), порядок провайдеров начинает влиять на результат, и вы получаете плавающие ошибки, зависящие от того, кто зарегистрировался первым.
Модуль в Laravel — это не граница, а соглашение. Реальную границу проводит контейнер: пока модуль A импортирует конкретный класс из модуля B напрямую, между ними нет никакой изоляции, есть только другая папка.
Где модуляризация ломается первой
Кеш конфигурации и провайдеров
В проде обычно запускают php artisan config:cache и route:cache. Кеш конфигурации сериализует все конфиги в один файл. Если модуль подтягивает конфиг через mergeConfigFrom() в register() — он попадёт в кеш корректно. Но если вы читаете env() внутри кода модуля напрямую (не через config()), после config:cache все вызовы env() вернут null, потому что при закешированном конфиге .env не загружается. Это классический баг миграции: локально всё работает, на проде модуль молча теряет настройки.
Общие миграции и порядок таблиц
loadMigrationsFrom() добавляет пути, но Laravel запускает миграции в порядке имён файлов по всем путям сразу. Если таблица модуля orders ссылается внешним ключом на users из ядра, а имя файла модуля лексикографически меньше — миграция упадёт: таблицы users ещё нет. Модульность не даёт независимого порядка миграций; общий таймлайн остаётся общим.
Циклические зависимости между модулями
Как только Billing вызывает класс Catalog, а Catalog — класс Billing, вы получили цикл. Composer его переживёт (автозагрузка ленивая), но архитектурно это тот же монолит с лишними папками. Признак проблемы: чтобы протестировать один модуль, приходится поднимать половину остальных.
Как измерить, что модуляризация что-то дала
Разбиение ради красоты дерева каталогов бесполезно. Мерьте конкретное:
| Показатель | Как замерить | Что говорит |
|---|---|---|
| Связность модулей | Число use из других модулей на модуль (грепом или deptrac) | Растёт — границы фиктивны |
| Время тестов одного модуля | Запуск только его набора тестов | Если тянет всё приложение — изоляции нет |
| Число провайдеров в bootstrap | Подсчёт в providers.php | Влияет на холодный старт |
| Размер закешированного конфига | Объём файла после config:cache | Раздутые модульные конфиги замедляют bootstrap |
Для контроля связей ставят deptrac — статический анализатор, который проверяет правила «модуль A не имеет права импортировать модуль B» и падает в CI при нарушении. Это единственный способ удержать границы: без автоматической проверки они размываются за пару спринтов.
Как правильно провести границу между модулями
Вместо прямого импорта класса чужого модуля используйте один из механизмов, которые фреймворк уже предоставляет:
- Контейнер и контракты. Модуль публикует интерфейс, остальные зависят от интерфейса, а не от реализации. Биндинг — в
register(). - События. Billing бросает
InvoicePaid, Catalog слушает — прямой связи между классами нет. - Публичный фасад модуля. Один класс-точка входа на модуль, всё остальное —
internalпо соглашению и проверяется deptrac.
Что делать дальше
Начните не с перекладывания файлов, а с замера связности текущего монолита: посчитайте, какие части импортируют друг друга, и режьте по самым слабым связям. Первый вынесенный модуль сделайте «эталонным» — с провайдером, своими маршрутами, миграциями и тестами, которые гоняются в изоляции. Поставьте deptrac в CI до того, как вынесете второй модуль, иначе границы поплывут. И проверьте поведение на проде отдельно: прогоните config:cache и route:cache в стейджинге, потому что именно кеширование вскрывает скрытые вызовы env() и неявные зависимости провайдеров.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Laravel 11.x — Service Providers, Laravel 11.x — Package Development (loadRoutesFrom, loadMigrationsFrom, mergeConfigFrom)
Частые вопросы
Нужен ли для модулей пакет вроде nwidart/laravel-modules?
А если у меня Laravel 10, а не 11 — инструкция подойдёт?
Модуль не грузится, класс not found — куда смотреть?
После деплоя модуль теряет настройки, хотя локально всё работает. Почему?
Как не дать модулям снова превратиться в монолит?
Миграция модуля падает из-за внешнего ключа на таблицу ядра. Что делать?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.