Files
2025-11-30 08:43:13 +08:00

24 KiB

TYPO3 PHP Architecture Standards

Source: TYPO3 Core API Reference - PHP Architecture Purpose: Dependency injection, services, events, Extbase, middleware patterns

Dependency Injection

TYPO3 uses Symfony's Dependency Injection Container for service management.

Constructor Injection (Preferred)

// ✅ Right: Constructor injection with readonly properties
<?php
declare(strict_types=1);

namespace Vendor\ExtensionKey\Controller;

use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
use Vendor\ExtensionKey\Domain\Repository\UserRepository;

final class UserController extends ActionController
{
    public function __construct(
        private readonly UserRepository $userRepository
    ) {}

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

Method Injection (inject* Methods)

// ✅ Right: Method injection for abstract classes
<?php
declare(strict_types=1);

namespace Vendor\ExtensionKey\Controller;

use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
use Vendor\ExtensionKey\Domain\Repository\UserRepository;

class UserController extends ActionController
{
    protected ?UserRepository $userRepository = null;

    public function injectUserRepository(UserRepository $userRepository): void
    {
        $this->userRepository = $userRepository;
    }
}

When to Use Method Injection:

  • Extending abstract core classes (ActionController, AbstractValidator)
  • Avoiding breaking changes when base class constructor changes
  • Optional dependencies

When to Use Constructor Injection:

  • All new code (preferred)
  • Required dependencies
  • Better testability

Interface Injection

// ✅ Right: Depend on interfaces, not implementations
<?php
declare(strict_types=1);

namespace Vendor\ExtensionKey\Controller;

use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
use Vendor\ExtensionKey\Domain\Repository\UserRepositoryInterface;

final class UserController extends ActionController
{
    public function __construct(
        private readonly UserRepositoryInterface $userRepository
    ) {}
}

Service Configuration

Configuration/Services.yaml

# ✅ Right: Proper service configuration
services:
  _defaults:
    autowire: true
    autoconfigure: true
    public: false

  # Auto-register all classes
  Vendor\ExtensionKey\:
    resource: '../Classes/*'

  # Explicit service configuration
  Vendor\ExtensionKey\Service\MyService:
    arguments:
      $configValue: '%env(MY_CONFIG_VALUE)%'

  # Factory pattern for Connection
  Vendor\ExtensionKey\Domain\Repository\MyTableRepository:
    factory: ['@TYPO3\CMS\Core\Database\ConnectionPool', 'getConnectionForTable']
    arguments:
      - 'my_table'

  # Interface binding
  Vendor\ExtensionKey\Domain\Repository\UserRepositoryInterface:
    class: Vendor\ExtensionKey\Domain\Repository\UserRepository

Autowire Attribute (TYPO3 v12+)

// ✅ Right: Inject configuration using Autowire attribute
<?php
declare(strict_types=1);

namespace Vendor\ExtensionKey\Service;

use TYPO3\CMS\Core\DependencyInjection\Attribute\Autowire;

final class MyService
{
    public function __construct(
        #[Autowire(expression: 'service("configuration.extension").get("my_extension", "mySetting")')]
        private readonly string $myExtensionSetting
    ) {}
}

PSR-14 Event Dispatcher

Defining Custom Events

// ✅ Right: Immutable event class with getters/setters
<?php
declare(strict_types=1);

namespace Vendor\ExtensionKey\Event;

final class BeforeUserCreatedEvent
{
    public function __construct(
        private string $username,
        private string $email,
        private array $additionalData = []
    ) {}

    public function getUsername(): string
    {
        return $this->username;
    }

    public function getEmail(): string
    {
        return $this->email;
    }

    public function getAdditionalData(): array
    {
        return $this->additionalData;
    }

    public function setAdditionalData(array $additionalData): void
    {
        $this->additionalData = $additionalData;
    }
}

Dispatching Events

// ✅ Right: Inject and dispatch events
<?php
declare(strict_types=1);

namespace Vendor\ExtensionKey\Service;

use Psr\EventDispatcher\EventDispatcherInterface;
use Vendor\ExtensionKey\Event\BeforeUserCreatedEvent;

final class UserService
{
    public function __construct(
        private readonly EventDispatcherInterface $eventDispatcher
    ) {}

    public function createUser(string $username, string $email): void
    {
        $event = new BeforeUserCreatedEvent($username, $email);
        $event = $this->eventDispatcher->dispatch($event);

        // Use potentially modified data from event
        $finalUsername = $event->getUsername();
        $finalEmail = $event->getEmail();

        // Create user with final data
    }
}

Event Listeners

// ✅ Right: Event listener with AsEventListener attribute
<?php
declare(strict_types=1);

namespace Vendor\ExtensionKey\EventListener;

use TYPO3\CMS\Core\Attribute\AsEventListener;
use Vendor\ExtensionKey\Event\BeforeUserCreatedEvent;

#[AsEventListener(
    identifier: 'vendor/extension-key/validate-user-creation',
    event: BeforeUserCreatedEvent::class
)]
final class ValidateUserCreationListener
{
    public function __invoke(BeforeUserCreatedEvent $event): void
    {
        // Validate email format
        if (!filter_var($event->getEmail(), FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException('Invalid email format');
        }

        // Add custom data
        $event->setAdditionalData([
            'validated_at' => time(),
            'validator' => 'ValidateUserCreationListener',
        ]);
    }
}

Event Listener Registration (Services.yaml)

# Alternative: Register event listeners in Services.yaml
services:
  Vendor\ExtensionKey\EventListener\ValidateUserCreationListener:
    tags:
      - name: event.listener
        identifier: 'vendor/extension-key/validate-user-creation'
        event: Vendor\ExtensionKey\Event\BeforeUserCreatedEvent
        method: '__invoke'

PSR-14 Event Class Standards (TYPO3 13+)

Modern event classes should follow these quality standards:

// ✅ Right: Modern event class with final keyword and readonly properties
<?php
declare(strict_types=1);

namespace Vendor\ExtensionKey\Event;

use Psr\Http\Message\ServerRequestInterface;

final class NewsListActionEvent  // ✅ Use 'final' keyword
{
    public function __construct(
        private NewsController $newsController,
        private array $assignedValues,
        private readonly ServerRequestInterface $request  // ✅ Use 'readonly' for immutable properties
    ) {}

    public function getNewsController(): NewsController
    {
        return $this->newsController;
    }

    public function getAssignedValues(): array
    {
        return $this->assignedValues;
    }

    public function setAssignedValues(array $assignedValues): void
    {
        $this->assignedValues = $assignedValues;
    }

    public function getRequest(): ServerRequestInterface
    {
        return $this->request;  // Read-only, no setter
    }
}

Event Class Quality Checklist:

  • Use final keyword (prevents inheritance, ensures immutability)
  • Use readonly for properties that should never change after construction
  • Provide getters for all properties
  • Provide setters ONLY for properties that should be modifiable
  • Type hint all properties and methods
  • Document the purpose and usage of the event

Why final for Events?

  • Events are data carriers, not meant to be extended
  • Prevents unexpected behavior from inheritance
  • Makes event behavior predictable and testable
  • Follows modern PHP best practices

Why readonly for Properties?

  • Some event data should never change (e.g., original request, user context)
  • Explicit immutability prevents accidental modifications
  • Clearly communicates intent to event listeners
  • Available in PHP 8.1+ (TYPO3 13 minimum is PHP 8.1)

TYPO3 13 Site Sets

Purpose: Modern configuration distribution system replacing static TypoScript includes

Site Sets Structure

Configuration/Sets/
├── MyExtension/           # Base configuration set
│   ├── config.yaml        # Set metadata and dependencies
│   ├── setup.typoscript   # Frontend TypoScript
│   ├── constants.typoscript
│   └── settings.definitions.yaml  # Setting definitions for extension configuration
├── RecordLinks/           # Optional feature set
│   ├── config.yaml
│   └── setup.typoscript
└── Bootstrap5/            # Frontend framework preset
    ├── config.yaml
    ├── setup.typoscript
    └── settings.yaml

config.yaml Structure

# ✅ Right: Proper Site Set configuration
name: vendor/extension-key
label: Extension Name Base Configuration

# Dependencies on other sets
dependencies:
  - typo3/fluid-styled-content
  - vendor/extension-key-styles

# Load order priority (optional)
priority: 50

# Settings that can be overridden
settings:
  mySetting:
    value: 'default value'
    type: string
    label: 'My Setting Label'
    description: 'Description of what this setting does'

settings.definitions.yaml

# ✅ Right: Define extension settings with validation
settings:
  # Text input
  mySetting:
    type: string
    default: 'default value'
    label: 'LLL:EXT:extension_key/Resources/Private/Language/locallang.xlf:settings.mySetting'
    description: 'LLL:EXT:extension_key/Resources/Private/Language/locallang.xlf:settings.mySetting.description'

  # Boolean checkbox
  enableFeature:
    type: bool
    default: false
    label: 'Enable Feature'

  # Integer input
  itemsPerPage:
    type: int
    default: 10
    label: 'Items per page'
    validators:
      - name: NumberRange
        options:
          minimum: 1
          maximum: 100

  # Select dropdown
  layout:
    type: string
    default: 'default'
    label: 'Layout'
    enum:
      default: 'Default'
      compact: 'Compact'
      detailed: 'Detailed'

Benefits of Site Sets

  1. Modular Configuration: Split configuration into focused, reusable sets
  2. Dependency Management: Declare dependencies on other sets
  3. Override Capability: Sites can override set settings without editing files
  4. Type Safety: Settings are validated with defined types
  5. Better UX: Settings UI auto-generated from definitions
  6. Version Control: Configuration changes tracked properly

Migration from Static TypoScript

// ❌ Old: Static TypoScript includes (TYPO3 12 and earlier)
Configuration/TCA/Overrides/sys_template.php:
<?php
\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addStaticFile(
    'extension_key',
    'Configuration/TypoScript',
    'Extension Name'
);
# ✅ New: Site Sets (TYPO3 13+)
Configuration/Sets/ExtensionKey/config.yaml:
name: vendor/extension-key
label: Extension Name

Site Sets Conformance Checklist:

  • Configuration/Sets/ directory exists
  • At least one base set with config.yaml
  • settings.definitions.yaml defines all extension settings
  • Set names follow vendor/package naming convention
  • Dependencies declared in config.yaml
  • Labels use LLL: references for translations
  • Settings have appropriate type validation

Advanced Services.yaml Patterns

Beyond basic service registration, modern TYPO3 extensions use advanced Services.yaml patterns.

Event Listeners

# ✅ Right: Event listener registration
services:
  Vendor\ExtensionKey\EventListener\HrefLangEventListener:
    tags:
      - name: event.listener
        identifier: 'ext-extension-key/modify-hreflang'
        event: TYPO3\CMS\Frontend\Event\ModifyHrefLangTagsEvent
        method: '__invoke'

  # Multiple listeners for same event
  Vendor\ExtensionKey\EventListener\PageCacheListener:
    tags:
      - name: event.listener
        identifier: 'ext-extension-key/cache-before'
        event: TYPO3\CMS\Core\Cache\Event\BeforePageCacheIdentifierIsHashedEvent
      - name: event.listener
        identifier: 'ext-extension-key/cache-after'
        event: TYPO3\CMS\Core\Cache\Event\AfterPageCacheIdentifierIsHashedEvent

Console Commands

# ✅ Right: Console command registration
services:
  Vendor\ExtensionKey\Command\ProxyClassRebuildCommand:
    tags:
      - name: 'console.command'
        command: 'extension:rebuildProxyClasses'
        description: 'Rebuild Extbase proxy classes'
        schedulable: false  # Cannot be run via scheduler

  Vendor\ExtensionKey\Command\CleanupCommand:
    tags:
      - name: 'console.command'
        command: 'extension:cleanup'
        description: 'Clean up old records'
        schedulable: true  # Can be run via scheduler
        hidden: false      # Visible in command list

Data Processors

# ✅ Right: Data processor registration for Fluid templates
services:
  Vendor\ExtensionKey\DataProcessing\AddNewsToMenuProcessor:
    tags:
      - name: 'data.processor'
        identifier: 'add-news-to-menu'

  Vendor\ExtensionKey\DataProcessing\CategoryProcessor:
    tags:
      - name: 'data.processor'
        identifier: 'category-processor'

Cache Services

# ✅ Right: Cache service configuration
services:
  cache.extension_custom:
    class: TYPO3\CMS\Core\Cache\Frontend\VariableFrontend
    factory:
      - '@TYPO3\CMS\Core\Cache\CacheManager'
      - 'getCache'
    arguments:
      - 'extension_custom'

Advanced Service Patterns

# ✅ Right: Comprehensive Services.yaml with advanced patterns
services:
  _defaults:
    autowire: true
    autoconfigure: true
    public: false

  # Auto-register all classes
  Vendor\ExtensionKey\:
    resource: '../Classes/*'
    exclude:
      - '../Classes/Domain/Model/*'  # Exclude Extbase models

  # Event Listeners
  Vendor\ExtensionKey\EventListener\NewsListActionListener:
    tags:
      - name: event.listener
        identifier: 'ext-extension-key/news-list'
        event: Vendor\ExtensionKey\Event\NewsListActionEvent

  # Console Commands
  Vendor\ExtensionKey\Command\ImportCommand:
    tags:
      - name: 'console.command'
        command: 'news:import'
        description: 'Import news from external source'
        schedulable: true

  # Data Processors
  Vendor\ExtensionKey\DataProcessing\MenuProcessor:
    tags:
      - name: 'data.processor'
        identifier: 'news-menu-processor'

  # Cache Factory
  cache.news_category:
    class: TYPO3\CMS\Core\Cache\Frontend\VariableFrontend
    factory: ['@TYPO3\CMS\Core\Cache\CacheManager', 'getCache']
    arguments: ['news_category']

  # ViewHelper registration (if needed for testing)
  Vendor\ExtensionKey\ViewHelpers\FormatViewHelper:
    public: true

Advanced Services.yaml Conformance Checklist:

  • Event listeners registered with proper tags
  • Console commands tagged with schedulable flag
  • Data processors registered with unique identifiers
  • Cache services use factory pattern
  • ViewHelpers marked public if needed externally
  • Service tags include all required attributes (identifier, event, method)
  • Commands have meaningful names and descriptions

PSR-15 Middleware

Middleware Structure

// ✅ Right: PSR-15 middleware implementation
<?php
declare(strict_types=1);

namespace Vendor\ExtensionKey\Middleware;

use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

final class StatusCheckMiddleware implements MiddlewareInterface
{
    public function __construct(
        private readonly ResponseFactoryInterface $responseFactory
    ) {}

    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler
    ): ResponseInterface {
        // Check for specific condition
        if (($request->getQueryParams()['status'] ?? null) === 'check') {
            $response = $this->responseFactory->createResponse(200, 'OK');
            $response->getBody()->write(json_encode([
                'status' => 'ok',
                'message' => 'System is healthy'
            ]));
            return $response->withHeader('Content-Type', 'application/json');
        }

        // Pass to next middleware
        return $handler->handle($request);
    }
}

Middleware Registration

// Configuration/RequestMiddlewares.php
<?php
return [
    'frontend' => [
        'vendor/extension-key/status-check' => [
            'target' => \Vendor\ExtensionKey\Middleware\StatusCheckMiddleware::class,
            'before' => [
                'typo3/cms-frontend/page-resolver',
            ],
            'after' => [
                'typo3/cms-core/normalized-params-attribute',
            ],
        ],
    ],
];

Extbase Architecture

Domain Models

// ✅ Right: Extbase domain model
<?php
declare(strict_types=1);

namespace Vendor\ExtensionKey\Domain\Model;

use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;

class Product extends AbstractEntity
{
    protected string $title = '';
    protected float $price = 0.0;
    protected bool $available = true;

    public function getTitle(): string
    {
        return $this->title;
    }

    public function setTitle(string $title): void
    {
        $this->title = $title;
    }

    public function getPrice(): float
    {
        return $this->price;
    }

    public function setPrice(float $price): void
    {
        $this->price = $price;
    }

    public function isAvailable(): bool
    {
        return $this->available;
    }

    public function setAvailable(bool $available): void
    {
        $this->available = $available;
    }
}

Repositories

// ✅ Right: Extbase repository with dependency injection
<?php
declare(strict_types=1);

namespace Vendor\ExtensionKey\Domain\Repository;

use TYPO3\CMS\Extbase\Persistence\Repository;
use Vendor\ExtensionKey\Domain\Model\Product;

class ProductRepository extends Repository
{
    /**
     * Find products by price range
     *
     * @param float $minPrice
     * @param float $maxPrice
     * @return array<Product>
     */
    public function findByPriceRange(float $minPrice, float $maxPrice): array
    {
        $query = $this->createQuery();
        $query->matching(
            $query->logicalAnd(
                $query->greaterThanOrEqual('price', $minPrice),
                $query->lessThanOrEqual('price', $maxPrice)
            )
        );
        return $query->execute()->toArray();
    }
}

Controllers

// ✅ Right: Extbase controller with dependency injection
<?php
declare(strict_types=1);

namespace Vendor\ExtensionKey\Controller;

use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
use Vendor\ExtensionKey\Domain\Repository\ProductRepository;

final class ProductController extends ActionController
{
    public function __construct(
        private readonly ProductRepository $productRepository
    ) {}

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

    public function showAction(int $productId): ResponseInterface
    {
        $product = $this->productRepository->findByUid($productId);
        $this->view->assign('product', $product);
        return $this->htmlResponse();
    }
}

Validators

// ✅ Right: Extbase validator with dependency injection
<?php
declare(strict_types=1);

namespace Vendor\ExtensionKey\Domain\Validator;

use TYPO3\CMS\Extbase\Validation\Validator\AbstractValidator;
use Vendor\ExtensionKey\Domain\Repository\ProductRepository;

class UniqueProductTitleValidator extends AbstractValidator
{
    public function __construct(
        private readonly ProductRepository $productRepository
    ) {}

    protected function isValid(mixed $value): void
    {
        if (!is_string($value)) {
            $this->addError('Value must be a string', 1234567890);
            return;
        }

        $existingProduct = $this->productRepository->findOneByTitle($value);
        if ($existingProduct !== null) {
            $this->addError(
                'Product with title "%s" already exists',
                1234567891,
                [$value]
            );
        }
    }
}

Common Patterns

Factory Pattern

// ✅ Right: Factory for Connection objects
services:
  Vendor\ExtensionKey\Domain\Repository\MyRepository:
    factory: ['@TYPO3\CMS\Core\Database\ConnectionPool', 'getConnectionForTable']
    arguments:
      - 'my_table'

Singleton Services

// ✅ Right: Use DI container, not Singleton pattern
// Services are automatically singleton by default

// ❌ Wrong: Don't use GeneralUtility::makeInstance() for new code
use TYPO3\CMS\Core\Utility\GeneralUtility;
$service = GeneralUtility::makeInstance(MyService::class);  // Deprecated

PSR Interfaces

// ✅ Right: Use PSR interfaces
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Client\ClientInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Psr\Clock\ClockInterface;

// Inject PSR-compliant services
public function __construct(
    private readonly LoggerInterface $logger,
    private readonly ClockInterface $clock
) {}

Anti-Patterns to Avoid

Wrong: Direct instantiation

$repository = new ProductRepository();  // Missing dependencies

Wrong: Using GeneralUtility::makeInstance()

use TYPO3\CMS\Core\Utility\GeneralUtility;
$repository = GeneralUtility::makeInstance(ProductRepository::class);

Wrong: Global state access

$user = $GLOBALS['BE_USER'];  // Avoid global state
$typoScript = $GLOBALS['TSFE']->tmpl->setup;

Right: Dependency injection

public function __construct(
    private readonly ProductRepository $repository,
    private readonly Context $context
) {}

Conformance Checklist

Basic Dependency Injection

  • Constructor injection used for all dependencies
  • Services registered in Configuration/Services.yaml
  • No direct class instantiation (new MyClass())
  • No GeneralUtility::makeInstance() for new services
  • PSR interfaces used (ResponseInterface, LoggerInterface, etc.)
  • No global state access ($GLOBALS)

PSR-14 Events (Mandatory)

  • PSR-14 events used instead of hooks
  • Event classes are immutable with proper getters/setters
  • Event listeners use #[AsEventListener] attribute or Services.yaml tags
  • Event classes use final keyword (TYPO3 13+)
  • Event classes use readonly for immutable properties (TYPO3 13+)

TYPO3 13 Site Sets (Mandatory for TYPO3 13)

  • Configuration/Sets/ directory exists
  • Base set has config.yaml with proper metadata
  • settings.definitions.yaml defines extension settings with types
  • Set names follow vendor/package convention
  • Dependencies declared in config.yaml

Advanced Services.yaml (Mandatory)

  • Event listeners registered with proper tags
  • Console commands tagged with schedulable flag
  • Data processors registered with unique identifiers
  • Cache services use factory pattern
  • Service tags include all required attributes

PSR-15 Middleware

  • PSR-15 middlewares registered in RequestMiddlewares.php
  • Middleware ordering defined with before/after

Extbase Architecture

  • Extbase models extend AbstractEntity
  • Repositories extend Repository base class
  • Controllers use constructor injection
  • Validators extend AbstractValidator

Factory Pattern

  • Factory pattern for complex object creation (e.g., Connection objects)