Extension Methods: Your Methods on Their Classes
Every PHP codebase has a file like helpers.php. It's where the functions live that should be methods but can't be: date_is_weekend($date), str_limit($text, $len), dom_first_by_class($el, $class). They take their would-be receiver as the first argument, they read backwards at the call site, and they exist because the class they belong on is final, or lives in vendor/, or is DateTimeImmutable itself.
PHP 9.0 gives those functions a home. This chapter walks the feature up from its smallest form to its full packaging story — start where your code is, and stop when it's enough.
Start small: a block in a file
extension \DateTimeImmutable $date {
public function isWeekend(): bool {
return in_array((int)$date->format('N'), [6, 7], true);
}
}
var_dump((new DateTimeImmutable('2026-07-11'))->isWeekend()); // bool(true)
That's an extension block. It declares additional methods for an existing class or interface from outside its definition, and calls dispatch to them whenever the class itself doesn't have the method. If you've used Swift's extension or C#'s extension members, this is deliberately the same idea.
Declared like this — anonymously — an extension behaves exactly like a plain function: once the declaring file is loaded, its methods resolve from anywhere in the request. For a helper inside your own application, that's precisely what you want. Drop the block in the file that needs it, or in that helpers.php you already autoload, and you're done.
Before going bigger, it's worth absorbing the two rules that make the feature safe to use at all.
There is no $this in here. Look at the block header again: extension \DateTimeImmutable $date. That $date is the receiver variable — you name it yourself, and inside each method it holds the object the method was called on. Writing $this in an extension body is a compile error. This isn't syntax trivia; it's the whole mental model. An extension method is not inside the class. It holds the object the same way any outside code holds it — which is exactly why it can only touch the target's public API. The visibility rule doesn't need a paragraph of explanation; it falls out of the syntax.
Extensions can never change working code. A class's own methods always win, whatever their visibility. So does __call, if the class has one. Extension lookup runs only where PHP today would throw "Call to undefined method" — so loading an extension cannot alter the behavior of any program that already runs. The same outsider framing explains the rest: no added properties or constants (object layout is fixed), and no magic methods — you can't inject a __construct or __toString into someone else's class from a distance.
Interfaces are valid targets too, and that's quietly one of the most useful corners: an extension on an interface acts like a default implementation for every conformer that lacks the method, written purely against the contract. Classes that define the method keep their own.
Grow up: named extensions and use extension
Global visibility is right for your app and wrong for packages. If two Composer packages both extend DOMElement anonymously, their methods become visible to each other and to all of your code, wanted or not — and when a teammate reads $el->firstByClass('hero'), nothing on the page says where that method came from.
So for anything you ship, the block gets a name, and consumers import it:
// vendor/acme/dom-kit/src/DomTraversal.php
namespace Acme\DomKit;
extension DomTraversal on \DOMElement $el {
public function firstByClass(string $class): ?\DOMElement { /* ... */ }
}
// app/render.php
use extension Acme\DomKit\DomTraversal;
$el->firstByClass('hero'); // resolves here
// app/other.php — no import
$el->firstByClass('hero'); // Error: Call to undefined method DOMElement::firstByClass()
A named extension's methods resolve only in files that imported it. This follows Kotlin's model: the file is the scoping unit, because the file is what a reader can actually see. Visibility follows where code was written, not who called it — a helper function defined in an importing file can use the extension even when invoked from elsewhere, the same way a function's use imports don't depend on its callers.
The import line does two jobs at once. It makes the method call greppable — use extension Acme\DomKit\DomTraversal; sits at the top of the file, and your IDE, your static analyzer, and your reviewer all resolve firstByClass the same way the engine does. And it makes a file's candidate set enumerable: instead of "any loaded extension might apply," tooling knows exactly which vocabularies are active here.
Because the extension now has a fully qualified name, it can be a real symbol — which means the autoloader can find it. Composer's class-map generator learns to map extension declarations to files exactly as it maps classes, and an imported-but-unloaded extension is resolved through the ordinary class autoloader at first use. No new autoloader API, no autoload.files entry in the package, no require_once in yours. The import grants visibility, and first use loads the file — the same lazy, boring, reliable story classes have had for fifteen years:
use extension Acme\DomKit\DomTraversal;
$el->firstByClass('hero'); // first use: autoloaded, resolved, called
It's my favorite kind of feature design: the new thing gets to be ordinary.
Now do it to strings
Everything above — both forms — works on the built-in value types too: string, int, float, bool, and array. The anonymous form, for your app:
extension string $s {
public function length(): int { return strlen($s); }
}
var_dump("hello"->length()); // int(5)
And the named form, for the ecosystem:
// vendor/acme/str/src/Str.php
namespace Acme\Str;
extension Str on string $s {
public function limit(int $max, string $end = '…'): string { /* ... */ }
}
use extension Acme\Str\Str;
echo $title->limit(80); // imported, autoloaded, called — like any named extension
Every previous attempt at "methods on strings" died in the same committee: which methods, named what, with whose argument order? PHP 9.0's answer is that it doesn't answer: the language ships the mechanism and zero methods. Userland defines, names, versions, and ships scalar APIs as ordinary Composer packages — and per-file imports are what make that viable. Two packages can publish competing string vocabularies — incompatible limit()s, disagreeing length()s — and coexist in one application, because each of your files imports the one it wants. The language never has to bless anyone's string API; the ecosystem argues about names in composer.json, where arguments belong.
The receiver is bound by value, like a parameter, because that's what PHP value semantics have always meant: mutate $s inside a method and the caller's string is untouched. Calls on null remain the error they've always been — ?-> already expresses the intentional case.
The small print, kept small
A few things worth knowing before you go looking for edge cases:
- First-class callables work for object-targeted extensions:
$obj->extMethod(...)binds the receiver at acquisition. Scalar extension methods can't be captured that way yet. - Reflection deliberately doesn't list extension methods on the target class — your
DateTimeImmutabledidn't actually grow a method, and tooling should reason from the source, not the runtime. - Ordinary method calls pay nothing for the feature's existence. Resolution lives entirely on today's error path.
Sources
What reads here as one proposal is, on the wiki, a stack of four — split so each engine change could be reviewed and voted on its own merits:
- PHP RFC: Extension Methods — the base construct
- PHP RFC: Extension Method Visibility (use extension) — named blocks and per-file imports
- PHP RFC: Extension Method Autoloading — the autoloader integration
- PHP RFC: Scalar Extension Methods — the five value types as targets
- composer/class-map-generator#43 — the Composer half of the autoloading story