ActionController: actions, arguments and responses 

A controller handles the request, coordinates repository and service calls, and returns a response. Business logic can live in the controller directly — for anything complex or reused, a dedicated service class keeps the controller focused. In Extbase every controller extends \TYPO3\CMS\Extbase\Mvc\Controller\ActionController .

Structure of an Extbase ActionController 

Controllers live in Classes/Controller/. Public methods with a name ending in Action are actions that can be mapped to plugin actions or backend module actions.

EXT:my_extension/Classes/Controller/ConferenceController.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Controller;

use MyVendor\MyExtension\Domain\Model\Conference;
use MyVendor\MyExtension\Domain\Repository\ConferenceRepository;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;

class ConferenceController extends ActionController
{
    public function __construct(
        protected readonly ConferenceRepository $conferenceRepository,
    ) {}

    public function listAction(): ResponseInterface
    {
        $this->view->assign('conferences', $this->conferenceRepository->findAll());
        return $this->htmlResponse();
    }

    public function showAction(Conference $conference): ResponseInterface
    {
        $this->view->assign('conference', $conference);
        return $this->htmlResponse();
    }
}
Copied!

Key rules:

  • Extend \TYPO3\CMS\Extbase\Mvc\Controller\ActionController .
  • Do not declare the class as final. Third parties should be able to extend controllers to customise behaviour.
  • Inject repositories and services via the constructor using dependency injection. Injected dependencies must be protected readonly, not private readonly, so subclasses can access them. In a service class that does not extend anything and carries no mutable state, you can declare the whole class readonly instead — but controllers extend ActionController and cannot use readonly class.
  • Every action method must return a \Psr\Http\Message\ResponseInterface .
  • Frontend plugins: use $this->view->assign() to pass variables to the Fluid template and return $this->htmlResponse() to render it. Actions can also return JSON, a redirect, or any other PSR-7 response.
  • Backend modules: inject ModuleTemplateFactory , create a ModuleTemplate instance per action, assign variables via $moduleTemplate->assignMultiple(), and return $moduleTemplate->renderResponse('ActionName'). Do not use $this->view or $this->htmlResponse() in a module controller. See Registering an Extbase backend module.

Action arguments and automatic Extbase object resolution 

Typed action parameters are resolved automatically from the request before the action is called. Scalars ( int, string, bool) are read directly from the request arguments. A parameter typed as a domain object (a class extending \TYPO3\CMS\Extbase\DomainObject\AbstractEntity ) triggers a repository lookup. Extbase converts the incoming UID to a fully hydrated object.

EXT:my_extension/Classes/Controller/ConferenceController.php
public function showAction(Conference $conference): ResponseInterface
{
    $this->view->assign('conference', $conference);
    return $this->htmlResponse();
}
Copied!

If, for example, a URL includes ?tx_myextension_pi1[conference]=5, Extbase loads Conference with UID 5 from the repository and passes the object directly to showAction(). A manual repository call is not necessary.

The lookup ignores the storagePid restriction but always respects enableFields — hidden records, records outside their starttime/endtime window, deleted records, and records from other workspaces will not be resolved. If the record cannot be found, Extbase calls errorAction() instead of the action.

Optional parameters require a default value. A nullable type alone ( ?Conference $conference) is not sufficient — without = null, PHP requires the caller to pass explicitly null, which Extbase will not do for a missing argument. Always combine the nullable type with a default:

EXT:my_extension/Classes/Controller/ConferenceController.php
public function listAction(int $page = 1): ResponseInterface { ... }

public function showAction(?Conference $conference = null): ResponseInterface { ... }
Copied!

If an argument is missing from the request and there is no default value, Extbase calls errorAction() instead. See errorAction: Extbase validation and argument-mapping errors.

Accessing TypoScript settings in an Extbase controller 

The merged TypoScript settings for the current plugin are available in every action via $this->settings:

EXT:my_extension/Classes/Controller/ConferenceController.php
public function listAction(): ResponseInterface
{
    $limit = (int)($this->settings['itemsPerPage'] ?? 10);
    $this->view->assign(
        'conferences',
        $this->conferenceRepository->findLatest($limit),
    );
    return $this->htmlResponse();
}
Copied!

$this->settings contains the merged result of plugin.tx_myextension.settings.*, plugin.tx_myextension_myplugin.settings.*, and any FlexForm overrides. It has nothing to do with site settings — those are a separate configuration layer defined in settings.yaml. Site settings can feed into TypoScript constants via the {$...} syntax, but that is an explicit mapping, not an automatic merge. Only values that flow through plugin.tx_myextension.settings.* end up in $this->settings. The full resolution order is covered in Extbase TypoScript configuration.

Extbase action response helpers 

ActionController provides two convenience methods for the most common response types:

htmlResponse(?string $html = null)
Returns a text/html PSR-7 response. Renders the current Fluid view without any arguments. Pass a string to use as the body.
jsonResponse(?string $json = null)
Returns an application/json PSR-7 response. Renders the current view without any arguments (use with \TYPO3\CMS\Extbase\Mvc\View\JsonView ). Pass a JSON string for the response.

For any other status code or content types, build the response manually using the injected $this->responseFactory and $this->streamFactory:

EXT:my_extension/Classes/Controller/ConferenceController.php
return $this->responseFactory
    ->createResponse(202)
    ->withHeader('Content-Type', 'text/plain; charset=utf-8')
    ->withBody($this->streamFactory->createStream('Accepted'));
Copied!

Redirecting and forwarding from an Extbase action 

After a write operation (create, update, delete), redirect the user to avoid a double-submit on page reload. All three methods described here return a \Psr\Http\Message\ResponseInterface — you must return them. They do not throw an exception or stop execution on their own.

redirect() — redirect to another Extbase action 

redirect() issues an HTTP 303 "See Other" response. The browser discards the POST body and issues a new GET request to the target action URL.

Discarding the POST body is intentional. After a write action (create, update, delete) you want the browser's address bar to show the result page URL, not a form submission URL. If the user presses F5 or the back button, the browser replays a GET — not the original POST — so that the write does not happen a second time. This pattern is called Post/Redirect/Get.

EXT:my_extension/Classes/Controller/ConferenceController.php
use MyVendor\MyExtension\Domain\Model\Conference;
use Psr\Http\Message\ResponseInterface;

public function updateAction(Conference $conference): ResponseInterface
{
    $this->conferenceRepository->update($conference);

    // Redirect to listAction on the same page, same plugin
    return $this->redirect('list');
}
Copied!

The full signature is:

EXT:my_extension/Classes/Controller/ConferenceController.php
// Go to showAction and pass a domain object as argument
return $this->redirect('show', null, null, ['conference' => $conference]);

// Go to listAction of ConferenceController on a different page
return $this->redirect('list', 'Conference', null, [], $targetPageUid);
Copied!
Parameter Default Purpose
$actionName (required) Name of the target action without the Action suffix, for example 'list'.
$controllerName null Short class name of the target controller. null means the current controller.
$extensionName null Extension name in UpperCamelCase. null means the current extension.
$arguments [] Array of arguments appended to the target URL as query parameters.
$pageUid null UID of the target page. null keeps the current page.
$statusCode 303 HTTP status code. Change only when you have a specific reason.

redirectToUri() — redirect to an arbitrary URL 

redirectToUri(string|\Psr\Http\Message\UriInterface $uri) issues the same HTTP 303 redirect but accepts a URL rather than an Extbase action name. Use it when the target URL is assembled outside the action, for example with UriBuilder :

EXT:my_extension/Classes/Controller/ConferenceController.php
use Psr\Http\Message\ResponseInterface;

public function deleteAction(Conference $conference): ResponseInterface
{
    $this->conferenceRepository->remove($conference);

    $uri = $this->uriBuilder
        ->reset()
        ->setTargetPageUid(42)
        ->uriFor('list', [], 'Conference');

    return $this->redirectToUri($uri);
}
Copied!

$this->uriBuilder is available in every action. Call ->reset() before building a new URI so settings from a previous call do not leak into the next one.

ForwardResponse — transfer control within the same request 

ForwardResponse transfers control to another action within the same request. There is no browser redirect or new HTTP round-trip. Use it to re-display a form after a validation failure without losing any submitted data:

EXT:my_extension/Classes/Controller/ConferenceController.php
use MyVendor\MyExtension\Domain\Model\Conference;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Extbase\Http\ForwardResponse;

public function createAction(Conference $conference): ResponseInterface
{
    if ($someConditionFails) {
        return (new ForwardResponse('new'))
            ->withArguments(['conference' => $conference]);
    }
    $this->conferenceRepository->add($conference);
    return $this->redirect('list');
}
Copied!

Unlike a redirect, ForwardResponse preserves the current request's arguments and flash messages so that the forwarded action can re-render the form with submitted values still in place. The browser URL does not change.

ForwardResponse accepts action name, withControllerName(), withExtensionName(), and withArguments() — but no explicit page UID. The dispatching always continues within the current request context. If you need to send the user to a specific page, use redirect() instead.

Flash messages in Extbase controllers 

Flash messages are one-time notifications that survive a redirect or a forward and are rendered in a Fluid template via <f:flashMessages />. They are also useful for in-page feedback when no redirect is involved. The message is rendered in the same response.

EXT:my_extension/Classes/Controller/ConferenceController.php
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;

$this->addFlashMessage(
    'Conference was saved.',
    'Success',
    ContextualFeedbackSeverity::OK,
);
return $this->redirect('list');
Copied!

The five severity levels are:

  • ContextualFeedbackSeverity::NOTICE
  • ContextualFeedbackSeverity::INFO
  • ContextualFeedbackSeverity::OK
  • ContextualFeedbackSeverity::WARNING
  • ContextualFeedbackSeverity::ERROR

By default, flash messages are stored inside the session and survive the redirect. To keep a message only for the current request, for example, when forwarding rather than redirecting, pass false as the fourth argument:

EXT:my_extension/Classes/Controller/ConferenceController.php
$this->addFlashMessage('Could not process your request.', '', ContextualFeedbackSeverity::ERROR, false);
Copied!

Render them in Fluid with the <f:flashMessages /> ViewHelper.

initializeAction and per-action initialization in Extbase 

initializeAction() is called before every controller action. Use it for setup that is common to all actions.

For setup that applies to a single action only, define a method named initialize + the capitalized action name + Action (for example initializeCreateAction() before createAction()). Extbase calls it automatically before the corresponding action:

EXT:my_extension/Classes/Controller/ConferenceController.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Controller;

use MyVendor\MyExtension\Domain\Model\Conference;
use MyVendor\MyExtension\Domain\Repository\ConferenceRepository;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;

class ConferenceController extends ActionController
{
    public function __construct(
        protected readonly ConferenceRepository $conferenceRepository,
    ) {}

    public function initializeCreateAction(): void
    {
        $this->arguments['conference']
            ->getPropertyMappingConfiguration()
            ->allowProperties('title', 'conferenceDate');
    }

    public function createAction(Conference $conference): ResponseInterface
    {
        $this->conferenceRepository->add($conference);
        return $this->redirect('list');
    }
}
Copied!

The per-action initializer is the standard place to configure property mapping, for example, to allow specific properties or to set custom date formats for arguments. See Configuring Extbase type converters.

Protecting Extbase actions with #[Authorize] 

New in version 14.0

The #[Authorize] attribute restricts access to individual action methods without having to write boilerplate login checks. It is repeatable so multiple attributes can be stacked to combine conditions.

EXT:my_extension/Classes/Controller/ConferenceController.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Controller;

use MyVendor\MyExtension\Domain\Model\Conference;
use MyVendor\MyExtension\Domain\Repository\ConferenceRepository;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Extbase\Attribute\Authorize;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;

class ConferenceController extends ActionController
{
    public function __construct(
        protected readonly ConferenceRepository $conferenceRepository,
    ) {}

    public function listAction(): ResponseInterface
    {
        $this->view->assign('conferences', $this->conferenceRepository->findAll());
        return $this->htmlResponse();
    }

    #[Authorize(requireLogin: true)]
    public function newAction(): ResponseInterface
    {
        return $this->htmlResponse();
    }

    #[Authorize(requireLogin: true)]
    public function createAction(Conference $conference): ResponseInterface
    {
        $this->conferenceRepository->add($conference);
        return $this->redirect('list');
    }

    #[Authorize(requireLogin: true, callback: 'isOwner')]
    public function deleteAction(Conference $conference): ResponseInterface
    {
        $this->conferenceRepository->remove($conference);
        return $this->redirect('list');
    }

    public function isOwner(Conference $conference): bool
    {
        $currentUserId = $this->request->getAttribute('frontend.user')?->getUserId();
        return $currentUserId !== null && $conference->getOwnerUid() === $currentUserId;
    }
}
Copied!

The three options:

requireLogin: true
Denies access if no frontend user is logged in. Returns a HTTP 403 Forbidden response via ErrorController .
requireGroups: [42] or requireGroups: ['editors']
Grants access only if the logged-in user belongs to at least one of the listed groups (by UID or title). Implies a login check.
callback: 'methodName' or callback: [SomeClass::class, 'method']

Calls a method on the controller (string form) or on an arbitrary class (array form). The method receives the same arguments as the action and must return bool. Use this for ownership or record-level checks.

The callback method must be public. Extbase verifies visibility via reflection and throws an exception if it is not.

Inside a controller callback, read the current frontend user from the request attribute frontend.user:

EXT:my_extension/Classes/Controller/ConferenceController.php
public function isOwner(Conference $conference): bool
{
    $currentUserId = $this->request->getAttribute('frontend.user')?->getUserId();
    return $currentUserId !== null
        && $conference->getOwnerUid() === $currentUserId;
}
Copied!

When access is denied, Extbase dispatches BeforeActionAuthorizationDeniedEvent which lets event listeners return a custom response instead of the default 403.

Rate-limiting Extbase actions with #[RateLimit] 

New in version 14.0

#[RateLimit] limits how often a visitor (identified by IP address) may call an action within a sliding time window. It is designed for write actions like form submissions.

EXT:my_extension/Classes/Controller/ConferenceController.php
use TYPO3\CMS\Extbase\Attribute\RateLimit;

#[RateLimit(limit: 3, interval: '1 hour')]
public function createAction(Conference $conference): ResponseInterface
{
    $this->conferenceRepository->add($conference);
    return $this->redirect('list');
}
Copied!

Options:

limit
Maximum number of calls allowed within the interval. Default: 5.
interval
Time window as a string parseable by \DateInterval or strtotime(), for example '15 minutes' or '1 hour'. Default: '15 minutes'.
policy
Throttling algorithm. Only 'sliding_window' is available. Default: 'sliding_window'.
message
LLL key for the message shown when the limit is exceeded. Resolved via LocalizationUtility against the current extension. Leave empty to use the built-in Extbase default message.

When the limit is exceeded, Extbase returns an HTTP 429 Too many requests response. Dispatch a BeforeActionRateLimitResponseEvent listener to customize this response.

errorAction: Extbase validation and argument-mapping errors 

If argument mapping or validation fails, Extbase calls errorAction() instead of the original action. The default implementation either dispatches back to the referring action (re-displaying the form with validation errors) or returns a plain HTTP 400 Bad Request text response.

Override getErrorFlashMessage() to customize the flash message that appears when validation fails:

EXT:my_extension/Classes/Controller/ConferenceController.php
#[\Override]
protected function getErrorFlashMessage(): bool|string
{
    return match ($this->actionMethodName) {
        'createAction' => 'Please correct the errors in the form.',
        default => parent::getErrorFlashMessage(),
    };
}
Copied!

Return false to suppress the flash message.

For full control over the error response, override errorAction(). Make sure you return a \ResponseInterface — the contract is the same as for normal actions.