PHP 9.0 doesn't exist. This chapter tours draft proposals that may change — or die — in review: eventually wrong, possibly before it ships. About this series →

Surfaces: Who Is This Method For?

Every mature class accumulates members with an awkward property: they're public, but they're not for everyone. The setClock() your tests need. The boot() only the framework should call. The emit() that exists for the compiler pass and nobody else. You can't make them private — the legitimate caller is outside the class. So they sit in the public API wearing a docblock that says please don't, and autocomplete offers them to every intern with an object in hand.

PHP 9.0's visibility axis has four points: private is "the class," protected is "the hierarchy," internal is "the module" (as the previous chapter covered), and public is "everyone." All four answer where does the caller live? Surfaces add the answer none of them can express: what role is the caller playing?

class RateLimiter {
    surface Testing;

    public function attempt(string $key): bool { /* decides using $this->clock */ }

    surface[Testing] function setClock(Clock $clock): void {
        $this->clock = $clock;
    }
}

setClock() is no longer in the default view. Calling it gets you an Error — unless your code has declared its role:

use RateLimiter with surface[Testing];

$limiter->setClock(new FrozenClock('2026-01-01'));   // this scope declared the role

That use ... with surface[...] line is called a grant, and it's the heart of the feature. Granting is always permitted — a surface is a signal of intended audience, not a lock (more on that below). What you gain is that the caller's intent now exists in the code, at the use site, in exactly one greppable line. "Which files touch the testing seams of RateLimiter?" stops being an archaeology project:

$ grep -rn 'surface\[Testing\]' src/ tests/

A class and its subclasses hold their own surfaces automatically, the way protected works — grants are for outsiders declaring a role. Members can sit on several surfaces (surface[Producer, Consumer]), properties and constants can be surfaced too, and grants can live at the file level or inside a single function body, whichever matches how narrowly the role applies.

Constructors with intended callers

My favorite trick in the proposal — surface[...] works on __construct:

final class User {
    surface Creation;
    surface[Creation] function __construct(public readonly string $name) {}
    public static function make(string $n): User { return new self($n); }
}

$u = new User("Ada");        // Error: Creation not granted
$u = User::make("Ada");      // fine — the class holds its own surfaces

final class UserFactory {
    public function fromName(string $n): User {
        use User with surface[Creation];
        return new User($n);              // the factory declared the role
    }
}

"Please construct this through the factory" has been a comment for twenty-five years. Now it's a semantic — and the factory's grant documents the exception.

Roles that are also contracts

A surface can bind an interface, which turns a role into a publishable type:

class Queue {
    surface Producer implements ProducerInterface;

    surface[Producer] function ingest(string $v): void { /* ... */ }
}

The class genuinely implements ProducerInterfaceinstanceof agrees, and interface-typed parameters accept it. And here the design makes a promise worth spelling out: anything reachable through an interface is never gated. A function typed ProducerInterface $p can call everything on that contract without knowing surfaces exist. The compile-time flip side: an interface-required member must be public or on a surface bound to that interface — so an implementor can never accidentally booby-trap an interface its consumers already depend on.

The idiom this enables is tidy: the role-holder works with the concrete class under a grant; everyone downstream receives the interface and stays entirely outside the surface system.

Like protected, on purpose

Surfaces are enforcement of communication, not access control. Any scope can grant itself any surface — the point is that it must say so, visibly, where a reviewer will read it. That's the same deal protected has always offered (anyone can subclass), and the proposal is explicit about keeping it: a blessed-callers-only variant was considered and rejected, because turning a signaling feature into a security feature would poison both.

If you use no surfaces, you pay nothing — the RFC's benchmarks make "unused = free" a measured claim, not a hope. And if the previous chapter's modules are on your mind: modules scope visibility to where code lives, surfaces to what role code plays. A library will plausibly use both — modules to hide its internals, surfaces to partition the API it does export.

Sources

  • PHP RFC: Surfaces — draft; the same gist carries the companion pitch with the motivation and design philosophy
  • Adjacent prior art: the Friends RFC, Rust's pub(in path), and the asymmetric-visibility lattice from PHP 8.4