← All resources

/// Article — Aug 2026

PHP Has Had Generics for Years. You're Probably Just Not Using Them.

/// Based on the talk

PHP Generics Today (almost)

PHP UK 2020. The video goes through the same ideas in more detail.

Watch the talk →

Ask a room of PHP developers what feature they'd most like added to the language, and generics will be near the top of the list.

It's been that way for as long as I can remember. It'll probably be that way for a while yet.

Here's the thing though. You can have almost all the benefits of generics in PHP today. No RFC. No waiting for PHP 9. No fork of the language.

You just need a static analyser and a few docblock annotations.

Why types matter in the first place

Before we get to generics, it's worth being clear about what type declarations actually give us. Take this function:

function process(User $user): void
{
    // ...
}

That User type does three separate jobs.

It documents. Any developer reading the code knows exactly what process expects. No guessing, no digging through the implementation.

It checks at runtime. Pass anything that isn't a User and PHP throws a TypeError right at the point of the bad call, instead of something weird happening five method calls later.

It enables static analysis. Tools like PHPStan and Psalm can find the bug without running the code at all.

That third one is the big deal. The later a bug is found, the more it costs to fix. A bug found in production is expensive. A bug found in CI is cheaper. A bug your IDE underlines as you type it is practically free.

So the more type information we give our tools, the earlier they find our bugs. Generics are about giving the tools type information in the one place PHP's type system traditionally falls short.

The collection problem

Here's where PHP runs out of road. Imagine a simple queue:

class Queue
{
    public function add($item): void { /* ... */ }

    public function getNext() { /* ... */ }
}

What type is $item? What does getNext() return?

We don't know. The whole point of a queue is that it can hold anything. Users, books, strings, jobs. When we write the class, we don't know what type it'll hold.

So we have no type information. No documentation for other developers, no runtime checks, and nothing for static analysis to work with.

There are a couple of traditional workarounds.

Option 1: a runtime-checked queue

class TypedQueue
{
    public function __construct(private string $type) {}

    public function add(object $item): void
    {
        if (!$item instanceof $this->type) {
            throw new TypeError("Expected {$this->type}");
        }
        // ...
    }
}

$queue = new TypedQueue(User::class);

One implementation for every type, and we get runtime checks. But static analysis can't help, because it has no idea what type a given queue holds unless it tracks where the queue was created.

Option 2: a queue per type

class UserQueue
{
    public function add(User $item): void { /* ... */ }

    public function getNext(): User { /* ... */ }
}

Runtime checks and static analysis. But now we need a BookQueue, a JobQueue, a StringQueue... Lots of near-identical classes to write and maintain.

What generics give you

Languages with generics solve this cleanly. In Java or C#, you'd write something like:

class Queue<T> {
    public void add(T item) { ... }
    public T getNext() { ... }
}

Queue<User> userQueue = new Queue<User>();

T is a placeholder. You supply the real type when you create the queue. One implementation, and you still get documentation, runtime checks and static analysis.

That's not valid PHP. Try it and you get a parse error.

Generics in docblocks

The trick is to put what the language can't express into docblocks. PHP ignores them. Static analysers read them.

Here's the queue again:

/**
 * @template T
 */
class Queue
{
    /** @param T $item */
    public function add($item): void { /* ... */ }

    /** @return T */
    public function getNext() { /* ... */ }
}

@template T declares a type placeholder, just like <T> in Java. Then we use T in the parameter and return types.

When we create a queue, we tell the analyser what T is:

/** @var Queue<User> $userQueue */
$userQueue = new Queue();

$userQueue->add(new User('Alice')); // OK
$userQueue->add('bob');             // Static analysis error

$user = $userQueue->getNext();      // PHPStan knows $user is a User

That's it. That's generics in PHP.

Run PHPStan or Psalm over this code and the add('bob') line gets flagged. And because the analyser knows $user is a User, it can check everything you do with $user afterwards too.

The workflow changes slightly. It used to be "write code, run code". Now it's "write code, run static analysis, run code". The static analyser becomes something like a compile step, checking types that the runtime never sees.

The naming is just convention, by the way. T for type, K and V for key and value. You can call them anything.

Typed arrays: the quick win

If you only take one thing from this article, make it this one.

You've almost certainly seen docblocks like this:

/** @return User[] */
function getUsers(): array
{
    return [new User('Jane'), 'james'];
}

That User[] annotation is a sort of proto-generic. The problem is nothing checks it. PHP will happily return an array containing a string, and pass it on to anything that accepts an array.

Static analysers do check it. They'll flag that return statement straight away.

You can also be more specific about arrays. array<V> specifies the value type. array<K, V> specifies both key and value types (keys can only be int or string, because that's all PHP arrays support):

/** @return array<string, Employee> */
public function getEmployees(): array { /* ... */ }

foreach ($business->getEmployees() as $name => $employee) {
    welcome($name);     // OK: $name is known to be a string
    promote($employee); // OK: $employee is known to be an Employee
}

With only Employee[], the analyser knows the values but not the keys, so it can't be sure $name is a string. With array<string, Employee> it can prove it.

These days I'd also reach for list<T> when an array should have sequential integer keys starting from zero. PHPStan and Psalm both understand it, and it catches the subtle bugs you get when someone uses unset() on an array and assumes the keys are still 0, 1, 2...

The same syntax works for collection libraries. Doctrine's ArrayCollection<K, V> is annotated this way.

Generic functions

Templates aren't just for classes. Functions and methods can have them too:

/**
 * @template T
 * @param T $value
 * @return list<T>
 */
function asList($value): array
{
    return [$value];
}

$values = asList(5);      // list<int>
$names = asList('Dave');  // list<string>

The analyser works out what T is from the argument you pass in, then uses that to work out the return type.

class-string: fixing your DI container

This is one of my favourites, because it fixes a problem almost every PHP project has.

Most dependency injection containers have a method like this:

public function make(string $className): object

Call $container->make(Person::class) and, as far as your tools are concerned, you get back an object. Not a Person. So every call to a method on the result is unchecked.

class-string<T> fixes it:

class Container
{
    /**
     * @template T of object
     * @param class-string<T> $className
     * @return T
     */
    public function make(string $className): object { /* ... */ }
}

$person = $container->make(Person::class); // PHPStan knows this is a Person
$person->getName();                        // Checked
$person->doesNotExist();                   // Static analysis error

class-string<T> means "a string containing the fully qualified name of class T". When you pass Person::class, the analyser binds T to Person, and the return type becomes Person.

This is also a good reason to always use Person::class rather than a hand-typed string. The analyser (and your IDE) can follow it.

Extending and implementing generic types

PHP has extends and implements. Docblock generics have @extends and @implements, which bind a template to a concrete type in a subclass.

The classic example is a repository:

/**
 * @template T of object
 */
abstract class Repository
{
    /** @return list<T> */
    public function findAll(): array { /* ... */ }

    /** @return T|null */
    public function findById(int $id): ?object { /* ... */ }
}

/**
 * @extends Repository<User>
 */
class UserRepository extends Repository
{
}

$user = $userRepository->findById(1); // User|null

Write the base repository once. Every concrete repository gets full type information with a one-line annotation.

Restricting templates

You can restrict what a template can be, using of:

/**
 * @template T of Animal
 */
interface AnimalGame
{
    /** @param T $animal */
    public function play($animal): void;
}

/**
 * @implements AnimalGame<Dog>
 */
class DogGame implements AnimalGame
{
    public function play($animal): void
    {
        $animal->bark(); // OK: the analyser knows this is a Dog
        $animal->meow(); // Error: dogs can't meow
    }
}

/**
 * @implements AnimalGame<Car>   // Error: a Car is not an Animal
 */
class CarGame implements AnimalGame { /* ... */ }

Two things are happening here. T of Animal means AnimalGame<Car> is an error. And inside DogGame, the analyser knows $animal is a Dog, even though there's no type declaration on the parameter. It worked that out from @implements AnimalGame<Dog>.

Making it work on a real codebase

All of this sounds great on a greenfield project. Real projects are messier. Here's what I've learnt about making it work.

Run the analyser at its strictest level

Generics checking is only trustworthy if the analyser knows the types of everything. At lower levels, PHPStan lets mixed slip through, and your nice generic types leak away.

Turning on the strictest level for an existing codebase will produce a lot of errors. Use a baseline. PHPStan and Psalm both have one built in. Record the existing issues, fail the build only on new ones, and chip away at the rest over time.

Third-party code without annotations

Your code might be fully annotated, but the libraries you depend on might not be. In rough order of preference, here's what to do:

  1. Get the library on board. Talk to the maintainer. Offer a PR adding annotations, or adding static analysis to their build. Lots of major libraries (Doctrine, PHPUnit, webmozart/assert, and plenty more) already ship generics annotations.

  2. Write an adapter. Wrap the untyped API in a small class of your own with proper types:

    final class CleanHasher
    {
        public function __construct(private Hasher $hasher) {}
    
        public function encode(int $id): string
        {
            return $this->hasher->encode($id);
        }
    }

    The rest of your code talks to CleanHasher and is fully analysable.

  3. Write stubs. A stub file re-declares the third-party class with just its signatures and your docblock annotations. You then tell the analyser to use the stubs. Be careful here. It's garbage in, garbage out. Get a stub wrong and the analyser will confidently tell you the wrong thing.

  4. Write an analyser extension. Sometimes needed for frameworks that do a lot of "magic". But an extension is tool-specific (a PHPStan extension won't work in Psalm) and much harder to write than the other options. Before writing one, check whether someone already has. There are good PHPStan extensions for Laravel, Symfony and Doctrine.

Legacy code and trust

On a legacy codebase you probably can't trust the generics analysis completely, because type information will be missing somewhere.

What does work is picking a contained area, such as a new module. Work out its boundary: the places where data enters it (usually far fewer than all its public methods). Guard those entry points with assertions that check the data really is the expected type. Then apply full, strict analysis inside that area.

Over time, you grow the trusted area.

The "almost" in the title

I'd be doing you a disservice if I pretended this was as good as real generics. There are trade-offs.

No runtime checks. Docblock generics are checked by static analysis only. PHP never sees them. If your analysis is incomplete, or someone isn't running the analyser, nothing stops the wrong type getting through.

When colleagues push back with "but we'll lose runtime checks", the first question is: which runtime checks? A User[] docblock has never been checked at runtime. For arrays and collections, you're usually going from no checks to static checks.

No official standard. These annotations aren't part of the language, and there's no PSR defining them. PHPStan and Psalm understand each other's syntax and have converged a lot over the years, but there are still occasional differences in behaviour.

IDE support. When I first gave this talk in 2020, this was the biggest pain point. No IDE understood @template, and using array<K, V> could actually make your IDE lose type information it previously had. That's much better now. PhpStorm has understood @template for years, and the IDE experience today is a world away from what it was. If you tried this a few years ago and gave up because of your editor, it's worth another look.

It's not always worth it. One of PHP's strengths is that you can pick the level of engineering for the job. For a script you'll run once and throw away, you don't need any of this.

Will PHP get native generics?

Maybe. Eventually. Partly.

In August 2025 the PHP Foundation published a blog post exploring compile-time generics. The idea is a smaller slice of generics: generic interfaces and abstract classes, with concrete classes filling in the type. That would cover a lot of real use cases, like the repository example above.

But the post is clear that something like new Repository<BlogPost>(), supplying the type when you create an object, is still not on the table. Doing that at runtime is an order of magnitude harder, and generics over union types like Repository<BlogPost|User> are likely never to be practical.

So native generics, if they arrive, will probably cover some of what docblock generics already do. The docblock approach isn't going anywhere, and everything you learn using it will carry straight across if and when native support lands.

Where to start

If you're not using a static analyser yet, start there:

composer require --dev phpstan/phpstan
vendor/bin/phpstan analyse src --level=max

Brace yourself for the output, then generate a baseline.

If you already use one, pick the easiest win. Find a method with @return Foo[] and make it @return list<Foo> or @return array<string, Foo>. Then find your DI container, or your base repository, and add @template.

You'll be surprised how many bugs the analyser suddenly finds.

For the full story, including the blue-sky ideas I had about how PHP could support generics syntax without runtime checks, watch the talk from PHP UK.

Are you already using docblock generics, or is there something stopping you?

Start a conversation

We help teams put these ideas into practice, through training, code review and working alongside you. Tell us where you are and what you're aiming for.