PHPDoc Generics With PHPStan And Psalm
Generics describe a relationship between types. They are useful when a function or class works with many types but should preserve the specific type supplied by the caller.
PHP does not currently have native generic syntax. PHPStan and Psalm support generic-like type relationships through PHPDoc. PHP still runs the ordinary native declarations, while the analyser reads annotations such as @template.
The Problem With mixed
Consider a function that returns the first item in a list.
<?php
declare(strict_types=1);
/**
* @param list<mixed> $items
*/
function firstOrNull(array $items): mixed
{
return $items[0] ?? null;
}
$name = firstOrNull(['Ada', 'Grace']);
var_dump($name);
// Prints:
// string(3) "Ada"
At runtime, $name is a string. Static analysis only sees mixed, so it cannot safely assume string methods or string-specific operations are valid.
A union such as string|int|object|null does not solve the general problem. The return type needs to depend on the type inside the input list.
Declare A Template Type
@template T introduces a type variable. The same T can then appear in parameter and return annotations.
<?php
declare(strict_types=1);
/**
* @template T
* @param list<T> $items
* @return T|null
*/
function firstOrNull(array $items): mixed
{
return $items[0] ?? null;
}
$name = firstOrNull(['Ada', 'Grace']);
$quantity = firstOrNull([10, 20]);
var_dump($name, $quantity);
// Prints:
// string(3) "Ada"
// int(10)
PHP still sees the native return type mixed. PHPStan and Psalm infer string|null for $name and int|null for $quantity.
The annotation does not convert or validate values at runtime. It gives the analyser a rule: whatever type enters through list<T> can leave as T|null.
Use Bounds When Any Type Is Too Broad
A template can be restricted to a parent class or interface.
<?php
declare(strict_types=1);
interface HasLabel
{
public function label(): string;
}
/**
* @template T of HasLabel
* @param T $item
* @return T
*/
function checkedItem(HasLabel $item): HasLabel
{
if ($item->label() === '') {
throw new InvalidArgumentException('A label is required.');
}
return $item;
}
The bound says T must implement HasLabel. The native declarations enforce the interface at runtime, while the template preserves the caller's more specific class in static analysis.
Prefer a native parameter or return type whenever PHP can express the contract. Use the template to add the relationship that native declarations cannot express.
Generic Classes And Collections
Templates can belong to a class or interface.
<?php
declare(strict_types=1);
/**
* @template T
*/
final class ItemList
{
/** @var list<T> */
private array $items = [];
/** @param T $item */
public function add(mixed $item): void
{
$this->items[] = $item;
}
/** @return T|null */
public function first(): mixed
{
return $this->items[0] ?? null;
}
}
/** @var ItemList<string> $names */
$names = new ItemList();
$names->add('Ada');
var_dump($names->first());
// Prints:
// string(3) "Ada"
The ItemList<string> annotation fixes T as string for that object. An analyser can then reject $names->add(42) and infer string|null from $names->first().
When a class implements a generic interface or extends a generic parent, annotations such as @implements and @extends specify or preserve its template types. Add these only when the relationship is useful; unnecessary generic abstractions make code harder to read.
PHPStan And Psalm Syntax
Preserve Types From Class Names
A class name passed as a string normally loses the relationship between the string and the object created from it. class-string<T> tells the analyser that the string names class T:
<?php
declare(strict_types=1);
final class Report
{
}
/**
* @template T of object
* @param class-string<T> $className
* @return T
*/
function instantiate(string $className): object
{
return new $className();
}
$report = instantiate(Report::class);
PHPStan and Psalm infer Report for $report, not merely object. This particular factory is suitable only for classes with a public zero-argument constructor. The annotation describes the returned type relationship; it does not prove that every supplied class can be constructed that way.
The same pattern is useful for repository lookups, serializer APIs, and registries that receive SomeClass::class and return an instance of that class.
Specify Generic Parents And Interfaces
When implementing a generic interface, state which concrete types the implementation supplies:
<?php
declare(strict_types=1);
/**
* @template TKey of array-key
* @template TValue
*/
interface ReadRepository
{
/**
* @param TKey $id
* @return TValue|null
*/
public function find(int|string $id): mixed;
}
final class Customer
{
public function __construct(public readonly int $id)
{
}
}
/** @implements ReadRepository<int, Customer> */
final class InMemoryCustomerRepository implements ReadRepository
{
/** @var array<int, Customer> */
private array $customers = [];
public function __construct(Customer ...$customers)
{
foreach ($customers as $customer) {
$this->customers[$customer->id] = $customer;
}
}
public function find(int|string $id): mixed
{
return is_int($id) ? ($this->customers[$id] ?? null) : null;
}
}
The interface remains reusable, while @implements ReadRepository<int, Customer> tells the analyser that this implementation accepts integer keys and returns Customer|null. If a child class or interface should remain generic, repeat its @template declaration and pass that template through with @extends or @implements instead of fixing it to one concrete type.
Be Careful With Variance
Generic collections are invariant by default. A Collection<Dog> is not automatically a Collection<Animal>, because code accepting Collection<Animal> might add a cat. Read-only producers and write-only consumers can permit safer variance in some analyser-specific designs, but add covariance or contravariance only when the project needs it and the mutation rules are understood.
For ordinary mutable collections, invariance is the safe expectation.
Both tools understand the common @template, @param, and @return forms used in this lesson. They also support tool-prefixed annotations such as @phpstan-template and @psalm-template for cases where a project needs tool-specific behaviour.
Start with shared annotations. Use a prefixed form only when the repository deliberately relies on a feature or interpretation specific to one analyser.
Common Mistakes
- Adding
@template Twithout usingTin a parameter, return, property, or parent relationship. - Replacing useful native types with
mixedwhen a native interface or class can still be declared. - Claiming a relationship the implementation does not preserve.
- Treating PHPDoc as runtime validation.
- Adding generics to simple code that already has a clear concrete type.
- Ignoring the analyser when it reports an unspecified template type.
What To Remember
PHPDoc generics let static analysers preserve relationships that native PHP declarations cannot currently express. Introduce a template with @template, use it consistently, keep the strongest useful native types, and verify the annotation with the project's configured PHPStan or Psalm command.
Before moving on, make sure you can explain why firstOrNull() returns T|null, how a bound restricts T, and why a generic annotation does not change runtime behaviour.
Practice
Task: Preserve A List Item Type
Improve this function so PHPStan or Psalm can infer the type returned from the input list.
<?php
declare(strict_types=1);
/**
* @param list<mixed> $items
*/
function lastOrNull(array $items): mixed
{
if ($items === []) {
return null;
}
return $items[array_key_last($items)];
}
Requirements
- Introduce one template type named
T. - Describe the parameter as a list of
T. - Describe the return value as
T|null. - Keep the native parameter type as
array. - Keep the native return type as
mixed. - Call the function once with strings and once with integers.
- Print both results with
var_dump(). - Include the expected output as comments in the same PHP block.
- Explain why the analyser can infer two different return types from one function.
Show solution
<?php
declare(strict_types=1);
/**
* @template T
* @param list<T> $items
* @return T|null
*/
function lastOrNull(array $items): mixed
{
if ($items === []) {
return null;
}
return $items[array_key_last($items)];
}
$name = lastOrNull(['Ada', 'Grace']);
$quantity = lastOrNull([10, 20]);
var_dump($name, $quantity);
// Prints:
// string(5) "Grace"
// int(20)
For the first call, the analyser infers T as string, so $name is string|null. For the second call, it infers T as int, so $quantity is int|null.
The native return type remains mixed because PHP cannot express this generic relationship directly. The PHPDoc gives PHPStan or Psalm the additional static-analysis rule without changing how PHP executes the function.