Разбиение Laravel-монолита на модули: цифры до и после
Монолит на Laravel вырос до 400 контроллеров, деплой занимал 14 минут, а composer dump-autoload — почти полторы. Разбираем, что реально ускорилось после выделения модулей, а что стало медленнее.

Приложение на Laravel 11 с 400+ контроллерами, ~1200 классами в app/ и одной общей папкой Models перестало помещаться в голове команды из шести человек. Конкретные цифры на старте: composer dump-autoload -o отрабатывал за 78 секунд, полный прогон PHPUnit — 9 минут 40 секунд, сборка в CI до готового артефакта — 14 минут. Мержи в общий routes/web.php на 2000 строк давали конфликт почти в каждом втором PR.
Мы вынесли код в модули (bounded contexts) и замерили, что изменилось. Разбор — про то, какие метрики реально сдвинулись, а какие оказались приятным самообманом. Это не инструкция «как сделать модули»: организация неймспейсов и выбор пакета — отдельная тема, здесь только цифры и их причины.
Что мерили и на чём
Замеры сняты на CI-раннере с 4 vCPU и 8 ГБ RAM, PHP 8.3, OPcache включён, Laravel 11.x. Локальные цифры — на MacBook с M1 Pro. Данные усреднены по пяти прогонам, разброс указан там, где он больше 10%.
Структуру «до» и «после» проще показать таблицей — она же объясняет, откуда взялись сдвиги.
| Что | Монолит (до) | Модули (после) |
|---|---|---|
| Файлов в общей автозагрузке PSR-4 | ~1200 в App\ | ~1200, но в 9 неймспейсах Modules\* |
composer dump-autoload -o | 78 с | 81 с |
| Полный прогон тестов | 9 мин 40 с | 9 мин 55 с (полный) / 40–90 с (один модуль) |
Холодный route:cache | 3.1 с | 3.4 с |
| Конфликты в PR по маршрутам | ~50% PR | <5% PR |
| Время на ориентацию нового разработчика в фиче | оценочно 2–3 дня | оценочно полдня |
Где ускорения не случилось — и почему
Главное разочарование: сама автозагрузка и тесты быстрее не стали. Классов столько же, PSR-4 сканирует те же файлы, только разложенные по другим папкам. Оптимизированный автолоадер строит одну плоскую карту «класс → путь» независимо от того, лежат классы в app/ или в modules/*/src/. Ожидать здесь выигрыша — ошибка в самой постановке.
То же с полным прогоном тестов. PHPUnit грузит весь фреймворк на каждый процесс; количество тест-кейсов не изменилось. Секунды сверху — накладные расходы на большее число PSR-4 префиксов в composer.json, эффект в пределах шума.
Модули не ускоряют выполнение кода. Они ускоряют людей: сокращают область, которую нужно держать в голове, читать в PR и прогонять в тестах на конкретную задачу.
Что реально сдвинулось
Выигрыш пришёл из другого места — из возможности работать не со всем приложением сразу.
- Селективные тесты. После разбиения PHPUnit-suite настроен по модулям. Прогон тестов одного модуля — 40–90 секунд вместо 9+ минут. На каждый PR в CI гоняются только затронутые модули плюс общий слой, полный прогон остался лишь на
main. - Конфликты в маршрутах исчезли. У каждого модуля свой
routes.php, регистрируемый в его сервис-провайдере. Общий файл на 2000 строк, который правили все, ушёл. Доля PR с конфликтом по маршрутам упала с ~50% до единичных случаев. - Скорость ориентации. Не метрика в секундах, но самая дорогая в деньгах. Новый разработчик открывает
modules/Billing/и видит модели, сервисы, маршруты и тесты биллинга в одном месте, а не ищет их по трём общим папкам.
Селективный прогон: как это устроено
Suite по модулям задаётся в phpunit.xml. Ключевой момент — отдельный <testsuite> на каждый модуль, чтобы CI запускал только нужный:
<!-- phpunit.xml, Laravel 11.x, PHPUnit 11 -->
<testsuites>
<testsuite name="Billing">
<directory>modules/Billing/tests</directory>
</testsuite>
<testsuite name="Catalog">
<directory>modules/Catalog/tests</directory>
</testsuite>
<testsuite name="Shared">
<directory>tests/Shared</directory>
</testsuite>
</testsuites>В CI определяем изменённые пути и запускаем нужные suite. Схематично, на bash в шаге пайплайна:
# Определяем затронутые модули по diff с целевой веткой
CHANGED=$(git diff --name-only origin/main...HEAD)
# Пример: если менялся Billing — гоним его suite и общий слой
if echo "$CHANGED" | grep -q '^modules/Billing/'; then
php artisan test --testsuite=Billing --testsuite=Shared
fiПроверка, что настроено верно: php artisan test --testsuite=Billing должен показать в выводе только тесты биллинга и общего слоя, а не весь список из 400+ кейсов.
Регистрация маршрутов модуля
Каждый модуль подключает свои маршруты через собственный сервис-провайдер в методе boot(). Так общий routes/web.php и правда пустеет:
<?php
// modules/Billing/src/BillingServiceProvider.php (Laravel 11.x)
namespace Modules\Billing;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Route;
class BillingServiceProvider extends ServiceProvider
{
public function boot(): void
{
Route::middleware('web')
->prefix('billing')
->group(__DIR__ . '/../routes/web.php');
}
}Провайдеры модулей регистрируются в bootstrap/providers.php (в Laravel 11 массив провайдеров переехал сюда из config/app.php — если у вас проект на 10.x, смотрите старое расположение). Проверка: php artisan route:list покажет маршруты с префиксом billing/.
Скрытая цена: связность между модулями
Разбиение обнажает то, что монолит прятал, — сколько классов на самом деле дёргают друг друга. Пока всё лежало в App\, никто не замечал, что заказы напрямую обращаются к моделям биллинга и склада. После выделения модулей эти связи стали use Modules\Billing\Models\Invoice; внутри модуля заказов — то есть явной зависимостью.
Здесь два пути, и у каждого своя цена:
- Оставить прямые обращения к классам чужого модуля. Дёшево на старте, но границы фиктивные: изменение внутренней модели биллинга ломает заказы, и селективный прогон тестов начинает врать — затронут один модуль, а падает другой.
- Общаться через публичный контракт (интерфейс/сервис) модуля. Дороже: нужно спроектировать API модуля и DTO вместо голых Eloquent-моделей. Зато граница реальная, и тесты модуля перестают зависеть от внутренностей соседа.
Проверить фактическую связность помогает Deptrac — статический анализатор архитектурных зависимостей. Он строит граф между слоями по правилам из конфига и падает в CI, если кто-то нарушил границу модуля. Без такого контроля модули за пару месяцев снова сплетаются в клубок, только с более длинными неймспейсами.
Где подход ломается
- Циклические зависимости между модулями. Заказы зависят от биллинга, биллинг — от заказов. Composer это проглотит (пакеты не отдельные), но логически модули склеены и порознь не тестируются. Такие циклы Deptrac и должен ловить.
- Общий слой распухает. То, что «нужно всем», сваливают в
Shared, и он превращается во второй монолит. Правило: вSharedпопадает только то, что не принадлежит ни одному домену, — например, базовый класс исключения, а не «вспомогательный» сервис заказов. - Миграции и общая БД. Модули логически разделены, но таблицы живут в одной схеме с внешними ключами между доменами. Физического разделения данных разбиение на модули не даёт — это уже отдельный переход к сервисам, и его не стоит путать с модуляризацией.
Что делать дальше
Если решаетесь на разбиение, не ждите ускорения рантайма — его не будет. Ставьте цель на метриках, которые действительно двигаются: время прогона тестов на PR, доля конфликтов при мержах, время входа в задачу. Замерьте их до — иначе после будет нечего показать заказчику.
Ближайшие шаги после первого выделенного модуля: настроить селективный прогон в CI по diff, подключить Deptrac с правилом «модуль не лезет во внутренности другого модуля» и завести для каждого модуля явный публичный контракт вместо прямых обращений к Eloquent-моделям соседа. Метрика, за которой стоит следить в проде после разбиения, — не производительность, а частота регрессий, задевающих несколько модулей сразу: если она не падает, границы нарисованы неправильно.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: Laravel Service Providers — официальная документация, Deptrac — GitHub репозиторий
Частые вопросы
У меня Laravel 10, а не 11 — примеры подойдут?
Стоит ли выносить каждый модуль в отдельный Composer-пакет?
Почему composer dump-autoload не ускорился после разбиения?
Селективный прогон тестов пропустит регрессию в соседнем модуле — как быть?
Что делать, если после разбиения появились циклические зависимости между модулями?
Разбиение на модули — это то же самое, что переход к микросервисам?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.