0

I have a menu entry like so:

  • ID: 524
  • Type: Articles->Create Article
  • Link: index.php?option=com_content&view=form&layout=edit

In the "old days" I'do Route::_('index.php?option=com_content&view=form&layout=edit') and it would return a SEF-friendly URL path like /articles/add or whatever.

But currently, it does return this kinda ugly URL path: /component/content/form?layout=edit&Itemid=101. (Itemid 101 refers to the home menu entry).

If I do Route::_('index.php?Itemid=524') it does work OK.

I'm I missing something?

Thanks.

3
  • What you're describing is also happening on my joomla instances, which are a variety of joomla 4 releases. I think you should raise an issue on the Joomla issue tracker at issues.joomla.org and see what they say in response. As far as I can see the site router has undergone several changes in the joomla 4 releases, and there seem to be a few issues with how it's identifying the menuitem Itemid to base the route on. Commented Sep 22, 2023 at 18:10
  • Thanks @RobbieJackson I'm doing just that. Commented Sep 23, 2023 at 16:40
  • I raised a request for something similar a couple of weeks ago, and it got rejected outright. So yours is obviously receiving some serious consideration :-). In the meantime you can try the answer I've written up if you like. Commented Oct 10, 2023 at 15:47

1 Answer 1

1

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:

  1. If the Itemid has been set in the Route::_() call then we use it, provided that the menuitem is associated with com_content.

  2. 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.

  3. 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.

  4. 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.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.