System Plugin Router Rules
Introduction
This example of a system plugin illustrates the flexibility of Joomla in allowing developers to change how the routing within com_content
works, without having to hack the Joomla code.
WARNING: This is a system plugin, which is loaded every time Joomla runs, on both the site front-end and administrator back-end. If you have a PHP syntax error in it then you will get locked out of the Joomla administrator back-end, and will have to go into phpmyadmin (or equivalent) to set to 0 the enabled
field within the plugin record in the #__extensions
table. If you're not comfortable doing this then using this plugin is not recommended.
For background information on how Joomla routing works you can read the documentation on routing.
When building an SEF URL the Joomla SiteRouter class uses a MenuRules class to work out the menuitem to base the URL upon. This is an important selection, as the menuitem affects not only the format of the SEF URL produced, but also affects the presentation of the web page and the associated modules which are displayed.
The com_content
component uses these rules, and you may find that you wish to change the format of some of the com_content
SEF URLs generated on your site. This system plugin shows you how you can build your own rules and get them used by com_content
to build SEF URLs which have the format you wish to have.
The key to doing this is to write our own component router class, and then get com_content
use our component router class instead of its own. As com_content
uses the RouterFactory to instantiate its router we need to get the RouterFactory to instantiate our router. As described in Accessing the Component Router class the RouterFactory instantiates a component Router class with the classname <namespace>\Site\Service\Router
, so if we inject our own namespace into this RouterFactory then it will instantiate our Router instead of the com_content
Router.
You can copy and adapt the code below, or download and install the plugin from system plugin router rules download.
If you're copying the code below, then you will need to write the following 5 files into folder called plg_custom_menurule
.
For simplicity the plugin uses English only; if you want to make it multilingual you can change it as described in basic content plugin.
Manifest file
<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" group="system" method="upgrade">
<name>Custom Menurule</name>
<version>1.0.0</version>
<creationDate>today</creationDate>
<author>me</author>
<description>This plugin overrides the com_content site router where it selects the menuitem for the SEF URL</description>
<namespace path="src">My\Plugin\System\CustomMenurule</namespace>
<files>
<folder plugin="custom_menurule">services</folder>
<folder>src</folder>
</files>
</extension>
Service Provider file
This is standard boilerplate code for plugins for instantiating the plugin via the Dependency Injection Container. You just have to adapt the standard code for your own plugin (basically 3 lines, plus we inject the Application as we'll use that within the plugin).
use My\Plugin\System\CustomMenurule\Extension\CustomMenurulePlugin;
return new class implements ServiceProviderInterface {
public function register(Container $container) {
$container->set(
PluginInterface::class,
function (Container $container) {
$dispatcher = $container->get(DispatcherInterface::class);
$plugin = new CustomMenurulePlugin(
$dispatcher,
(array) PluginHelper::getPlugin('system', 'custom_menurule')
);
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
Extension class file
This is the entry point for the plugin. It registers to listen for the 'onAfterExtensionBoot' event, which is raised within loadExtension
in libraries/src/Extension/ExtensionManagerTrait.php. Joomla runs this code every time it loads an extension, and this code:
- runs the component's services/provider.php file to load it and its dependencies into the component's child DIC
- triggers the 'onAfterExtensionBoot' event
- gets the extension and its dependencies out of the child DIC.
So we need to change the RouterFactory dependency to inject our own namespace instead of the com_content
namespace. When we call registerServiceProvider
passing the RouterFactory with our namespace then it will replace the RouterFactory entry in the child DIC.
<?php
namespace My\Plugin\System\CustomMenurule\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;
use Joomla\CMS\Extension\ComponentInterface;
use Joomla\CMS\Extension\Service\Provider\RouterFactory;
class CustomMenurulePlugin extends CMSPlugin implements SubscriberInterface {
public static function getSubscribedEvents(): array {
return [
'onAfterExtensionBoot' => 'replaceRouterFactory',
];
}
public function replaceRouterFactory(Event $event): void {
if (!$this->getApplication()->isClient("site")) {
return;
}
[$subject, $type, $extensionName, $container] = array_values($event->getArguments());
if (($type === ComponentInterface::class) && ($extensionName === "content")) {
$container->registerServiceProvider(new RouterFactory('\\My\\Plugin\\System\\CustomMenurule'));
}
}
}
Component Router
Because the RouterFactory will try to instantiate a component router with a fully qualified classname of <namespace>\Site\Service\Router
, this defines the classname for our Router and the location of the PHP file. We make our Router class similar to that of com_content
by extending the com_content
Router class, but we change the MenuRules
class which it attaches to be our own MenuRules class.
We also have to define the getName
function to return the string "content", as this will be used in getting the relevant menuitems, namely those which are associated with com_content
.
<?php
namespace My\Plugin\System\CustomMenurule\Site\Service;
use Joomla\CMS\Application\SiteApplication;
use Joomla\CMS\Categories\CategoryFactoryInterface;
use Joomla\CMS\Component\Router\Rules\MenuRules;
use Joomla\CMS\Menu\AbstractMenu;
use Joomla\Database\DatabaseInterface;
\defined('_JEXEC') or die;
class Router extends \Joomla\Component\Content\Site\Service\Router
{
public function __construct(SiteApplication $app, AbstractMenu $menu, CategoryFactoryInterface $categoryFactory, DatabaseInterface $db)
{
// run the com_content Router constructor
parent::__construct($app, $menu, $categoryFactory, $db);
// detach the MenuRules which was set up in the com_content constructor
$rules = $this->getRules();
foreach ($rules as $rule) {
if ($rule instanceof \Joomla\CMS\Component\Router\Rules\MenuRules) {
$this->detachRule($rule);
break;
}
}
// and attach our own MenuRules
$this->attachRule(new \My\Plugin\System\CustomMenurule\Site\Service\MenuRules($this));
}
public function getName()
{
return "content";
}
}
MenuRules class
We're now in a position to write our own rules for determining how the menuitem is chosen on which to base the com_content
SEF URLs. What's below is an example of how you can modify the Joomla code in libraries/src/Component/Router/Rules/MenuRules.php. Because our rules class inherits from the Joomla rules class, you can write your own rules in the preprocess
function, and if you can't find a suitable menuitem you can just drop back to the Joomla version by calling parent::preprocess(&$query)
.
This function differs from the standard Joomla router in a number of areas:
-
If the
Itemid
has been set in theRoute::_()
call then we use it, provided that the menuitem is associated withcom_content
. -
If it's a multilingual site then we remove from the lookup array the entry associated with the home page of the
"*"
language. This is for the case where you assign language-specific home pages as described in Setup a Multilingual Site/Creating menus, and unpublish the main menu module. If we don't remove this entry then it's possible to get incorrect routing, eg if the"*"
home page points to one article and a language-specific home page points to a different article. -
It looks through the lookup table to try and find an exact match between the parameters specified in the
Route::_()
call and a menuitem on the site. If it finds one then it uses that menuitem'sItemid
. -
If the current page (ie the
active
menuitem) belongs tocom_content
then it uses that menuitem'sItemid
.
If the above fails to find a suitable menuitem then it drops back to the standard Joomla code.
<?php
namespace My\Plugin\System\CustomMenurule\Site\Service;
use Joomla\CMS\Component\Router\Rules\RulesInterface;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Component\Router\RouterView;
use Joomla\CMS\Language\Multilanguage;
\defined('JPATH_PLATFORM') or die;
class MenuRules extends \Joomla\CMS\Component\Router\Rules\MenuRules
{
private static $allLangHomeRemoved = false;
public function preprocess(&$query)
{
$active = $this->router->menu->getActive();
/**
* If the active item id is not the same as the supplied item id or we have a supplied item id and no active
* menu item then we just use the supplied menu item and continue
*/
if (isset($query['Itemid']) && ($active === null || $query['Itemid'] != $active->id)) {
return;
}
// Get query language
$language = isset($query['lang']) ? $query['lang'] : '*';
// Set the language to the current one when multilang is enabled and item is tagged to ALL
if (Multilanguage::isEnabled() && $language === '*') {
$language = $this->router->app->get('language');
}
// build the reverse lookup for the language (the buildLookup() for language "*" is already done in the constructor)
// $this->lookup is a multidimensional array of the menuitems which match the component (com_content),
// the language, and filtered by access.
// It is keyed by:
// - firstly language - eg "*" or "en-GB"
// - secondly view or view:layout - the view, and possibly also layout, defined in the menuitem
// - thirdly id - whatever is the id defined in the menuitem, or 0 if no id is specified
// The value of this element in the array is the Itemid of the menuitem.
if (!isset($this->lookup[$language])) {
$this->buildLookup($language);
}
// If the &Itemid=.. has been specified in the Route::_() call then use it if it's suitable
// (ie if it's in the lookup array)
if (isset($query['Itemid'])) {
if (array_search((int)$query['Itemid'], $this->lookup, true) !== false) {
return; // just use that Itemid
}
}
/* The following is superfluous given that we'll take the supplied menu item if it's found above
// Check if the active menu item matches the requested query
if ($active !== null && isset($query['Itemid'])) {
// Check if active->query and supplied query are the same
$match = true;
foreach ($active->query as $k => $v) {
if (isset($query[$k]) && $v !== $query[$k]) {
// Compare again without alias
if (\is_string($v) && $v == current(explode(':', $query[$k], 2))) {
continue;
}
$match = false;
break;
}
}
if ($match) {
// Just use the supplied menu item
return;
}
}
*/
// If it's a multilingual site then ensure we don't use the home page of the "*" language
if (Multilanguage::isEnabled() && !self::$allLangHomeRemoved) {
$homeItems = $this->router->menu->getItems(array('language', 'home'), array('*', 1));
if ($homeItems) {
$allLangHome = $homeItems[0]->id;
foreach ($this->lookup as $lang => $viewArray) {
foreach ($viewArray as $view => $idArray) {
foreach ($idArray as $id => $itemid) {
if ($itemid == $allLangHome) {
if (count($this->lookup[$lang][$view]) == 1) {
unset($this->lookup[$lang][$view]);
} else {
unset($this->lookup[$lang][$view][$id]);
}
break;
}
}
}
}
}
self::$allLangHomeRemoved = true;
}
// Form the equivalent of view:layout based on the query parameters, and try to match in the lookup array
if (isset($query['view'])) {
$searchKey = $query['view'];
if (isset($query['layout']) && $query['layout'] !== 'default') {
$searchKey .= ":" . $query['layout'];
}
foreach ($this->lookup as $lang => $arr) {
if (array_key_exists($searchKey, $arr)) { // find if there's a matching view:layout
$matchingViews = $arr[$searchKey];
// now see if we can find an exact match with the id
if (isset($query['id'])) {
$idKey = (int) $query['id'];
if (array_key_exists($idKey, $matchingViews)) {
$query['Itemid'] = $matchingViews[$idKey];
return;
}
} else { // if we haven't got an id in the query array
if (array_key_exists(0, $matchingViews)) {
$query['Itemid'] = $matchingViews[0];
return;
}
}
}
}
}
// Use the active menuitem if it's a com_content one
if ($active && $active->component === "com_content") {
$query["Itemid"] = $active->id;
return;
}
// if we didn't find one above, then fall back to the standard Joomla code below
$needles = $this->router->getPath($query);
$layout = isset($query['layout']) && $query['layout'] !== 'default' ? ':' . $query['layout'] : '';
if ($needles) {
foreach ($needles as $view => $ids) {
$viewLayout = $view . $layout;
if ($layout && isset($this->lookup[$language][$viewLayout])) {
if (\is_bool($ids)) {
$query['Itemid'] = $this->lookup[$language][$viewLayout];
return;
}
foreach ($ids as $id => $segment) {
if (isset($this->lookup[$language][$viewLayout][(int) $id])) {
$query['Itemid'] = $this->lookup[$language][$viewLayout][(int) $id];
return;
}
}
}
if (isset($this->lookup[$language][$view])) {
if (\is_bool($ids)) {
$query['Itemid'] = $this->lookup[$language][$view];
return;
}
foreach ($ids as $id => $segment) {
if (isset($this->lookup[$language][$view][(int) $id])) {
$query['Itemid'] = $this->lookup[$language][$view][(int) $id];
return;
}
}
}
}
}
// Check if the active menuitem matches the requested language
if (
$active && $active->component === 'com_' . $this->router->getName()
&& ($language === '*' || \in_array($active->language, ['*', $language]) || !Multilanguage::isEnabled())
) {
$query['Itemid'] = $active->id;
return;
}
// If not found, return language specific home link
$default = $this->router->menu->getDefault($language);
if (!empty($default->id)) {
$query['Itemid'] = $default->id;
}
}
}
Installation
Once you have created the files above, then zip up the folder and install the extension. Then go into System / Plugins or System / Extensions and Enable the plugin. Experiment with com_content
menuitems, categories and articles to see the difference in how the SEF URLs appear.