
Hi guys,
In my PIT Solutions days, a backend module meant a call to ExtensionUtility::registerModule() in ext_tables.php, a long array of options, and a bit of hope. That way was removed in TYPO3 v12, when the new module registration API arrived (Feature-96733). Today a module is a plain PHP array in Configuration/Backend/Modules.php plus a controller that the dependency injection container knows about.
I still see old tutorials ranking high in search, so here is a complete small module that runs on v13 and v14. It lists the redirects of the installation, read-only, so editors can look up where an old URL goes without opening the Redirects module with full rights. The extension key is sitepackage and the namespace HarvisoftSitepackage; change both for your project.
Step 1: register the module
<?php
// EXT:sitepackage/Configuration/Backend/Modules.php
use HarvisoftSitepackageControllerRedirectOverviewController;
return [
'sitepackage_redirects' => [
'parent' => 'web',
'position' => ['after' => '*'],
'access' => 'user',
'workspaces' => 'live',
'path' => '/module/sitepackage/redirects',
'iconIdentifier' => 'mimetypes-x-sys_redirect',
'labels' => 'LLL:EXT:sitepackage/Resources/Private/Language/locallang_mod_redirects.xlf',
'routes' => [
'_default' => [
'target' => RedirectOverviewController::class . '::handleRequest',
],
],
],
];A few keys need a word:
parent: in v14 the main module “Web” was renamed to “Content”, with the identifiercontent(Feature-107628). The old identifierwebis registered as an alias, and the module factory resolves aliases inparent. I checked that in the v14.3 source. Sowebworks on both versions. For a v14-only extension, writecontent.access:usermeans editors can get it through their group rights.adminandsystemMaintainerare the stricter values. Since v14.2 these checks are “access gates”, and you can even add your own with#[AsModuleAccessGate](Feature-109365), but that does not exist in v13.labels: anLLL:path to a file with the keysmlang_tabs_tab,mlang_labels_tabdescrandmlang_labels_tablabel. v14 also understands the new translation domain syntax likesitepackage.module, but the file path works on both.routes: for a non-Extbase module you point_defaultto a controller method. Extbase modules useextensionNameandcontrollerActionsinstead.
The label file is the classic XLIFF:
<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" datatype="plaintext" original="EXT:sitepackage/Resources/Private/Language/locallang_mod_redirects.xlf" date="2026-10-15T00:00:00Z" product-name="sitepackage">
<body>
<trans-unit id="mlang_tabs_tab">
<source>Redirect overview</source>
</trans-unit>
<trans-unit id="mlang_labels_tablabel">
<source>Where old URLs go</source>
</trans-unit>
<trans-unit id="mlang_labels_tabdescr">
<source>Read-only list of all active redirects.</source>
</trans-unit>
</body>
</file>
</xliff>Step 2: the controller
<?php
declare(strict_types=1);
namespace HarvisoftSitepackageController;
use PsrHttpMessageResponseInterface;
use PsrHttpMessageServerRequestInterface;
use TYPO3CMSBackendAttributeAsController;
use TYPO3CMSBackendTemplateModuleTemplateFactory;
use TYPO3CMSCoreDatabaseConnectionPool;
#[AsController]
final readonly class RedirectOverviewController
{
public function __construct(
private ModuleTemplateFactory $moduleTemplateFactory,
private ConnectionPool $connectionPool,
) {}
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_redirect');
$redirects = $queryBuilder
->select('source_host', 'source_path', 'target', 'target_statuscode', 'hitcount')
->from('sys_redirect')
->orderBy('source_host')
->addOrderBy('source_path')
->executeQuery()
->fetchAllAssociative();
$view = $this->moduleTemplateFactory->create($request);
$view->setTitle('Redirect overview');
$view->assign('redirects', $redirects);
return $view->renderResponse('RedirectOverview/Index');
}
}Things I like here. #[AsController] tags the class so the backend can create it with its dependencies; it came in v13.0 and replaced the v12 #[Controller] attribute (Feature-102631). The query builder applies the default restrictions, so deleted and disabled redirects are not listed. And ModuleTemplateFactory::create() gives you the full backend frame: doc header, flash messages, the right CSS. The core’s own About module is built the same way, if you want a reference to read.
The controller needs DI, so the extension must have a Configuration/Services.yaml with autowire: true, autoconfigure: true and the Classes/ folder as resource. Most sitepackages have it already.
Step 3: the template
For non-Extbase modules, the view looks for templates in Resources/Private/Templates of the extension that registered the route, and the layout Module comes from EXT:backend. So the file is Resources/Private/Templates/RedirectOverview/Index.html:
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
<f:layout name="Module" />
<f:section name="Content">
<h1>Redirect overview</h1>
<f:if condition="{redirects}">
<f:then>
<div class="table-fit">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Host</th>
<th>Old path</th>
<th>Target</th>
<th>Status</th>
<th>Hits</th>
</tr>
</thead>
<tbody>
<f:for each="{redirects}" as="redirect">
<tr>
<td>{redirect.source_host}</td>
<td><code>{redirect.source_path}</code></td>
<td>{redirect.target}</td>
<td>{redirect.target_statuscode}</td>
<td>{redirect.hitcount}</td>
</tr>
</f:for>
</tbody>
</table>
</div>
</f:then>
<f:else>
<f:be.infobox title="No redirects" state="-1">There are no active redirects.</f:be.infobox>
</f:else>
</f:if>
</f:section>
</html>Note the plain .html name. v14 also accepts Index.fluid.html and prefers it, but v13 does not know that extension. For a module that must run on both, stay with .html.
Step 4: access for editors
Admins see the module right away after a cache flush. Editors do not, and that is correct. In the backend, open the editor’s backend user group, go to the tab with module permissions and tick “Redirect overview”. The permission lands in be_groups.groupMods. If you deploy groups as code, add the module identifier sitepackage_redirects there.
One more thing to think about: the module shows the whole sys_redirect table, across all sites. The table definition of EXT:redirects ignores web mounts on purpose, and my query does too. For a multi-site installation where editors should only see their own site, filter by source_host using the sites the editor can access. Do not hand out data just because the module was easy to build.
Check it
ddev exec vendor/bin/typo3 cache:flushThen log in once as admin and once as a test editor. The admin should see “Redirect overview” below the Web (v13) or Content (v14) main module, the editor only after the group got the permission. If opening the module gives an error that the controller cannot be created, the attribute was not picked up. In my experience that is almost always a missing or wrong Services.yaml, or a cache that was not flushed after adding it.
That is all. No ext_tables.php, no magic strings for the module key, and a controller you can unit test. If this post saved you an evening, I will be really happy.