saschaegerer/phpstan-typo3
PHPStan extension for TYPO3 CMS that teaches static analysis about TYPO3 APIs, service container, Extbase, and framework classes. It adds TYPO3-specific rules and stubs to catch type errors and bugs earlier in extensions and projects.
1. Install alongside PHPStan
composer require --dev phpstan/phpstan phpstan/extension-installer saschaegerer/phpstan-typo3
phpstan/extension-installer is optional but recommended — it auto-registers every installed PHPStan extension, so you do not have to touch your config when adding this package.
2. Create phpstan.neon (or .dist)
includes:
- vendor/saschaegerer/phpstan-typo3/extension.neon
parameters:
level: 5
paths:
- packages/my_sitepackage/Classes
- config/system
# TYPO3 projects routinely blow the default 128M
# (set on the CLI instead if you prefer)
If extension-installer is active, drop the includes: line so you do not register the extension twice.
3. Confirm TYPO3 core is actually installed
The extension works by reflecting real TYPO3 classes. If typo3/cms-core (and the specific system extensions you use) are not in vendor/, PHPStan will report hundreds of "class not found" errors before it ever gets to your code. Composer-based TYPO3 v11+ projects already satisfy this.
4. First run
vendor/bin/phpstan analyse --memory-limit=2G
Where to look first — the package is small and readable; read these in order:
vendor/saschaegerer/phpstan-typo3/extension.neon — shows exactly which reflection extensions and rules are registered.src/ — dynamic return type extensions (primarily GeneralUtility::makeInstance()) and $GLOBALS type providers.src/Rules/ — the framework-specific rules. Read each rule class; they are short, and knowing what they assert tells you what the extension adds on top of plain PHPStan.First useful case: an Extbase extension where GeneralUtility::makeInstance(MyClass::class) currently resolves to object and every call site needs /** @var MyClass $x */. After enabling the extension the return type is inferred from the class-string<T> argument, so those annotations can be deleted and the real errors underneath become visible.
Adopt by ratcheting, not by fixing everything
Legacy TYPO3 codebases will not pass level 5 cold. The productive loop is:
vendor/bin/phpstan analyse --generate-baseline
vendor/bin/phpstan analyse # green
# then bump level, regenerate baseline, repeat
Commit phpstan-baseline.neon so the team shares the same debt ceiling. Delete baseline entries as you touch files; never let a PR add new ones.
Scope paths deliberately
Analyse your own code, not vendor/:
parameters:
paths:
- packages/*/Classes
- packages/*/Configuration
excludePaths:
- packages/*/Classes/Domain/Model/*
Domain models in Extbase are mostly property declarations plus getters/setters — low signal, high noise at low levels.
Lean on GeneralUtility::makeInstance() inference
// return type is MyService — no @var needed
$service = GeneralUtility::makeInstance(MyService::class);
// stays `object`: argument is not a literal class-string
$service = GeneralUtility::makeInstance($this->classNameFromTypoScript);
When you must pass a dynamic class name, carry the generic yourself:
/** @var class-string<MyService> $className */
$className = $configuration['className'];
$service = GeneralUtility::makeInstance($className); // MyService
Document Extbase magic finders with @method
The extension reflects classes; it does not teach PHPStan about Repository::__call() magic. Declare the methods explicitly:
/**
* @method MyModel|null findOneBySlug(string $slug)
* @method MyModel[] findByPid(int $pid)
* @extends \TYPO3\CMS\Extbase\Persistence\Repository<MyModel>
*/
class MyModelRepository extends Repository {}
Same for @Inject properties — annotate the property type, since PHPStan cannot see the DI container.
Per-extension configs, merged at the root
Keep each sitepackage self-describing:
# packages/my_sitepackage/phpstan.neon
includes:
- ../../vendor/saschaegerer/phpstan-typo3/extension.neon
parameters:
level: 6
paths:
- Classes
and in the root phpstan.neon include each one and use paths for the union. This lets an individual extension be stricter than the legacy remainder.
Use error identifiers for surgical ignores
PHPStan attaches identifiers to its errors (and so do custom rules). Ignore by identifier, never by message regex:
parameters:
ignoreErrors:
- identifier: typo3.someRuleName
path: packages/legacy_ext/*
Run with --error-format=raw and -v to surface identifiers when you are unsure.
CI wiring
- run: composer install --no-progress
- run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=2G
Cache the tmpDir (default .phpstan in the project root, or whatever you set via parameters.tmpDir) between runs — on large TYPO3 instances the result cache is the difference between a 40-second and a 6-minute job. Set tmpDir outside vendor/.
Pin the package against your PHPStan major
This is the single biggest failure mode. The package's composer.json constrains both phpstan/phpstan and the PHP versions it supports. Upgrading PHPStan 1 → 2 without checking the constraint will either fail to install or silently drop reflection services. Read the constraint before any PHPStan bump, and run a full analysis on the upgrade branch — a missing reflection extension shows up as a flood of new object/mixed related errors, not as a crash.
Class aliases and legacy names do not resolve
TYPO3 ships a class alias map for renamed classes. PHPStan only sees what Composer's autoloader can resolve. If you analyse old code using pre-v9 class names, either migrate the names or help the analyser find them:
parameters:
scanFiles:
- typo3/sysext/core/Classes/Core/ClassAliasMap.php
Prefer migrating to FQCNs — aliases resolve at runtime but produce weak or wrong types.
Extensions outside Composer are invisible
typo3conf/ext/* in a non-Composer (legacy) install is not autoloaded. Either add PSR-4 autoload entries to the root composer.json, or point PHPStan at the sources:
parameters:
scanDirectories:
- typo3conf/ext/my_ext/Classes
Without this, every reference to that extension's classes becomes an error.
XCLASS overrides are not modelled
TYPO3's XCLASS mechanism replaces a core class at runtime. PHPStan reflects the original class only. Code that relies on an XCLASS-modified signature will type-check against the core definition and can fail at runtime. Treat XCLASS-affected call sites as needing manual review; consider them a candidate for migration to real DI/events anyway.
$GLOBALS['TCA'] typing is coarse
The extension gives $GLOBALS a useful shape, but array keys are not table- or column-precise. Inside a TCA file you will still get mixed for column config. Narrow locally:
/** @var array{type: string, config?: array<string, mixed>} $column */
$column = $GLOBALS['TCA']['tt_content']['columns']['my_field'];
Do not chase perfect TCA typing — it is a data structure, not an API.
makeInstance() falls back to object silently
No error, no warning — you just lose types downstream. Symptoms: Cannot call method foo() on object far away from the real cause. When you see that, grep for makeInstance( and check whether the argument is a literal ::class, a class-string<T>-annotated variable, or something untyped.
Double registration
If phpstan/extension-installer is installed and you also includes: extension.neon manually, rules can be registered twice and behave oddly (duplicate errors, or ignore rules not matching). Pick one mechanism. Teams that vendor a shared config often disable the installer ("phpstan/extension-installer": {"allow": false} style config) and include explicitly for reproducibility.
Baseline hygiene
--generate-baseline writes messages, and messages change when you upgrade PHPStan or this package, producing "ignored error pattern was not matched" failures. Set reportUnmatchedIgnoredErrors: false while upgrading, then regenerate the baseline and turn the check back on.
Debugging a surprising type
Drop into the code you are analysing:
\PHPStan\dumpType($service); // prints the inferred type, then fails the build
Combine with vendor/bin/phpstan analyse -v --debug path/to/File.php to see which reflection extension handled the expression. If a TYPO3 class comes back as object or mixed when you expected better, the cause is almost always (a) core not installed, (b) class not autoloadable, or (c) a non-literal class-string argument.
Extensions points worth knowing about
parameters.tmpDir — move off the default if your CI mounts the workspace read-only or if you want a clean cache per job.parameters.bootstrapFiles — for a TYPO3 Environment bootstrap or constant definitions your code relies on (e.g. TYPO3_MODE, TYPO3_REQUESTTYPE constants in older versions); define them via define() in a bootstrap file to avoid false "undefined constant" errors.extension.neon. To disable one, remove the corresponding service entry from a copy of the config, or ignore its identifier in ignoreErrors. Read the rule source first; most are one-screen checks and easy to reason about.--memory-limit — TYPO3's class graph is large. 2G is a sane default for CI; some monorepos need more.How can I help you explore Laravel packages today?