Как я сделал старый монолит на Laravel 8 — модульным, чтобы не плодить форки под каждого клиента

от автора

Небольшая предыстория — без рассказа про сам продукт, только про боль и как я её разгребал

Изначально это был монолитный портал на Laravel 8, где user‑фронт и admin‑фронт лежали рядом с беком в одной папке.

Проект ставился каждому клиенту на его собственный хостинг, и единого портала «для всех» не существовало, между клиентами отличался и бек, и фронт. 

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

Хуже всего это вылезало в поддержке. Приходит баг, лезешь в код и понимаешь, у другого клиента это давно починено, просто фикс так и не доехал до этой копии. Каждая правка жила в своей репе.

Поэтому, когда появилось время, я занялся этим проектом. Сразу поставил рамку, фреймворк не меняю, остаёмся на Laravel 8. Хотелось не переходить на другой язык и не переписывать весь бек целиком. План был такой: отделить всё в модули, а потом поэтапно переписывать отдельные куски.


Шаг первый: вынести фронт

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

Шаг второй: Идея универсального ядра

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

В первой итерации всё было наивно: в app/ появилась папка Modules, куда я копировал папку модуля, и он автоматически подгружался в бек.

Любой класс под app/Modules/Quiz/… с неймспейсом App\Modules\Quiz\… подгружается сам, без отдельной записи в composer.json. Оставалось только найти провайдеры модулей и зарегистрировать их. В наивной версии я просто пробегался по папкам глобом:

foreach (glob(app_path('Modules/*/*ServiceProvider.php')) as $file) {    $class = 'App\\Modules\\'        . basename(dirname($file)) . '\\'        . basename($file, '.php');    if (class_exists($class)) {        $this->app->register($class);    }}

Сам модуль в своём провайдере уже подтягивал что ему надо: конфиг, миграции, роуты, обычными средствами Laravel (mergeConfigFrom, loadMigrationsFrom, loadRoutesFrom). Но модуль был намертво вшит в образ бека, а глоб регистрировал вообще всё подряд, без разбора, включён модуль или нет.

Шаг третий: Манифесты и sidecar‑контейнеры

Папка Modules переехала из app/ в корень репозитория, и появились манифесты. Манифест описывает, что делать с модулем при старте контейнера — какой провайдер поднимать, куда класть код, какие гонять миграции и сиды, какие роуты добавлять в nginx. Некоторые модули ещё и проверяют окружение перед установкой (preflight), например, ClamAV не даст себя поставить на ВМ с 1 ГБ ОЗУ.

А ещё в манифесте появились sidecar‑контейнеры. Некоторые модули требуют для работы внешние docker‑контейнеры. Например, антивирус, в беке код лишь слушает, что появился новый файл, и шлёт его во внешний контейнер clamd, который и ведёт обработку.

// modules/virusscan/manifest.phpreturn [    'name'        => 'Антивирусная проверка',    'version'     => '1.0.1',    'min_backend' => '1.0.0',    'backend' => [        'service_provider' => App\Modules\VirusScan\VirusScanServiceProvider::class,        'module_dir'       => 'VirusScan',    ],    'sidecars' => [        'clamd' => [            'compose_service' => 'clamd',            'compose' => [                'image'    => 'clamav/clamav:stable_base',                'profiles' => ['module-virusscan', 'all-in-one'],                'healthcheck' => [                    'test' => ['CMD', 'clamdscan', '--ping'],                ],                'mem_limit' => '${CLAMD_MEM:-2g}',                'cpus'      => '${CLAMD_CPUS:-1.0}',                // volume под базы, expose 3310, networks            ],        ],    ],    'preflight' => ['min_free_ram_mb' => 2048],];

Как модуль добавляет свои роуты в nginx

Отдельная история — nginx. Некоторым модулям мало PHP, им нужен свой кусок конфига: свои location, проксирование на sidecar и так далее. Сделал по схеме available/enabled, как a2ensite в apache.

Конфиги модулей (modules/<key>/nginx/*.conf) при сборке образа nginx копятся в modules‑available. Лежат, но не подключены. Когда модуль включают, агент через docker exec делает симлинк из modules‑available в modules‑enabled, а nginx инклюдит только modules‑enabled/*.conf. Выключили модуль — симлинк убрали, роут исчез. Контейнер при этом не пересобирается, хватает reload.

В манифесте это описано так:

'nginx_routes' => [    'quiz' => [        // .conf вшит в образ nginx на этапе сборки        'available'    => '/etc/nginx/conf.d/modules-available/quiz.conf',        // симлинк сюда создаёт агент, когда модуль включён        'enabled_path' => '/etc/nginx/conf.d/modules-enabled/quiz.conf',    ],],

Грабля с регистрацией модулей

Тут я наступил на грабли. Регистрировать модули в фазе register() нельзя, база ещё не готова, запрос к таблице модулей падает, и всё тихо проваливается в fallback. Плюс провайдер, зарегистрированный внутри boot‑цикла, не получает свой boot() и роуты модуля не подхватываются.

Лечится это тем, что всю регистрацию откладываешь до полной загрузки приложения через app→booted():

public function register(): void{    $this->app->booted(function () {        $this->registerEnabledModules();    });}

И ещё один важный момент, я сделал так, чтобы каждый модуль регистрировался изолированно. Раньше одно исключение в цикле снимало все модули разом. Теперь у каждого свой try‑catch, упал один, остальные работают.

foreach ($keys as $key) {    try {        $this->registerModule($key);    } catch (\Throwable $e) {        Log::error("Модуль '{$key}' не зарегистрирован: " . $e->getMessage());    }}

Совместимость модуля с ядром

Помимо min_backend в манифесте, в ядре есть константа ModuleApi::CURRENT — версия API, которую ядро даёт модулям. Перед загрузкой проверяю, что модуль не просит API новее или старше, чем ядро умеет. Не подходит — модуль пропускается, в лог падает строчка, весь бек при этом не валится.

Итого min_backend страхует от слишком старого ядра, а module_api — от модуля, который ядро уже не тянет.

А что видит фронт?

Модуль может быть выключен или вообще не куплен по лицензии, и фронт не должен показывать его кнопки и пускать на его страницы. Собирать под каждого клиента свою версию фронта я больше не хотел, поэтому фронт один на всех, а что рисовать — решается в рантайме.

При старте админка дёргает /features и получает, что включено:

// stores/app.jsasync fetchFeatures() {    const { data } = await http.get('/features');    this.features = data;               // { quiz: true/false, ... }}// геттерquizEnabled: (s) => s.features === null || s.features.quiz !== false,

Дальше два уровня. В меню пункт просто прячется через v‑if:

<el-sub-menu index="quiz" v-if="quizEnabled && auth.canAny('quiz.view')">

А в роутере висит гвард, даже если url модуля вбить руками, выключенный модуль тебя развернёт:

// у роутов модуля стоит meta: { quizModule: true }if (to.meta.quizModule && !app.quizEnabled) return { path: '/settings/contests' };

Получается, бек — источник правды (installed / enabled + лицензия), а фронт просто спрашивает «что показывать» и подстраивается. На публичном фронте портала то же самое.

Что модуль вообще умеет объявить

Если собрать в кучу, один манифест может притащить:

  • провайдер в бек (код, листенеры, роуты API);

  • sidecar‑контейнер (тот же clamd);

  • свой кусок конфига nginx;

  • бакеты в хранилище (у quiz — отдельный quiz‑certs под сгенерированные сертификаты);

  • preflight‑проверки железа перед установкой;

  • дефолтные env/config и сидеры с начальными данными;

  • события для системы уведомлений.

То есть модуль — это не «папка с классами», а самодостаточная вертикаль — бек, инфра, фронт‑гейтинг и данные. Ядро про конкретный модуль ничего не знает, оно только читает манифест и делает, что там написано.

Шаг четыре: Как доставлять это клиентам?

Дальше мне стало интересно, как доставлять новые модули и обновления до клиентов автоматически. Сначала я пытался научить сам бек скачивать новый образ контейнера себя же и модулей. Почему оказалось плохой думаю объяснять не стоит, бек не может надежно с fallback откатом редеплоить сам себя.

Но и идея, когда клиент лезет через SSH на свой сервер, чтобы поставить новую версию приложения мне не нравится.

Появился stack‑installer. Самодостаточный визард, которому доверили первую установку приложения, а также процесс обновления, в админке окно обновления стримит SSE из его агента, не из основного бека, агент ставит maintaince mode и следит, чтобы все новые контейнеры получили healthy статус. Хардкода в stack‑installer нет, он не заточен под конкретный проект.

В CI каждого релиза формируются три файла: два docker‑compose и setup‑manifest.yaml. Клиенту нужно один раз закинуть их на ВМ, ничего не меняя и не создавая никаких.env, сделать docker compose up ‑d и через красивый визард установить портал. Подробнее об этом расскажу в отдельной статье.

Агент, лицензии и маркетплейс

Внутри stack‑installer сидит агент, который на сервере лицензий проверяет апдейты и обновляет портал. Разделение простое. Агент обновляет ядро (меняет образы, compose, катает миграции), а модули обновляет сам бек — он лучше знает свои installed/enabled и совместимость.

При этом модули привязаны к лицензии клиента. Нет модуля в лицензии — он не приедет и не поставится. По сути, вышел целый маркетплейс.

Как модуль физически приезжает

Код модуля не лежит в образе бека. Каждый модуль — это отдельный образ module‑<key>, и тегается он своей версией из манифеста (version в manifest.php), независимо от версии ядра. Захотел обновить один модуль — обновляешь только его, ядро не трогаешь.

Когда клиент ставит модуль, агент вытаскивает код из образа module‑<key>:<версия> в общий volume modules_code, откуда его уже видит автолоад бека. Дальше бек финализирует установку — помечает модуль installed/enabled, пишет installed_version, раскладывает config модуля в настройки, гоняет сидеры и install‑хуки, перезапускает воркеры, чтобы те подхватили листенеры модуля. Всё идемпотентно, тот же путь работает и для обновления.

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

Что в итоге

Забавно, что проект по факту стал распределённым: 2 SPA, sidecar‑контейнеры, сервер лицензий, агент — всё крутится отдельно. Специально я к этому не шёл, просто в какой‑то момент модули и их зависимости перестали влезать в один PHP‑процесс.

А само ядро как было монолитом на Laravel 8, так и осталось.

ссылка на оригинал статьи https://habr.com/ru/articles/1066192/