Replacing TYPO3 Hooks with PSR-14 Event Listeners: A Practical Migration
TYPO3's PSR-14 event listeners replace stringly-typed hooks with typed, ordered, injectable classes. Here's a worked migration example, plus where hooks are still unavoidable.
11 Feb 2026, 16:01 UTC

Your extension still registers a hook in ext_localconf.php that reaches into a global array, points at a static method, and hopes nothing else overwrites it. It works — until two extensions fight over the same hook and the winner depends on load order you don't control. TYPO3's answer to this is PSR-14 event listeners: typed, ordered, and injectable. This post shows what the switch looks like in practice, with a concrete example, and where the honest limits are.
Why the old hook pattern hurts
Classic TYPO3 hooks are stringly-typed: you register a class name or a function reference in a global configuration array, and the core calls it at some point. Three problems follow. First, there's no signature — you get whatever arguments the caller passes, and you find out by reading core source. Second, ordering is implicit; if two extensions hook the same point, precedence is an accident. Third, hooks are usually static methods or manually instantiated classes, so you can't cleanly inject a logger or a repository.
PSR-14 fixes all three. An event is a real class with typed properties. Listeners are services in the dependency-injection container, so constructor injection just works. And ordering between listeners on the same event is declared explicitly with before and after attributes.
Registering a listener in Services.yaml
A listener is any class with an __invoke method (or a method you point the tag at). Registration happens in your extension's Configuration/Services.yaml:
services:
MyVendor\MyExtension\EventListener\EnrichFrontendPage:
tags:
- name: event.listener
identifier: 'my-extension/enrich-page'
event: TYPO3\CMS\Frontend\Event\AfterCacheableContentIsGeneratedEvent
after: 'some-other-listener-identifier'The identifier matters more than it looks: it's the handle other listeners use to order themselves relative to yours. Without it, TYPO3 derives one, but being explicit makes your ordering intentions readable. The after (or before) attribute replaces the old "hope my hook runs last" gamble with a declared dependency.
A worked example: modifying a record before it's shown
Say you want to append a disclaimer to a page's content under certain conditions. The listener class is plain PHP:
<?php
namespace MyVendor\MyExtension\EventListener;
use Psr\Log\LoggerInterface;
use TYPO3\CMS\Frontend\Event\AfterCacheableContentIsGeneratedEvent;
final class EnrichFrontendPage
{
public function __construct(
private readonly LoggerInterface $logger,
) {}
public function __invoke(AfterCacheableContentIsGeneratedEvent $event): void
{
$request = $event->getRequest();
$pageType = (int)($request->getQueryParams()['type'] ?? 0);
if ($pageType !== 0) {
return; // only touch the default page rendering
}
$this->logger->debug('Enriching page output');
// mutate via the event's API, if the event class allows it
}
}Two things to notice. The logger arrives through the constructor — no GeneralUtility::makeInstance() scavenger hunt, and the class is trivially unit-testable. And the listener checks its own preconditions (here, the page type) instead of relying on registration-time conditions, which keeps the logic in one place.
Read the event class before you write to it
Here's the part tutorials gloss over: each event class decides what you're allowed to change. Some events are read-only notifications — getters only, pure "this happened" signals. Others expose setters or mutable objects specifically so listeners can alter behavior. The event class is the contract.
Before writing a listener, open the event's source in your installed vendor/ directory and check: does it have a setter for what I want to change? Is it marked @internal? Events not covered by TYPO3's backwards-compatibility policy can change between minor releases, and building on one is a maintenance debt you're choosing knowingly.
The honest trade-off: hooks aren't dead yet
Not every legacy hook has a PSR-14 equivalent. Real-world extensions commonly mix both: listeners where events exist, an old-style hook or even an XCLASS for the gaps. That's not failure — it's the current state of the API surface. The practical rule: prefer a listener whenever a suitable, non-internal event exists for your TYPO3 version; fall back to hooks only where you must, and isolate that fallback behind a small class so it's easy to swap later.
Also note that available events differ significantly between TYPO3 v11, v12, and v13, and tag attributes have evolved. A registration snippet copied from an older tutorial can silently fail to fire. Always confirm the event exists in the changelog or official PSR-14 event list for your major version.
Verify it actually fires
Don't trust the YAML. Register a minimal listener that does nothing but log, then trigger the action in a local install and check the log. If nothing appears, the usual suspects are: the event doesn't exist in your version, the tag name is misspelled, or the container wasn't rebuilt (flush the cache after changing Services.yaml). Once dispatch is confirmed, add a second listener and verify your before/after ordering does what you expect.
The payoff for this small migration is real: typed contracts instead of string references, declared ordering instead of load-order luck, and testable classes instead of static hooks. Start with one hook — the one that bites you most often — and move it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.