Building an Extbase backend module without a page tree
A conference team maintains its conferences in a backend module of their own. The records live in one storage folder, and nobody working in the module cares which page that is. The module therefore shows no page tree: it opens straight to a list of all conferences, with a search field and a filter.
Without a page tree the module has no page context, and everything a page would otherwise supply has to be provided by extension configuration:
- The module sees global TypoScript only, see Which TypoScript an Extbase backend module sees.
- There is no selected page to fall back on, so the storagePid has to be
configured, or every query has the restriction to only touch page
0. - There is no site, so the module works in the default language unless another one is explicitly chosen, see The language in an Extbase backend module.
This page builds that module: registration, configuration, a list with search, filter and pagination, and filters that are still set when the editor comes back. Creating, editing and removing the conferences is covered on the next page.
Table of contents
Registering an Extbase backend module without a page tree
A module shows the page tree when its main module does. The
toplevel module content does, so a
module registered below it inherits the page tree unless it opts out with
inheritNavigationComponentFromMainModule:
<?php
declare(strict_types=1);
use MyVendor\MyExtension\Controller\ConferenceModuleController;
return [
'my_extension_conferences' => [
'parent' => 'content',
'position' => ['after' => 'records'],
'access' => 'user',
'path' => '/module/my-extension/conferences',
'iconIdentifier' => 'my-extension-conference-module',
'labels' => 'my_extension.modules.conferences',
// Content modules show the page tree by default. This one does not.
'inheritNavigationComponentFromMainModule' => false,
'extensionName' => 'MyExtension',
'controllerActions' => [
ConferenceModuleController::class => [
'list',
],
],
],
];
Core's form module is registered the same way: it sits in
Content, where editors look for it, but shows no tree. The other
way is to choose a toplevel module that has no navigation component in the
first place, for example site or admin. Which one fits depends on who
uses the module, also custom ones are possible. The options for main modules
and submodules are described in Modules.php - backend module
configuration.
The module has a single action, list. Creating and editing conferences needs
no action of its own, see Editing records from an Extbase backend module. The keys extension and
controller are described in Registering an Extbase backend
module.
Configuring the storagePid of a module without a page tree
A module without a page tree gets no page TypoScript, so its configuration
has to be global. Register it in the extension's ext_localconf.php
with
\TYPO3\:
<?php
declare(strict_types=1);
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
defined('TYPO3') or die();
ExtensionManagementUtility::addTypoScriptSetup('
module.tx_myextension.persistence {
storagePid = 42
# Include records stored one level below page 42
recursive = 1
}
');
The
module. scope applies to every module of the
extension. To configure this module alone, append the module identifier:
module.. Without the
storage the list stays empty, unless the conferences are
stored on page 0: there is no selected page for Extbase to fall back on.
The full resolution order is described in The storagePid resolution
chain in a backend module.
A module with many users and many folders would make the storage folder configurable per site instead. This is the case the module with a page tree solves.
A demand object for search and filters in a backend module
The search field and the status filter are submitted together, as one object. Such an object is called a demand: it describes which records the editor wants to see, and the repository turns it into a query. Core's backend user module works with the same pattern.
The demand is a plain PHP class, not a domain model. It has no table and is never persisted by Extbase:
<?php
declare(strict_types=1);
namespace MyVendor\MyExtension\Domain\Model;
final readonly class ConferenceDemand
{
public const STATUS_PUBLISHED = 'published';
public const STATUS_UNPUBLISHED = 'unpublished';
public function __construct(
public string $searchWord = '',
public string $status = '',
) {}
public static function fromArray(array $data): self
{
return new self(
(string)($data['searchWord'] ?? ''),
(string)($data['status'] ?? ''),
);
}
public function toArray(): array
{
return [
'searchWord' => $this->searchWord,
'status' => $this->status,
];
}
}
When the filter form is submitted, Extbase maps the form fields to the
constructor arguments of the same name, see How Extbase property mapping
works.
from and
to convert the demand from and to a plain array, which is the
form in which it is stored between requests, as described in the next
section.
Note
Keep the class out of the dependency injection container. Extbase only
passes the submitted values to the constructor when the class is not a
service. The usual Services. of an extension excludes
Classes/, which is why the demand lives there.
The repository method builds its constraints from the demand. Each filter the editor left empty adds no constraint:
<?php
declare(strict_types=1);
namespace MyVendor\MyExtension\Domain\Repository;
use MyVendor\MyExtension\Domain\Model\Conference;
use MyVendor\MyExtension\Domain\Model\ConferenceDemand;
use TYPO3\CMS\Extbase\Persistence\QueryInterface;
use TYPO3\CMS\Extbase\Persistence\QueryResultInterface;
use TYPO3\CMS\Extbase\Persistence\Repository;
class ConferenceRepository extends Repository
{
/** @return QueryResultInterface<Conference> */
public function findDemanded(ConferenceDemand $demand): QueryResultInterface
{
$query = $this->createQuery();
$constraints = [];
if ($demand->searchWord !== '') {
$pattern = '%' . $demand->searchWord . '%';
$constraints[] = $query->logicalOr(
$query->like('title', $pattern),
$query->like('description', $pattern),
);
}
if ($demand->status === ConferenceDemand::STATUS_PUBLISHED) {
$constraints[] = $query->equals('published', true);
}
if ($demand->status === ConferenceDemand::STATUS_UNPUBLISHED) {
$constraints[] = $query->equals('published', false);
}
if ($constraints !== []) {
$query->matching($query->logicalAnd(...$constraints));
}
$query->setOrderings(['title' => QueryInterface::ORDER_ASCENDING]);
return $query->execute();
}
}
How to build constraints and combine them is described in Building Extbase queries.
Keeping the filters of an Extbase backend module in ModuleData
An editor who sets a filter, opens a conference and comes back expects the
filter to still be set. The backend keeps such settings per user and per
module, in the module data object. It
arrives with every module request as the module request attribute.
The list action reads the demand from the module data when the request brings none, and stores it there when it does:
<?php
declare(strict_types=1);
namespace MyVendor\MyExtension\Controller;
use MyVendor\MyExtension\Domain\Model\ConferenceDemand;
use MyVendor\MyExtension\Domain\Repository\ConferenceRepository;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Backend\Module\ModuleData;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Pagination\SimplePagination;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
use TYPO3\CMS\Extbase\Pagination\QueryResultPaginator;
class ConferenceModuleController extends ActionController
{
protected ?ModuleData $moduleData = null;
protected ?ModuleTemplate $moduleTemplate = null;
public function __construct(
protected readonly ModuleTemplateFactory $moduleTemplateFactory,
protected readonly ConferenceRepository $conferenceRepository,
protected readonly ExtensionConfiguration $extensionConfiguration,
) {}
#[\Override]
protected function initializeAction(): void
{
// The constructor runs before the request is known. Everything that
// depends on the request is set up here, before each action.
$this->moduleData = $this->request->getAttribute('moduleData');
$this->moduleTemplate = $this->moduleTemplateFactory->create($this->request);
}
public function listAction(
?ConferenceDemand $demand = null,
int $currentPage = 1,
string $operation = '',
): ResponseInterface {
if ($operation === 'reset-filters') {
$this->moduleData->set('demand', []);
$demand = null;
}
if ($demand === null) {
$demand = ConferenceDemand::fromArray(
(array)$this->moduleData->get('demand', []),
);
} else {
$this->moduleData->set('demand', $demand->toArray());
}
// set() only changes this request. Store the module data in the
// user's settings so the filters are still there next time.
$this->getBackendUser()->pushModuleData(
$this->moduleData->getModuleIdentifier(),
$this->moduleData->toArray(),
);
$conferences = $this->conferenceRepository->findDemanded($demand);
$paginator = new QueryResultPaginator($conferences, $currentPage, 25);
$this->moduleTemplate->assignMultiple([
'demand' => $demand,
'paginator' => $paginator,
'pagination' => new SimplePagination($paginator),
'newRecordPid' => $this->getNewRecordPid(),
]);
return $this->moduleTemplate->renderResponse('ConferenceModule/List');
}
protected function getNewRecordPid(): int
{
// A single page. Extension configuration values are strings.
return (int)$this->extensionConfiguration->get('my_extension', 'newRecordPid');
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
Warning
Module changes the module data of the current request
only. Without the call to
push the
filters are gone with the next request.
The module data is saved serialized in the backend user's settings, and the
Core restores it with
['allowed_. An object would not
come back as an object, so the demand goes in as a plain array.
Resetting the filters of an Extbase backend module
Because the filters are kept, the editor needs a way to clear them. The
$operation
argument of the list action does that: a link with
operation set to reset- empties the stored demand before the list
is built. The link is part of the filter form, shown below.
Listing the filtered records with pagination
With the demand known, the list action asks the repository for the matching conferences and paginates them. The template receives the paginator, the pagination and the demand, which prefills the filter form:
<?php
declare(strict_types=1);
namespace MyVendor\MyExtension\Controller;
use MyVendor\MyExtension\Domain\Model\ConferenceDemand;
use MyVendor\MyExtension\Domain\Repository\ConferenceRepository;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Backend\Module\ModuleData;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Pagination\SimplePagination;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
use TYPO3\CMS\Extbase\Pagination\QueryResultPaginator;
class ConferenceModuleController extends ActionController
{
protected ?ModuleData $moduleData = null;
protected ?ModuleTemplate $moduleTemplate = null;
public function __construct(
protected readonly ModuleTemplateFactory $moduleTemplateFactory,
protected readonly ConferenceRepository $conferenceRepository,
protected readonly ExtensionConfiguration $extensionConfiguration,
) {}
#[\Override]
protected function initializeAction(): void
{
// The constructor runs before the request is known. Everything that
// depends on the request is set up here, before each action.
$this->moduleData = $this->request->getAttribute('moduleData');
$this->moduleTemplate = $this->moduleTemplateFactory->create($this->request);
}
public function listAction(
?ConferenceDemand $demand = null,
int $currentPage = 1,
string $operation = '',
): ResponseInterface {
if ($operation === 'reset-filters') {
$this->moduleData->set('demand', []);
$demand = null;
}
if ($demand === null) {
$demand = ConferenceDemand::fromArray(
(array)$this->moduleData->get('demand', []),
);
} else {
$this->moduleData->set('demand', $demand->toArray());
}
// set() only changes this request. Store the module data in the
// user's settings so the filters are still there next time.
$this->getBackendUser()->pushModuleData(
$this->moduleData->getModuleIdentifier(),
$this->moduleData->toArray(),
);
$conferences = $this->conferenceRepository->findDemanded($demand);
$paginator = new QueryResultPaginator($conferences, $currentPage, 25);
$this->moduleTemplate->assignMultiple([
'demand' => $demand,
'paginator' => $paginator,
'pagination' => new SimplePagination($paginator),
'newRecordPid' => $this->getNewRecordPid(),
]);
return $this->moduleTemplate->renderResponse('ConferenceModule/List');
}
protected function getNewRecordPid(): int
{
// A single page. Extension configuration values are strings.
return (int)$this->extensionConfiguration->get('my_extension', 'newRecordPid');
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
Paginators and pagination strategies are described in Paginating a query result. The same classes work in a module as in a frontend plugin.
The template uses the backend Module layout, which
Module wraps in the backend
page frame. The filter form is an ordinary Extbase form bound to the demand:
<html
xmlns:be="http://typo3.org/ns/TYPO3/CMS/Backend/ViewHelpers"
xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
data-namespace-typo3-fluid="true">
<f:layout name="Module" />
<f:section name="Content">
<f:asset.module identifier="@typo3/backend/modal.js" />
<h1>Conferences</h1>
<f:form action="list" name="demand" object="{demand}">
<f:form.textfield property="searchWord" placeholder="Search" />
<f:form.select
property="status"
options="{published: 'Published', unpublished: 'Unpublished'}"
prependOptionLabel="All"
prependOptionValue=""
/>
<f:form.submit value="Filter" />
<f:link.action action="list" arguments="{operation: 'reset-filters'}">
Reset filters
</f:link.action>
</f:form>
<be:link.newRecord
table="tx_myextension_domain_model_conference"
pid="{newRecordPid}"
>
New conference
</be:link.newRecord>
<table class="table table-striped">
<f:for each="{paginator.paginatedItems}" as="conference">
<tr>
<td>
<be:link.editRecord
table="tx_myextension_domain_model_conference"
uid="{conference.uid}"
fields="title"
contextual="true"
>
{conference.title}
</be:link.editRecord>
</td>
<td>
<div class="btn-group">
<be:link.editRecord
table="tx_myextension_domain_model_conference"
uid="{conference.uid}"
class="btn btn-default"
>
Edit
</be:link.editRecord>
<a
href="{be:moduleLink(
route: 'tce_db',
query: 'cmd[tx_myextension_domain_model_conference][{conference.uid}][delete]=1',
currentUrlParameterName: 'redirect'
)}"
class="btn btn-default t3js-modal-trigger"
data-severity="warning"
data-title="Delete conference"
data-content="This conference will be deleted."
data-button-close-text="Cancel"
data-button-ok-text="Delete"
>
Delete
</a>
</div>
</td>
</tr>
</f:for>
</table>
<nav aria-label="Pagination">
<f:for each="{pagination.allPageNumbers}" as="page">
<f:link.action action="list" arguments="{currentPage: page}">
{page}
</f:link.action>
</f:for>
</nav>
</f:section>
</html>
The page links pass only current. The demand comes from the module data,
so the filters stay applied while the editor browses through the list.
Setting up an Extbase module controller in initializeAction()
The controller reads the module data and creates the module template in
initialize, not in its constructor:
<?php
declare(strict_types=1);
namespace MyVendor\MyExtension\Controller;
use MyVendor\MyExtension\Domain\Model\ConferenceDemand;
use MyVendor\MyExtension\Domain\Repository\ConferenceRepository;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Backend\Module\ModuleData;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Pagination\SimplePagination;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
use TYPO3\CMS\Extbase\Pagination\QueryResultPaginator;
class ConferenceModuleController extends ActionController
{
protected ?ModuleData $moduleData = null;
protected ?ModuleTemplate $moduleTemplate = null;
public function __construct(
protected readonly ModuleTemplateFactory $moduleTemplateFactory,
protected readonly ConferenceRepository $conferenceRepository,
protected readonly ExtensionConfiguration $extensionConfiguration,
) {}
#[\Override]
protected function initializeAction(): void
{
// The constructor runs before the request is known. Everything that
// depends on the request is set up here, before each action.
$this->moduleData = $this->request->getAttribute('moduleData');
$this->moduleTemplate = $this->moduleTemplateFactory->create($this->request);
}
public function listAction(
?ConferenceDemand $demand = null,
int $currentPage = 1,
string $operation = '',
): ResponseInterface {
if ($operation === 'reset-filters') {
$this->moduleData->set('demand', []);
$demand = null;
}
if ($demand === null) {
$demand = ConferenceDemand::fromArray(
(array)$this->moduleData->get('demand', []),
);
} else {
$this->moduleData->set('demand', $demand->toArray());
}
// set() only changes this request. Store the module data in the
// user's settings so the filters are still there next time.
$this->getBackendUser()->pushModuleData(
$this->moduleData->getModuleIdentifier(),
$this->moduleData->toArray(),
);
$conferences = $this->conferenceRepository->findDemanded($demand);
$paginator = new QueryResultPaginator($conferences, $currentPage, 25);
$this->moduleTemplate->assignMultiple([
'demand' => $demand,
'paginator' => $paginator,
'pagination' => new SimplePagination($paginator),
'newRecordPid' => $this->getNewRecordPid(),
]);
return $this->moduleTemplate->renderResponse('ConferenceModule/List');
}
protected function getNewRecordPid(): int
{
// A single page. Extension configuration values are strings.
return (int)$this->extensionConfiguration->get('my_extension', 'newRecordPid');
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
The constructor runs when the controller is created, before Extbase hands it
the request. Anything that depends on the request, and in a module that is
most of its state, therefore belongs in
initialize, which runs
before every action. See initializeAction and per-action initialization.
Next steps for the module without a page tree
The module lists and filters conferences, but cannot change them yet. The list template already contains the links that do, and none of them needs a controller action: Editing records from an Extbase backend module explains them.
The opposite case, a module whose page tree supplies the storage folder, the site and the language, is built in Building an Extbase backend module with a page tree.