← All resources

/// Article — May 2025

PHP's RFC Process Takes Years. You Can Add Language Features This Afternoon.

/// Based on the talk

Extending the PHP Language

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

Watch the talk →

Want a new feature in PHP? Here's the process.

Write an RFC. Discuss it on the internals mailing list. Revise it. Put it to a vote, where it needs a two-thirds majority. Get it implemented. Wait for the next yearly release.

With perfect timing, that's six months from idea to a feature you can use. In practice, plenty of features fail their first vote and take three or four years. Some never make it at all.

And that's how it should be. Changes to a language used by millions of people should be slow and careful.

But there's another way to get some features today. No RFC, no vote, no waiting. You write them yourself, using PHP attributes and static analysis.

I've done it for features like C++'s friend, Java's package-level visibility, and sealed classes. Here's how it works, and how you can do the same.

Which features can you add?

Let's be clear about the limits first. You can't add everything this way.

Think about what private actually does. When you mark a method private, you're communicating intent: "this is an implementation detail, nobody outside this class should call it."

PHP enforces that at runtime. But it doesn't need to. A static analysis tool can look at your code and tell you, without running anything, that you're calling a private method from outside its class.

The same is true of final, readonly, protected and return types. A big chunk of the language exists to express intent, and intent can be checked statically.

That's the category of feature you can add yourself: things that communicate what a developer means, and constrain how code can be used.

What you can't add this way: anything that changes runtime behaviour, security features, performance improvements, or new syntax. Those still need the RFC process.

This isn't as radical as it sounds

If you're using generics in PHP, you're already using a language feature that exists only in static analysis.

PHP doesn't support generics. But write @return list<User> or @template T in a docblock and PHPStan and Psalm will enforce it. The runtime has no idea. The tools do all the work. (I've written about that in PHP Has Had Generics for Years.)

There's also precedent for features proving themselves in static analysis before making it into the language. The never return type existed in PHPStan and Psalm before PHP 8.1 added it natively. When the RFC came along, the idea had already been used in real codebases.

A real problem that PHP's visibility can't solve

Here's where this started for me.

Imagine an application that sends text messages. There's a TextMessageSender that talks to an SMS provider's API. It's slow, it makes HTTP calls, and it sometimes needs retrying.

So the architecture looks like this:

  • Application code calls TextMessageGateway, which puts the message on a queue.
  • TextMessageQueueProcessor picks messages off the queue and calls TextMessageSender.

The rule is simple: nothing except the queue processor should call TextMessageSender directly.

How do you enforce that?

private won't work, because the processor is a different class. protected won't work, because the processor doesn't (and shouldn't) extend the sender. sendMessage() has to be public, and public means anyone can call it.

So you're left with the usual options. A comment saying "don't call this directly". A note in the README. Hoping someone spots it in code review.

All of those rely on people remembering. Six months from now, a new developer (or future you, or an AI agent) will call TextMessageSender from a controller, because it's the obvious thing to do and nothing stops them.

Step 1: a hard-coded rule

The first version is a PHPStan rule that knows exactly which classes are involved.

We're interested in method calls, so the rule looks at MethodCall nodes. For each one, it asks two questions: which class is making the call, and what type of object is it being called on?

/**
 * @implements Rule<MethodCall>
 */
final class TextMessageSenderCallRule implements Rule
{
    public function getNodeType(): string
    {
        return MethodCall::class;
    }

    public function processNode(Node $node, Scope $scope): array
    {
        // 1. Which class is making the call?
        $callingClass = $scope->getClassReflection()?->getName();
        if ($callingClass === TextMessageQueueProcessor::class) {
            return [];
        }

        // 2. What is the method being called on?
        $calledOnType = $scope->getType($node->var);

        foreach ($calledOnType->getObjectClassNames() as $targetClass) {
            if ($targetClass === TextMessageSender::class) {
                return [
                    RuleErrorBuilder::message('TextMessageSender can only be called from TextMessageQueueProcessor')
                        ->identifier('architecture.textMessageSender')
                        ->build(),
                ];
            }
        }

        return [];
    }
}

Two things to notice.

$scope->getClassReflection() tells us which class contains the code being analysed. That's our "calling class". It returns null if the call isn't inside a class at all, hence the ?->.

getObjectClassNames() returns a list of class names, not a single one. That matters. A variable might be typed as TextMessageSender|WhatsAppSender, and we need to check every possibility. Never assume a type resolves to exactly one class.

This works. But it only solves one problem.

Step 2: a configurable rule

The obvious next step is to make the classes configurable:

public function __construct(
    private string $allowedCallingClass,
    private string $targetClass,
) {
}

Swap the hard-coded class names in processNode() for these properties, and configure it in phpstan.neon:

services:
    -
        class: App\Build\PHPStan\RestrictedCallRule
        arguments:
            allowedCallingClass: App\Sms\TextMessageQueueProcessor
            targetClass: App\Sms\TextMessageSender
        tags:
            - phpstan.rules.rule

Now the rule is reusable. And it's roughly where tools that check class dependencies, like PHPArch and Deptrac, operate.

But there's a problem with putting constraints in config files.

The constraint lives a long way from the code it applies to. Someone reading TextMessageSender has no idea it's restricted unless they go looking in phpstan.neon. And if someone renames the class using their IDE's refactoring tools, the IDE won't update the config. The rule silently stops working.

Step 3: an attribute

PHP 8 gave us attributes. They're part of the language, they live right next to the code they describe, and IDEs understand them. Rename a class referenced in an attribute and your IDE updates it.

So instead of config, let's put the constraint on the class itself:

#[Friend(TextMessageQueueProcessor::class)]
final class TextMessageSender
{
    public function sendMessage(string $to, string $message): void
    {
        // ...
    }
}

Anyone reading the class can see straight away who's allowed to use it. The constraint is documented in the code, and it's enforced.

The attribute itself is almost nothing:

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
final class Friend
{
    /** @var list<class-string> */
    public readonly array $friends;

    /** @param class-string ...$friends */
    public function __construct(string ...$friends)
    {
        $this->friends = array_values($friends);
    }
}

It does nothing at runtime. It's just data. The PHPStan rule reads that data and enforces it:

/**
 * @implements Rule<MethodCall>
 */
final class FriendRule implements Rule
{
    public function getNodeType(): string
    {
        return MethodCall::class;
    }

    public function processNode(Node $node, Scope $scope): array
    {
        $callingClass = $scope->getClassReflection()?->getName();
        $calledOnType = $scope->getType($node->var);

        foreach ($calledOnType->getObjectClassReflections() as $targetClass) {
            $attributes = $targetClass->getNativeReflection()->getAttributes(Friend::class);

            if (count($attributes) !== 1) {
                continue;
            }

            $friends = $attributes[0]->getArguments();
            if (in_array($callingClass, $friends, true)) {
                continue;
            }

            return [
                RuleErrorBuilder::message(sprintf(
                    '%s can only be called by its friends (%s), not from %s',
                    $targetClass->getName(),
                    implode(', ', $friends),
                    $callingClass ?? 'outside a class',
                ))
                    ->identifier('friend.notFriend')
                    ->build(),
            ];
        }

        return [];
    }
}

getObjectClassReflections() gives us PHPStan's reflection for each class the object might be. From there we can reach PHP's native reflection and read the attribute's arguments.

When I first wrote this, PHPStan had no built-in way to read attributes, so dropping to native reflection was the only option. PHPStan 2.1 added getAttributes() to its own reflection objects, which is neater. Either works.

This is a simplified version. The real implementation also handles #[Friend] on individual methods, combines class-level and method-level friends, and covers new and static calls. But the core idea fits in a screen of code.

We've just added a language feature to PHP.

The features I've built

I've packaged a set of these into a library called PHP Language Extensions, with the PHPStan rules that enforce them in a separate package. Here are some of them.

Friend

Inspired by C++. Only the listed classes can call a class or method. Friend RFCs have been proposed for PHP and haven't passed.

A lovely use case is forcing construction through a builder or factory:

final class Person
{
    #[Friend(PersonBuilder::class)]
    public function __construct()
    {
        // ...
    }
}

$person = new Person(); // ERROR: only PersonBuilder can do this

You can list multiple friends. Class-level and method-level friends combine.

NamespaceVisibility

Inspired by Java's package-level visibility. A class or method can only be used by code in the same namespace (and, by default, sub-namespaces).

namespace App\Pricing;

#[NamespaceVisibility]
final class DiscountCalculator
{
    // ...
}

new DiscountCalculator() is fine from anywhere in App\Pricing. From App\Controllers, it's an error.

This sits between public and protected, and it's something I've wanted in PHP for years. It lets you have lots of small, focused classes inside a module, while only exposing a few of them as the module's public API.

Sealed

Inspired by the sealed classes RFC, which was rejected. You list exactly which classes can extend a class or implement an interface:

#[Sealed([Success::class, Failure::class])]
abstract class Result {}

final class Success extends Result {}       // OK
final class Failure extends Result {}       // OK
final class SomethingElse extends Result {} // ERROR

MustUseResult

Great for immutable objects, where forgetting to use the return value is a classic bug:

final class Money
{
    public function __construct(public readonly int $pence) {}

    #[MustUseResult]
    public function add(int $pence): self
    {
        return new self($this->pence + $pence);
    }
}

$cost = new Money(5);
$cost->add(6);                // ERROR: result is thrown away
$newCost = $cost->add(6);     // OK

InjectableVersion

Marks an interface (or base class) as the type that should be used for dependency injection. Type-hint a concrete implementation instead and you get an error:

#[InjectableVersion]
interface Mailer {}

final class MailgunMailer implements Mailer {}
final class Welcomer
{
    public function __construct(
        private Mailer $mailer,            // OK
        private MailgunMailer $mailgun,    // ERROR: use Mailer
    ) {}
}

TestTag

This one comes from my electronic engineering degree. Circuit boards often have test pins: connection points that exist purely so the board can be tested, and aren't used in normal operation.

#[TestTag] marks a public method as only callable from test code:

final class Person
{
    #[TestTag]
    public function setId(int $id): void
    {
        $this->id = $id;
    }
}

The classic example is a Doctrine entity. In production, the database sets the ID. Nothing in application code should ever call setId(). But in unit tests, you might need to.

I'll admit this one's opinionated. Not everyone will agree with it. But I prefer it to the alternative of using reflection in tests to poke at private properties, because if someone renames or removes the method, the consequences in the test code are obvious.

Why attributes are the right vehicle

I've tried other approaches over the years. Attributes win for three reasons.

They're part of the language. Attribute data is in the AST, so every static analysis tool can read it. Nobody has to agree on a docblock syntax.

They live with the code. Anyone reading the class sees the constraint. Compare that with a comment (which nobody reads) or a config file (which nobody knows exists).

They survive refactoring. Rename a class with your IDE and any ::class references in attributes are updated too. Config files full of class names as strings don't get that treatment.

There's another design decision I'd recommend if you build your own: keep the attributes and the rules in separate packages. The attributes are the specification of the feature. The PHPStan rules are one implementation. That leaves the door open for Psalm, PhpStorm, or anything else to implement the same checks.

The library also has an examples directory. Each example is a small annotated PHP file showing what should and shouldn't be an error. Those examples are the spec. If there's a debate about how a feature should behave, it starts as example code, before anyone touches an implementation.

You don't need a library to do this

You don't have to build general-purpose language features. The same technique works for your own project's rules.

Some I've written or seen on real projects:

  • The Doctrine EntityManager can only be used inside repository classes.
  • Repository methods that save must be called persist, not save or update.
  • find* methods return an entity or null. get* methods return an entity and never null.
  • Repository persist methods can only be called from the service layer.

Each of these is a line in a coding standards document that someone will eventually forget. As a rule, they're enforced every time PHPStan runs.

The catch

As with anything that sounds too good to be true, there are limits.

Your type information has to be good. The rule can only fire if PHPStan knows what type an object is. If your code is full of untyped parameters and mixed, the rules will miss things. This works best in a codebase with types on everything.

Nothing is enforced at runtime. If you don't run the static analysis, the attributes are just decoration. That's fine for your own team, as long as it runs in CI. For libraries, it's trickier. If you put #[Friend] on a class in your library, the constraint only applies to users who run PHPStan with the extension installed.

It's intent only. I'll say it again because it matters: this is for features that communicate intent. It's not a replacement for the RFC process in general.

A proving ground for RFCs

Here's the bit I find most exciting.

When an RFC is proposed, a lot of the discussion is hypothetical. How will people use this? What edge cases will come up? Will it cause more problems than it solves?

If a feature has been available as an attribute and a PHPStan rule for a couple of years, you have answers. Real codebases have used it. The edge cases have been found and fixed. The semantics have been debated with actual code rather than imagined code.

That makes for a much stronger RFC. It's the never story all over again. And PHP 8.3's native #[\Override] attribute shows the language itself is happy to use attributes to express intent.

Where to start

Think of a rule in your codebase that relies on people remembering. "Don't call this directly." "Only use this from tests." "Always go through the factory."

Then decide: is this a one-off for your project, or a general feature?

If it's general, try PHP Language Extensions:

composer require dave-liddament/php-language-extensions
composer require --dev dave-liddament/phpstan-php-language-extensions

If it's project-specific, write the rule. Start hard-coded, generalise if you need to, and move to an attribute when you want the constraint to live with the code.

For the full walkthrough, including building the rule step by step, watch the talk from PHP UK 2023.

What language feature would you add to PHP if you didn't have to wait for an RFC?

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.