Holly Schilling — v0.9 pitch, July 2026. This document makes the case for the feature: the problem, the design philosophy, and why it belongs in the engine. The syntax, semantics, and engine mechanics are specified separately in the Surfaces RFC, which has a complete prototype.
PHP's member visibilities — public, protected, private, and PHP 8.4's asymmetric private(set)/protected(set) — describe where in the type hierarchy a member may be touched. They say nothing about which audience a member is for. A single class routinely serves several — code that consumes it, code that extends it, a factory that constructs it, a test that exercises it — and PHP gives an author no way to say which members belong to which, short of extracting interfaces and trusting every call site to narrow to them.
This pitch proposes Surfaces: named, class-scoped roles that partition a class's API into logical slices. A member may be assigned to one or more surfaces, which removes it from the default (public) view and makes it reachable only from code that explicitly grants itself that surface.
Consider the seam almost every codebase already has, expressed as a comment — a method made public purely so a test can reach it, with a note begging no one to call it in production. A surface makes that intent real:
class RateLimiter {
surface Testing;
public function attempt(string $key): bool { /* ...decides using $this->clock... */ }
// On the Testing surface: removed from the object's default view.
surface[Testing] function setClock(Clock $clock): void {
$this->clock = $clock;
}
}Production code holding a RateLimiter sees attempt() and nothing else; writing $limiter->setClock(...) there is an error. A test declares that it is playing the Testing role, and only then can reach the seam:
use App\RateLimiter with surface[Testing];
$limiter = new RateLimiter();
$limiter->setClock(new FrozenClock('2026-01-01')); // visible, because the test asked
$this->assertFalse($limiter->attempt('user:42'));The grant is the heart of the feature: using a surface member requires declaring use C with surface[S]; in the scope that uses it, so a consumer's intent is local, explicit, and visible to a reviewer reading exactly the code under review.
Surfaces extend an axis PHP already has. The existing visibility modifiers are best understood not as enforcement but as signals of intended audience:
private— for the class itselfprotected— for the class and its subclassespublic— for everyone, with no role specifiedsurface[R]— for code acting in role R
The lineage runs through protected. Its defenders have long conceded that protected grants no real protection — a subclass can widen it to public at will — so its value was never enforcement but signal: it tells a reader that a member is intended for subclasses. That defense is correct, and the signaling it describes is the part worth generalizing. If a modifier's real job is to declare an intended audience, that audience need not be the single hardcoded group the language happens to name. Surfaces lift protected's signal from "subclasses" to arbitrary, author-named roles.
The name comes from how objects are drawn. In a collaboration graph an object is a shape, and different collaborators touch different faces of it — the producer writes to one face, the consumer reads from another. Those faces are its surfaces.
Granting is therefore always permitted by design. Like protected, a surface is a declaration of intent, not a gate; the contribution is not a new way to restrict access but a standard, checkable way to declare which role a piece of code is using — the missing fourth point on the visibility axis. Everything else is the mechanical consequence of taking that one idea seriously.
Consider a User whose construction runs validation and is meant to happen only through a factory. Today you either make the constructor private and bolt a static factory method onto the class, or hand a separate factory class friend-level access to User's internals — and either way, a new User(...) appearing in a diff tells a reviewer nothing about whether it is the blessed path or a mistake. The knowledge of who may construct a User lives in the author's head, not in the code.
That is the general shape of a problem the three visibility modifiers cannot express. Classes routinely serve audiences beyond "everyone," "subclasses," and "the class itself": members meant for the framework that hosts a component but not the application above it; a constructor meant for one factory but not general callers; hooks meant for a test harness but not the production paths that must never touch them. All of it lands in one flat public API, so a reviewer reading a change has no signal for which role a given piece of code has taken on — that knowledge lives only in convention.
Surfaces turn that implicit knowledge into a declared structure on both sides. A library author groups members into named roles — an Extension surface for framework-extending code, a Creation surface for construction, a Testing surface for seams — so the grouping becomes part of the published contract rather than a comment. A consumer, in turn, declares at each use site which role it depends on, by granting the surface in the surrounding scope.
The declaration is the point. A diff that newly contains use CacheManager with surface[Extension]; tells a reviewer, in one line, that this code has taken on the extension role of that object — whether that role is powerful or trivial. Nothing about public/protected/private can say "this is reachable, but a consumer should state which role it is relying on."
The author writing a grant gains a little; the reader of a diff they did not write gains more. A surface grant is a single line that states, in reviewable form, that a change has taken on a dependency on a particular role — the precise signal a reviewer scans for and the one most easily lost in a large diff of individually-plausible method calls. Because the grant is greppable (grep -rn 'surface\[') and machine-checkable, a reviewer or a tool can confirm that the roles a change depends on are the roles its purpose implies.
This matters more as more code is produced by AI coding agents. Such code tends to be voluminous and locally plausible, which shifts a reviewer's attention from "is each line correct" toward "is this reaching further than the task requires." Surfaces make scope-of-use an explicit, diffable artifact rather than something a reviewer must reconstruct by reading every call site. Surfaces do not constrain what an agent can generate — granting is always permitted, so an agent will simply write the grant it needs — the value is that the agent is obliged to declare that intent in a form a human or tool can review.
Independently of any consumer, surfaces give an author a vocabulary for saying "these members form one coherent role." Today that intent can be conveyed only by splitting the class into multiple interfaces — which forces a type hierarchy and a naming decision for every grouping — or by documentation the language does not check. A surface declaration lets an author name the roles directly on the class, attach members to them, optionally bind a role to an interface, and have that structure be part of the contract that consumers and tooling can see.
Interface segregation can express some of these groupings, but only conditionally: the author must have predefined exactly the right interface, and the consumer must have chosen to depend on it rather than the concrete class. It fails outright on the cases that matter most — one concrete, stateful object serving several audiences — because those audiences share the object's state and cannot be pulled into separate types. Suppose we tried to segregate a driver manager's consumer API from its extension API:
interface ResolvesDrivers { public function driver(?string $name = null): mixed; }
interface RegistersDrivers { public function extend(string $name, Closure $factory): static; }
final class CacheManager implements ResolvesDrivers, RegistersDrivers {
// one object, one shared driver registry
public function driver(?string $name = null): mixed { /* ... */ }
public function extend(string $name, Closure $factory): static { /* ... */ }
}
$cache = resolveManager(); // typed as CacheManager, as it almost always is
$cache->driver('redis'); // resolving a driver — the consumer role
$cache->extend('mongo', $factory); // registering one — also right here, unbiddenThe interfaces change nothing for code holding the concrete CacheManager: extend() sits in reach beside driver(), and nothing records which role a given call is playing. Surfaces make the declaration mandatory and put it at the point of use — to reach a role, a consumer names it — so the intent is recorded whether or not anyone planned an interface. They also compose: a consumer that needs two roles grants both, with no predefined combined interface.
Each of these is a legitimate audience the existing three modifiers cannot name. The first two also resist the reflexive "extract an interface" answer, because the roles share one concrete, stateful object that every consumer holds in full.
- Extension and plugin APIs. A component carrying both a consumer API and an extension API on the same object — the shape behind Laravel's
Managerfamily (CacheManager,SessionManager, and the rest).driver()and its dynamic forwarding serve the code resolving a driver;extend()registers a new driver and is meant to be called from a service provider extending the framework — yet it sitspublic, in the consumer's autocomplete, right besidedriver(). Markingextend()surface[Extension]moves it out of the consumer's view: registration code declares that it is extending the framework, and code that merely resolves a driver never sees it. - Module- and package-internal members. PHP has no visibility between "everyone" and "this class or its subclasses": a class cannot expose a member to its sibling classes within the same module or package without also exposing it to that module's external consumers. A surface named for the module fills the gap — collaborating classes inside the module grant it, while the module's public API stays clean. It is the missing "package-private," expressed as an intended audience rather than a location.
- Test seams. Members a test must reach — a clock injector, a state reset, a fake-transport hook — that have no place in the production API. A
surface[Testing]member is reachable only where a test grants the surface, so the seam is stated explicitly and can never silently leak into a production path. - Factory-gated construction. A
surface[Creation]constructor confinesnewto code that declares it is constructing, letting a factory live anywhere rather than being chained to a static method on the class, and without a friend declaration's grant of total access. Direct construction outside the intended factory announces itself in the diff.
Granting a surface is always permitted — any reference to a class may grant any surface it declares, because the surface is intrinsic to the object. Surfaces describe and segregate intent; they are not an access-control mechanism, and a grant requires no authorization. Making a consumer's intended use legible — not restricting which code may call what — is the entire goal.
A surface may implement a single interface, which exposes the role as a type:
final class Queue {
surface Producer implements ProducerInterface;
surface Consumer;
surface[Producer] function ingest(string $value): void { /* ... */ }
surface[Consumer] function next(): string { /* ... */ }
}The class genuinely implements ProducerInterface — instanceof is true, and the object satisfies interface-typed parameters — so a Queue interoperates with all existing interface-typed code, and object identity is preserved (no wrapper, no view object). One role, one contract: a role that needs two interfaces is a role that wants splitting.
Interfaces and surfaces meet in one guarantee: a member reachable through an interface type is never gated. Code holding a ProducerInterface calls its whole contract without knowing the implementor routed it through a surface — and, to make that a theorem rather than a convention, the language requires the converse at class-link time: any method an implemented interface declares must be public or on a surface bound to that interface. An author cannot accidentally strand an interface's method behind a role its callers never heard of.
The full vision gates the handoff: passing a Queue as ProducerInterface would require the Producer grant, recording intent once, at the conversion, after which interface-typed use flows freely. That conversion gate is inherently a static-type check, so it is the province of static analysis and a future engine iteration; the RFC specifies today's honest engine semantics (a member reachable through a bound interface is reachable, full stop) and defers the gate. The trade is explicit: binding an interface to a surface publishes the role as a type and trades runtime gating for reviewable grants plus tooling discipline. Surfaces without a bound interface — the Testing, Creation, and module-private cases above — are fully enforced by the engine.
An early draft of this idea specified compile-time checks keyed on the receiver's static type. Implementation taught us that is the wrong model for PHP's engine: PHP compiles every file independently, so the compiler generally cannot see the target class at all when it compiles a call site. What the engine can do — and now does, in the prototype — is enforce surfaces exactly the way it enforces private and protected: at runtime, in member resolution, keyed on the receiver's runtime class and the calling code's lexically-compiled grants.
That model turns out to be stronger than the static sketch in the places users would notice: dynamic calls ($obj->$method(), call_user_func, dynamic new) are checked rather than exempted, and there is no way to launder a value past the check through an untyped variable. Static analyzers are invited to layer the stricter static-type discipline (including the conversion gate) on top; the engine's dynamic rule is the authoritative floor. Reflection bypasses surfaces exactly as it bypasses visibility — consistent existing behavior, not a new hole.
A surface is a visibility level, and visibility is the one part of this that no one argues belongs in a linter. Each existing modifier names an intended audience — private is "me," protected is "me and my subclasses," public is "everyone, role unspecified" — and a surface names "anyone acting in role R." Member access already asks the engine a question on every call: is this member visible to this access site? Surfaces add inputs to that existing computation; they do not introduce a new kind of check. A static analyzer, by contrast, would have to reimplement the engine's visibility resolution in a parallel, per-tool, non-authoritative dialect to answer the same question.
Three further reasons the engine is the right home:
Standardization. This is the native-types precedent. PHPStan and Psalm can already express role constraints, but in different annotation dialects: a library author who adopts one fragments their audience, and a consumer who runs neither analyzer sees nothing at all. A language construct is universal — every reader, every tool, every consumer sees the same contract. PHP has repeatedly absorbed analyzer concepts (parameter and return types, readonly, enums) for exactly this reason.
Authority. A library cannot require its consumers to run an analyzer. Declared role segregation only means something if a consumer cannot silently ignore it. An advisory annotation cannot provide that; a language rule can.
The interface binding cannot be done in userland. Making $queue instanceof ProducerInterface true is a change to the object's runtime type identity — a property of the running object, not a fact about source text. No static analyzer can do this even in principle, so the surface→interface feature is not reducible to static analysis at all.
To be honest about the boundary: for pure segregation in fully-typed code, an analyzer could approximate much of what surfaces do. Conceding that one case is what earns credibility for the four it cannot match — universality, authority, runtime identity, and being the authoritative answer rather than an opt-in second opinion. And on the objection that this is "a lot of syntax for a check": the syntax is one contextual word, a bracket modifier, and a with clause on an existing statement, with no new keyword — and that syntax is the contract a consumer declares and a reviewer reads. It is not overhead wrapped around a check; it is the feature. Enums added far more surface area for "typed constants," because the declaration was the point.
The honest boundaries, updated for the runtime model:
- Interface-bound surfaces trade gating for contract. Until the conversion gate lands (analyzer-first), a member reachable through a surface-bound interface is reachable without a grant. Unbound surfaces are fully enforced.
- Reflection bypasses surfaces, exactly as it bypasses
private/protectedtoday. - Grants are per role-and-type, not per object. A grant covers every receiver of the granted class (or subtype) in scope; per-binding grants are future scope.
- Extensions that replace object handlers opt out of standard resolution and therefore out of surface checks for their objects — the same posture the engine takes toward visibility.
Every one of these is shared by, or strictly narrower than, what a static analyzer could promise under the same constraints. The honest scope of the headline claim is intent is legible and enforced where resolution runs — which, in the runtime model, includes dynamic code that a static checker must wave through.
It can read as ironic that a feature like this should land in PHP, whose runtime checks are the least strict among major typed languages — surely a stricter language is its natural home? The opposite is true. A language that can enforce role boundaries at runtime tends to reach for genuine access control when it wants this, so the feature's signaling nature never gets isolated as an idea in its own right. The concept here in fact began in Swift, from precisely the observation in the design philosophy above: that protected never protected anything and was only ever a signal of intended audience. PHP, unable to lean on compile-time enforcement, is forced to take the feature as what it fundamentally is — signal, not protection. That is not a weakness to apologize for; it is why the idea appears here in its clearest form.
Several existing and proposed features address the general problem of exposing part of a class to some callers but not others. Surfaces draw on this lineage and differ chiefly in direction — they let a consumer declare the role it is using, rather than letting a definition site enumerate who may access it.
Friend classes — PHP RFC: Friends. The Friends RFC (Daniel Scherzer, under discussion) lets a class name other classes as friends, e.g. friend UserFactory;; a named friend may then access all of the declaring class's private and protected members. It fits its motivating case well: a factory or builder in the same library that must reach a private constructor. The mechanism is pairwise and declared by the accessed class — it enumerates specific collaborators by concrete name, the granted access is total (every private and protected member rather than a curated slice), and the declaration records who may reach in rather than which members form a role or how the class is meant to be used, so it carries no documentation value and leaves no signal at the call site. Surfaces address the same underlying need — partial, intentional exposure — from a complementary direction: the consumer names a role rather than the author naming a class, the grant is per-role rather than total, no collaborator need be known in advance, and the role is a named, optionally interface-bound grouping that documents the class and is visible in review. The two are not mutually exclusive: friends targets a specific intra-library relationship; surfaces target consumer-declared, externally-exercised roles.
Scoped privates. PHP already lets instances of the same class reach each other's private members, and related proposals would extend private sharing to closely-associated classes. This serves cohesion among code that ships together but offers nothing to a library whose roles are exercised by outside callers — the audience surfaces is built for. Adjacent problem, opposite audience.
Module-relative visibility. Surfaces' lexical grant rhymes with Rust's pub(in path), where a member's visibility is expressed relative to a region of the program rather than a single global level. The using site, not a global rank, determines reach.
Object capabilities. Capability systems (E, Newspeak) and membrane patterns are the nearest philosophical relative: what a holder may do is governed by the reference it was handed, not by who the holder is. Surfaces share that "the using site declares its role" spirit but are deliberately not capabilities — a true capability is unforgeable, whereas a surface grant is freely available to any holder of the object and records intent rather than enforcing it.
- The
ClassName+Surfacetype operator (earlier drafts). Subsumed by the lexical grant, which is simpler, needs no flow typing, and — by living at the use site — better records a consumer's intent for review. - Access-controlled surfaces. Making a surface grantable only by certain callers would turn surfaces into an access-control feature, whereas this proposal concerns itself solely with making intent legible. The two are orthogonal.
- Surfaces declared on interfaces. Mandating role structure on implementers had no compelling use case; the valuable direction — exposing a class's role as a type — is already served by surface→interface binding.
- Compile-time enforcement in the engine. See "Enforcement" above: the right split is runtime enforcement in the engine (uniform, covers dynamic code) plus the static-type discipline in analyzers.
A complete prototype exists (branch surfaces, stacked on the extension-methods work): declarations, member gating for methods/properties/constants, grants at file and body scope, hierarchy rules, interface binding, surface-gated construction, opcache/JIT hardening, and Reflection, with the full php-src test suite green. The Surfaces RFC specifies the semantics as implemented, the composition rules with extension methods, and the open issues.