Полгода назад мне посчастливилось решать интересную и нетипичную задачу по затаскиванию Symfony в Yii2 монолит. Вводные были такими:
Объем кодовой базы 180+ тысяч строк PHP кода.
Монолит долгое время писался аутсорсом, что отразилось на качестве кода.
Связанность кода была очень высокой.
Монолит был сделан на базе Advanced шаблона. Количество точек входа в приложение – семь, то есть, 7 файлов index.php.
В монолите не использовался DIC (что скорее облегчало задачу).
Из пожеланий руководства имелось следующее:
Весь старый код должен работать без изменений на прежних роутах.
Возможность дорабатывать старый код должна сохраняться.
Весь новый код команда будет стараться писать под управлением Symfony.
Все семь точек входа в приложение должны сохраниться в неизменном виде, причем для каждой точки входа должна быть возможность абсолютно прозрачно и незаметно для внешнего наблюдателя выполнять как старый код на Yii2, так и новый код на Symfony.
Весь старый код, обеспечивающий аутентификацию и авторизацию, должен работать, также должна быть возможность в новом коде пользоваться Symfony Security. Иными словами, нужно скрестить две секьюрити.
В данном материале я подробно и пошагово распишу весь процесс решения этой задачи с пояснениями, почему были выбраны именно такие решения и как это работает.
Зачем переходить на Symfony и почему должен остаться Yii2?
Для начала, возможно, стоит пояснить, что вообще команда хотела получить от смены фреймворка:
Всю мощь симфонийского DIC.
Возможность использовать любой компонент Symfony из коробки, просто выполнив composer install, вместо того, чтобы прикручивать каждый компонент вручную.
Переход с Active Record на Doctrine опять же без ручного прикручивания ORM к старому коду.
Конечно, было бы здорово просто выпилить Yii2 из проекта, но это было просто невозможно по многим причинам. Вот лишь некоторые из них (напомню, кодовая база - 180 тыс. строк только PHP кода):
В огромном количестве контроллеров вплетено множество бизнес-правил и правил контроля доступом.
Более 250 моделей ActiveRecord несут в себе не только мапинг на таблицы БД, но и обилие логики.
Проект содержит огромное количество интеграций со сторонними сервисами (особенность бизнеса). Большая часть этих интеграций завязана на конфиги Yii2.
Обращения к глобальному объекту Yii пронизывают весь код проекта.
Разумеется, не смотря на то, что Yii2 остается, в команде был консенсус по поводу того, что старый код дописывается только в самых крайних случаях, и остается он лишь как неизбежное легаси-зло.
Пожалуй, ко всем перечисленным выше условиям следует добавить и требования бизнеса:
Ресурсов на глобальный рефакторинг нет.
Выпуск новых фич не должен прерываться и даже замедляться из-за смены фреймворка.
С чего начать?
С первого взгляда на эту задачу были очевидны несколько проблем:
Yii2 должен работать, как работал, то есть в приложении должно ужиться два фреймворка так, чтобы ни один из них не мешал другому.
В легаси-коде, работающем под Yii2, должен быть доступ к DIC Symfony для работы как с компонентами фреймворка (сериалайзер, валидатор и т. п.), так и с сервисами, которые будут написаны командой в будущем.
В новом Symfony коде должны исправно работать все ActiveRecord, т. к. перетащить базу из 250 таблиц на доктрину было просто нереально.
Каким-то образом должны слаженно работать оба роутинга: иишный и симфонийский.
Для начала следовало вообще понять, как подступиться к такой задаче. На сайте Symfony есть статья, туманно и в общих чертах описывающая возможные решения по переходу с другого фреймворка на Symfony.
Вкратце, там предлагается два подхода. Первый: “Front Controller with Legacy Bridge”. В рамках этого подхода предлагается вручную вызвать инициализацию симфонийского объекта Request
, скормить его симфонийскому же Кернелу и, в случае получения ответа 404, скормить тот же Request
некоему LegacyBridge
, который должны придумать мы сами. Вот и все советы от Симфони. Лично мне в таком подходе сразу не понравилось две вещи:
Если я весь такой рестовый и хочу в своем контроллере ответить 404 по собственной воле, то это будет приводить к повторной обработке моего запроса вторым фреймворком.
Последовательный запуск фреймворков не помогает решить проблему доступа к ActiveRecord из Symfony приложения и, наоборот, доступа к симфонийскому DIC из Yii2 приложения.
Второй подход называется “Legacy Route Loader” и предполагает какое-то совсем простое устройство того приложения, с которого осуществляется миграция. Судя по примерам кода, предполагается назначить некоторому количеству php файлов старого приложения отдельные роуты (по одному на файл), при вызове которых эти самые файлы будут реквайриться. Не очень понятно, где вообще такое может быть применимо, но точно не в нашем случае.
Итого, полезной информации ноль. Что ж, не беда, мы и сами с усами, разработаем собственное решение, главное терпение, дотошность и наличие хороших QA, которые перед выкаткой в прод проверят, что весь старый функционал исправно работает.)
Проектирование решения
Для того, чтобы подойти к этой задаче, нужно в принципе понимать, как фреймворк обрабатывает запрос. У Symfony есть отличная статья, описывающая, как это происходит.
Вторым источником информации нам послужит сам код, в основном классы Symfony\Component\HttpKernel\HttpKernel
и yii\base\Application
. Для наглядности я набросал упрощенную схемку обработки запроса обоими фреймфорками.

Не знаю как у вас, а у меня при взгляде на оба цикла, размещенные рядом, сразу появилось желание посравнивать два фреймворка в этом разрезе.) Но, поскольку цель данной статьи не в этом, я сделал это сравнение (с неожиданным для себя выводом) в отдельном посте в своем telegram канале. А мы с вами двигаемся дальше.
Обратите внимание на концептуальное сходство обоих циклов обработки запроса. В сущности, решение, которое напрашивается само собой, это запуск одного цикла внутри другого. У этих двух циклов есть лишь два общих места: это сырые данные для создания объекта Request
($_GET
, $_POST,
$_COOKIE
, $_FILES
, $_SERVER
) и поток вывода php скрипта, куда будет отправлено содержимое объекта Response
(тело и заголовки).
Трудно представить себе ситуацию, в которой один из фреймворков будет менять то содержимое глобальных массивов, из которого собирается Request
. То есть, в этой точке фреймворки мешать друг другу не будут. Что касается потока вывода, то тут все просто: мы перехватим Response
одного из фреймворков до отправки на вывод и преобразуем его в Response
другого фреймворка.
Поскольку фреймворком, на который осуществляется переход, является все же Symfony, да и даже по нашей упрощенной схеме видно, что этот фреймворк дает больше возможностей по управлению циклом обработки запроса, запускать мы все-таки будем Yii2 внутри Symfony.
Такой подход позволяет решить почти все сформулированные выше проблемы:
Yii2 приложение будет запускаться «как обычно», ничего не зная о том, что оно запущено внутри Symfony.
При этом из Yii2 приложения будет доступ к DIC Symfony
Поскольку Yii2 предоставляет доступ ко всем своим компонентам через глобальный объект, то и внутри Symfony будет доступ к компонентам Yii2 (прежде всего к Active Record).
Единственная проблема, которую предстоит решить, это «скрещивание» роутинга двух фреймворков, и чуть позже я опишу ее решение, но сначала давайте разберем, как осуществляется загрузка двух фреймворков.
Подготовка Yii2 к загрузке внутри Symfony
Если еще раз взглянуть на схему цикла обработки запроса фреймворком Yii2 и сопоставить ее с кодом, то получается такая последовательность:
# index.php: инстанцируем и запускаем приложение
(new yii\web\Application($config))->run();
#---------------------------------------------
# yii\base\Application::run(): обрабатываем запрос, формируем ответ и отправляем его
$response = $this->handleRequest($this->getRequest());
//...
$response->send();
#---------------------------------------------
# yii\web\Response::send(): вываливаем Response на вывод php скрипта
$this->sendHeaders(); //внутри уже вызов php функции header()
$this->sendContent(); //внутри уже вызов echo
В этой схеме нас не устраивает то, что, в отличие от Symfony, Yii2 не отдает никуда наружу свой Response
, а сразу же его вываливает в вывод скрипта. Да, есть событие EVENT_AFTER_REQUEST
, и, судя по коду, можно в обработчике этого события установить свойство Request::isSent
в true
, что остановит отправку содержимого в вывод скрипта, но я решил здесь пойти другим путем и просто отнаследовался от классов yii\web\Application и yii\web\Response
, переопределив методы run()
и send()
соответственно. В обоих методах была изменена, по сути, одна строчка:
class Application extends yii\web\Application
{
public function run(): ?Response
{
try {
//…
$response->send();
$this->state = self::STATE_END;
return $response; //возвращаем отсюда весь Response вместо $response->exitStatus
} catch (ExitException $e) {
//…
}
}
}
class SilentResponse extends yii\web\Response
{
public function send(): void
{
//…
$this->trigger(self::EVENT_AFTER_PREPARE);
//просто выпиливаем вызовы $this->sendHeaders() и $this->sendContent()
$this->trigger(self::EVENT_AFTER_SEND);
$this->isSent = true;
}
}
Теперь наше Yii2 приложение, завершив обработку запроса, просто вернет из метода run()
свой Response
, который мы спокойненько можем перелить в симфонийский Response
. А значит, можно реализовывать последовательную загрузку двух фреймворков.
Реализация загрузки двух фреймворков
Как в Symfony, так и в Yii2 экземпляры приложений инстанцируются в файле index.php
, поэтому вначале я приведу код уже объединенного мной файла index.php
, а потом объясню, как это работает.
use src\Common\Infrastructure\Symfony\Kernel;
defined('YII_DEBUG') or define('YII_DEBUG', true);
defined('YII_ENV') or define('YII_ENV', 'dev');
defined('YII_ENABLE_ERROR_HANDLER') or define('YII_ENABLE_ERROR_HANDLER', false);
require __DIR__ . '/../../vendor/autoload_runtime.php';
return function (array $context) {
putenv('YII_APP_NAME=biz');
return new Kernel($context['APP_ENV'], (bool)$context['APP_DEBUG']);
};
Как видите, от иишного файла здесь остались только объявления констант. А к стандартному коду запуска Symfony добавлена инициализация переменной окружения YII_APP_NAME
, содержащей имя текущего Yii2 приложения. Это нужно для того, чтобы без плясок с бубном внутри Symfony понимать, через какой файл index.php
мы вошли в приложение.
Поскольку структура папок в нашем проекте уже продиктована шаблоном Yii2, и по условиям задачи она должна остаться, то и куча файлов index.php
по одному для каждого приложения в каждом окружении у нас остается. То есть, переход на Симфони не должен требовать переконфигурации веб-сервера. Подробнее об этом я расскажу в конце статьи.
Обратите внимание на отключение иишного error handler через константу YII_ENABLE_ERROR_HANDLER
. Это нужно сделать, поскольку оба фреймворка вызывают функцию PHP set_error_handler()
со своим обработчиком. Подробнее об этом я также расскажу в конце статьи.
Следующее, что необходимо сделать, это написать класс, который будет выполнять загрузку Yii2 приложения. Поскольку Yii2 нам нужно запускать только в том случае, если запрашивается один из старых роутов, то держать этот фреймворк постоянно загруженным не имеет смысла. Вот код этого класса:
class ApplicationLoader
{
public function __construct(
private readonly string $projectRoot,
private readonly string $yiiAppName,
private ?Application $app = null,
) {
}
public function load(): void
{
if (null !== $this->app) {
return;
}
require $this->projectRoot . '/vendor/yiisoft/yii2/Yii.php';
require $this->projectRoot . '/common/helpers/shortcuts.php';
require $this->projectRoot . '/common/config/bootstrap.php';
$localBootstrap = $this->projectRoot . '/' . $this->yiiAppName . '/config/bootstrap.php';
if (file_exists($localBootstrap)) {
require $localBootstrap;
}
$localConfigFile = $this->projectRoot . '/' . $this->yiiAppName . '/config/main-local.php';
$localConfig = [];
if (file_exists($localConfigFile)) {
$localConfig = require $localConfigFile;
}
$config = ArrayHelper::merge(
require $this->projectRoot . '/common/config/main.php',
require $this->projectRoot . '/common/config/main-local.php',
require $this->projectRoot . '/' . $this->yiiAppName . '/config/main.php',
$localConfig
);
$this->app = new Application($config); //здесь наш Application из примера кода выше
}
public function getApp(): ?Application
{
if (null === $this->app) {
$this->load();
}
return $this->app;
}
public function isLoaded(): bool
{
return null !== $this->app;
}
}
Этот класс зарегистрирован в симфонийском DIC, и параметры $projectRoot
и $yiiAppName
(помните, мы в index.php
объявляли env YII_APP_NAME
?) пробрасываются в него через контейнер. Назначение класса понятно: загружать фреймворк по запросу и следить за тем, чтобы был загружен всегда только один экземпляр.
Поскольку Yii2 загружает все свои компоненты при вызове конструктора класса Application
, то, стоит нам вызвать наш ApplicationLoader
, как нам сразу станет доступен глобальный объект Yii
со всеми его компонентами, включая уже наполненный данными Request
и соединение с БД. С этого момента любой класс ActiveRecord
будет работать, и мы можем доставать наши сущности из базы в любом месте симфонийского приложения (включая консоль).
Теперь осталось как-то и где-то принимать решение о необходимости загрузки Yii2 и передаче в него управления. И тут мы подходим к самому интересному.
Реализуем роутинг
Теперь, когда все готово к последовательному запуску двух фреймворков, осталось разобраться с роутингом. Именно роутинг потребовал от меня наибольшего внимания во время решения задачи по переходу с Yii2 на Symfony.
Поскольку Yii2 у нас запускается внутри Symfony, то есть, вторым по очереди, и при этом Yii2 ничего о Симфони не знает, то логично предположить, что иишный роутинг должен работать без изменений, и именно Symfony предстоит решать, куда направить запрос: на один из своих контроллеров, или в Yii2.
Очевидно, что при таком подходе симфонийские роуты могут перекрывать иишные, но так работает роутинг в принципе, как в Symfony, так и в Yii2: отрабатывает то правило роутинга, которое первым по списку выполнилось для обрабатываемого запроса.
При разработке объединенного роутинга мне пришлось решать две проблемы. Первая, попроще, это возможность возвращать из симфонийского приложения ответы 404 так, чтобы это не тригерило запуск Yii2.
Вторая, более сложная, проблема состояла в том, что симфонийский роутинг построен на симфонийских же встроенных событиях. И при попытке вставить свой обработчик события в череду тех, что идут от самого фреймворка, я получал тот, или иной сайд эффект, в зависимости от того, на какое событие я подписывался и какой приоритет присваивал своему обработчику.
Либо ломалась обработка фреймворком исключительных ситуаций (не выводилась стандартная страница об ошибке, некорректно отрабатывало событие kernel.exception
, и т. д.), либо на этапе выбора фреймворка не было контекста Security (не было понятно, какой юзер залогинен), либо ломался симфонийский роутинг, либо происходила еще какая-то дичь.
В конце концов, решение было найдено, и состояло оно в том, чтобы задекорировать симфонийский RouterListener
вместо того, чтобы пытаться делать что-то до или после него. Сначала приведу код, а потом поясню его.
#[AsDecorator(decorates: 'router_listener')]
class ApplicationSwitcher extends RouterListener
{
public function __construct(
private readonly ApplicationLoader $applicationLoader,
//здесь идут зависимости родительского класса, опускаю их
) {
parent::__construct($matcher, $requestStack, $context, $logger, $projectDir, $debug);
}
public function onKernelRequest(RequestEvent $event): void
{
try {
parent::onKernelRequest($event);
} catch (NotFoundHttpException|MethodNotAllowedHttpException $e) {
$this->applicationLoader->transferControl($e);
}
}
}
В конструкторе от себя я добавил только инъекцию своего ApplicationLoader
, а все остальные аргументы продиктованы родительским классом. В переопределенном методе onKernelRequest()
делается очень простая вещь:
Сначала вызываем родительский метод
onKernelRequest()
. Именно в нем происходит работа симфонийского роутинга, и определние, каким контроллером будет обработан запрос.Если контроллер не был разрезолвлен, то из родительского метода
onKernelRequest()
будет выброшено одно из двух исключений, которые мы отлавливаем.В обработчике этих двух исключений мы вызываем метод
ApplicationLoader::transferControl()
, делая тем самым две вещи: во-первых, мы устанавливаем флаг, что управление должно быть передано фреймворку Yii2, а во-вторых, мы как бы откладываем симфонийское исключение, запоминая его на будущее.
Вот код методов transferControl()
и isControlTransferred()
(который понадобится нам позже) класса ApplicationLoader
.
public function transferControl(\Throwable $symfonyException): void
{
$this->controlTransferred = true;
$this->symfonyException = $symfonyException;
}
public function isControlTransferred(): bool
{
return $this->controlTransferred;
}
У такого решения по роутингу есть очевидные плюсы:
Оно легковесное, кода очень мало.
Мы никак не вмешиваемся в работу симфонийского роутинга (в отличие от решения с собственными обработчиками того же
kernel.request
).Если в ходе работы Symfony возникнут какие-то ошибки, мы их никак не заглушаем, т. к. отлавливаем всего два исключения: на ненайденный роут и неподдерживаемый HTTP метод.
Теперь из симфонийских контроллеров спокойно можно бросать любые HTTP эксцепшены, включая
NotFound
, и это не будет тригерить загрузку Yii2.Загрузка Yii2 выполняется отложенно и не привязана к роутингу. Здесь, в роутинге мы только делаем пометочку, что надо загрузить Yii2 в будущем.
Симфонийские HTTP исключения
NotFound
иMethodNotAllowed
мы сохраняем на будущее и, если в Yii2 также не найдется роута на обрабатываемый запрос, мы выбросим сохраненное исключение так, будто никакого Yii2 и не было, то есть, даже здесь мы не вмешиваемся в работу Symfony.
Последний компонент, который нам понадобится, это еще один обработчик kernel.request
, который будет отрабатывать после роутинга (на самом деле, вообще самым последним) и, если была сделана пометка на передачу управления в Yii2, загружать этот фреймворк. Итак, вот код класса YiiApplicationRunner
.
#[AsEventListener(priority: -1000)]
class YiiApplicationRunner
{
public function __construct(
private readonly ApplicationLoader $applicationLoader,
private readonly Security $security,
private readonly string $symfonyAppEnv
) {
}
public function __invoke(RequestEvent $event): void
{
if (!$this->applicationLoader->isControlTransferred()) {
return;
}
try {
$config = $this->security->getFirewallConfig($event->getRequest());
$application = $this->applicationLoader->getApp();
if (null !== $config && $config->isSecurityEnabled()) {
$symfonyUser = $this->security->getUser();
$usersEqual = $application->user?->getId() === $symfonyUser?->getId();
if (null === $symfonyUser || !$usersEqual) {
$application->user->logout();
}
if (null !== $symfonyUser && !$usersEqual) {
$yiiUser = User::findByUsernameOrEmail($symfonyUser->getUserIdentifier());
$application->user->login($yiiUser);
}
}
$response = $application->run();
$response->send();
} catch (YiiNotFoundHttpException) {
throw $this->applicationLoader->getSymfonyException();
} catch (\Throwable $e) {
if ('prod' !== $this->symfonyAppEnv) {
throw $e;
}
$errorHandler = $this->applicationLoader->getApp()->getErrorHandler();
$errorHandler->silentExitOnException = true;
$errorHandler->handleException($e);
}
$symfonyResponse = ResponseBridge::yiiToSymfony($this->applicationLoader->getApp()->getResponse());
$event->setResponse($symfonyResponse);
}
}
Давайте разберемся по порядку, что здесь происходит. Если запрос был обработан симфонийским контроллером, и метод ApplicationLoader::transferControl()
не вызывался, то мы просто выходим из обработчика, продолжая обычную работу Symfony.
Если управление было передано в Yii2, то мы загружаем фреймворк вызовом ApplicationLoader::getApp()
и получаем конфиг Symfony Security. Если для обрабатываемого URL существует настроенный Symfony Security Firewall, то мы вытаскиваем залогиненного в Symfony юзера.
Это нужно для объединения Security двух фреймворков. В каждом из фреймворков должен быть залогинен один и тот же юзер, или не залогинен никто. За это и отвечают несколько следующих строк кода.
Если Yii2 не смог подобрать роут для обработки запроса, то вместо YiiNotFoundHttpException
мы выбросим то симфонийское исключение, которое отложили тогда, когда оно вылетело из симфонийского роутинга.
В остальной части кода выполняется вызов иишного error handler в случае ошибки, и преобразование иишного Response
в симфонийский с последующей передачей его в событие RequestEvent
, откуда его уже достанет HttpKernel
для отправки его содержимого на вывод php скрипта.
Вызов иишного error handler необходим для того, чтобы отрисовывалась красивая кастомная вьюшка с ошибкой. Если приложение работает не в prod окружении, то иишный error_handler не загружается, и вместо него отработает симфонийский, который выведет стандартную симфонийскую страницу с ошибкой.
По сути, основной костяк решения мы рассмотрели и одновременно с этим подошли к следующему вопросу.
Соединение Security двух фреймворков
В предыдущем разделе мы уже немного коснулись этого вопроса. В общем виде алгоритм решения такой:
Перевести иишную форму логина с иишного на симфонийский эндпоинт. То есть, отправлять введенные в старой, отрисовываемой Yii2 форме, логин-пароль в новый Symfony контроллер.
Реализовать симфонийский
UserInterface
, создав класс юзера и перенеся в него необходимый минимум полей из иишного классаUser
. В дальнейшем этот класс можно будет расширять, перенося по потребности все больше полей из Yii2 или придумывая новые поля.Реализовать симфонийский же
UserLoaderInterface
(по сути, сделать обычный репозиторий), который будет доставать из базы юзера.Написать симфонийский
UserProvider
, который будет использовать созданный нами репозиторий.Сделать свой
PasswordHasher
, который внутри себя будет дергать иишныйPasswordHasher
и подсунуть его в конфиг Symfony.
Итак, давайте разбираться. Первый пункт подробно расписывать не буду, так как в нем просто используется стандартный механизм аутентификации Form Login, который из коробки умеет обрабатывать запросы на аутентификацию, прилетающие от формы. Все, что здесь потребовалось от меня, это немного подправить HTML самой формы, которая рендерится иишной вьюшкой.
Что касается второго пункта, то, чтобы понять как он реализован, нужно немного погрузиться в архитектурный подход CQRS. Поскольку эта статья и так получилась немаленькой, я вынес из нее кусок, дающий базовый минимум по этой теме, в отдельный пост своего телеграм канала. Можете при желании прочитать сначала его, а потом вернуться сюда.
Чтобы логинить в Symfony иишных пользователей, нам нужно создать класс, который будет реализовывать симфонийский UserInterface
и одновременно являться чем-то вроде CQRS проекции на уже существующую сущность юзера. На самом деле, такой подход я использую и в чисто симфонийских проектах, например для аутентификации по JWT. Наш новый класс (я обычно его называю SecurityUser
) можно наполнять данными тремя способами:
Сделать из него доктриновскую сущность и просто разметить его поля доктриновскими аннотациями.
В репозитории делать прямые запросы в БД и выполнять гидрацию вручную.
В репозитории обращаться к старой
ActiveRecord
и вручную переливать данные из старого классаUser
в новый.
Лично я предпочел первый способ. Вообще, я большой противник использования доктриновских аннотаций в классах сущностей, но в том-то и дело, что наш SecurityUser
– это не какая не сущность, а представление залогинившегося пользователя, несущее в себе только тот срез данных, который нужен для работы Security.
Итак, с юзером и репозиторием разобрались, остались UserProvider
и PasswordHasher
. Поскольку эти классы очень просты, давайте я просто выложу их код без комментариев, опустив пустые или заглушенные методы.
class UserProvider implements UserProviderInterface, PasswordUpgraderInterface
{
public function __construct(
private readonly SecurityUserRepository $userRepository
) {
}
public function loadUserByIdentifier(string $identifier): UserInterface
{
$user = $this->userRepository->loadUserByIdentifier($identifier);
if (null === $user) {
throw new UserNotFoundException();
}
return $user;
}
}
use yii\base\Security;
class YiiPasswordHasher implements PasswordHasherInterface
{
private readonly Security $yiiSecurity;
public function __construct()
{
$this->yiiSecurity = new Security();
}
public function hash(#[\SensitiveParameter] string $plainPassword): string
{
return $this->yiiSecurity->generatePasswordHash($plainPassword);
}
public function verify(string $hashedPassword, #[\SensitiveParameter] string $plainPassword): bool
{
return $this->yiiSecurity->validatePassword($plainPassword, $hashedPassword);
}
}
Ну и, пожалуй, выложу я тут кусок security.yaml
.
security:
password_hashers:
app_hasher:
id: src\Common\Infrastructure\Symfony\Security\YiiPasswordHasher
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
providers:
user_provider:
id: src\Common\Infrastructure\Symfony\Security\UserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
biz:
request_matcher: firewall_biz_request_matcher
lazy: true
provider: user_provider
form_login:
login_path: /user/signup
check_path: /user/login
logout:
path: /user/logout
Итого, что мы имеем:
Аутентификация юзеров из общей таблицы БД прозрачно работает как в Symfony, так и в Yii2.
Внутри Yii2 кода у нас доступен старый класс юзера, ничего не изменилось, можно дописывать при нужде какие-то правила авторизации.
Внутри Symfony у нас появился свой класс юзера, который наполняется данными из старой таблицы. У нас полностью развязаны руки по реализации любой модели управления доступом на основе старых полей юзера.
На этом заканчивается описание решения основных задач по объединению двух фреймворков. Осталось некоторое количество второстепенных задач, которые я обещал описать к концу статьи. Поехали.
ResponseBridge и CookieBridge
После того, как Yii2 отработал и вернул свой Response
, нам необходимо перекидать его содержимое в симфонийский Response
. Отдельно необходимо озадачиться переносом куки. Вот соответствующие классы:
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use yii\web\Response as YiiResponse;
abstract class ResponseBridge
{
public static function yiiToSymfony(YiiResponse $yiiResponse): SymfonyResponse
{
$symfonyResponse = new SymfonyResponse();
$symfonyResponse->setStatusCode($yiiResponse->getStatusCode());
$symfonyResponse->headers->add($yiiResponse->getHeaders()->toArray());
$symfonyResponse->setContent($yiiResponse->content);
return $symfonyResponse;
}
}
use src\Common\Infrastructure\Yii2\Application;
use Symfony\Component\HttpFoundation\Cookie;
abstract class CookieBridge
{
/**
* @param Application $app
* @return Cookie[]
*/
public static function getYiiApplicationCookiesAsSymfonyCookies(Application $app): array
{
$result = [];
$yiiCookies = $app->response->getCookies()->toArray();
$request = $app->getRequest();
if ($request->enableCookieValidation && !empty($request->cookieValidationKey)) {
$validationKey = $request->cookieValidationKey;
}
foreach ($yiiCookies as $cookie) {
$value = $cookie->value;
if ($cookie->expire != 1 && isset($validationKey)) {
$value = $app->getSecurity()->hashData(serialize([$cookie->name, $value]), $validationKey);
}
$result[] = new Cookie(
$cookie->name,
$value,
$cookie->expire,
$cookie->path,
$cookie->domain,
$cookie->secure,
$cookie->httpOnly,
false,
$cookie->sameSite
);
}
return $result;
}
}
Различение в Symfony вида Yii2 Приложения
Напомню, что наше приложение построено на шаблоне Yii2 Advanced и содержит семь точек входа, то есть семь файлов index.php
. Внутри Symfony нам необходимо различать, на какой именно index.php
поступил запрос, чтобы корректно настраивать роутинг. Можно было бы завязаться на хост, ведь на каждый index.php
смотрит свой домен или поддомен. Проблема здесь состоит в том, что в проде, деве и на различных стендах эти домены и поддомены разные.
Поэтому для различения того, на какое Yii2 приложение (на какой index.php
) поступил запрос, была введена переменная окружения YII_APP_NAME
, которая инициализируется отдельным значением в каждом index.php
. Далее эту переменную можно пробрасывать через симфонийский DIC в любой сервис.
Для того, чтобы использовать эту переменную в роутинге, можно воспользоваться routing condition service.
#[AsRoutingConditionService(alias: 'app_kind_route_checker')]
class AppKindRouteChecker
{
public function __construct(
private readonly string $currentAppKind
) {
}
public function check(Request $request, string $requiredAppKind): bool
{
return $this->currentAppKind === $requiredAppKind;
}
}
Приведенный выше класс параметрируется той самой переменной YII_APP_NAME
и осведомлен о том, в контексте какого Yii2 приложения обрабатывается запрос (на какой index.php
был сделан запрос). В своем единственном методе наш AppKindRouteChecker
просто сравнивает переданный в него appKind
с текущим Yii2 приложением. Дальше мы можем просто повесить атрибут #[Route]
на контроллер, указав тем самым, что этот контроллер обрабатывает только те запросы, которые поступили с конкретного index.php
#[Route(condition: "service('app_kind_route_checker').check(request, 'api')")]
class ApiController extends AbstractController
{
//…
}
В примере выше 'api'
- это как раз один из семи видов (имен) Yii2 приложения. Теперь наш ApiController
будет обрабатывать только те запросы, которые пришли на соответствующий index.php
(на который смотрит свой поддомен).
Проброс Symfony компонентов в Yii2
Если компоненты Yii2 мы легко можем использовать как в старом, так и в новом коде, просто обратившись к глобальному объекту Yii
, то для использования компонентов Symfony внутри легаси-кода нам потребуется заинжектить каждый компонент в наш класс Application
, который мы наследовали от yii\web\Application
.
class Application extends YiiWebApplication
{
private ?LoggerInterface $logger = null;
public function run(): ?Response
{
//…
}
public function initSymfonyComponents(
LoggerInterface $logger
): void {
$this->logger = $logger;
}
public function getLogger(): ?LoggerInterface
{
return $this->logger;
}
}
Метод initSymfonyComponents()
мы вызываем в ApplicationLoader
в самом конце:
class ApplicationLoader
{
//…
public function load(): void
{
//…
$this->app = new Application($config);
$this->app->initSymfonyComponents($this->logger);
}
}
Теперь наш логгер доступен через \Yii::$app->getLogger()
.
Объединение логов
Для того, чтобы Yii2 приложение начало писать в симфонийский лог вместо своего, нам понадобится написать свой Log Target.
class MonologTarget extends Target
{
public function export(): void
{
$formattedMessages = array_map([$this, 'formatMessage'], $this->messages);
foreach ($formattedMessages as $message) {
\Yii::$app->getLogger()->log(
$message['level'],
$message['message'],
$message['context']
);
}
}
public function formatMessage($log): array
{
[$message, $level, $category] = $log;
$traces = self::formatTracesIfExists($log);
$record = [
'level' => $this->yiiLogLevelToPsr($level),
'context' => [
'user' => $this->getUserInfo(),
'category' => $category,
],
'message' => $this->extractMessage($message),
];
if (!empty($traces)) {
$record['context']['traces'] = $traces;
}
return $record;
}
//…
}
Полный код вываливать сюда не стал, его вы можете посмотреть в моем гите (см. ниже).
Заключение
В заключении представлю итоговую схему того, как работает все решение.

Я оставил за скобками описание некоторых второстепенных задач, так как статья и так получилась объемной. Ознакомиться с полным кодом решения, включая конфиги фреймворков, можно в гите. Ссылку я оставил в посте в моем телеграм канале.
Это моя первая статья, надеюсь она окажется для вас полезной или, как минимум, интересной. Если что-то осталось непонятным, вы можете писать свои вопросы в комментариях, я постараюсь на них ответить и, при необходимости, дополнить статью. Спасибо за внимание.)
Комментарии (21)
EnChikiben
06.02.2025 17:33извините :)
slayervc Автор
06.02.2025 17:33Ну вообще цель была остановить то, что у вас на правой картинке, как-то это задепрекейтить и начать писать более упорядоченно, перейдя на современный стек. Вижу по комментам, что у читателей сложилось противоположное мнение. Значит, я не до конца донес свою мысль. На то она и первая статья.)
AlexLeonov
06.02.2025 17:33Весьма неплохо.
Разумеется, во всех случаях, когда реально требовалась работа двух приложений - я всегда делал буквально два приложения в двух изолированных контейнерах и роутинг между ними возлагал на третье приложение - роутер (к примеру на nginx и карту URL внутри него). Но ваш велосипед тоже интересен.
Graid
06.02.2025 17:33Тут выходит вместо двух приложений стало три, да еще и за роутингом следить нужно отдельно.
Мы в свое время мигрировали с symfony 1 и использовали "Legacy Bridge", сразу решив, что вместе их не мешаем, изначальное приложение не трогаем. Любой новый функионла на новой версии, запрос на обновления фунционал это прееписываение старого роута на новой системе. Оставили только хотфиксы. Из минусов возможно пара роутов сейчас все еще крутится на symfony 1, кто знает.
slayervc Автор
06.02.2025 17:33Вот у вас первое приложение, в котором 180 тыс. строк PHP кода и 250 таблиц. Команда из 10 бэков. Стартап, который очень нервно считает свои деньги. Менеджмент льет на команду требования по выпуску десятков фичей.
Как вы, начав новое приложение в отдельном контейнере будете опираться на бизнес-логику и данные из старого? Конечно, если есть возможность начать с чистого листа, любой ей воспользуется, извращенцев нет.)
Тут как раз речь шла о ситуации, когда сменилась команда с аутсорсной на продуктовую, все смотрели на старый код и выли. И нужно было как-то начать писать по-новому, не отказываясь от старого, не тратя много ресурсов на переход и ни на день не стопоря выпуск новых фичей.
Мне при написании статьи эта мысль казалась настолько очевидной, что я об этом даже не упомянул.) Возможно, стоит написать еще одну статью, которая дополнит эту.)
AlexLeonov
06.02.2025 17:33Как опираться? Да так и опираться - данные шарить между приложениями. База-то одна остается.
scarab
06.02.2025 17:33Не очень понятно, зачем в названии статьи упомянут монолит. По идее, альтернативой монолиту может быть микросервисная архитектура, SOA, serverless - но у Вас не то и не другое. Просто впихнули элементы одного фреймворка в другой, а приложение как было монолитным, так и осталось.
Притом что тот же DI-контейнер в Yii2 вполне есть и переписать код на гибкие зависимости можно было даже без симфони, не скрещивая ежа с ужом.
slayervc Автор
06.02.2025 17:33Да, все верно: монолит как был, так и остался. Оттого и упомянут, что в микросервисной архитектуре подход к решению задачи был бы другим. Вернее, там такая задача и не встала бы скорее всего.
А на счет переписать 180 тыс. строк высокосвязанного кода в проекте, который нонстопом дорабатывает 10 бэков, выпуская по несколько фичей в день - это что-то за гранью реального.)
scarab
06.02.2025 17:33Ну симфони вы же туда вкрутили)
А какие конкретные цели преследовались, когда принималось такое решение? Просто начать дорабатывать новые фичи уже на базе другого фреймворка, обеспечив между ними мостик?
slayervc Автор
06.02.2025 17:33Цели постарался в начале статьи перечислить, но коротко вы удачно описали: начать дорабатывать новые фичи уже на базе другого фреймворка.) В целом ситуация была такая: есть проект, написанный аутсорсом как попало. Дальше проект был передан продуктовой команде, которая при виде такого счастья пришла в уныние. Встал вопрос: как без остановки поставки бизнес-ценностей начать писать более аккуратный код на более понятном и приятном стеке.
Хочется же и валидатор, и сериалайзер, и доктрину, и другие симфонийские радости.) Тут надо понимать, что помимо смены фреймворка была и смена парадигмы разработки и архитектурного подхода. Просто описать все это в одной статье нереально, я и так старался покомпактнее, а получился большой текст.)
Я единственно хотел бы подчеркнуть, что не симфони был вкручен в проект, а проект был обернут в симфони. Больше того, новый код писался в новых папках. То есть, мы старое легаси оставили в проекте, но старались его не трогать, то есть не дописывать без крайней нужды. А когда старый код вызывался, то это делалось это по уму (про это отдельную статью можно написать).
А так, у нас появилась возможность писать новые чистенькие контроллеры, сервисы, модельки и прочее.
anitspam
06.02.2025 17:33нет, случаем, грусти, что через год-два появится статья типа "как я ускорял заскорузлый проект на symfony/yii с помощью go и laravel"?)
slayervc Автор
06.02.2025 17:33Сто процентов нет такой грусти.) А вот о статье, как пользоваться профайлером xdebug для этих целей действительно подумываю.) Боюсь спросить, как Laravel ускоряет проекты.) На Yii2 действительно видел один раз проект лет пять назад, где загрузка самого фреймворка забирала почти 2 секунды в каждом запросе. Не погружался тогда в причины, т. к. не мой проект был. Что до Symfony, то тоже как-то раз видел долгоживущий проект, где почти все классы были загружены в DIC, что добавляло нормально времени на его загрузку. Но в целом, скорость не от фреймворка зависит, а от разработчика. На любом фреймворке и языке можно шляпу написать.)
anitspam
06.02.2025 17:33На любом фреймворке и языке можно шляпу написать.)
Ну я собственно про это и написал.
Хочется увидеть в статьях про переход или внедрение
1. что-то измеримое. Типа у нас было 180 тыс строк. Это плохо или хорошо, чем и как измеряли. Их написала другая команда за такой-то период. До внедрения своего решения добавляли столько-то строк в месяц, после - вот столько. Было столько "фич" в месяц, стало столько. Про что вообще фичи. Просто в комментарий про "несколько фич в день" как-то не верится.
2. какие-то примеры кода, мясо так сказать бизнесовое. Типа вот так у нас формируется отчёт в прежнем коде, а вот так с новым подходом. Вот так сообщения пользователю отправлялись раньше, а теперь вот так. Смотрите, как здорово получилось. И можно тогда обсудить, действительно ли здорово. Или может средствами йии такое можно сделать.
3. как вообще донесли до бизнеса смену/дополнение фреймворка? обычно бизнесу-то всё равно, что там внутри. А фреймворк меняют, чтобы очередной руководитель смог в резюме добавить строчку про разработку вот с такой хорошей штукой.
А пока проект с symfony/yii выглядит так, что следующая команда будет поддерживать проект со шляпой в квадрате. С очередными тремя конвертами.slayervc Автор
06.02.2025 17:33На счет ваших пожеланий: здесь жанр другой. Рассказываю что и как делал, вдруг кому потребуется повторить. Цели как-то рекламировать решение не было и агитировать повторять за мной.
Строчку в резюме обязательно добавлю, хоть и не руководитель и решения не принимал.)
Количество строк измеряли плагином статистика в шторме. Плохо или хорошо иметь 180 тыс. строк связанного кода, решайте сами. Мне - плохо. За сколько их написали - понятия не имею, и не знаю, чем такая информация может быть полезна.
По второму пункту ответить вам сложно. Переход на фреймворк не повлиял на старые фичи, они как работали, так и работают, в том и была цель, чтобы их не зааффектить.
А на счет того, как в ваших глазах выглядит проект: правда в глазах смотрящего. В моих глазах он стал выглядеть намного лучше. И если я ни текстом статьи ни ответами на ваши и подобные комментарии не смог донести, почему стало лучше, то уже, видимо и не смогу.
vanxant
06.02.2025 17:33Из статьи непонятно, как вы дружили orm-ы? Или просто два отдельных pdo коннекта к бд?
slayervc Автор
06.02.2025 17:33Да, два отдельных коннекта, каждый из которых является оберткой PDO. ActiveRecord и Доктрина друг другу никак не мешают. В Yii коннект к базе представлен, как и все прочее, компонентом фреймворка и доступен через глобальный объект \Yii. Когда иишный Appication инстанцируется, загружаются и все компоненты Yii. Соответственно, с этой секунды нам доступен как сам коннект (параметры которого берутся из конфига Yii), Так и все классы-наследники ActiveRecord, которые внутри себя дергают этот самый коннект через обращение к Yii::$app->getDb().
Соответственно, в симфонийском DIC у нас зарегистрирован доктриновский DBAL Connection, который также является оберткой над PDO и сконфигурирован из doctrine.yaml. Даже если представить себе ситуацию, когда мы в одной репе начнем дергать ActiveRecord и доктриновские EntityRepository одновременно, под капотом будут именно два PDO коннекта к одной и той же базе.
В плане работы с моделями подход такой: Легаси-модели, наследники ActiveRecord стараемся дорабатывать как можно реже, только при крайней нужде. При решении новых задач создаем новые модели, которые мапятся на те же таблицы. Для того, чтобы не терять темп разработки, разрешается в рамках задачи мапить только те поля, которые нужны для ее решения. То есть, если у нас старая модель ActiveRecord на 50 полей, то новую можно сделать только на 5 полей, если для решения текущей задачи их достаточно. При решении последующих задач новую модель можно дополнять полями, можно выделять новые модели, которые смотрят на ту же таблицу. Была у нас монструозная легаси-модель на 50 полей, а мы ее постепенно разбили на 5 новых красивых моделей, пусть они и наполняются данными из одной таблицы.
Надеюсь, понятно объяснил, тут уже начинается история про архитектуру, можно несколько отдельных статей написать.)
vanxant
06.02.2025 17:33Спасибо, по сути сказанного всё понятно. Но ведь при таком подходе с транзакциями беда. Если при обработке одного запроса лезть в одну таблицу из двух pdo коннектов, они же будут друг друга блокировать на уровне бд...
Я уж молчу о том, что два коннекта это перерасход ресурсов на пустом месте
slayervc Автор
06.02.2025 17:33Очень хорошее замечание, спасибо. Тут надо понимать, что в описываемом проекте вообще нет хороших решений.) Есть тяжелый код, который надо дорабатывать, команда немаленькая, кодовая база ежедневно пополняется. Искался путь хоть как-то облегчить написание нового кода и задепрекейтить старый, трогать его как можно реже, что почти невозможно, т. к. все равно на старый код приходится опираться (тут уже надо интерфейсами отгораживаться, но я снова ухожу в архитектуру)).
Соответственно, если при решении своей задачи разработчик попал в ситуацию, которую вы описываете, то у него есть как минимум два пути:
Сделать новый доктриновский мапинг на все данные, изменение которых происходит внутри транзакции.
Открыть транзакцию доктриновским коннектом, флашнуть те энтити, которые у него есть, а остальные данные изменить нативными SQL запросами (можно востользоваться DBALовским QueryBuilder).
Я однозначно за первый вариант: второй грязноват, да и новый мапинг в будущем все равно пригодится, ведь в идеале хотелось бы со временем покрыть доктриной все 250 таблиц и оставить старые ActiveRecord только как носители кусков бизнес-логики. Как я сказал, в этом проекте нет хороших решений, приходится работать с тем, что есть.)
slayervc Автор
06.02.2025 17:33Ну и на счет траты ресурсов добавлю.) Поверьте это такая мелочь на фоне многочисленных кусков, где в тройном вложенном цикле дергается одна штучка ActiveRecord, что порождает десятки тысяч запросов к базе.
Чтобы начать думать о том, о чем вы говорите, нужно сначала забороть все вот такие вот места.)
vanxant
06.02.2025 17:33Думаю, многие будут использовать вашу статью как практическое howto, т.к. проблема есть у многих, а материалов такого уровня детализации в инете нет, включая англоязычный. Мне просто захотелось узнать, а вдруг вы как-то решили этот вопрос, и он настолько простой, что вы забыли об этом упомянуть:)
qeeveex
Если скрестить ужа с ежом - получится полтора метра колючей проволоки.