
TYPO3 13 and 14 both require PHP 8.2 or newer. That means a lot of PHP features that were “nice, but not for extensions that must run on old servers” are now simply available. I came to TYPO3 when pibase and arrays-of-arrays were normal, so some of these still feel like a small luxury.
Here is the short list I reach for in every new extension, each with a TYPO3-flavoured example and the PHP version it needs. Pay attention to the last two: they need PHP 8.3, which is more than the TYPO3 minimum.
Constructor promotion with dependency injection (PHP 8.0)
Services in TYPO3 are autowired through Services.yaml. With constructor promotion, a service with dependencies needs no property declarations at all:
<?php
declare(strict_types=1);
namespace MyVendorMyEventsService;
use TYPO3CMSCoreContextContext;
use TYPO3CMSCoreDatabaseConnection;
use TYPO3CMSCoreDatabaseConnectionPool;
final class EventCounter
{
public function __construct(
private readonly ConnectionPool $connectionPool,
private readonly Context $context,
) {}
public function countUpcoming(): int
{
$now = $this->context->getPropertyFromAspect('date', 'timestamp');
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_myevents_domain_model_event');
return (int)$queryBuilder
->count('uid')
->from('tx_myevents_domain_model_event')
->where($queryBuilder->expr()->gte('start_date', $queryBuilder->createNamedParameter($now, Connection::PARAM_INT)))
->executeQuery()
->fetchOne();
}
}private readonly (readonly properties came with PHP 8.1) says what it means: this dependency is set once and never replaced. No more inject*() methods for new code.
Enums for select values (PHP 8.1)
Every extension has a field with a fixed set of values: a status, a type, a layout. Before, these were integer constants scattered around. Now they are a backed enum:
<?php
declare(strict_types=1);
namespace MyVendorMyEventsDomainModel;
enum EventStatus: string
{
case Planned = 'planned';
case Confirmed = 'confirmed';
case Cancelled = 'cancelled';
public function label(): string
{
return 'LLL:EXT:my_events/Resources/Private/Language/locallang_db.xlf:status.' . $this->value;
}
}The TCA can build its select items from the enum, so the list of allowed values lives in one place:
'status' => [
'label' => 'LLL:EXT:my_events/Resources/Private/Language/locallang_db.xlf:status',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'items' => array_map(
static fn(EventStatus $status): array => ['label' => $status->label(), 'value' => $status->value],
EventStatus::cases()
),
'default' => EventStatus::Planned->value,
],
],And since TYPO3 13.0, Extbase maps backed enums in domain models directly (Feature #101396). The property is simply typed with the enum:
protected EventStatus $status = EventStatus::Planned;The old TYPO3CMSCoreTypeEnumeration class is deprecated since 13.0, so new code should not extend it.
First-class callable syntax (PHP 8.1)
Small, but I use it every day. Instead of [$this, 'mapRow'] or a string with a function name, you write the call with (...):
$rows = $queryBuilder->select('uid', 'title', 'start_date')
->from('tx_myevents_domain_model_event')
->executeQuery()
->fetchAllAssociative();
return array_map($this->mapRow(...), $rows);Your IDE and PHPStan understand it, and renaming the method updates the call. A string callback is invisible to both. A bare name without quotes is even worse: PHP 8 treats it as an undefined constant and stops with a fatal error. I met exactly that in an old WordPress theme on my own blog.
Readonly classes for events and DTOs (PHP 8.2)
PSR-14 events that only carry data, or small value objects, become one short class. Since PHP 8.2 the whole class can be readonly:
<?php
declare(strict_types=1);
namespace MyVendorMyEventsEvent;
use MyVendorMyEventsDomainModelEventStatus;
final readonly class EventStatusChangedEvent
{
public function __construct(
public int $eventUid,
public EventStatus $oldStatus,
public EventStatus $newStatus,
) {}
}A listener with the #[AsEventListener] attribute (TYPO3 13.0 and newer) needs no Services.yaml entry:
use TYPO3CMSCoreAttributeAsEventListener;
#[AsEventListener(identifier: 'my-events/notify-on-cancel')]
final readonly class NotifyOnCancel
{
public function __construct(private MailNotifier $notifier) {}
public function __invoke(EventStatusChangedEvent $event): void
{
if ($event->newStatus === EventStatus::Cancelled) {
$this->notifier->sendCancellation($event->eventUid);
}
}
}Careful: an event that listeners are meant to modify must not be readonly. Use readonly only for events that inform. The TYPO3 core itself uses final readonly class for several of its newer events, so you are in good company.
Typed class constants (PHP 8.3)
Table names and other fixed strings used to be untyped constants. With PHP 8.3 you can give them a type, and a child class cannot change it to something else:
final class EventTable
{
public const string NAME = 'tx_myevents_domain_model_event';
public const int MAX_TEASER_ITEMS = 3;
}The #[Override] attribute (PHP 8.3)
When you extend a core class, for example a ViewHelper or a form element, mark the methods that should override a parent method:
final class PriceViewHelper extends AbstractViewHelper
{
#[Override]
public function initializeArguments(): void
{
$this->registerArgument('amount', 'int', 'Amount in cent', true);
}
public function render(): string
{
return number_format($this->arguments['amount'] / 100, 2, ',', '.') . ' €';
}
}If a future TYPO3 version renames or removes the parent method, PHP stops with an error at compile time instead of your method silently never being called. During major upgrades that is exactly the early warning you want.
The catch with PHP 8.3 features
TYPO3 13 and 14 require PHP 8.2, not 8.3. On a PHP 8.2 server, a typed constant is a parse error and the whole file breaks. #[Override] is less dramatic: PHP 8.2 does not know the attribute and ignores it, so you simply lose the check. So:
- For a project, check the PHP version of production and set
config.platform.phpincomposer.json. On hoja.in it is pinned to 8.4, so everything here is fine. - For a public extension, require
"php": "^8.3"in itscomposer.jsonif you use them, or stay with the 8.2 features.
How I introduce them in old extensions
I do not rewrite a working extension just to use new syntax. During an upgrade, I touch the files anyway, so that is the moment. Constructor promotion and readonly come first, because Rector can do most of it and the diff is easy to review. Enums come when I have to touch the TCA of a field anyway, because then I can also check that the values in the database really match the cases. For a non-nullable enum property, Extbase maps the value with from(), so a database value that is not a case of the enum throws an error when the record is loaded, so run a quick SELECT DISTINCT on the column before you switch.
None of these features is spectacular alone. Together, they make extension code shorter, more honest about types, and easier for tools like PHPStan to check. If you are still writing inject*() methods and integer constants, try one of these in your next small extension. Feedbacks are the champion’s breakfast, so tell me which one you use most.