Skip to content

Instantly share code, notes, and snippets.

@hollyschilling
Last active July 10, 2026 21:56
Show Gist options
  • Select an option

  • Save hollyschilling/7f51ae04895367ae007f687dcde52e8f to your computer and use it in GitHub Desktop.

Select an option

Save hollyschilling/7f51ae04895367ae007f687dcde52e8f to your computer and use it in GitHub Desktop.
Surfaces

Pitch: Surfaces for PHP

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.

Introduction

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.

Design philosophy

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 itself
  • protected — for the class and its subclasses
  • public — for everyone, with no role specified
  • surface[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.

Motivation

Surfaces make a consumer's intent legible

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 benefit is largest for the reviewer

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.

Authors gain a way to express logical groupings

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.

Interfaces record intent only when everyone opts in

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, unbidden

The 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.

Representative use cases

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 Manager family (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 sits public, in the consumer's autocomplete, right beside driver(). Marking extend() 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 confines new to 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.

Scope

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.

Roles as types: binding an interface

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 ProducerInterfaceinstanceof 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.

Enforcement: like visibility, not like a type system

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.

Why in the engine, and not static analysis

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.

Limitations

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/protected today.
  • 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.

Why PHP

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.

Prior art

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.

Rejected directions

  • The ClassName+Surface type 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.

Status

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.

PHP RFC: Surfaces

Version 1.0
Date 2026-07-10
Author Holly Schilling, holly.a.schilling@outlook.com
Status Draft
Implementation https://github.com/hollyschilling/php-src/tree/surfaces
Discussion thread tbd
Voting thread tbd

Introduction

This RFC proposes surfaces: named, class-scoped roles that partition a class's API. A member assigned to a surface is removed from the default (public) view and is reachable only from the declaring hierarchy or from code that grants itself that surface with a use C with surface[...] statement. Surfaces extend the visibility axis PHP already has — private is "the class," protected is "the hierarchy," public is "everyone" — with the missing fourth point: "code acting in role R." Like protected, a surface is a signal of intended audience, not an access-control mechanism: granting is always permitted; the contribution is that a consumer's role is declared at the use site, in one greppable line a reviewer can see.

class RateLimiter {
    surface Testing;

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

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

$limiter->setClock(...);            // Error: Testing not granted here

use RateLimiter with surface[Testing];
$limiter->setClock(new FrozenClock('2026-01-01'));   // this scope declared the role

The motivating case for the feature — why role segregation belongs in the language rather than in interface extraction or analyzer annotations — is argued in the companion pitch document; this RFC specifies the syntax, semantics, and engine mechanics.

Proposal

Syntax

Declaring surfaces

A class, abstract class, enum, or anonymous class declares surfaces with surface statements in its body, one per statement, optionally binding one interface:

class Foo {
    surface Producer implements ProducerInterface;
    surface Consumer;
}
  • surface is a contextual keyword, lexed as T_SURFACE only when followed by an identifier or by [ (the enum/extension lookahead technique, including the surface extends/surface implements carve-out). surface remains valid as a class, function, constant, method, and property-type name.
  • Surface names are case-sensitive identifiers scoped to the declaring class (like enum cases); they are not types, are not autoloadable, and occupy no namespace. Declaring the same name twice on one class is a compile-time error; redeclaring a name owned by an ancestor is a link-time error.
  • A surface binds at most one interface (design rule: one role, one contract; see the pitch). The bound interface is implemented nominally — it is appended to the class's interface list at compile time and linked by the ordinary implements machinery, so instanceof is true and interface-typed parameters accept the object (see Semantics).
  • Interfaces cannot declare surfaces (rejected feature); traits cannot yet (open issue) — both are compile-time errors.

Surface members

A member — method, property, or class constant — is assigned to one or more surfaces with the surface[...] modifier:

class Foo {
    surface Producer;
    surface Consumer;

    surface[Producer] function ingest(string $v): void { /* ... */ }
    surface[Consumer] function next(): string { /* ... */ }
    surface[Producer, Consumer] function depth(): int { /* ... */ }   // reachable via either role
    surface[Producer] private(set) int $lastSize = 0;                 // get on the surface, set internal
    surface[Consumer] const BATCH = 64;
}
  • surface[...] occupies the get/call-visibility slot: combining it with public, protected, or private is a compile-time error. It composes with final, abstract, readonly, and the set-visibility modifiers (private(set)/protected(set)); set-visibility must be no wider than the surface, per the existing asymmetric-visibility rule. Placing surface[...] itself in the set slot (public surface[Writer](set)) is not part of this proposal (Future Scope).
  • Internally, surface members carry ZEND_ACC_PUBLIC plus the surface assignment; interface conformance and the no-narrowing override checks therefore come from the existing machinery unchanged.
  • static members cannot carry a surface (there is no receiver to key a grant on; Future Scope). Magic methods cannot be surface members — they dispatch through class-entry slots and object handlers, never through the resolution path where the check lives — with one exception: __construct, which is gated through get_constructor (see Surface-gated construction).
  • Constructor property promotion cannot carry surface[...] (compile-time error), and property hooks take no surface of their own (the property's assignment governs).

Granting: use ... with surface[...]

A grant is a with surface[...] clause on a use declaration. with is a conjunction inside the statement (like as), not a reserved word; userland with() functions are unaffected.

use App\Events\Dispatcher with surface[Emitter];              // import + grant
use App\Events\Dispatcher as Bus with surface[Emitter];      // aliased
use App\Events\{
    Dispatcher as Bus with surface[Emitter],                  // per-member in group imports
    Queue with surface[Producer, Consumer],
    Logger,
};
use Dispatcher with surface[Emitter];                          // grant-only on a name already in scope

function publishBatch(Dispatcher $bus, array $events): void {
    use Dispatcher with surface[Emitter];                      // body grant: from here, grant-only
    $bus->emit("batch.start", null);
    array_walk($events, fn($e) => $bus->emit("item", $e));    // arrow functions inherit the grant
}
  • File level: the clause rides an ordinary import. If the name is already imported identically, or is an unqualified name outside any namespace (where a plain import is a warned-about no-op), the statement is grant-only. use function/use const/use extension cannot carry the clause, and a group prefix cannot (members carry it individually).
  • Inside a function or method body: use becomes an ordinary statement whose only permitted form is a grant. It performs no import; the name resolves like a class reference at that point (current imports + namespace). Aliasing in a body grant is a compile-time error, as is a body use without the clause.
  • Scope: file-level grants are position-sensitive — they cover the bodies of functions, methods, and closures declared after the statement (each function snapshots the file's grant set at its compile position, the same mechanism as use extension import sets). A body grant covers that body; arrow functions inherit the enclosing body's grants (they already auto-capture their scope), long closures and nested named functions do not — they restate what they need. In the current implementation a body grant covers the whole body rather than only the statements after it (open issue).
  • The named surfaces are not validated against the class at compile time — the class is generally not loaded while the file compiles, the same reason use imports are unchecked. A grant naming an unknown surface simply never matches (open issue: diagnose lazily).

Semantics

Access model

Every member has an accessibility set; the existing visibilities are its fixed points, and a named surface sits strictly between public and protected:

  • public — everyone.
  • surface[S] — the declaring hierarchy and any scope granting S.
  • protected — the declaring class and its subclasses.
  • private — the declaring class.

Access is permitted iff the accessing scope holds one of the member's surfaces. A scope holds a surface two ways:

  1. Hierarchy auto-hold. The declaring class and its subclasses reach their own surface members with no grant, exactly like protected (which is why protected surface[...] is redundant and rejected). This covers $this access, parent:: calls, and same-hierarchy instances.
  2. A grant in the calling code. A grant use C with surface[S] held by the nearest user frame's function covers every receiver whose class is C or a subtype of C. Granting a base class covers subtype receivers; granting a subtype does not cover a plain base-class receiver — the grant's class doubles as a receiver lower bound ("I produce only into priority queues here").

Enforcement is at runtime, in the member-resolution paths, keyed on the receiver's runtime class — the same model as private/protected today. This is a deliberate difference from the pitch's static-type sketch: PHP compiles every file independently, so the compiler generally cannot see the target class at all, let alone member surface assignments. Making the check dynamic has three consequences worth stating plainly:

  • Dynamic access is checked. $obj->$method(), call_user_func([$obj, 'm']), Closure::fromCallable, dynamic property names, and new $class all pass through the same runtime gate — stronger and more uniform than a static model, which must exempt them.
  • Grants key on runtime classes. use Child with surface[S] covers a variable statically typed as the parent when it holds a Child at runtime. Static analyzers can (and should) implement the stricter static-type discipline on top; the engine rule is the dynamic analogue.
  • First-class callables and callable arrays are checked at acquisition, where resolution runs (open issue: acquisition vs invocation).

Reflection-based invocation bypasses surfaces exactly as it bypasses visibility today.

Enforcement points

The check is one predicate — zend_surfaces_access_allowed(owner, receiver_ce, surface_set) in the new Zend/zend_surfaces.{c,h} — consulted from the resolution slow paths only:

  1. zend_std_get_method (after the existing visibility handling): an ungranted surface method throws; it does not fall through to __call or to extension methods — the member exists, and existence wins.
  2. zend_get_property_offset / zend_get_property_info at their resolution point, before the per-call-site cache is populated. Denials are never cached.
  3. ZEND_FETCH_CLASS_CONSTANT and zend_get_class_constant_ex. Compile-time class-constant substitution (zend_try_ct_eval_class_const) and the optimizer's substitution (zend_fetch_class_const_info, used by pass1 and inference) refuse surface constants, so the fetch always reaches the runtime gate.
  4. zend_std_get_constructor (see below).
  5. zend_is_callable_check_func — callable-array resolution looks methods up directly rather than via get_method, so it carries its own gate.

The calling code is identified as the nearest user frame (the use extension technique); its function's grant set is compiled in (see Engine mechanics). EG(fake_scope) is honored for the hierarchy hold, like visibility.

Surface-bound interfaces and the reachability guarantee

A surface's bound interface is a nominal implementation: $queue instanceof ProducerInterface is true, Reflection lists it, and runtime parameter checks accept the object. Declaring the binding is enough — an explicit implements of the same interface may coexist but is not required (the interface list carries one entry either way).

The central rule is the interface-reachability guarantee:

A member reachable through an interface type is never gated. Correspondingly, a method required by any interface the class implements must be either public or on a surface bound to that interface — a link-time error otherwise.

This single rule replaces and subsumes the earlier no-spanning formulation, and it exists to protect interface consumers: an interface-typed caller (function sink(ProducerInterface $p)) must be able to call every method of its contract without knowing or caring that the implementor routed it through a surface. Without the conformance half, an author could put an interface-required method on an unrelated surface and every interface-typed call site in existence would start throwing. Consequences:

  • An interface cannot be spanned across two surfaces (neither is bound to it for the other's members); multiple surfaces may each satisfy the same interface independently.
  • A surfaced method that some ordinarily-implemented interface requires without a binding is rejected at link time: Method Node::emit() satisfies interface Walkable but is not on a surface bound to it.
  • An interface __construct constrains the signature only and is exempt from the rule — construction is not instance dispatch, and a gated constructor stays gated (see Surface-gated construction).
  • Members of a bound surface that the interface does not declare (surface[Producer] function flush() where the interface only declares ingest()) are not interface-reachable and stay fully gated.

At runtime the guarantee is implemented as an exemption in the member gate: a surfaced method declared by any of the receiver's interfaces has a public interface face (fast path: its prototype's scope is an interface; fallback: a scan of the receiver's interface list) and skips the predicate. Since the runtime cannot distinguish an interface-typed holder from a class-typed one, direct calls on the concrete class are equally exempt — the link-time rule makes that exemption sound rather than accidental.

The pitch additionally gates the implicit conversion of a class-typed value to the bound interface ("intent recorded once, at the handoff"). That conversion gate is not in this proposal (Future Scope): it requires exactly the static receiver-type knowledge the engine does not have. The honest summary: binding an interface to a surface trades that surface's runtime gate for a published contract plus reviewable grants; static tooling can restore the conversion discipline the engine defers.

Surface-gated construction

surface[...] on __construct removes new from the default view; the check runs in zend_std_get_constructor with the same predicate:

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

$u = new User("Ada");                       // Error: Creation not granted
$u = User::make("Ada");                     // 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
    }
}

A child constructor calls parent::__construct() freely (auto-hold); external new Child(...) follows the hierarchy grant rule. Because enforcement is dynamic, new $class(...) is checked too. Reflection's newInstance bypasses, like visibility.

Inheritance

A surface is owned by the class that declares it; ownership fixes its membership and interface binding for the whole hierarchy. All checks run at class link time (including the compile-time direct-link and early-binding paths):

  • Subclasses hold inherited surfaces automatically (the auto-hold above) but cannot redeclare a name an ancestor owns.
  • A subclass may not add members to an inherited surface — new surface members go on surfaces the subclass itself declares. Otherwise the role would mean different things for different subtypes.
  • An override's accessibility must be a superset of the parent member's: it may widen to public (dropping off the surface — permitted, consistent with protected → public widening), may add surfaces the subclass owns, but may not drop an inherited surface or narrow to protected/private (the latter falls out of the existing no-narrowing check, since surface members are internally public).

Engine mechanics

Metadata is name-based — surface names are unique per hierarchy, so names identify surfaces with no link-time index renumbering:

  • zend_class_entry gains two compile-time tables (NULL when unused): surface_decls (own declarations: name → bound-interface name or null) and surface_members (kind-prefixed member keys m:/p:/c: → packed array of surface names). Lookups walk the parent chain.
  • zend_op_array gains surface_grants: a packed table of "lcclass:Surface" strings. It follows the extension_imports discipline exactly — the file context holds the current set, each function snapshots it by pointer at its compile position (copy-on-write per statement), body grants unshare the function's own table, arrow functions snapshot the enclosing body's set.
  • The persisted image is never mutated at runtime. All surface metadata and grant sets are written at compile time and persist with the script: SHM (zend_persist), file_cache serialization, and size calculation all handle the new tables, and the verified matrix includes opcache.protect_memory=1.
  • No new opcodes, no VM regeneration beyond one handler edit (the class-constant gate), and no JIT changes: checks live behind the resolution slow paths, and the per-call-site inline caches remain sound because grants are lexical per function — a call site that resolved once under its function's grants resolves identically forever. Verified under opcache.jit=tracing with run-tests' aggressive hotness settings, including hot loops over granted and denied members.

Performance

A class with no surfaces costs one NULL pointer test on the affected slow paths and nothing anywhere else. Surface members pay the predicate only where resolution already left the fast path: first access per call site for methods/properties (the result is then cached per call site as today; denials are not cached), every fetch for surface constants (their compile-time and optimizer substitution is disabled by design), and each new/callable resolution. The predicate itself is a hierarchy walk plus a linear scan of the calling function's grant table.

Measured on the prototype (release NTS builds of the branch point, the extension-methods branch, and the surfaces branch; Apple Silicon; best-of-N wall time):

  • Code that uses no surfaces is unaffected. Zend/bench.php and Zend/micro_bench.php totals are identical across all three builds within run noise (≤ ~1%), in all of interpreter, opcache, and opcache.jit=tracing configurations. A targeted microbenchmark of every touched path — monomorphic and megamorphic (inline-cache-defeating) method calls and property reads, static and dynamic class-constant fetches, new, dynamic method names, call_user_func — likewise shows parity within noise (e.g. megamorphic method calls ~14 ns/op on all three builds under opcache).
  • Granted surface members on cached call sites cost the same as public members. Monomorphic surface method calls and property reads are indistinguishable from their public twins (~7 ns and ~4.7 ns/op interpreted; ~3.3 ns and ~1.1 ns JIT'd): the predicate runs once at resolution, then the per-call-site cache serves every subsequent access.
  • Surface constants pay the runtime check on every fetch because their constant-substitution is disabled: ~4.1 ns vs ~2.7 ns/op under opcache, and ~2.2 ns vs ~0.8 ns JIT'd. Absolute cost is small but, unlike methods and properties, it does not amortize.
  • The realistic worst case is a megamorphic call site over one hierarchy's surface members. A call site cycling through many receiver classes (a tree walker calling surface[Compiler] function emit() across 32 node subclasses, say) defeats the per-call-site cache, so the predicate runs every call: ~44 ns/op versus ~14 ns for the public equivalent, with a single base-class grant covering the hierarchy. Such sites are already the engine's slow path today; a per-function memoized grant index is the natural optimization if this shows up in practice (Future Scope). (The predicate is linear in the calling function's grant-table length — a contrived 33-grant scope measures ~192 ns/op — but realistic scopes hold one to three grants, so the 1-grant number is the meaningful one.)
  • Heterogeneous collections in practice share an interface, and interface-reachable members skip the gate entirely (see the reachability guarantee): a megamorphic loop over Walkable-typed objects calling emit() pays only the interface-face test on each cache miss — a prototype flag check in the common case — not the grant scan. The megamorphic cliff above is therefore confined to same-hierarchy, no-interface designs.

Memory: one pointer per zend_op_array plus two per zend_class_entry when unused; the tables themselves exist only for functions holding grants and classes declaring surfaces, and are shared/persisted like extension import sets.

Reflection

  • ReflectionClass::getSurfaceNames(): array — declared and inherited.
  • ReflectionClass::hasSurface(string $name): bool
  • ReflectionClass::getSurfaceInterface(string $surface): ?string — throws ReflectionException for unknown surfaces.
  • ReflectionMethod::getSurfaceNames(), ReflectionProperty::getSurfaceNames(), ReflectionClassConstant::getSurfaceNames() — empty array for members on no surface.
  • ReflectionClass::getInterfaceNames() includes surface-bound interfaces (nominal implementations). Reflection-based access continues to bypass surfaces. IS_SURFACE modifier flags are not included (no free ZEND_ACC bit; open issue).

Interaction with Extension Methods

This RFC is independent of the Extension Methods RFC family, but the two prototypes share a branch lineage and their composition is specified and tested:

  • Existence beats extensions. Extension methods resolve only on the method-miss path; a surface member exists, so an ungranted access throws rather than falling through to a same-named extension method. An extension can never shadow or "unlock" a surface member.
  • Interface targets reach through. An extension method targeting a surface-bound interface reaches implementing objects without any grant — the binding is a real implementation. Authors who bind an interface to a surface should know its extension surface comes with it.
  • Grants reuse the use extension compile-and-persist plumbing (per-function snapshots, opcache persistence, nearest-user-frame consultation), so the two features' scoping rules stay aligned by construction. use extension Foo with surface[...] is a compile-time error.

Backward Incompatible Changes

  • surface becomes a contextual keyword when followed by an identifier or [. Classes, functions, constants, methods, and type uses named surface continue to work. Two narrow breaks: surface Producer-shaped token sequences (identifier follows the word — the same class of break enum accepted) and surface[0]-shaped array dereferences of a constant named surface no longer parse.
  • use becomes syntactically valid as an ordinary statement; inside function bodies only the grant form compiles (everything else is a compile-time error), and at the top level of nested blocks plain imports now parse where they previously were parse errors (they behave as file-level imports; open issue).
  • with gains meaning only inside use declarations; it is not reserved.
  • No runtime behavior of existing programs changes; a class that declares no surfaces behaves exactly as today.

Proposed PHP Version(s)

Next PHP 8.x.

RFC Impact

To the Ecosystem

IDEs and static analyzers must learn the three syntactic forms (declaration, modifier, grant clause). Analyzers are explicitly invited to enforce the stricter static-type grant rule from the pitch (including the deferred conversion gate) on top of the engine's dynamic rule; the engine's checks are the authoritative floor, not the ceiling. Grants are greppable (grep -rn 'surface\['), which is much of the feature's point.

To Existing Extensions

zend_class_entry and zend_op_array each gain fields (ABI break, routine for a minor version). Extensions that implement their own get_method/property handlers bypass the checks for their objects, consistent with their opting out of standard resolution. Internal classes cannot declare surfaces.

To SAPIs

None beyond the ABI note.

To Opcache

Two CE tables and one op_array table persist to SHM and file_cache alongside attributes and extension imports; all are written at compile time only, upholding the immutable-image invariant under protect_memory. Class-constant substitution in the optimizer consults the surface tables and skips surface constants — the one optimizer change. Inheritance-cache and preload paths run the link-time validation like any linking.

Open Issues

  1. Traits declaring surfaces or surface members — rejected in v1 ("not yet supported"); flattening semantics for ownership need a design.
  2. Body-grant position sensitivity — a body grant currently covers its whole function body, not just statements after it (file-level grants are position-sensitive). Fixing this needs opline-indexed grant validity.
  3. First-class callables — the gate runs at acquisition (where resolution runs). Decide acquisition vs invocation; invocation-time checking would need the closure to re-check per call.
  4. Grant validation — grants naming unknown classes/surfaces are silently inert; a lazy diagnostic (first-use warning) may be worth the plumbing.
  5. Serialization and debug output — surface properties are internally public and appear in var_dump/serialize()/json_encode(). Arguably correct (the surface is intrinsic to the object), but undecided.
  6. Reachability guarantee for interface constants and hooked properties — the link-time rule currently covers methods only; interface constants and (8.4+) interface hooked properties satisfied by surface members need the same treatment.
  7. Reflection IS_SURFACE modifier flags — deferred for want of a flag bit; may ride the ce_flags2/fn_flags2 expansion instead.

Unaffected PHP Functionality

public/protected/private, asymmetric visibility, readonly, interfaces, traits, enums, closures, and all existing Reflection behavior are unchanged. Every program that compiles today compiles and behaves identically unless it declares a surface (modulo the two narrow lexical breaks above).

Future Scope

  • The interface conversion gate — gating the handoff of a class-typed value to a surface-bound interface, restoring the pitch's "intent recorded once, at the handoff" semantics. Deliverable as an analyzer rule immediately; as engine semantics only with static receiver types.
  • surface[...](set) — surfaces in the set-visibility slot.
  • Surfaces on static members — needs a scoping rule without a receiver.
  • Per-binding grants (one variable rather than a type in scope) and surface bundles (named grant sets).
  • A memoized grant index — replacing the per-resolution linear grant-table scan with a per-function (receiver class → verdict) cache, for megamorphic call sites over surface members (see Performance).
  • Multiple interfaces per surface.

Voting Choices

Primary vote requiring a 2/3 majority to accept the RFC (per the php/policies voting guidelines):

Add surfaces to PHP as outlined in the RFC? Yes / No

Patches and Tests

Implemented on the surfaces branch (stacked on the extension-methods branch): contextual T_SURFACE lexing; ZEND_AST_SURFACE_DECL/ZEND_AST_SURFACE_MEMBER/ZEND_AST_USE_GRANT (wrapper nodes — no existing AST arity changes); the modifier-list rework carrying surface names as AST children; use moved into the statement grammar with zero parser conflicts (%expect 0 holds); the Zend/zend_surfaces.{c,h} predicate and link-time validation; enforcement hooks in zend_object_handlers.c, zend_constants.c, the FETCH_CLASS_CONSTANT handler, and zend_is_callable_check_func; compile-time and optimizer constant-substitution suppression; opcache SHM/file_cache persistence for all three tables; and the Reflection API. Zend/tests/surfaces/ (16 tests) covers gating for all member kinds, grants at every scope, hierarchy rules, interface binding and no-spanning, construction, dynamic access, extension-methods composition, Reflection, and an opcache file_cache round-trip. Full test suite green (13,300+ tests), including opcache.protect_memory=1 and opcache.jit=tracing matrices.

Implementation

After acceptance: link to the merged commit(s), PHP version, and documentation.

References

  • Companion pitch: the motivation, design philosophy, and prior-art survey for surfaces (pitch-surfaces.md alongside this RFC; wiki location tbd).
  • PHP RFC: Friends — the adjacent access-granting proposal, compared in the pitch.
  • PHP 8.4 asymmetric visibility RFC (private(set)/protected(set)) — the set-visibility lattice this RFC composes with.
  • Rust pub(in path) module-relative visibility; object-capability systems (E, Newspeak) — nearest relatives, discussed in the pitch.

Rejected Features

  • The ClassName+Surface type operator (early drafts): subsumed by the lexical grant — simpler, no flow typing, and the intent lands at the use site.
  • surface(...) with parentheses: collides with the (set) modifier; brackets keep "which role" and "which operation" in different token families.
  • A reserved grant/with keyword: the grant is a clause on use; no new reserved word.
  • Access-controlled surfaces (grantable only by blessed callers): would turn a signaling feature into an access-control feature; deliberately excluded.
  • Surfaces declared on interfaces: mandating role structure on implementers had no compelling case; surface→interface binding already exposes a role as a type.
  • Compile-time enforcement in the engine: the pitch's static-type check is unimplementable in an engine that compiles files independently and generally cannot see the target class at compile time. Specified instead as runtime enforcement (this RFC) plus an analyzer-layer static rule; the conversion gate moved to Future Scope. Bit-indexed surface metadata was likewise rejected in favor of name-based tables — names are hierarchy-unique, and indices would need link-time renumbering that fights opcache's immutable images.

Changelog

  • 1.1 (2026-07-10): the interface-reachability guarantee — a member reachable through any implemented interface is never gated, enforced by generalizing the link-time conformance rule from surface-bound interfaces to all implemented interfaces (a surfaced method satisfying an interface must be on a surface bound to it; previously an unbound interface's method could sit on a surface and break every interface-typed caller). Interface __construct exempted (signature-only); a surface binding now coexists with an explicit implements of the same interface. Performance section reframed around measured numbers: the realistic megamorphic worst case (~44 ns/op, single-grant hierarchy) leads; the grant-count-linear bound is a parenthetical; interface-reachable members documented as skipping the gate on megamorphic sites.
  • 1.0 (2026-07-10): initial RFC, extracted from the combined draft (now the companion pitch, v0.9). Specifies the implemented semantics: runtime enforcement in the resolution slow paths keyed on runtime receiver classes; dynamic access checked; interface-face exemption with the conversion gate moved to Future Scope; name-based metadata; grants on the use extension snapshot/persistence plumbing; extension-methods composition rules; Reflection API. Prototype complete on the surfaces branch with the full-suite/opcache/JIT verification matrix.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment