Разработчики сайтов, веб-мастера, рассматривая Joomla как CMS, чаще всего используют компоненты ядра такими, какие они есть. Но компоненты ядра, обеспечивающие CRUD-ы в Joomla, следует рассматривать ещё и как примеры использования Joomla в качестве фреймворка. Иногда реалии проекта таковы, что требуется внести изменения именно в логику классов ядра Joomla. Я покажу это на нескольких примерах: как исхитрялись раньше и какие возможности появились в современных версиях Joomla.
Сразу оговорюсь: речь не о том, чтобы править файлы ядра. Это плохая идея почти всегда. При обновлении Joomla такие изменения будут потеряны, а сопровождать их потом придётся вручную. Речь о другом: как изменить точку создания MVC-классов компонента через плагин и DI-контейнер, не залезая в core-файлы.
А зачем это надо?..
Это бывает нужно в тех случаях, когда на сайте активно используется функционал ядра и нецелесообразно переносить все процессы на работу с другим расширением, которого может и не существовать в природе.
Простой пример: мультикатегории, когда материал находится в одной основной категории и нескольких дополнительных. Этого функционала до сих пор нет в составе ядра Joomla, но сама задача востребована. Поэтому сторонние разработчики по-разному решали вопрос: как добавить материалу Joomla несколько категорий, не переписывая весь com_content и не создавая отдельный компонент только ради этой логики.
Мне известно несколько плагинов, которые решали эту задачу через подмену стандартной модели MVC на собственную. В целом это нормальное направление мысли: если список материалов должен учитывать дополнительные категории, то логика выборки действительно живёт в модели. Вопрос только в том, каким способом эту модель подменять в разных поколениях Joomla. Эта статья написана спустя 1,5 года после выхода WT Multicategories плагина мультикатегорий Joomla.
Немного археологии: как раньше работала подмена моделей MVC ядра Joomla 2.5 - Joomla 3.x на onAfterRoute()
В старых решениях для Joomla подмена классов часто делалась следующим образом: системным плагином на событие onAfterRoute подключали файл со своим классом через require_once / include_once. Новое подключение класса файлом происходило раньше, чем ядро доходило до подключения своего класса модели.
Это не фокус, а следствие тогдашнего способа загрузки MVC-классов: ядро сначала проверяло, существует ли нужный PHP-класс, и только потом подключало файл модели из компонента. Если плагин успевал объявить класс с тем же именем, Joomla уже не подключала штатный файл.
Как это работало в Joomla 2.5.x
Для Joomla 2.5 цепочка загрузки моделей MVC была такая:
route()вызывалonAfterRoute, а компонент запускался позже вdispatch()черезJComponentHelper::renderComponent().- Входной файл
com_contentсоздавал контроллер. - Стандартный
display()создавал модель, аJModel::getInstance()сначала делалclass_exists($modelClass)и только если класса ещё не было, выполнялrequire_onceфайла модели.
Значит, если системный плагин уже объявил ContentModelArticle, ядро файл components/com_content/models/article.php уже не подключало. Такой подход был хрупким, но понятным: нужно было выиграть гонку загрузки класса.
Как это работало в Joomla 3.x
Joomla 3 существовала очень долго, и внутри этой линейки были заметные архитектурные различия. Но общая логика подмены моделей оставалась похожей на Joomla 2.5.
- Событие
onAfterRouteдля системных плагинов происходило до запуска активного компонента. - Компонент запускался позже через
ComponentHelper::renderComponent(). - Для legacy-MVC по умолчанию использовались механизмы, совместимые со старым подходом.
BaseDatabaseModel::getInstance()снова сначала проверялclass_exists, и лишь потом подключал файл модели.
Поэтому заранее объявленный класс мог перехватить создание модели. На этой технике выросло немало практических решений. Однако в современных версиях Joomla это уже не тот слой, в который стоит вмешиваться.
Древние изощрённые способы замены классов ядра Joomla от особо творческих личностей
Были также и другие, ещё более изощрённые способы: например, плагином переименовывать файл ядра, а на его место класть свой PHP-класс. И делать это каждый раз, когда класс ядра изменялся после обновления.
Ещё один вариант мне встречался: в состав плагина включались классы оригинальные ядра. На onAfterInitialise() переименовывались классы ядра в $coreClass = $class . 'Core', свои классы с изменениями наследовались от созданных *Core -классов, а имена новых классов равны классам ядра. И дальше, при проверке наличия оригинальных *Core-классов, заменялось содержимое файлов классов ядра с помощью file_put_contents. Ну как красиво же и как страшно!
<?php
// Фрагмент одного плагина
protected function overrideClass($class = null)
{
$classes = array(
'FileLayout' => JPATH_ROOT . '/libraries/src/Layout/FileLayout.php',
'HTMLHelper' => JPATH_ROOT . '/libraries/src/HTML/HTMLHelper.php',
'HtmlView' => JPATH_ROOT . '/libraries/src/MVC/View/HtmlView.php',
'ModuleHelper' => JPATH_ROOT . '/libraries/src/Helper/ModuleHelper.php',
'BaseController' => JPATH_ROOT . '/libraries/src/MVC/Controller/BaseController.php',
);
if (!empty($classes[$class]) && !class_exists($class))
{
$coreClass = $class . 'Core';
if (!class_exists($coreClass))
{
$path = Path::clean($classes[$class]);
$core = Path::clean(__DIR__ . '/classes/' . $coreClass . '.php');
$override = Path::clean(__DIR__ . '/classes/' . $class . '.php');
if (!file_exists($core))
{
file_put_contents($core, '');
}
$context = file_get_contents($path);
$context = str_replace('class ' . $class, 'class ' . $coreClass, $context);
if (file_get_contents($core) !== $context)
{
file_put_contents($core, $context);
}
require_once $core;
require_once $override;
}
}
}
Так раньше делать было нельзя, а теперь уже и не нужно. Давайте теперь посмотрим...
Что изменилось в Joomla 5 / 6+
В Joomla 4+ компоненты работают иначе. Поддержка Joomla 4 уже завершилась, поэтому мы не будем специально фокусироваться на старой версии CMS. Событие onAfterRoute никуда не исчезло, но современные компоненты создают MVC-объекты через MVCFactory. Фабрика использует namespace компонента и собирает полное имя класса: например, Joomla\Component\Content\Site\Model\ArticleModel, а не legacy-класс вида ContentModelArticle.
Поэтому старая идея "подключим файл с таким же именем класса пораньше" перестаёт быть нормальной точкой расширения. У современного компонента есть service provider, дочерний DI-контейнер, зарегистрированные сервисы и фабрика, которая знает, как создавать контроллеры, модели, представления и таблицы.
В Joomla 6.1 современная часть этой цепочки проверяется по следующим файлам ядра:
libraries/src/Extension/ExtensionManagerTrait.php- загрузка расширения и событияonBeforeExtensionBoot/onAfterExtensionBoot;libraries/src/Extension/Service/Provider/MVCFactory.php- регистрацияMVCFactoryInterfaceв контейнере компонента;libraries/src/MVC/Factory/MVCFactory.php- создание контроллеров, моделей, view и table;libraries/src/Dispatcher/ComponentDispatcherFactory.phpиlibraries/src/Dispatcher/ComponentDispatcher.php- передача фабрики в диспетчер компонента;libraries/src/MVC/Controller/BaseController.php- создание модели через фабрику контроллера;administrator/components/com_content/services/provider.php- регистрация сервисов компонента ядра на примере материалов Joomla
Что такое DI-контейнер Joomla?
DI-контейнер Joomla хранит правила создания сервисов. В контейнер кладут данные вида ключ -> значение.
- Ключ DI-контейнера Joomla
- Чаще всего это интерфейс или имя класса. Например,
MVCFactoryInterface::class. Но ключом может быть и строка, и alias. - Значение DI-контейнера Joomla
- Это может быть готовый объект, но чаще - замыкание, которое создаёт объект в момент вызова
$container->get().
Для современного компонента Joomla важнее всего не глобальный контейнер приложения, а дочерний контейнер конкретного компонента. Именно туда service provider компонента регистрирует его фабрики и сам объект компонента.
Что важно: в DI-контейнер компонента Joomla не помещается отдельный сервис для каждой модели, view или table. В контейнер помещается сервис
MVCFactoryInterface, а уже фабрика знает, какой MVC-класс создать. Чисто теоретически компонент может использовать DI-контейнер как угодно и помещать туда и модели, но в стандартной архитектуре MVC Joomla таких примеров нет.
Отсюда следует главный вывод: если нужно повлиять на создание модели современного компонента, то подменять надо не файл модели через include, а поведение MVCFactoryInterface в контейнере этого компонента.
Дальше нашим модельным организмом и подопытным кроликом будет компонент материалов Joomla.
Как com_content регистрирует фабрику?
Все компоненты ядра Joomla (кроме com_ajax) работают по этому паттерну. В administrator/components/com_content/services/provider.php Joomla регистрирует несколько service provider-ов:
$container->registerServiceProvider(new CategoryFactory('\\Joomla\\Component\\Content'));
$container->registerServiceProvider(new MVCFactory('\\Joomla\\Component\\Content'));
$container->registerServiceProvider(new ComponentDispatcherFactory('\\Joomla\\Component\\Content'));
$container->registerServiceProvider(new RouterFactory('\\Joomla\\Component\\Content'));
Затем регистрируется сам компонент:
$container->set(
ComponentInterface::class,
function (Container $container) {
$component = new ContentComponent($container->get(ComponentDispatcherFactoryInterface::class));
$component->setRegistry($container->get(Registry::class));
$component->setMVCFactory($container->get(MVCFactoryInterface::class));
$component->setCategoryFactory($container->get(CategoryFactoryInterface::class));
$component->setAssociationExtension($container->get(AssociationExtensionInterface::class));
$component->setRouterFactory($container->get(RouterFactoryInterface::class));
return $component;
}
);
Здесь видно две важные вещи. Во-первых, MVCFactoryInterface живёт в контейнере компонента. Во-вторых, фабрика нужна не только самому объекту компонента через setMVCFactory(), но и ComponentDispatcherFactory. А диспетчер компонента потом создаёт контроллер через эту фабрику.
Где нужно вмешиваться: ExtensionManagerTrait::loadExtension()
Современная загрузка расширения идёт через Joomla\CMS\Extension\ExtensionManagerTrait, метод loadExtension(). Нас интересует порядок действий внутри него:
- Joomla проверяет, не загружено ли расширение ранее.
- Создаётся дочерний контейнер расширения:
$this->getContainer()->createChild(). - Диспетчер вызывает событие
onBeforeExtensionBoot. - Подключается
services/provider.phpрасширения, и provider регистрирует сервисы в контейнере. - Если provider не зарегистрировал объект расширения, Joomla включает fallback для legacy-компонента, модуля или плагина.
- Диспетчер вызывает событие
onAfterExtensionBoot. - Только после этого Joomla получает объект расширения из контейнера:
$container->get($type). - Если расширение реализует
BootableExtensionInterface, вызываетсяboot($container). - Объект расширения кешируется.
Для нашей задачи это почти готовая инструкция. На onBeforeExtensionBoot дочерний контейнер уже есть, но service provider компонента ещё не выполнился. Значит, MVCFactoryInterface у современного компонента обычно ещё не зарегистрирован. На onAfterExtensionBoot provider уже отработал, но объект компонента ещё не создан. Именно в этот момент можно заменить или расширить MVCFactoryInterface так, чтобы новая фабрика попала и в компонент, и в dispatcher factory.
Как не промахнуться? Для подмены
MVCFactoryInterfaceсовременного компонента из системного плагина нужно наonAfterExtensionBootбрать контейнер, который пришёл в событии. Именно его использует компонент потом. Глобальный контейнер приложения здесь не тот уровень.
Почему не глобальный контейнер и не поздний setMVCFactory()
На первый взгляд может показаться, что достаточно сделать что-то вроде этого:
$component = $app->bootComponent('com_content');
$component->setMVCFactory(new MyFactory());
Иногда это действительно изменит результат прямого вызова:
$app->bootComponent('com_content')
->getMVCFactory()
->createModel('Article', 'Site');
Но для обычного рендера компонента этого недостаточно. ComponentHelper::renderComponent() вызывает:
$app->bootComponent($option)->getDispatcher($app)->dispatch();
getDispatcher() обращается к ComponentDispatcherFactory, а тот уже хранит фабрику, которую получил при создании объекта компонента. Если компонент уже создан, поздняя замена публичной фабрики через setMVCFactory() не обязана заменить фабрику внутри dispatcher factory.
Поэтому для стандартной MVC-цепочки важно успеть до создания ComponentInterface. В ExtensionManagerTrait::loadExtension() это как раз промежуток между регистрацией services/provider.php и $container->get(ComponentInterface::class), то есть событие onAfterExtensionBoot.
Способ 1. Расширить сервис через $container->extend()
У контейнера Joomla есть метод extend(). Он берёт уже зарегистрированный сервис и заворачивает его в новую фабрику. Это похоже на паттерн "декоратор": у нас остаётся исходный объект, но мы возвращаем наружу обёртку с дополнительным поведением.
Пример условного системного плагина:
<?php
namespace Webtolk\Plugin\System\MVCFactoryOverride\Extension;
\defined('_JEXEC') or die;
use Joomla\CMS\Event\AfterExtensionBootEvent;
use Joomla\CMS\Extension\ComponentInterface;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\DI\Container;
use Joomla\Event\SubscriberInterface;
use Webtolk\Plugin\System\MVCFactoryOverride\MVC\ContentMVCFactoryDecorator;
final class MVCFactoryOverride extends CMSPlugin implements SubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
'onAfterExtensionBoot' => 'onAfterExtensionBoot',
];
}
public function onAfterExtensionBoot(AfterExtensionBootEvent $event): void
{
if ($event->getExtensionType() !== ComponentInterface::class) {
return;
}
if (strtolower($event->getExtensionName()) !== 'content') {
return;
}
// Берём контейнер дочерний! Именно он используется компонентом
$container = $event->getContainer();
if (!$container->has(MVCFactoryInterface::class) || $container->isProtected(MVCFactoryInterface::class)) {
return;
}
$container->extend(
MVCFactoryInterface::class,
static function (MVCFactoryInterface $factory, Container $container): MVCFactoryInterface {
return new ContentMVCFactoryDecorator($factory);
}
);
}
}
Обратите внимание на имя расширения: для com_content в событии будет content, без префикса com_. Так приложение вызывает bootComponent() и передаёт имя в loadExtension().
Чтобы современный плагин был создан через service provider, ему нужен services/provider.php.
<?php
\defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Webtolk\Plugin\System\MVCFactoryOverride\Extension\MVCFactoryOverride;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
// $container->lazy начиная с Joomla 5.4.0.
// Для Joomla ДО 5.4.0 нужно использовать анонимную функцию-замыкание.
// Данный пример не будет работать на Joomla 4 и Joomla <5.4.0.
$container->lazy(MVCFactoryOverride::class, function (Container $container) {
$plugin = new MVCFactoryOverride(
(array) PluginHelper::getPlugin('system', 'mvcfactoryoverride')
);
$plugin->setApplication(Factory::getApplication());
return $plugin;
})
);
}
};
Декоратор фабрики Joomla на минималках
Декоратор реализует тот же MVCFactoryInterface и получает исходную фабрику в конструктор. Это позволяет не создавать заново штатную MVCFactory и не угадывать, какие зависимости Joomla уже передала в неё через service provider.
<?php
namespace Webtolk\Plugin\System\MVCFactoryOverride\MVC;
\defined('_JEXEC') or die;
use Joomla\CMS\Application\CMSApplicationInterface;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\Input\Input;
final class ContentMVCFactoryDecorator implements MVCFactoryInterface
{
public function __construct(
private readonly MVCFactoryInterface $inner
) {
}
public function createController($name, $prefix, array $config, CMSApplicationInterface $app, Input $input)
{
return $this->inner->createController($name, $prefix, $config, $app, $input);
}
public function createModel($name, $prefix = '', array $config = [])
{
if ($name === 'Article' && $prefix === 'Site') {
// Здесь крутим и вертим модель ядра как нам нужно/
// Или возвращаем собственный объект модели.
}
return $this->inner->createModel($name, $prefix, $config);
}
public function createView($name, $prefix = '', $type = '', array $config = [])
{
return $this->inner->createView($name, $prefix, $type, $config);
}
public function createTable($name, $prefix = '', array $config = [])
{
return $this->inner->createTable($name, $prefix, $config);
}
}
Такой декоратор хорош как безопасная иллюстрация принципа и как рабочий путь для прямых вызовов $component->getMVCFactory()->createModel(...). Но у него есть важная тонкость, которую легко пропустить.
Важная ловушка: createController() и $controller->getModel()
В стандартной цепочке рендера компонента сначала создаётся контроллер. В ComponentDispatcher::getController() вызывается:
$controller = $this->mvcFactory->createController(
$name,
$client,
$config,
$this->app,
$this->input
);
Если в контейнере лежит декоратор, этот вызов попадёт в декоратор. Но если декоратор просто делегирует createController() во внутреннюю фабрику, дальше вступает в дело MVCFactory::createController() ядра:
$controller = new $className($config, $this, $app, $input);
Здесь $this - уже внутренняя фабрика Joomla, а не ваш декоратор. Значит, контроллер сохранит внутри себя исходную фабрику. Когда потом BaseController::getModel() вызовет $this->factory->createModel(), он может пройти мимо вашего декоратора.
Отсюда практический вывод: минимальный декоратор createModel() не всегда достаточен для подмены модели в обычном рендере компонента. Он достаточен для прямых вызовов фабрики, но для контроллерной MVC-цепочки нужно отдельно проверить, какая фабрика попала в контроллер.
Теоретически можно сделать декоратор, который сам создаёт контроллер и передаёт в его конструктор именно себя. Но тогда вы начинаете повторять часть логики MVCFactory::createController(): вычисление класса, создание объекта, передачу зависимостей вроде form factory, dispatcher, router, cache controller, user factory, mailer factory и logger. В ядре часть этой инициализации находится в private-методах MVCFactory, поэтому аккуратно повторить её снаружи непросто.
Использовать рефлексию (reflection), чтобы после создания контроллера заменить protected-свойство factory, технически возможно, но это уже не хороший расширяемый API, а зависимость от внутреннего устройства BaseController.
Способ 2. Полностью заменить MVCFactoryInterface через $container->set()
Другой путь - не декорировать фабрику, а зарегистрировать вместо неё свою. Например, наследоваться от Joomla\CMS\MVC\Factory\MVCFactory и переопределить getClassName(). Тогда все обычные методы createController(), createModel(), createView() и createTable() останутся из ядра, но имя класса можно будет заменить.
$container->set(
MVCFactoryInterface::class,
static function (Container $container) use ($extensionName) {
$factory = new class ('Joomla\\Component\\' . ucfirst($extensionName)) extends MVCFactory {
protected function getClassName(string $suffix, string $prefix)
{
$class = parent::getClassName($suffix, $prefix);
return match ($class) {
\Joomla\Component\Content\Site\Model\ArticleModel::class
=> \Webtolk\Plugin\System\MVCFactoryOverride\Model\ArticleModel::class,
default => $class,
};
}
};
// И мы помним, что фабрике нужны все зависимости, которые мы видим
// в provider.php компонента. Устанавливаем их здесь.
$factory->setFormFactory($container->get(FormFactoryInterface::class));
$factory->setDispatcher($container->get(DispatcherInterface::class));
$factory->setDatabase($container->get(DatabaseInterface::class));
$factory->setSiteRouter($container->get(SiteRouter::class));
$factory->setCacheControllerFactory($container->get(CacheControllerFactoryInterface::class));
$factory->setUserFactory($container->get(UserFactoryInterface::class));
$factory->setMailerFactory($container->get(MailerFactoryInterface::class));
return $factory;
}
);
Плюс этого подхода в том, что контроллер будет создан этой же фабрикой, а значит при последующем $controller->getModel() он снова обратится к ней. Для задачи "заменить вот эти модели ядра на мои классы" это понятная и прямая схема.
Минус тоже очевиден: теперь вы повторяете код из Joomla\CMS\Extension\Service\Provider\MVCFactory. В Joomla 6.1 provider выбирает между MVCFactory и ApiMVCFactory в зависимости от типа приложения и передаёт в фабрику несколько зависимостей: FormFactoryInterface, DispatcherInterface, DatabaseInterface, SiteRouter, CacheControllerFactoryInterface, UserFactoryInterface, MailerFactoryInterface. Если в будущей версии Joomla этот набор изменится, ваш код нужно будет проверить и обновить.
Что и как использовать?
extend()меньше привязан к внутренней сборке фабрики, но минимальный декоратор может не перехватить модели, создаваемые через контроллер. Полная замена черезset()лучше подходит для сопоставления "штатный класс - мой класс", но требует аккуратно повторять создание и настройку фабрики.
Если компонент ваш собственный - плагин обычно не нужен
Да, и всю эту статью можно было не читать. Можно просто написать сразу вменяемую модель и/или другие классы компонента. Компонент сам объявляет свою инфраструктуру, и вам не нужно зависеть от порядка системных плагинов.
Что с legacy-компонентами
Не каждый компонент в реальной жизни уже живёт в новой архитектуре. Если у компонента нет services/provider.php и Joomla включает fallback, создаётся LegacyComponent. Такой объект умеет вернуть LegacyFactory через getMVCFactory(), но это не тот же сценарий, где service provider компонента зарегистрировал MVCFactoryInterface в дочернем контейнере.
Поэтому перед подменой всегда надо проверить:
- это действительно компонент, а не модуль или плагин;
- имя компонента то, которое вам нужно;
- в контейнере есть
MVCFactoryInterface::class; - сервис не protected;
- вы вмешиваетесь до создания
ComponentInterface.
Что будет, если несколько плагинов Joomla меняют одну фабрику?
Это риск. Системные плагины выполняются в определённом порядке, установленным в админке сайта. Также на их выполнение влияет приоритет плагина, указанный в коде самого плагина для каждого конкретного триггера. Поэтому если несколько плагинов вмешиваются в MVCFactoryInterface одного и того же компонента, результат зависит от выбранного способа.
- Если несколько плагинов делают
$container->set(MVCFactoryInterface::class, ...), фактически выигрывает тот, кто перезаписал сервис последним. Дворовая шутка из за гаражей вспоминается: "Кто последний, тот и папа". - Если несколько плагинов делают
$container->extend(), может получиться цепочка декораторов. Что само по себе не страшно, так как паттерн декоратор для этого и придуман был. Главное, что он не во всех сценариях может гарантированно отработать. - Если два плагина подменяют один и тот же класс модели, итоговый результат всё равно надо проверять отдельно.
Поэтому хороший плагин должен ограничивать область вмешательства: только нужный компонент, только нужный client, только нужная модель или table. Не стоит превращать MVCFactory в универсальный перехватчик всего сайта. И в целом нужно помнить. что MVCFactory может работать в разных типах приложения (сайт, админка, CLI, REST API и даже Daemon-приложение). И системные плагины тоже очень острый инструмент.
Когда нужна подмена MVCFactory, а когда - нет?
Подмена фабрики нужна только в тех случаях, когда вы на проекте работаете с компонентом ядра Joomla (а их не так-то много на самом деле) и вам нужно изменить или дополнить работу штатных классов. Всё.
Если у вас свой компонент - вам не нужна подмена MVCFactory таким изощрённым способом и эта статья тоже.