SF
{ }
Symfony · PHPStan · Doctrine · Statische Analyse
Symfony + PHPStan: magische
Methoden und Doctrine-Typen

PHPStan ist der mächtigste statische Analyzer für PHP — aber in Symfony-Projekten stoßen Teams schnell auf Grenzen: magische Methoden in Repositories, Doctrine-Spaltentypen die sich von PHP-Typen unterscheiden, Container-Parameter ohne Typen und komplexe Generic-Annotierungen. Dieser Beitrag zeigt, wie man PHPStan in Symfony systematisch auf Level 9 bringt.

19 Min. Lesezeit Level 9 · phpstan-symfony · phpstan-doctrine · Custom Rules PHPStan 2.x · Symfony 7.x · PHP 8.3+

1. Warum PHPStan in Symfony-Projekten besondere Herausforderungen hat

PHPStan analysiert PHP-Code statisch, ohne ihn auszuführen — und das ist genau das Problem mit Symfony-Projekten. Symfony macht intensiven Gebrauch von Patterns, die PHPStan ohne zusätzliche Konfiguration nicht versteht: Dependency Injection durch den DI-Container, magische Methoden in Doctrine Repositories (findByName(), findOneByEmail()), dynamische Konfigurationsparameter als Strings, und die Typkonvertierungen zwischen PHP und Datenbank-Spaltentypen, die Doctrine transparent durchführt. Ohne die richtigen Extensions würde PHPStan in jedem dieser Bereiche Fehler melden, die technisch keine echten Fehler sind.

Das Ziel ist PHPStan Level 9 — das höchste Analyse-Level, das unter anderem strikte Typen für alle Rückgabewerte, Argument-Typen und Property-Typen verlangt. Level 9 ist in Symfony-Projekten ohne die richtigen Extensions und Annotierungen nicht erreichbar, weil zu viele Framework-Konstrukte dynamisch sind. Mit phpstan/phpstan-symfony und phpstan/phpstan-doctrine werden die wichtigsten Framework-Konstrukte korrekt analysiert. Darüber hinaus braucht man Generics für Collections und Repositories sowie sorgfältig ausgewählte PHPDoc-Blöcke für die wenigen Stellen, die statisch nicht vollständig herleitbar sind.

2. PHPStan Grundkonfiguration für Symfony-Projekte

Die PHPStan-Konfiguration für Symfony-Projekte beginnt mit der phpstan.neon-Datei im Projekt-Root. Die wichtigsten Parameter: level: 9 für maximale Analyse-Tiefe, paths für die zu analysierenden Verzeichnisse (typischerweise src/ und tests/), und die includes-Liste der Extension-Konfigurationen. Die Extension phpstan/phpstan-symfony bringt die extension.neon-Datei mit, die für Symfony-spezifische Analysen zuständig ist — sie versteht Service-Container, Form-Typen, Twig-Extensions und Event-Subscriber-Interfaces.

Ein häufiger Konfigurationsfehler: Der container_xml_path muss auf den kompilierten DI-Container zeigen, damit phpstan-symfony die Service-Typen kennt. Ohne diesen Pfad kann PHPStan nicht prüfen, ob Services die richtigen Interfaces implementieren. Der Pfad lautet typischerweise var/cache/dev/App_KernelDevDebugContainer.xml. Das Analysieren des Tests-Verzeichnisses erfordert oft einen separaten Level oder zusätzliche Ignore-Regeln, weil Test-Klassen bewusst vereinfachte Typen verwenden. Ein Baseline-File hilft, bestehende Fehler zu erfassen und schrittweise zu beheben, statt Level 9 auf einmal in einem alten Projekt einzuführen.


# phpstan.neon — PHPStan configuration for a Symfony 7 project at level 9

includes:
  - vendor/phpstan/phpstan-symfony/extension.neon
  - vendor/phpstan/phpstan-symfony/rules.neon
  - vendor/phpstan/phpstan-doctrine/extension.neon
  - vendor/phpstan/phpstan-doctrine/rules.neon

parameters:
  level: 9

  paths:
    - src/
    - tests/

  # Point to the compiled DI container — required for service type resolution
  symfony:
    container_xml_path: var/cache/dev/App_KernelDevDebugContainer.xml
    console_application_loader: bin/console

  # Doctrine: enable object manager provider for type inference on repositories
  doctrine:
    objectManagerLoader: tests/object-manager.php
    reportUnresolvableQueryBuilderTypes: true

  # Ignore known false positives that cannot be fixed with PHPDoc alone
  ignoreErrors:
    # Symfony's getParameter() returns mixed — acceptable at framework boundaries
    - message: '#Call to method getParameter\(\) on an unknown class#'
      path: src/

  # Treat all files as if they have strict_types=1
  treatPhpDocTypesAsCertain: false

3. Magische Methoden und Properties typisieren

Doctrine Repositories haben eine __call()-Methode, die dynamisch Methoden wie findByEmail() und findOneByStatus() erzeugt. Ohne Annotation weiß PHPStan nicht, was diese Methoden zurückgeben. Die korrekte Lösung ist eine @method-PHPDoc-Annotation auf der Repository-Klasse, die jede magische Methode mit ihren Typen explizit deklariert. Das fühlt sich redundant an, ist aber der einzige Weg, PHPStan korrekte Typen zu liefern, ohne auf @phpstan-ignore zurückzufallen.

Bei magischen Properties — __get(), __set(), __isset() — ist die Situation ähnlich. Die @property-PHPDoc-Annotation auf der Klasse deklariert den Typ einer Property, die dynamisch generiert wird. Symfony Form-Klassen nutzen dieses Muster, wenn getData() einen mixed-Typ zurückgibt. Mit einem spezifischen @return FormInterface<ProductFormData>-Annotation auf dem Form-Builder oder einem Generics-Parameter auf der Form-Klasse lässt sich PHPStan Level 9 auch hier erreichen. Die Herausforderung ist, diese Annotierungen konsistent zu pflegen, wenn sich die Domain-Klassen ändern.


<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * Repository for Product entities with typed magic method declarations.
 *
 * @extends ServiceEntityRepository<Product>
 *
 * Magic methods generated by Doctrine's __call() — declared for PHPStan level 9:
 * @method Product|null findOneByName(string $name)
 * @method Product|null findOneBySku(string $sku)
 * @method Product|null findOneBySlug(string $slug)
 * @method Product[]    findByCategory(int $categoryId)
 * @method Product[]    findByStatus(string $status)
 * @method Product[]    findAll()
 * @method Product|null find(mixed $id, mixed $lockMode = null, mixed $lockVersion = null)
 * @method Product[]    findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null)
 */
final class ProductRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Product::class);
    }

    /**
     * Find active products below a price threshold — fully typed, no magic.
     *
     * @return list<Product>
     */
    public function findActiveBelow(float $maxPrice): array
    {
        return $this->createQueryBuilder('p')
            ->where('p.price < :maxPrice')
            ->andWhere('p.status = :status')
            ->setParameter('maxPrice', $maxPrice)
            ->setParameter('status', 'active')
            ->orderBy('p.price', 'ASC')
            ->getQuery()
            ->getResult();
    }
}

4. Doctrine-Spaltentypen und PHP-Typen korrekt abbilden

Doctrine-Spaltentypen und PHP-Typen sind nicht identisch, und das ist eine der häufigsten Quellen für PHPStan-Fehler in Symfony-Projekten. Ein decimal-Spaltentyp gibt in PHP einen string zurück, kein float — Doctrine macht keine automatische Konversion. Ein datetime_immutable-Typ gibt DateTimeImmutable zurück, aber date gibt ebenfalls DateTimeImmutable zurück. Wer die Property-Typen in Doctrine-Entities nicht präzise auf die tatsächlichen PHP-Rückgabetypen des Doctrine-Typsystems abstimmt, bekommt PHPStan-Fehler, die echte Risiken widerspiegeln.

Die Extension phpstan/phpstan-doctrine kennt das Mapping zwischen Doctrine-Typen und PHP-Typen und prüft, ob die deklarierten PHP-Typen der Entity-Properties mit dem Doctrine-ORM-Mapping übereinstimmen. Wenn eine Property als float $price typisiert ist, aber mit #[ORM\Column(type: 'decimal')] annotiert, meldet phpstan-doctrine einen Fehler: decimal liefert string, nicht float. Die Korrektur ist entweder die Property als string $price zu typisieren oder einen Custom Doctrine Type zu erstellen, der die Konversion transparent macht. Für Geldbeträge ist die Verwendung von string oder einem Value-Object mit einem Custom Type der empfohlene Ansatz.

5. Generics für Repositories und Collections

Generics in PHPStan ermöglichen es, typsichere Collections und Repositories zu definieren, ohne für jeden Entity-Typ eine eigene Interface-Methode zu schreiben. Das Template-Pattern: @template T of object auf der Basis-Repository-Klasse, @extends ServiceEntityRepository<T> auf der Unterklasse und @phpstan-return T auf Methoden. Damit weiß PHPStan, dass ProductRepository::find(1) ein Product zurückgibt und nicht object.

Für Doctrine Collections ist die Generic-Annotation besonders wertvoll: ArrayCollection<int, Product> kommuniziert, dass die Collection Integer-Keys und Product-Werte enthält. Ohne diese Annotation gibt Collection::get() mixed zurück — jede Verwendung des Elements erfordert eine explizite Prüfung oder einen Cast. Mit der Generic-Annotation erkennt PHPStan den Typ automatisch und meldet Fehler, wenn ein falscher Typ in die Collection eingefügt oder ein inkompatibles Ergebnis aus ihr herausgelesen wird. Die PHPDoc-Syntax für Generics ist in PHPStan vollständig unterstützt und eine der mächtigsten Techniken für Typ-Sicherheit in Symfony-Projekten.


<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\OrderRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: OrderRepository::class)]
class Order
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;

    // decimal → string in PHP — PHPStan would flag this as float
    #[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
    private string $totalAmount = '0.00';

    // datetime_immutable → DateTimeImmutable — correct mapping
    #[ORM\Column(type: 'datetime_immutable')]
    private \DateTimeImmutable $placedAt;

    /**
     * @var Collection<int, OrderItem>  — Generic annotation for PHPStan
     */
    #[ORM\OneToMany(mappedBy: 'order', targetEntity: OrderItem::class, cascade: ['persist', 'remove'])]
    private Collection $items;

    public function __construct()
    {
        $this->items   = new ArrayCollection();
        $this->placedAt = new \DateTimeImmutable();
    }

    /**
     * @return Collection<int, OrderItem>
     */
    public function getItems(): Collection
    {
        return $this->items;
    }

    /**
     * Add an item — PHPStan knows $item must be OrderItem due to collection generic.
     */
    public function addItem(OrderItem $item): void
    {
        if (!$this->items->contains($item)) {
            $this->items->add($item);
            $item->setOrder($this);
        }
    }

    public function getTotalAmount(): string   { return $this->totalAmount; }
    public function getPlacedAt(): \DateTimeImmutable { return $this->placedAt; }
}

6. phpstan-symfony: Container-Parameter und Service-Typen

Die Extension phpstan-symfony verbindet den kompilierten Symfony DI-Container mit PHPStan. Das ermöglicht, dass PHPStan versteht, welche Services welche Interfaces implementieren, welche Container-Parameter existieren und welchen Typ sie haben. Ohne diese Extension gibt $this->getContainer()->get('some_service') immer mixed zurück. Mit phpstan-symfony und dem Pfad zur Container-XML gibt der Call den korrekten Service-Typ zurück — sofern der Service mit einem Interface oder einer Klasse registriert ist.

Container-Parameter — über $this->getParameter('some_param') in Commands und Controllers aufgerufen — geben in Symfony 6+ konkrete Typen zurück, wenn sie korrekt im Container als getypte Parameter definiert sind. phpstan-symfony prüft, ob der erwartete Typ mit dem tatsächlichen Parameter-Typ übereinstimmt. Für Symfony Commands analysiert die Extension, ob der InputInterface korrekt verwendet wird: getArgument('name') gibt mixed zurück, was einen expliziten Cast oder eine Assertion erfordert. PHPStan mit phpstan-symfony wird so zum Architektur-Reviewer: Er erkennt, wenn Commands Argumente ohne Typ-Prüfung verwenden.

7. Eigene PHPStan-Rules für Architektur-Regeln

Eigene PHPStan-Rules sind eine unterschätzte Möglichkeit, Architektur-Regeln automatisch zu prüfen. Statt in Code-Reviews nach Verletzungen zu suchen, erzwingt eine Custom Rule die Regel bei jedem Analyse-Lauf. Typische Architektur-Regeln für Symfony-Projekte: Repositories dürfen nur von Service-Klassen aufgerufen werden, nicht von Controllern. Entities dürfen keine Service-Abhängigkeiten haben. Handler-Klassen dürfen nur in bestimmten Namespaces liegen. Jede dieser Regeln lässt sich als PHPStan-Rule implementieren, die auf den AST (Abstract Syntax Tree) des Codes zugreift und bei Verletzung einen Fehler meldet.

Eine Custom Rule implementiert PHPStan\Rules\Rule mit einer getNodeType()-Methode, die den AST-Knoten-Typ zurückgibt, der analysiert werden soll, und einer processNode()-Methode, die den Knoten prüft und eine Liste von RuleError-Objekten zurückgibt. Das Registrieren der Rule erfolgt über die phpstan.neon-Konfiguration unter services. Sobald registriert, läuft die Rule bei jedem vendor/bin/phpstan analyse-Aufruf und meldet Verletzungen wie alle anderen PHPStan-Fehler. Custom Rules in der CI-Pipeline einzusetzen schützt die Architektur automatisch vor Regression.

8. Stubs für externe Libraries ohne Typen

Manche externe Libraries haben keine vollständigen PHPDoc-Annotierungen oder verwenden @return mixed für Methoden, die in der Praxis immer einen konkreten Typ zurückgeben. Für diese Fälle schreibt man PHPStan-Stubs: PHP-Dateien, die nur die Interface-Signaturen mit korrekten Typen enthalten, ohne Implementation. PHPStan liest diese Stubs als Typ-Deklarationen und verwendet sie statt der tatsächlichen Library-Klassen. Das ermöglicht Level-9-Analyse auch in Projekten, die Libraries mit schwachen Typen verwenden.

In Symfony-Projekten sind die häufigsten Stub-Kandidaten ältere Symfony-Komponenten, die noch mixed zurückgeben, oder Third-Party-Bundles, die keine PHPStan-Extensions haben. Die Stub-Dateien landen in einem phpstan/stubs/-Verzeichnis und werden in der phpstan.neon unter stubFiles referenziert. Stubs zu pflegen ist Aufwand — deshalb ist es ratsam, zunächst zu prüfen, ob es ein Community-Stub-Projekt gibt (phpstan/phpstan-strict-rules, phpstan/phpstan-beberlei-assert etc.), bevor man eigene schreibt. Für den Symfony-Kern sind Stubs in der Regel nicht nötig, weil Symfony selbst sehr gut typisiert ist.

9. PHPStan-Level im Vergleich

Die neun PHPStan-Levels bauen aufeinander auf — jedes Level fügt strengere Prüfungen hinzu. Der richtige Einstiegslevel für bestehende Projekte ist der erste Level, der keine Fehler produziert, um eine Baseline zu schaffen.

Level Wichtigste Prüfungen Symfony-Hürden Empfehlung
0–3 Undefined variables, basic types Wenige Einstieg für Legacy-Projekte
4–5 Return types, argument types Magic methods, Container Neuprojekte ohne Extensions
6–7 Never types, union type checks Doctrine-Typen, Generics Mit phpstan-doctrine erforderlich
8 Strict mixed, nullable checks Strict phpstan-symfony nötig Mit vollständigen Extensions
9 Alle Prüfungen, strikte Typen Custom Rules, Stubs, Generics Ziel für neue Symfony-Projekte

Der Sprung von Level 8 auf Level 9 ist in Symfony-Projekten der aufwendigste. Level 9 prüft, dass alle Methoden-Rückgabetypen präzise sind — keine mixed-Rückgaben, keine ungetypten Arrays. Mit phpstan-symfony, phpstan-doctrine, vollständigen @method-Annotierungen für Doctrine Repositories und Generic-Annotierungen für Collections ist Level 9 erreichbar und haltbar.

Mironsoft

PHPStan-Integration, Symfony Code-Qualität und statische Analyse

PHPStan Level 9 in eurem Symfony-Projekt einführen?

Wir führen PHPStan systematisch in bestehende Symfony-Projekte ein — von der Baseline über phpstan-symfony und phpstan-doctrine bis zu Custom Rules für eure Architektur-Standards.

Baseline & Migration

PHPStan-Baseline erstellen, schrittweise auf Level 9 migrieren ohne den Entwicklungs-Flow zu unterbrechen

Extension-Setup

phpstan-symfony, phpstan-doctrine und weitere Extensions konfigurieren für vollständige Symfony-Integration

Custom Rules

Architektur-Regeln als PHPStan Custom Rules automatisieren — Repository-Zugriff, Namespace-Trennung, Dependency-Verbote

10. Zusammenfassung

PHPStan Level 9 in Symfony-Projekten ist erreichbar, wenn man die richtigen Extensions und Annotierungs-Strategien konsequent anwendet. phpstan/phpstan-symfony verbindet den DI-Container mit der statischen Analyse. phpstan/phpstan-doctrine prüft das Mapping zwischen Doctrine-Spaltentypen und PHP-Typen. @method-Annotierungen auf Repositories typisieren die magischen Doctrine-Methoden. Generics für Collections und Repositories eliminieren mixed-Typen aus der Entity-Interaktion. Stubs schließen Lücken bei Libraries ohne vollständige Typen.

Custom Rules machen PHPStan zum automatischen Architektur-Wächter: Regeln, die sonst in Code-Reviews geprüft werden, werden bei jedem Analyse-Lauf durchgesetzt. In der CI-Pipeline integriert ist PHPStan damit eine Sicherheitsnetze, das Typ-Fehler, Architektur-Verletzungen und Symfony-Framework-Missverwendungen erkennt, bevor Code in die Produktion gelangt. Die Investition in vollständige PHPStan-Integration zahlt sich in reduziertem Debugging-Aufwand und mehr Vertrauen in Refactorings aus.

Symfony + PHPStan — Das Wichtigste auf einen Blick

Zwei Pflicht-Extensions

phpstan-symfony (Container, Services) + phpstan-doctrine (Spaltentypen, Queries). Ohne beide sind Level 8+ in Symfony-Projekten nicht sinnvoll erreichbar.

Magic Methods typisieren

@method-PHPDoc auf Repository-Klassen für alle Doctrine-Magic-Methoden. @extends ServiceEntityRepository<EntityClass> für Generics-Kompatibilität.

Doctrine-Typen beachten

decimal → string, nicht float. date → DateTimeImmutable. PHPStan-doctrine meldet Typ-Missmatches zwischen PHP-Annotation und ORM-Mapping.

Custom Rules

Rule::processNode() prüft AST-Knoten. In CI-Pipeline erzwingt das Architektur-Regeln automatisch — kein manueller Code-Review für strukturelle Verletzungen nötig.

11. FAQ: PHPStan in Symfony-Projekten

1Was ist PHPStan?
Statischer Analyzer für PHP — erkennt Typ-Fehler, undefinierte Variablen und falsche Methodenaufrufe ohne Code-Ausführung. Neun Analyse-Levels mit steigender Strenge.
2Warum phpstan-symfony und phpstan-doctrine?
PHPStan kennt Symfony und Doctrine nicht nativ. phpstan-symfony verbindet DI-Container, phpstan-doctrine prüft ORM-Typ-Mappings. Ohne beide sind Level 8+ nicht sinnvoll erreichbar.
3Magic Doctrine-Methoden typisieren?
@method-PHPDoc auf Repository-Klasse für jede magische Methode. @extends ServiceEntityRepository<Product> für Generics. Ohne diese Annotierungen gibt PHPStan Fehler für jeden findBy...-Aufruf.
4decimal als string statt float?
Doctrine gibt decimal als string zurück — Präzisionsverlust durch float vermeiden. PHPStan-doctrine meldet Fehler bei float-Typisierung. Korrekt: string oder Custom Type.
5Generics für Symfony nutzen?
@template T, @extends ServiceEntityRepository<T>, Collection<int, Product> — PHPStan kennt dann den Element-Typ. Keine mixed-Rückgaben mehr beim Collection-Zugriff.
6Eigene PHPStan-Rule schreiben?
Rule-Interface mit getNodeType() und processNode() implementieren. In phpstan.neon unter services mit Tag phpstan.rules.rule registrieren. Dann bei jedem Analyse-Lauf aktiv.
7Was ist eine PHPStan-Baseline?
Erfasst bestehende Fehler — beim nächsten Lauf werden sie ignoriert, nur neue Fehler gemeldet. Ermöglicht schrittweise Migration in Legacy-Projekten ohne sofortigen Fix aller Fehler.
8Welches Level für neue Projekte?
Level 9 von Anfang an mit phpstan-symfony und phpstan-doctrine. Für bestehende Projekte: Baseline erstellen, schrittweise auf Level 9 migrieren.
9PHPStan in CI integrieren?
vendor/bin/phpstan analyse als CI-Job. Bei GitHub Actions: phpstan/phpstan-action. Fehler blockieren den Merge. Baseline-Datei committen für konsistente Ergebnisse.
10Was sind PHPStan-Stubs?
PHP-Dateien mit Typ-Signaturen für Libraries ohne vollständige Typen. PHPStan liest Stubs statt echte Library-Klassen. Nötig bei @return mixed in Third-Party-Bundles.