The turbofish meet the elephpant: 🐟

Somewhere, probably on an infinite step of grass, the turbofish meets the elephpant. You’ll need imagination to picture that, for sure, but by November, you’ll probably have it in your reality. So, what is this new fish, and what does it have to do with generic types? Let’s take a look at the future.

The Bound-erased Generics RFC

If you have been following php.internals lately, you have probably noticed that the long-awaited generics RFC finally has some serious RFC candidate. And buried inside the proposal is a weird piece of syntax. It is borrowed straight from Rust, and it is coming with its own folklore: the turbofish.

The name alone is worth a moment. It was coined spontaneously in a Reddit thread by Rust developers who looked at the ::<> operator and thought it looked like a speeding fish. They were not wrong, but it takes some culture or some effort to find it. Look at that:

// This is a turbofish 🐟
collect::>()

Squint a little. The double colon is the body, the angle brackets are the tail fins, and the whole thing seems to be charging forward to the left, at high velocity. The Rust community embraced the funny name, and it has stuck for years. As the elePHPant community, we are in no position to blame them.

Now, the recent PHP generics RFC, also called the Bound-Erased Generic Types RFC, written by Seifeddine Gmati, brings that exact same syntax to PHP. Now that the presentations are done, what does it actually do?

The Problem Turbofish Solves

Generics let you write code that works over a type rather than a specific type. Think of the classic Cache: a cache that holds elements of whatever type is used when it is used (sic). The PHP RFC puts it elegantly:

Generics are to types what functions are to values.

A function abstracts over a value; generics abstract over a type.

But here is the catch. Once you have a generic function like this

function identity(T $value): T { return $value; }

how do you tell PHP, at a specific call site, which type T should be? Most of the time you don’t need to: PHP, and any good static analyser, can infer it from the passed argument. Now, this only happens at execution time, and sometimes it pays to be explicit. That or the engine genuinely cannot figure it out on its own. That’s exactly when the turbofish arrives:

<?php

// Turbofish: explicitly telling PHP which type T is
$result = identity::<Pair<int, string>>($swapped);

// Also works on new, method calls, static calls...
$greeting = new Box::<string>("hello, world");
$paired   = $greeting->zip::<int>(42);

?>

Worth noting: the turbofish is completely optional. Adding generics to an existing function or class does not break any call. Old call sites continue to work unchanged. You only write :: when you specifically want to be explicit. This is the backward-compatibility safety net baked into the design.

Why the Weird ::<> Syntax?

This is the question everyone asks, and it has a genuinely interesting answer. Why not just write identity($x), like you would in TypeScript or Java?

The short answer is: because the parser doesn’t like it.

Imagine the PHP parser sees identity. It has to make an instant decision about what that < means. Is it the start of a type argument list? Or is it a less-than comparison, like $a < $b? Without looking ahead several tokens, and through potentially complex nested expressions, the parser genuinely cannot know.

Rust faced the exact same problem. Its solution, which this RFC borrows wholesale, is to prefix the angle brackets with :: (or the infamous Paamayim Nekudotayim operator). With PHP, ::is already the static class operator, used for accessing static members, methods and constants. It signals "compile-time, namespace-level thing coming up". So when the parser sees ::<, it immediately knows: these are type arguments, not a comparison. Ambiguity resolved. Parser happy. Everyone goes home. Here, I'll mention a subtle gotcha: the RFC notes that turbofish is whitespace-sensitive. Foo::is a valid turbofish, but Foo:: (note the space) is not: the parser reads it as scope resolution followed by a comparison. One more reason to run a formatter. Too bad, there is always a lot of fun to have with whitespaces when writing code.

Rust Solved This Problem

In Rust, the turbofish appears most often with methods like collect(), which can produce many different output types (a Vec, a HashSet, a BTreeSet, and lots of Rust things). Without turbofish, the Rust compiler simply refuses:

// Rust: does not compile β€” type annotations needed
let a = (0..10).collect();

// Rust: works fine βœ“
let a = (0..10).collect::>();

// You can also let Rust infer the element type with _
let a = (0..10).collect::>();

That _ is neat: it says "infer this part for me." Rust knows the items are integers, so it fills in Vec automatically. You get to specify what kind of container you want, while the compiler handles what it contains.

PHP's turbofish works on the same principle. You opt in to explicit type arguments exactly where you want them, and nowhere else.

What This Might Look Like in Real PHP

The RFC ships with a full, working example that shows the whole generics system in action. Here it is, lightly annotated:

<?php
final readonly class Pair<+L, +R> {
    public function __construct(
        public L $left,
        public R $right,
    ) {}

    public function swap(): Pair<R, L> {
        return new Pair($this->right, $this->left);
    }
}

final readonly class Box<+T> {
    public function __construct(
        public T $value,
    ) {}

    public function map<U>(callable $fn): Box<U> {
        return new Box(($fn)($this->value));
    }
}

function identity<T>(T $value): T {
    return $value;
}

// 🐟 Turbofishes everywhere!
$greeting = new Box::<string>("hello, world");
$paired   = $greeting->zip::<int>(42);
$swapped  = $paired->value->swap();
$result   = identity::<Pair<int, string>>($swapped);

var_dump($result->left);  // int(42)
var_dump($result->right); // string(12) "hello, world"

?>

Notice the +L, +R, +T prefixes: those are variance markers (covariant, in this case, - for contravariant). And the turbofishes? They appear at new Box::, at the method call zip::, and at the function call identity::>. Completely optional, completely explicit.

Why This Matters For Generics In PHP?

Here's the thing: PHP developers have been writing generics for years. They just call them @template annotations.

Open any major PHP framework today and you'll find things like this tucked into the docblocks:

<?php

/**
 * @template TKey of array-key
 * @template TModel of Illuminate\Database\Eloquent\Model
 *
 * @extends Illuminate\Support\Collection<TKey, TModel>
 */
class Collection extends BaseCollection {
}

?>

That's Laravel. There are over 202,000 PHP files on GitHub using @template. Static analysers, like mago from the very same author of this RFC, parse these annotations and provide type-safe generics today. It works! But it's a parallel type system living in comments, invisible to the actual PHP parser and runtime.

The RFC's approach, aptly named "bound-erased" generics, is pragmatic and honest about this. At runtime, Box and Box are the same class. Type parameters get replaced by their declared bound. The engine sees ordinary PHP types it already knows how to check. The parametric relationship between types, aka "this function returns the same type it receives", is validated by the static analyser, exactly as it is today with @template.

What changes is that the syntax is now in the PHP language, not in comments. The parser can validate it. IDEs can refactor it. ReflectionClass can expose it. And the turbofish sites get runtime arity and bounds checking.

The Migration Path Is Gentle

One of the smartest aspects of this RFC is how it handles migration. If you add generic type parameters to an existing function, no existing caller has to change. Turbofish is opt-in. Old code keeps compiling, keeps running, keeps working.

The migration from docblock generics to native syntax is essentially mechanical. Take a function that currently looks like:

<?php

/**
 * @template TNode
 * @template TWeight
 * @param DirectedGraph<TNode, TWeight> $graph
 * @param TNode $from
 */
function add_edge(
    DirectedGraph|UndirectedGraph $graph,
    mixed $from,
    mixed $to,
): DirectedGraph|UndirectedGraph { ... }
?>

And it becomes:

<?php

function add_edge<TNode, TWeight>(
    DirectedGraph<TNode, TWeight>|UndirectedGraph<TNode, TWeight> $graph,
    TNode $from,
    TNode $to,
): DirectedGraph<TNode, TWeight>|UndirectedGraph<TNode, TWeight> { ... }

// Syntax colors still works quite well for a future feature! 

?> 

The docblock collapses. The type information moves into the signature. The existing calles don't notice. The static analyser is now reading from the language, not from a comment convention it invented on its own.

Fun fact: The RFC notes that all new syntax was previously a parse error in PHP. So there are zero backward compatibility breaks at the user level. Everything new is net-new surface.

Is This PHP or Did I Accidentally Open a Rust File?

Fair question. The syntax is undeniably more angular than your average PHP code. Between variance markers (+T, -T), bounds (T : Animal), defaults (T = string), and turbofishes (::>), there is a bit more decorum in the syntax.

But consider: PHP developers have been writing these concepts in @template annotations for years. The ideas aren't new. What's new is that the language finally has a proper home for them. You'll still write perfectly normal PHP 99% of the time: and the turbofish will sit quietly in its corner, available when you need it, invisible when you don't.

The RFC is still under discussion (version 0.22 at time of writing, dated May 2026), so some details may shift before it goes to vote. There's even a secondary vote planned on whether variance should be written as +T/-T or T/out T. Even the syntax bikeshedding gets its own vote. Very PHP Internals.

The Long View

Generics in PHP have been discussed since at least 2014. Multiple RFCs have stalled. Nikita Popov's reified generics implementation surfaced "super-linear complexity" issues. The PHP Foundation's Arnaud Le Blanc hit a wall with cross-file type inference under PHP's per-file compilation model. This RFC takes a different bet: ship the syntax now, with bound erasure, and let the runtime model evolve on top.

It's a pragmatic choice. Java shipped erased generics in 2004. Two decades later, nobody is clamouring for their removal. Kotlin and Scala use the same approach. Hack, PHP's spiritual cousin from Meta, ships erased generics with opt-in reification on top, which is exactly the door this RFC leaves open for future PHP versions.

The turbofish is not the hero of this story. It's a small, necessary oddity: a punctuation mark workaround that exists because parsers don't like ambiguity. But it's a good sign: it means the authors of this RFC have done the hard work of thinking through how type arguments actually appear in code, down to the parser level.

PHP generics are coming. They will look a little different. And somewhere in your future codebase, there will be a tiny fish ::<> that somebody will ask you about in a professional meeting.

Now you know what to say.

The Bound-Erased Generic Types RFC is authored by Seifeddine Gmati and is currently under discussion on php.internals. The implementation PR is at php/php-src#21969. The turbofish concept originated in Rust. For the full backstory, Gunnar Karlsson's writeup is excellent.