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:

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.

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:

EXT:my_extension/Configuration/Backend/Modules.php
<?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',
      ],
    ],
  ],
];
Copied!

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 extensionName and controllerActions 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\CMS\Core\Utility\ExtensionManagementUtility::addTypoScriptSetup() :

EXT:my_extension/ext_localconf.php
<?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
  }
');
Copied!

The module.tx_myextension scope applies to every module of the extension. To configure this module alone, append the module identifier: module.tx_myextension_my_extension_conferences . Without the storagePid 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:

EXT:my_extension/Classes/Domain/Model/ConferenceDemand.php
<?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,
    ];
  }
}
Copied!

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. fromArray() and toArray() 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.

The repository method builds its constraints from the demand. Each filter the editor left empty adds no constraint:

EXT:my_extension/Classes/Domain/Repository/ConferenceRepository.php
<?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();
  }
}
Copied!

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 moduleData request attribute.

The list action reads the demand from the module data when the request brings none, and stores it there when it does:

EXT:my_extension/Classes/Controller/ConferenceModuleController.php
<?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'];
  }
}
Copied!

The module data is saved serialized in the backend user's settings, and the Core restores it with ['allowed_classes' => false] . 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-filters 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:

EXT:my_extension/Classes/Controller/ConferenceModuleController.php
<?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'];
  }
}
Copied!

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 ModuleTemplate wraps in the backend page frame. The filter form is an ordinary Extbase form bound to the demand:

EXT:my_extension/Resources/Private/Templates/ConferenceModule/List.fluid.html
<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>
Copied!

The page links pass only currentPage. 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 initializeAction() , not in its constructor:

EXT:my_extension/Classes/Controller/ConferenceModuleController.php
<?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'];
  }
}
Copied!

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 initializeAction() , 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.