На основе официальной документации Joomla и практического опыта отладки, вот исчерпывающий набор файлов и правил, который закрывает все известные подводные камни.

Архитектурная схема (что происходит внутри Joomla)

Прежде чем писать код, поймите цепочку:

  1. Route::_('index.php?option=com_example&view=item&id=5') вызывает SiteRouter.
  2. SiteRouter проверяет, реализует ли класс компонента RouterServiceInterface. Если нет — используется RouterLegacy, который не умеет строить SEF.
  3. Если реализует — вызывается $component->createRouter($app, $menu).
  4. RouterServiceTrait::createRouter() берёт сохранённый RouterFactory и вызывает $this->routerFactory->createRouter($app, $menu).
  5. RouterFactory формирует имя класса: <namespace>\Site\Service\Router (на фронте) или <namespace>\Administrator\Service\Router (в админке).
  6. Если класс существует — создаётся экземпляр Router, который реализует RouterInterface (или extends RouterView).
  7. SiteRouter вызывает preprocess() → build() (для генерации URL) или parse() (для разбора URL).

Ключевой вывод: без implements RouterServiceInterface на классе компонента вся цепочка рвётся на шаге 2.

Структура файлов

administrator/components/com_example/
├── services/
│   └── provider.php
└── src/
    └── Extension/
        └── ExampleComponent.php

components/com_example/
├── services/
│   └── provider.php          (дубль, Joomla может игнорировать)
└── src/
    └── Service/
        └── Router.php

1. administrator/components/com_example/services/provider.php

Регистрирует DI-сервисы. Должен быть в админской части — Joomla 5 использует его и на фронтенде.

<?php
\defined('_JEXEC') or die;

use Joomla\CMS\Component\Router\RouterFactoryInterface;
use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface;
use Joomla\CMS\Extension\ComponentInterface;
use Joomla\CMS\Extension\Service\Provider\ComponentDispatcherFactory;
use Joomla\CMS\Extension\Service\Provider\MVCFactory;
use Joomla\CMS\Extension\Service\Provider\RouterFactory;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;

return new class implements ServiceProviderInterface
{
    public function register(Container $container)
    {
        $namespace = '\\Vendor\\Component\\Example';

        $container->registerServiceProvider(new ComponentDispatcherFactory($namespace));
        $container->registerServiceProvider(new MVCFactory($namespace));
        $container->registerServiceProvider(new RouterFactory($namespace));

        $container->set(
            ComponentInterface::class,
            function (Container $container) {
                $component = new \Vendor\Component\Example\Administrator\Extension\ExampleComponent(
                    $container->get(ComponentDispatcherFactoryInterface::class)
                );
                $component->setMVCFactory($container->get(MVCFactoryInterface::class));
                $component->setRouterFactory($container->get(RouterFactoryInterface::class));

                return $component;
            }
        );
    }
};

Внимание: RouterFactory здесь — это провайдер Joomla\CMS\Extension\Service\Provider\RouterFactory, а не сам класс фабрики. Он регистрирует в контейнере RouterFactoryInterface, который при получении создаёт настоящий Joomla\CMS\Component\Router\RouterFactory.

2. administrator/components/com_example/src/Extension/ExampleComponent.php

Критическая строка — implements RouterServiceInterface. Без неё SiteRouter не вызовет createRouter().

<?php
namespace Vendor\Component\Example\Administrator\Extension;

\defined('_JEXEC') or die;

use Joomla\CMS\Component\Router\RouterServiceInterface;
use Joomla\CMS\Component\Router\RouterServiceTrait;
use Joomla\CMS\Extension\MVCComponent;
use Joomla\CMS\MVC\Factory\MVCFactoryServiceTrait;

class ExampleComponent extends MVCComponent implements RouterServiceInterface
{
    use MVCFactoryServiceTrait;
    use RouterServiceTrait;
}

RouterServiceTrait даёт методы createRouter() и setRouterFactory(), но не объявляет implements RouterServiceInterface. Это нужно сделать явно.

3. components/com_example/services/provider.php

Дублирует админский провайдер. Joomla 5 на фронтенде может использовать админский provider.php, но наличие дубля не мешает и соответствует структуре пакета.

<?php
// Идентичное содержимое с administrator/components/com_example/services/provider.php

4. components/com_example/src/Service/Router.php

Выбор подхода: RouterInterface или RouterView

RouterView — если структура повторяет com_content (категории → элементы, вложенные URL). Даёт готовые MenuRules и StandardRules.

RouterInterface — если структура простая (один список, одна карточка). Проще, предсказуемее, легче отлаживать.

Для простого компонента используйте RouterInterface:

<?php
namespace Vendor\Component\Example\Site\Service;

\defined('_JEXEC') or die;

use Joomla\CMS\Application\SiteApplication;
use Joomla\CMS\Categories\CategoryFactoryInterface;
use Joomla\CMS\Component\Router\RouterInterface;
use Joomla\CMS\Menu\AbstractMenu;
use Joomla\Database\DatabaseInterface;
use Joomla\Database\ParameterType;

class Router implements RouterInterface
{
    private SiteApplication $app;
    private AbstractMenu $menu;
    private ?DatabaseInterface $db;

    public function __construct(
        SiteApplication $app,
        AbstractMenu $menu,
        ?CategoryFactoryInterface $categoryFactory = null,
        ?DatabaseInterface $db = null
    ) {
        $this->app  = $app;
        $this->menu = $menu;
        $this->db   = $db;
    }

    public function build(&$query)
    {
        $segments = [];

        if (!isset($query['view'])) {
            return $segments;
        }

        $view = $query['view'];

        if ($view === 'item' && isset($query['id'])) {
            $segments[] = 'item-' . (int) $query['id'];
            unset($query['id'], $query['view']);
        } elseif ($view === 'items') {
            unset($query['view']);
        }

        return $segments;
    }

    public function parse(&$segments)
    {
        $vars = [];

        if (empty($segments)) {
            return $vars;
        }

        $first = array_shift($segments);

        if (preg_match('/^item-(\d+)$/', $first, $m)) {
            $vars['view'] = 'item';
            $vars['id']   = (int) $m[1];
        }

        return $vars;
    }

    public function preprocess($query)
    {
        if (!empty($query['Itemid'])) {
            return $query;
        }

        if (isset($query['view'])) {
            // Для карточки элемента привязываем к пункту меню списка
            $lookupView = ($query['view'] === 'item') ? 'items' : $query['view'];
            $itemid = $this->findMenuId($lookupView);

            if ($itemid > 0) {
                $query['Itemid'] = $itemid;
            }
        }

        return $query;
    }

    private function findMenuId(string $view): int
    {
        $db = $this->db;
        $q  = $db->getQuery(true)
            ->select($db->quoteName('id'))
            ->from($db->quoteName('#__menu'))
            ->where($db->quoteName('link') . ' LIKE :link')
            ->where($db->quoteName('published') . ' = 1')
            ->where($db->quoteName('client_id') . ' = 0')
            ->setLimit(1);

        // PHP 8.1: второй аргумент bind() должен быть переменной
        $link = '%view=' . $view . '%';
        $q->bind(':link', $link, ParameterType::STRING);

        $db->setQuery($q);

        return (int) $db->loadResult();
    }
}

Частые ошибки и их точные причины

1. ?view= остаётся в URL, SEF не работает

Причина: класс компонента не реализует RouterServiceInterface. RouterServiceTrait сам по себе не включает instanceof-проверку в SiteRouter.

Решение: добавить implements RouterServiceInterface.

2. bind(): Argument #2 could not be passed by reference

Причина: PHP 8.1 требует, чтобы второй аргумент bind() был переменной. Конкатенация '%view=' . $view . '%' передаётся как выражение и не может быть ссылкой.

Решение:

$link = '%view=' . $view . '%';
$q->bind(':link', $link, ParameterType::STRING);

3. Карточка открывается по /item/item-5 вместо /items/item-5

Причина: preprocess() подставил Itemid пункта меню карточки (item), а не списка (items). Joomla привязывает URL к пункту меню с alias item → /item/....

Решение: для вложенного view искать Itemid родительского пункта меню:

$lookupView = ($query['view'] === 'item') ? 'items' : $query['view'];

4. class_exists('...Router') = true, но роутер не работает

Причина: class_exists проверяет только наличие класса. SiteRouter проверяет instanceof RouterServiceInterface на компоненте, а не на роутере. Если компонент не реализует интерфейс — роутер не будет вызван, даже если класс существует.

5. Роутер не вызывается, хотя файлы на месте

Проверьте:

  • provider.php лежит в administrator/components/com_example/services/ (не только на фронте).
  • В #__extensions для компонента manifest_cache содержит "namespace":"Vendor\\Component\\Example".
  • administrator/cache/autoload_psr4.php содержит префикс Vendor\Component\Example\Site\ → components/com_example/src.

6. Изменения не подхватываются

Решение: очистить кэш Joomla (System → Maintenance → Clear Cache) и удалить administrator/cache/autoload_psr4.php (Joomla пересоберёт при следующем запросе).

Быстрая диагностика (если роутер не работает)

Добавьте временный лог в preprocess():

public function preprocess($query)
{
    file_put_contents(JPATH_ROOT . '/router-debug.log', date('c') . ' preprocess: ' . json_encode($query) . "\n", FILE_APPEND);
    // ...
}

Откройте страницу. Если router-debug.log не появился — preprocess() не вызывается. Проверьте:

  1. implements RouterServiceInterface на компоненте.
  2. provider.php в админке.
  3. Кэш очищен.

Если preprocess вызывается, но build — нет, проверьте, что preprocess возвращает $query с Itemid.

Когда использовать RouterView

RouterView с MenuRules и StandardRules подходит, если компонент повторяет com_content (категории → элементы). Пример из документации:

class Router extends RouterView
{
    public function __construct(SiteApplication $app, AbstractMenu $menu, DatabaseInterface $db)
    {
        $items = new RouterViewConfiguration('items');
        $this->registerView($items);

        $item = new RouterViewConfiguration('item');
        $item->setKey('id')->setParent($items);
        $this->registerView($item);

        parent::__construct($app, $menu);
        $this->attachRule(new MenuRules($this));
        $this->attachRule(new StandardRules($this));
    }

    public function getItemSegment($id, $query) { /* ... */ }
    public function getItemId($segment, $query) { /* ... */ }
}

Предупреждение из документации: если RouterView не заработает, отладка практически невозможна. Для простых компонентов используйте ручной RouterInterface.

Чеклист перед запуском

  • provider.php существует в administrator/components/com_example/services/
  • RouterFactory зарегистрирован в provider.php
  • setRouterFactory() вызывается в closure ComponentInterface
  • Класс компонента implements RouterServiceInterface
  • Router.php в components/com_example/src/Service/
  • Namespace в Router.php — Vendor\Component\Example\Site\Service
  • Router реализует RouterInterface (или extends RouterView)
  • bind() использует переменную, а не выражение
  • Для вложенного view preprocess() ищет Itemid родительского пункта меню
  • Кэш очищен (administrator/cache/autoload_psr4.php удалён)
  • manifest_cache в #__extensions содержит "namespace"

При соблюдении этих пунктов костяк компонента с роутером создаётся за одну итерацию.


Автор статьи — ИИ, в партнёрстве с Геннадием Едигом. Сайт: http://jedig.ru