Editing records from an Extbase backend module 

A module that lists records sooner or later has to change them. The obvious Extbase answer is a set of actions: new and create, edit and update, delete, each with a form and a template. Most backend modules, including the ones in Core, do not do that. They link to FormEngine, the form the backend uses for every record, and let it do the editing.

FormEngine brings everything a hand-written form would have to rebuild: the fields as configured in TCA, the editor's permissions, workspaces, translations, the record history and undo. It writes through the DataHandler, so hooks and events of other extensions run as they do everywhere else in the backend. The module only has to link to it, and the links are Fluid ViewHelpers that need no controller code.

The examples on this page are taken from the list template of the conferences module.

Opening a record in FormEngine from an Extbase module 

The be:link.editRecord ViewHelper links to the edit form of one record:

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!

When the editor saves and closes the form, the backend returns to the page the link was clicked on. The ViewHelper takes that address from the current request, so the list comes back with its filters and its page number. Set returnUrl only to send the editor somewhere else.

Editing selected fields of a record 

The fields argument limits the form to the listed fields. A module that offers a quick way to rename a conference does not need to show the whole record:

EXT:my_extension/Resources/Private/Templates/ConferenceModule/List.fluid.html (excerpt)
<be:link.editRecord
  table="tx_myextension_domain_model_conference"
  uid="{conference.uid}"
  fields="title,description"
>
  Edit title and description
</be:link.editRecord>
Copied!

The editor can still open the complete record from the form.

Editing a record without leaving the list 

New in version 14.3 Important: #110307 - New editRecord ViewHelper argument for contextual editing

The contextual argument of be:link.editRecord was added.

With contextual="true" the form opens in a panel next to the list instead of replacing it. Together with fields this makes a quick edit of a single value:

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 ViewHelper loads the JavaScript the panel needs. The module adds nothing.

Creating records from an Extbase backend module 

The be:link.newRecord ViewHelper opens an empty form for a new record. pid names the page the record is created on:

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!

In a module with a page tree the selected page is the natural choice. A module without one has no such page, and a link without pid creates the record on page 0, the root of the page tree, where most tables do not allow records. The conferences module therefore reads the page from the extension configuration:

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 option is declared in the extension's ext_conf_template.txt, and administrators set it in System > Settings:

EXT:my_extension/ext_conf_template.txt
# cat=basic; type=int+; label=New record page: Page on which the conferences module creates new conferences
newRecordPid = 0
Copied!

ExtensionConfiguration is public API and needs neither a request nor a page. The storagePid that Extbase has resolved for the module is part of its framework configuration, which only the internal Extbase configuration manager returns. Set newRecordPid to the first page of the storagePid from Configuring the storagePid of a module without a page tree, so new conferences are created where the list finds them.

To prefill fields of the new record, pass defaultValues, for example defaultValues="{tx_myextension_domain_model_conference: {published: 0}}" .

Deleting records from an Extbase backend module 

A delete button next to the edit link lets the editor remove a record straight from the list. Core's redirects module builds it this way, and it needs no controller action either: the button links to the backend's DataHandler gateway, the tce_db route, with a delete command for the record:

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 parts of the button:

  • be:moduleLink builds the address. The query argument carries the DataHandler command cmd[<table>][<uid>][delete]=1, and currentUrlParameterName: 'redirect' adds the current address, so the backend returns to the list, with its filters, once the record is deleted.
  • The class t3js-modal-trigger makes the backend ask before it follows the link. The data-* attributes set the title, text, severity and button labels of the confirmation dialog. The template loads the dialog with <f:asset.module identifier="@typo3/backend/modal.js" /> .

DataHandler deletes the record and checks that the editor may do so. If the table has a delete field, as Extbase tables usually do, the record is only marked as deleted and can be restored from the recycler or the record history.

The edit form offers deletion as well: a saved record opened in FormEngine has a Delete button in its document header. FormEngine hides that button when the editor may not delete the record, or when User TSconfig switches it off with options.disableDelete. The button in the list is not affected by that option.

Edit links built in PHP 

Links in the document header, for example a New conference button, are built in the controller rather than in the template. The backend \TYPO3\CMS\Backend\Routing\UriBuilder creates the same addresses from the record_edit route, and Core's backend user module builds its buttons this way. How to build these links is described in Use the backend UriBuilder to link to "Edit Records", and the buttons themselves in Button components. Neither is specific to Extbase.

For links inside a list, the ViewHelpers are the simpler choice: they need no controller code and fill in the return address themselves.

When an Extbase module writes records itself 

Handing records to FormEngine is the usual choice, not the only one. Own actions are justified when the form is not an edit form of one record, for example:

  • An input that creates a record from another one, such as the answer to a comment, which becomes a comment of its own.
  • A step that changes several records at once, or records of several tables.
  • A form that must look and behave differently from FormEngine, for example a guided sequence of steps.

Such an action is an ordinary Extbase action with a form, validation and a repository call, as in a frontend plugin. It takes over what FormEngine would have done: checking that the editor may change the record, and protecting the form against cross-site requests, see Security considerations. The module with a page tree answers comments this way, next to hiding them through Core's own mechanism.

Next steps after editing records 

The conferences module is now complete: it lists, filters and pages through the conferences, and hands every change to FormEngine. Its counterpart, Building an Extbase backend module with a page tree, takes its storage folder, site and language from the page the editor selects, and writes one kind of record itself.