Methods and Doctrine Types
PHPStan is the most powerful static analyzer for PHP, but in Symfony projects teams quickly hit limits: magic methods in repositories, Doctrine column types that differ from PHP types, container parameters without types and complex generic annotations. This article shows how to systematically bring PHPStan in Symfony to level 9.
Table of Contents
- 1. Why PHPStan faces particular challenges in Symfony projects
- 2. Basic PHPStan configuration for Symfony projects
- 3. Typing magic methods and properties
- 4. Mapping Doctrine column types and PHP types correctly
- 5. Generics for repositories and collections
- 6. phpstan-symfony: container parameters and service types
- 7. Writing custom PHPStan rules for architecture rules
- 8. Stubs for external libraries without types
- 9. PHPStan levels compared
- 10. Summary
- 11. FAQ
1. Why PHPStan faces particular challenges in Symfony projects
PHPStan analyzes PHP code statically, without executing it, and that is exactly the problem with Symfony projects. Symfony makes intensive use of patterns that PHPStan does not understand without additional configuration: dependency injection through the DI container, magic methods in Doctrine repositories (findByName(), findOneByEmail()), dynamic configuration parameters as strings, and the type conversions between PHP and database column types that Doctrine performs transparently. Without the right extensions, PHPStan would report errors in every one of these areas that are technically not real errors.
The goal is PHPStan level 9, the highest analysis level, which among other things demands strict types for all return values, argument types and property types. Level 9 is not reachable in Symfony projects without the right extensions and annotations, because too many framework constructs are dynamic. With phpstan/phpstan-symfony and phpstan/phpstan-doctrine, the most important framework constructs are analyzed correctly. On top of that you need generics for collections and repositories, plus carefully chosen PHPDoc blocks for the few spots that cannot be fully inferred statically.
2. Basic PHPStan configuration for Symfony projects
The PHPStan configuration for Symfony projects starts with the phpstan.neon file in the project root. The most important parameters: level: 9 for maximum analysis depth, paths for the directories to analyze (typically src/ and tests/), and the includes list of extension configurations. The extension phpstan/phpstan-symfony ships the extension.neon file, which is responsible for Symfony-specific analysis. It understands the service container, form types, Twig extensions and event subscriber interfaces.
A common configuration mistake: the container_xml_path must point to the compiled DI container so that phpstan-symfony knows the service types. Without this path, PHPStan cannot check whether services implement the correct interfaces. The path is typically var/cache/dev/App_KernelDevDebugContainer.xml. Analyzing the tests directory often requires a separate level or additional ignore rules, because test classes deliberately use simplified types. A baseline file helps capture existing errors and fix them incrementally instead of introducing level 9 all at once in an old project.
# 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. Typing magic methods and properties
Doctrine repositories have a __call() method that dynamically generates methods such as findByEmail() and findOneByStatus(). Without an annotation, PHPStan does not know what these methods return. The correct solution is an @method PHPDoc annotation on the repository class that explicitly declares every magic method with its types. It feels redundant, but it is the only way to give PHPStan correct types without falling back to @phpstan-ignore.
For magic properties, __get(), __set(), __isset(), the situation is similar. The @property PHPDoc annotation on the class declares the type of a property that is generated dynamically. Symfony form classes use this pattern when getData() returns a mixed type. With a specific @return FormInterface<ProductFormData> annotation on the form builder, or a generics parameter on the form class, PHPStan level 9 becomes reachable here too. The challenge is keeping these annotations consistently maintained as the domain classes change.
<?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. Mapping Doctrine column types and PHP types correctly
Doctrine column types and PHP types are not identical, and this is one of the most common sources of PHPStan errors in Symfony projects. A decimal column type returns a string in PHP, not a float. Doctrine does not perform an automatic conversion. A datetime_immutable type returns DateTimeImmutable, but date also returns DateTimeImmutable. Anyone who does not precisely align the property types in Doctrine entities with the actual PHP return types of the Doctrine type system gets PHPStan errors that reflect real risks.
The extension phpstan/phpstan-doctrine knows the mapping between Doctrine types and PHP types and checks whether the declared PHP types of the entity properties match the Doctrine ORM mapping. If a property is typed as float $price but annotated with #[ORM\Column(type: 'decimal')], phpstan-doctrine reports an error: decimal returns string, not float. The fix is either to type the property as string $price or to create a custom Doctrine type that makes the conversion transparent. For money amounts, using string or a value object with a custom type is the recommended approach.
5. Generics for repositories and collections
Generics in PHPStan make it possible to define type-safe collections and repositories without writing a separate interface method for every entity type. The template pattern: @template T of object on the base repository class, @extends ServiceEntityRepository<T> on the subclass, and @phpstan-return T on methods. This tells PHPStan that ProductRepository::find(1) returns a Product and not object.
For Doctrine collections, the generic annotation is especially valuable: ArrayCollection<int, Product> communicates that the collection has integer keys and Product values. Without this annotation, Collection::get() returns mixed, and every use of the element requires an explicit check or a cast. With the generic annotation, PHPStan recognizes the type automatically and reports an error if the wrong type is inserted into the collection or an incompatible result is read out of it. The PHPDoc syntax for generics is fully supported in PHPStan and one of the most powerful techniques for type safety in Symfony projects.
<?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 maps to string in PHP, PHPStan would flag this as float
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private string $totalAmount = '0.00';
// datetime_immutable maps to 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 parameters and service types
The extension phpstan-symfony connects the compiled Symfony DI container to PHPStan. This lets PHPStan understand which services implement which interfaces, which container parameters exist and what type they have. Without this extension, $this->getContainer()->get('some_service') always returns mixed. With phpstan-symfony and the path to the container XML, the call returns the correct service type, provided the service is registered with an interface or a class.
Container parameters, retrieved via $this->getParameter('some_param') in commands and controllers, return concrete types in Symfony 6+ when they are correctly defined as typed parameters in the container. phpstan-symfony checks whether the expected type matches the actual parameter type. For Symfony commands, the extension analyzes whether InputInterface is used correctly: getArgument('name') returns mixed, which requires an explicit cast or an assertion. PHPStan with phpstan-symfony thus becomes an architecture reviewer: it detects when commands use arguments without a type check.
7. Writing custom PHPStan rules for architecture rules
Custom PHPStan rules are an underrated way to check architecture rules automatically. Instead of searching for violations in code reviews, a custom rule enforces the rule on every analysis run. Typical architecture rules for Symfony projects: repositories may only be called from service classes, not from controllers. Entities may not have service dependencies. Handler classes may only live in certain namespaces. Every one of these rules can be implemented as a PHPStan rule that accesses the AST (abstract syntax tree) of the code and reports an error on violation.
A custom rule implements PHPStan\Rules\Rule with a getNodeType() method that returns the AST node type to be analyzed, and a processNode() method that checks the node and returns a list of RuleError objects. Registering the rule happens through the phpstan.neon configuration under services. Once registered, the rule runs on every vendor/bin/phpstan analyse call and reports violations like any other PHPStan error. Deploying custom rules in the CI pipeline automatically protects the architecture from regression.
8. Stubs for external libraries without types
Some external libraries do not have complete PHPDoc annotations or use @return mixed for methods that in practice always return a concrete type. For these cases you write PHPStan stubs: PHP files that contain only the interface signatures with correct types, without implementation. PHPStan reads these stubs as type declarations and uses them instead of the actual library classes. This enables level-9 analysis even in projects that use libraries with weak types.
In Symfony projects, the most common stub candidates are older Symfony components that still return mixed, or third-party bundles that have no PHPStan extensions. The stub files live in a phpstan/stubs/ directory and are referenced in phpstan.neon under stubFiles. Maintaining stubs is effort, so it is advisable to first check whether a community stub project exists (phpstan/phpstan-strict-rules, phpstan/phpstan-beberlei-assert, etc.) before writing your own. For Symfony's core, stubs are generally not needed, because Symfony itself is very well typed.
9. PHPStan levels compared
The nine PHPStan levels build on each other, each level adding stricter checks. The right entry level for existing projects is the first level that produces no errors, so you can create a baseline.
| Level | Key Checks | Symfony Hurdles | Recommendation |
|---|---|---|---|
| 0-3 | Undefined variables, basic types | Few | Entry point for legacy projects |
| 4-5 | Return types, argument types | Magic methods, container | New projects without extensions |
| 6-7 | Never types, union type checks | Doctrine types, generics | Requires phpstan-doctrine |
| 8 | Strict mixed, nullable checks | Strict phpstan-symfony needed | With full extensions |
| 9 | All checks, strict types | Custom rules, stubs, generics | Target for new Symfony projects |
The jump from level 8 to level 9 is the most expensive one in Symfony projects. Level 9 checks that all method return types are precise, no mixed returns, no untyped arrays. With phpstan-symfony, phpstan-doctrine, complete @method annotations for Doctrine repositories and generic annotations for collections, level 9 is reachable and maintainable.
Mironsoft
PHPStan integration, Symfony code quality and static analysis
Want PHPStan level 9 in your Symfony project?
We introduce PHPStan systematically into existing Symfony projects, from the baseline through phpstan-symfony and phpstan-doctrine to custom rules for your architecture standards.
Baseline & Migration
Create a PHPStan baseline, migrate incrementally to level 9 without disrupting the development flow
Extension Setup
Configure phpstan-symfony, phpstan-doctrine and further extensions for full Symfony integration
Custom Rules
Automate architecture rules as PHPStan custom rules, repository access, namespace separation, dependency bans
10. Summary
PHPStan level 9 in Symfony projects is reachable if you apply the right extensions and annotation strategies consistently. phpstan/phpstan-symfony connects the DI container with static analysis. phpstan/phpstan-doctrine checks the mapping between Doctrine column types and PHP types. @method annotations on repositories type the magic Doctrine methods. Generics for collections and repositories eliminate mixed types from entity interaction. Stubs close gaps for libraries without complete types.
Custom rules turn PHPStan into an automatic architecture guard: rules that would otherwise be checked in code reviews are enforced on every analysis run. Integrated into the CI pipeline, PHPStan becomes a safety net that catches type errors, architecture violations and Symfony framework misuse before code reaches production. The investment in complete PHPStan integration pays off in reduced debugging effort and more confidence in refactorings.
Symfony + PHPStan: The Essentials at a Glance
Two Mandatory Extensions
phpstan-symfony (container, services) + phpstan-doctrine (column types, queries). Without both, level 8+ is not reasonably reachable in Symfony projects.
Type Magic Methods
@method PHPDoc on repository classes for all Doctrine magic methods. @extends ServiceEntityRepository<EntityClass> for generics compatibility.
Mind Doctrine Types
decimal to string, not float. date to DateTimeImmutable. PHPStan-doctrine reports type mismatches between the PHP annotation and the ORM mapping.
Custom Rules
Rule::processNode() checks AST nodes. In a CI pipeline this enforces architecture rules automatically, no manual code review needed for structural violations.