24 KiB
TYPO3 Extension Best Practices
Source: TYPO3 Best Practices (Tea Extension) and Core API Standards Purpose: Real-world patterns and organizational best practices for TYPO3 extensions
Project Structure
Complete Extension Layout
my_extension/
├── .ddev/ # DDEV configuration
│ └── config.yaml
├── .github/ # GitHub Actions CI/CD
│ └── workflows/
│ └── tests.yml
├── Build/ # Build tools and configs
│ ├── phpunit/
│ │ ├── UnitTests.xml
│ │ └── FunctionalTests.xml
│ └── Scripts/
│ └── runTests.sh
├── Classes/ # PHP source code
│ ├── Controller/
│ ├── Domain/
│ │ ├── Model/
│ │ └── Repository/
│ ├── Service/
│ ├── Utility/
│ ├── EventListener/
│ └── ViewHelper/
├── Configuration/ # TYPO3 configuration
│ ├── Backend/
│ │ └── Modules.php
│ ├── Services.yaml
│ ├── TCA/
│ ├── TypoScript/
│ │ ├── setup.typoscript
│ │ └── constants.typoscript
│ └── Sets/ # TYPO3 v13+
│ └── MySet/
│ └── config.yaml
├── Documentation/ # RST documentation
│ ├── Index.rst
│ ├── Settings.cfg
│ ├── Introduction/
│ ├── Installation/
│ ├── Configuration/
│ ├── Developer/
│ └── Editor/
├── Resources/
│ ├── Private/
│ │ ├── Language/
│ │ │ ├── locallang.xlf
│ │ │ └── de.locallang.xlf
│ │ ├── Layouts/
│ │ ├── Partials/
│ │ └── Templates/
│ └── Public/
│ ├── Css/
│ ├── Icons/
│ ├── Images/
│ └── JavaScript/
├── Tests/
│ ├── Unit/
│ ├── Functional/
│ │ └── Fixtures/
│ └── Acceptance/
│ ├── Support/
│ └── codeception.yml
├── .editorconfig # Editor configuration
├── .gitattributes # Git attributes
├── .gitignore # Git ignore rules
├── .php-cs-fixer.dist.php # PHP CS Fixer config
├── composer.json # Composer configuration
├── composer.lock # Locked dependencies
├── ext_emconf.php # Extension metadata
├── ext_localconf.php # Global configuration
├── LICENSE # License file
├── phpstan.neon # PHPStan configuration
└── README.md # Project README
Best Practices by Category
1. Dependency Management
composer.json Best Practices:
{
"name": "vendor/my-extension",
"type": "typo3-cms-extension",
"description": "Clear, concise extension description",
"license": "GPL-2.0-or-later",
"authors": [
{
"name": "Author Name",
"email": "author@example.com",
"role": "Developer"
}
],
"require": {
"php": "^8.1",
"typo3/cms-core": "^12.4 || ^13.0",
"typo3/cms-backend": "^12.4 || ^13.0",
"typo3/cms-extbase": "^12.4 || ^13.0",
"typo3/cms-fluid": "^12.4 || ^13.0"
},
"require-dev": {
"typo3/coding-standards": "^0.7",
"typo3/testing-framework": "^8.0",
"phpunit/phpunit": "^10.5",
"phpstan/phpstan": "^1.10",
"friendsofphp/php-cs-fixer": "^3.0"
},
"autoload": {
"psr-4": {
"Vendor\\MyExtension\\": "Classes/"
}
},
"autoload-dev": {
"psr-4": {
"Vendor\\MyExtension\\Tests\\": "Tests/"
}
},
"config": {
"vendor-dir": ".Build/vendor",
"bin-dir": ".Build/bin",
"sort-packages": true,
"allow-plugins": {
"typo3/class-alias-loader": true,
"typo3/cms-composer-installers": true
}
},
"extra": {
"typo3/cms": {
"extension-key": "my_extension",
"web-dir": ".Build/Web"
}
}
}
2. Code Quality Tools
.php-cs-fixer.dist.php:
<?php
declare(strict_types=1);
$config = \TYPO3\CodingStandards\CsFixerConfig::create();
$config->getFinder()
->in(__DIR__ . '/Classes')
->in(__DIR__ . '/Configuration')
->in(__DIR__ . '/Tests');
return $config;
phpstan.neon:
includes:
- .Build/vendor/phpstan/phpstan/conf/bleedingEdge.neon
parameters:
level: 9
paths:
- Classes
- Configuration
- Tests
excludePaths:
- .Build
- vendor
PHPStan Level 10 Best Practices for TYPO3
Handling $GLOBALS['TCA'] in Tests:
PHPStan cannot infer types for runtime-configured $GLOBALS arrays. Use ignore annotations:
// ✅ Right: Suppress offsetAccess warnings for $GLOBALS['TCA']
/** @var array<string, mixed> $tcaConfig */
$tcaConfig = [
'type' => 'text',
'enableRichtext' => true,
];
// @phpstan-ignore-next-line offsetAccess.nonOffsetAccessible
$GLOBALS['TCA']['tt_content']['columns']['bodytext']['config'] = $tcaConfig;
// ❌ Wrong: No type annotation or suppression
$GLOBALS['TCA']['tt_content']['columns']['bodytext']['config'] = [
'type' => 'text',
]; // PHPStan error: offsetAccess.nonOffsetAccessible
Factory Methods vs Property Initialization:
Avoid uninitialized property errors in test classes:
// ❌ Wrong: PHPStan warns about uninitialized property
final class MyServiceTest extends UnitTestCase
{
private MyService $subject; // Uninitialized property
protected function setUp(): void
{
parent::setUp();
$this->subject = new MyService();
}
}
// ✅ Right: Use factory method
final class MyServiceTest extends UnitTestCase
{
private function createSubject(): MyService
{
return new MyService();
}
#[Test]
public function testSomething(): void
{
$subject = $this->createSubject();
// Use $subject
}
}
Type Assertions for Dynamic Arrays:
When testing arrays modified by reference:
// ❌ Wrong: PHPStan cannot verify type after modification
public function testFieldProcessing(): void
{
$fieldArray = ['bodytext' => '<p>Test</p>'];
$this->subject->processFields($fieldArray);
// PHPStan error: Cannot access offset on mixed
self::assertStringContainsString('Test', $fieldArray['bodytext']);
}
// ✅ Right: Add type assertions
public function testFieldProcessing(): void
{
$fieldArray = ['bodytext' => '<p>Test</p>'];
$this->subject->processFields($fieldArray);
self::assertArrayHasKey('bodytext', $fieldArray);
self::assertIsString($fieldArray['bodytext']);
self::assertStringContainsString('Test', $fieldArray['bodytext']);
}
Intersection Types for Mocks:
Use intersection types for proper PHPStan analysis of mocks:
// ✅ Right: Intersection type for mock
/** @var ResourceFactory&MockObject $resourceFactoryMock */
$resourceFactoryMock = $this->createMock(ResourceFactory::class);
// Alternative: @phpstan-var annotation
$resourceFactoryMock = $this->createMock(ResourceFactory::class);
/** @phpstan-var ResourceFactory&MockObject $resourceFactoryMock */
Common PHPStan Suppressions for TYPO3:
// Suppress $GLOBALS['TCA'] access
// @phpstan-ignore-next-line offsetAccess.nonOffsetAccessible
$GLOBALS['TCA']['table']['columns']['field'] = $config;
// Suppress $GLOBALS['TYPO3_CONF_VARS'] access
// @phpstan-ignore-next-line offsetAccess.nonOffsetAccessible
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['key'] = MyClass::class;
// Suppress mixed type from legacy code
// @phpstan-ignore-next-line argument.type
$this->view->assign('data', $legacyArray);
Type Hints for Service Container Retrieval:
// ✅ Right: Type hint service retrieval
/** @var DataHandler $dataHandler */
$dataHandler = $this->get(DataHandler::class);
/** @var ResourceFactory $resourceFactory */
$resourceFactory = $this->get(ResourceFactory::class);
3. Service Configuration
Configuration/Services.yaml:
services:
_defaults:
autowire: true
autoconfigure: true
public: false
# Auto-register all classes
Vendor\MyExtension\:
resource: '../Classes/*'
# Exclude specific directories
Vendor\MyExtension\Domain\Model\:
resource: '../Classes/Domain/Model/*'
autoconfigure: false
# Explicit service configuration example
Vendor\MyExtension\Service\EmailService:
arguments:
$fromEmail: '%env(DEFAULT_FROM_EMAIL)%'
$fromName: 'TYPO3 Extension'
# Tag configuration example
Vendor\MyExtension\Command\ImportCommand:
tags:
- name: 'console.command'
command: 'myext:import'
description: 'Import data from external source'
4. Backend Module Configuration
Configuration/Backend/Modules.php:
<?php
return [
'web_myext' => [
'parent' => 'web',
'position' => ['after' => 'web_info'],
'access' => 'user',
'workspaces' => 'live',
'path' => '/module/web/myext',
'labels' => 'LLL:EXT:my_extension/Resources/Private/Language/locallang_mod.xlf',
'extensionName' => 'MyExtension',
'controllerActions' => [
\Vendor\MyExtension\Controller\BackendController::class => [
'list',
'show',
'edit',
'update',
],
],
],
];
5. Testing Infrastructure
Build/Scripts/runTests.sh:
#!/usr/bin/env bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
# Run unit tests
if [ "$1" = "unit" ]; then
php vendor/bin/phpunit -c Build/phpunit/UnitTests.xml
fi
# Run functional tests
if [ "$1" = "functional" ]; then
typo3DatabaseDriver=pdo_sqlite \
php vendor/bin/phpunit -c Build/phpunit/FunctionalTests.xml
fi
# Run all tests
if [ "$1" = "all" ]; then
php vendor/bin/phpunit -c Build/phpunit/UnitTests.xml
typo3DatabaseDriver=pdo_sqlite \
php vendor/bin/phpunit -c Build/phpunit/FunctionalTests.xml
fi
6. CI/CD Configuration
.github/workflows/tests.yml:
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
tests:
name: Tests (PHP ${{ matrix.php }}, TYPO3 ${{ matrix.typo3 }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3']
typo3: ['12.4', '13.0']
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mbstring, xml, json, zip, curl
coverage: none
- name: Get Composer Cache Directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v3
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Lint PHP
run: find . -name \*.php ! -path "./vendor/*" ! -path "./.Build/*" -exec php -l {} \;
- name: PHP CS Fixer
run: .Build/bin/php-cs-fixer fix --dry-run --diff
- name: PHPStan
run: .Build/bin/phpstan analyze
- name: Unit Tests
run: .Build/bin/phpunit -c Build/phpunit/UnitTests.xml
- name: Functional Tests
run: |
typo3DatabaseDriver=pdo_sqlite \
.Build/bin/phpunit -c Build/phpunit/FunctionalTests.xml
7. Documentation Standards
Documentation/Index.rst:
.. include:: /Includes.rst.txt
==============
My Extension
==============
:Extension key:
my_extension
:Package name:
vendor/my-extension
:Version:
|release|
:Language:
en
:Author:
Author Name
:License:
This document is published under the
`Creative Commons BY 4.0 <https://creativecommons.org/licenses/by/4.0/>`__
license.
:Rendered:
|today|
----
Clear and concise extension description explaining the purpose and main features.
----
**Table of Contents:**
.. toctree::
:maxdepth: 2
:titlesonly:
Introduction/Index
Installation/Index
Configuration/Index
Editor/Index
Developer/Index
Sitemap
Page Size Guidelines:
Follow TYPO3 documentation best practices for page organization and sizing:
Index.rst (Landing Page):
- Target: 80-150 lines
- Maximum: 200 lines
- Purpose: Entry point with metadata, brief description, and navigation only
- Contains: Extension metadata, brief description, card-grid (optional), toctree, license
- Anti-pattern: ❌ Embedding all content (introduction, requirements, contributing, credits, etc.)
Content Pages:
- Target: 100-300 lines per file
- Optimal: 150-200 lines
- Maximum: 400 lines (split if larger)
- Structure: Focused on single topic or logically related concepts
- Split Strategy: Create subdirectories for complex topics with multiple aspects
Red Flags:
- ❌ Index.rst >200 lines → Extract content to Introduction/, Contributing/, etc.
- ❌ Single file >400 lines → Split into multiple focused pages
- ❌ All content in Index.rst → Create proper section directories
- ❌ Navigation by scrolling → Use card-grid + toctree structure
Proper Structure Example:
Documentation/
├── Index.rst # Landing page (80-150 lines)
├── Introduction/ # Getting started
│ └── Index.rst # Features, requirements, quick start
├── Installation/ # Setup instructions
│ └── Index.rst
├── Configuration/ # Configuration guides
│ ├── Index.rst
│ ├── Basic.rst
│ └── Advanced.rst
├── Contributing/ # Contribution guidelines
│ └── Index.rst # Code, translations, credits, resources
├── Examples/ # Usage examples
├── Troubleshooting/ # Problem solving
└── API/ # Developer reference
Benefits:
- ✅ Better user experience (focused, scannable pages)
- ✅ Easier maintenance (smaller, manageable files)
- ✅ Improved search results (specific pages rank better)
- ✅ Clear information architecture
- ✅ Follows TYPO3 documentation standards
- ✅ Mobile-friendly navigation
Reference: TYPO3 tea extension - exemplary documentation structure
8. Version Control Best Practices
Default Branch Naming
✅ Use main as the default branch instead of master
Rationale:
- Industry Standard: GitHub, GitLab, and Bitbucket all default to
mainfor new repositories - Modern Convention: Aligns with current version control ecosystem standards
- Inclusive Language: Part of broader industry shift toward inclusive terminology
- Consistency: Matches TYPO3 Core and most modern TYPO3 extensions
Migration from master to main:
If your extension currently uses master, migrate to main:
# 1. Create main branch from master
git checkout master
git pull origin master
git checkout -b main
git push -u origin main
# 2. Change default branch on GitHub
gh repo edit --default-branch main
# 3. Update all branch references in codebase
# - CI/CD workflows (.github/workflows/*.yml)
# - Documentation (guides.xml, *.rst files)
# - URLs in CONTRIBUTING.md, README.md
# 4. Delete old master branch
git branch -d master
git push origin --delete master
Example CI/CD workflow update:
# .github/workflows/tests.yml
on:
push:
branches: [main, develop] # Changed from: master
pull_request:
branches: [main] # Changed from: master
Example documentation update:
<!-- Documentation/guides.xml -->
<extension edit-on-github-branch="main" /> <!-- Changed from: master -->
Branch Protection Enforcement
Prevent accidental master branch recreation and protect main branch using GitHub Repository Rulesets.
Block master branch - prevents creation and pushes:
Create ruleset-block-master.json:
{
"name": "Block master branch",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["refs/heads/master"],
"exclude": []
}
},
"rules": [
{
"type": "creation"
},
{
"type": "update"
},
{
"type": "deletion"
}
],
"bypass_actors": []
}
Apply the ruleset:
gh api -X POST repos/OWNER/REPO/rulesets \
--input ruleset-block-master.json
Protect main branch - requires CI and prevents force pushes:
Create ruleset-protect-main.json:
{
"name": "Protect main branch",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["refs/heads/main"],
"exclude": []
}
},
"rules": [
{
"type": "required_status_checks",
"parameters": {
"required_status_checks": [
{
"context": "build"
}
],
"strict_required_status_checks_policy": false
}
},
{
"type": "non_fast_forward"
}
],
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "always"
}
]
}
Apply the ruleset:
gh api -X POST repos/OWNER/REPO/rulesets \
--input ruleset-protect-main.json
Verify rulesets are active:
# List all rulesets
gh api repos/OWNER/REPO/rulesets
# Test master branch is blocked (should fail)
git push origin test-branch:master
# Expected: remote: error: GH013: Repository rule violations found
Benefits of Repository Rulesets:
- ✅ Prevents accidental
masterbranch recreation - ✅ Enforces CI status checks before merging to
main - ✅ Prevents force pushes to protected branches
- ✅ Allows admin bypass for emergency situations
- ✅ More flexible than legacy branch protection rules
- ✅ Supports complex conditions and multiple rule types
9. Language File Organization
Resources/Private/Language/locallang.xlf:
<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" datatype="plaintext"
original="EXT:my_extension/Resources/Private/Language/locallang.xlf"
date="2024-01-01T12:00:00Z"
product-name="my_extension">
<header/>
<body>
<trans-unit id="plugin.title" resname="plugin.title">
<source>My Extension Plugin</source>
</trans-unit>
<trans-unit id="plugin.description" resname="plugin.description">
<source>Displays product list with filters</source>
</trans-unit>
</body>
</file>
</xliff>
10. TCA Best Practices
Configuration/TCA/tx_myext_domain_model_product.php:
<?php
return [
'ctrl' => [
'title' => 'LLL:EXT:my_extension/Resources/Private/Language/locallang_db.xlf:tx_myext_domain_model_product',
'label' => 'title',
'tstamp' => 'tstamp',
'crdate' => 'crdate',
'delete' => 'deleted',
'sortby' => 'sorting',
'versioningWS' => true,
'origUid' => 't3_origuid',
'languageField' => 'sys_language_uid',
'transOrigPointerField' => 'l10n_parent',
'transOrigDiffSourceField' => 'l10n_diffsource',
'translationSource' => 'l10n_source',
'enablecolumns' => [
'disabled' => 'hidden',
'starttime' => 'starttime',
'endtime' => 'endtime',
],
'searchFields' => 'title,description',
'iconfile' => 'EXT:my_extension/Resources/Public/Icons/product.svg',
],
'types' => [
'1' => [
'showitem' => '
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:general,
title, description,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access,
hidden, starttime, endtime
',
],
],
'columns' => [
'title' => [
'label' => 'LLL:EXT:my_extension/Resources/Private/Language/locallang_db.xlf:tx_myext_domain_model_product.title',
'config' => [
'type' => 'input',
'size' => 30,
'eval' => 'trim,required',
'max' => 255,
],
],
'description' => [
'label' => 'LLL:EXT:my_extension/Resources/Private/Language/locallang_db.xlf:tx_myext_domain_model_product.description',
'config' => [
'type' => 'text',
'enableRichtext' => true,
'richtextConfiguration' => 'default',
],
],
],
];
11. Security Best Practices
✅ Input Validation:
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
// Validate integer input
if (!MathUtility::canBeInterpretedAsInteger($input)) {
throw new \InvalidArgumentException('Invalid integer value');
}
// Sanitize email
$email = GeneralUtility::validEmail($input) ? $input : '';
// Escape output in templates
{product.title -> f:format.htmlspecialchars()}
✅ SQL Injection Prevention:
// Use QueryBuilder with bound parameters
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_myext_domain_model_product');
$products = $queryBuilder
->select('*')
->from('tx_myext_domain_model_product')
->where(
$queryBuilder->expr()->eq(
'uid',
$queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)
)
)
->executeQuery()
->fetchAllAssociative();
✅ CSRF Protection:
<!-- Always include form protection token -->
<f:form.hidden property="__trustedProperties" value="{formProtection}" />
Common Anti-Patterns to Avoid
❌ Don't: Use GeneralUtility::makeInstance() for Services
// Old way (deprecated)
$repository = GeneralUtility::makeInstance(ProductRepository::class);
✅ Do: Use Dependency Injection
// Modern way
public function __construct(
private readonly ProductRepository $repository
) {}
❌ Don't: Access $GLOBALS directly
// Avoid global state
$user = $GLOBALS['BE_USER'];
$tsfe = $GLOBALS['TSFE'];
✅ Do: Inject Context and Services
public function __construct(
private readonly Context $context,
private readonly TypoScriptService $typoScriptService
) {}
❌ Don't: Use ext_tables.php for configuration
// ext_tables.php (deprecated for most uses)
✅ Do: Use dedicated configuration files
// Configuration/Backend/Modules.php
// Configuration/TCA/
// Configuration/Services.yaml
Conformance Checklist
- Complete directory structure following best practices
- composer.json with proper PSR-4 autoloading
- Quality tools configured (php-cs-fixer, phpstan)
- CI/CD pipeline (GitHub Actions or GitLab CI)
- Comprehensive test coverage (unit, functional, acceptance)
- Complete documentation in RST format
- Service configuration in Services.yaml
- Backend modules in Configuration/Backend/
- TCA files in Configuration/TCA/
- Language files in XLIFF format
- Dependency injection throughout
- No global state access
- Security best practices followed
- .editorconfig for consistent formatting
- README.md with clear instructions
- LICENSE file present