As there are a number of issues arising like this, this answer explains how to override the com_content router to select a better menuitem to base an SEF URL on.
WARNING
This is a system plugin, and if you have a PHP syntax error in it you will get locked out of the Joomla admin back-end, and will have to go into phpmyadmin (or equivalent) to set to 0 the enabled
field within the plugin record in #__extensions. If you're not comfortable doing this then I would recommend not using this plugin.
Into a folder plg_custom_menurule create the following 5 files.
plg_custom_menurule/custom_menurule.xml - this is the manifest file for the plugin.
<?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>
plg_custom_menurule/services/provider.php - this is just standard boilerplate code for instantiating the plugin via the dependency injection container.
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
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')
);
return $plugin;
}
);
}
};
plg_custom_menurule/src/Extension/CustomMenurulePlugin.php. 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.
When the event is raised, if it's for the site com_content then it registers in the component's child Dependency Injection Container a RouterFactory with the plugin's namespace, which will overwrite the usual com_content entry (set in administrator/components/com_content/services/provider.php).
<?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;
use Joomla\CMS\Factory;
class CustomMenurulePlugin extends CMSPlugin implements SubscriberInterface {
public static function getSubscribedEvents(): array {
return [
'onAfterExtensionBoot' => 'replaceRouterFactory',
];
}
public function replaceRouterFactory(Event $event): void {
if (!Factory::getApplication()->isClient("site")) {
return;
}
if (($event['type'] === ComponentInterface::class) && ($event['extensionName'] === "content")) {
$container = $event["container"];
$container->registerServiceProvider(new RouterFactory('\\My\\Plugin\\System\\CustomMenurule'));
}
}
}
When the RouterFactory is called up to create a Router instance, it tries to instantiate a class <namespace>\Site\Service\Router
so this is what we want to define.
plg_custom_menurule/src/Site/Service/Router.php. Our class extends the com_content router class, and we use its configuration of the Router views, and the routing rules (set up in its constructor in components/com_content/src/Service/Router.php). Then we detach the menu rule that had been set and in its place attach our own. We need also to override the getName
method.
<?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)
{
parent::__construct($app, $menu, $categoryFactory, $db);
$rules = $this->getRules();
foreach ($rules as $rule) {
if ($rule instanceof \Joomla\CMS\Component\Router\Rules\MenuRules) {
$this->detachRule($rule);
break;
}
}
$this->attachRule(new \My\Plugin\System\CustomMenurule\Site\Service\MenuRules($this));
}
public function getName()
{
return "content";
}
}
plg_custom_menurule/src/Site/Service/MenuRules.php - This is our version of the preprocess
function which is used to set the Itemid of the menuitem to build an SEF URL upon. The code extends the equivalent Joomla class in libraries/src/Component/Router/Rules/MenuRules.php, so that we reuse its buildLookup
function, but override the preprocess
function.
Note that this code is based on the Joomla 4.3.4 version. Other versions may have different Joomla code in this file.
<?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;
}
}
}
This function differs from the standard Joomla router in a number of areas:
If the Itemid has been set in the Route::_()
call then we use it, provided that the menuitem is associated with com_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 https://docs.joomla.org/J3.x: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's Itemid.
If the current page (ie the active menuitem) belongs to com_content then it uses that menuitem's Itemid.
If the above fails to find a suitable menuitem then it drops back to the standard Joomla code.
You can obviously tailor these rules to suit your own needs.
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.
Your feedback is particularly welcome, as it will probably benefit others as well, and I'll try and maintain this code bug-free.