Protected Was Never About Protection

Developers resist change, and in 2014 I was no exception. When I started writing Swift, one absence offended me more than any syntax quirk: Swift has no protected. Coming from C#, Java, and PHP, where protected was simply part of how you built a class hierarchy, this felt like a mistake someone ought to fix. I spent more hours than I'd like to admit on the Swift Evolution forums, reading proposal after proposal to add it, nodding along with everyone who agreed it was an oversight. Eventually I accepted the obvious: whatever I felt about it, Swift was never going to add protected.

What surprised me came later. As I kept writing Swift, the missing modifier never actually hurt me. I had assumed my designs would spring leaks — that without protected, members meant for subclasses would be exposed to the world and my careful boundaries would collapse. They didn't. And the reason was almost embarrassing in its simplicity: I didn't call the things I wasn't supposed to call. The boundary held because I respected it, not because a compiler forced me to.

That should have been the end of it. But something still nagged. I missed protected, and I couldn't square that feeling against the fact that its absence had cost me nothing. It took me an unreasonably long time to work out why.

What protected was actually for

Here is what I eventually understood: I had never valued protected for protection. The people who defended Swift's choice were right, and they were right for a reason they rarely said out loud. protected doesn't protect anything. Any subclass can override a protected member and widen it to public on a whim; the "protection" is a convention the author hopes you'll honor, backed by nothing. What protected actually did — the thing I missed — was communicate. It told the next person reading the class: this member is here for subclasses. It was a signal of intended audience, wearing the costume of access control.

Once I saw it that way, a different question surfaced, and it's the one this whole essay is about. If the real job of a visibility modifier is to declare who a member is for, why do we only get three answers?

Three audiences on stone tablets

Every mainstream language hands you the same three: private for the class itself, protected for the class and its subclasses, public for everyone. We treat that trio as though it were handed down on stone tablets. It wasn't. It's an accident of how object orientation grew up — three audiences that happened to be easy to implement and common enough to name.

Real classes serve audiences that trio can't describe. A class might have members meant for the framework that hosts it, but not the application built on top. Members meant for a plugin, but not the end user. Members meant for a test harness, but not for production. Members meant for a factory, but not for general construction. Every one of those is a legitimate, nameable audience — and the language gives us no word for any of them. So we round them all up to public and leave a comment. Or we bury them behind naming conventions and hope. Or we contort the class into a tangle of interfaces. We reach for public and then spend our documentation begging people not to take us at our word.

I didn't arrive at that list in the abstract. I arrived at it because one of those missing audiences cost me an afternoon I still remember.

The wall

Years ago, well before I understood any of this, I was integrating a third-party analytics library into an app. Like a lot of mature SDKs, it was built around a single central object — the thing you configured, the thing your code talked to, and, it turned out, the thing its own plugins talked to as well. It had one API for people like me, consuming it from the outside, and another for plugins extending it from the inside. Both lived on the same class, as public methods, indistinguishable from one another.

I was writing something that sat halfway between those worlds, and I needed to call into the object. I found a method that looked right — the name fit, the signature fit, autocomplete offered it to me without complaint. I called it. The app deadlocked.

It took me a while to understand what I'd done. The method I'd reached for wasn't meant for code in my position; it was meant to be called from within a plugin, at a specific point in a lifecycle I wasn't part of, and calling it from where I stood re-entered a lock that was already held. Nothing in the type system had warned me, because as far as the type system was concerned I had called a perfectly ordinary public method. The knowledge that I shouldn't call it existed — the library's authors obviously knew — but it had no way to reach me. It wasn't in the signature. It wasn't in the visibility. If I was lucky it was in the docs, somewhere, which I found only after I already knew what I was looking for.

I don't remember the exact library, and it doesn't matter; a decade on, I've made peace with the deadlock. What stuck with me was the shape of the problem. The author knew something crucial about who each method was for, and the language gave them no way to say it — at least, no way that survived the trip across the boundary to me. The intent existed and couldn't be expressed.

And here is the part that matters most for where I've ended up: you cannot fix that object with interfaces. That's the reflex answer, and it's wrong for a specific reason. Yes, you could declare a ConsumerApi interface and a PluginApi interface and have the class implement both. But they'd be implemented by one concrete object, sharing one set of buffers and locks and lifecycle state. The moment anyone holds the concrete type — which they will, because it's the thing you configure and pass around — both interfaces are right there, fully in reach, and the separation you drew evaporates. Splitting a stateful object into interfaces separates it in the type system while leaving it whole in memory. The audiences were never really apart.

I couldn't have articulated any of that at the time. I was the junior-ish consumer who got burned and moved on. But the problem lodged somewhere and stayed. These days I'm usually on the other side of that boundary — I lead teams that build SDKs and libraries, the kind of code where some methods are for your users and some are for your own machinery. And I keep hitting the same wall from the author's side: I know exactly who each member is for, and I have no honest way to say so.

Naming the audience

So here is the idea. What if a class could name its own audiences, and members could be assigned to them?

I've been calling those audiences surfaces. The name comes from how we draw objects when we sketch a system: an object is a shape, and different collaborators touch different faces of it. A surface is one of those faces — a named role the class exposes.

A class declares the surfaces it offers, and marks members as belonging to them:

class RateLimiter {
    surface Testing;

    public function attempt(string $key): bool {
        // ...uses $this->clock internally...
    }

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

A surface[...] member isn't public. It's removed from the object's default view entirely. If you're holding a RateLimiter and you call attempt(), fine — that's public. But setClock() isn't there for you. It won't autocomplete, and if you type it out anyway, it won't compile.

To reach it, a caller has to say, in so many words, that it's using that role:

use App\RateLimiter with surface[Testing];

$limiter->setClock($frozenClock);   // visible now, because you asked

That use ... with surface[...] line is a declaration of intent. It's the caller saying, out loud and on the record, "I am using the Testing role of this object." Which is exactly the sentence the analytics library couldn't say to me.

Now look at a real class you already know. Laravel's Manager — the base behind CacheManager, SessionManager, AuthManager, and the rest — is a near-perfect specimen of the problem. On one concrete object it carries three different audiences. There's driver() and the dynamic __call forwarding, which is what you and I use to actually get a cache or session driver. There's extend(), which registers a new driver and is meant to be called from a service provider by someone extending the framework. And there are the protected create…Driver() methods, which exist for whoever subclasses the manager. Three audiences — and only one of them, the subclass one, has a real modifier. extend() is just public, sitting in your autocomplete right next to driver(), even though you have no business calling it while you're resolving a cache.

With surfaces, the class could say what it means:

class CacheManager {
    surface Extension;

    public function driver(?string $name = null): mixed { /* ... */ }

    surface[Extension] function extend(string $driver, Closure $callback): static {
        /* ... */
    }

    protected function createDriver(string $driver): mixed { /* ... */ }
}

Now the service provider that registers a driver declares it's doing framework-extension work — use Illuminate\Cache\CacheManager with surface[Extension]; — and a controller that just wants a Redis connection never sees extend() at all. The three audiences finally have three different names.

The range of it

That testing example above isn't incidental. Every one of us has written a method we were slightly ashamed of — a reset(), a setClock(), a withFakeTransport() — made it public because the tests needed it, and left a comment praying no one would call it in production. A Testing surface is that comment, made real. The test file declares the role; production code physically cannot see the seam.

Construction is the one that, for me, earns the idea its keep, because it takes something people currently solve with heavy tools and makes it ordinary. Say you want a User that should only be built through a factory — because construction runs validation, or touches a repository, or simply because you want one blessed path. Today you have two options, and neither is good. You can make the constructor private and add a static factory method, which works but chains the factory to the class forever; the factory must be a static method on User, because nothing else can see a private constructor. Or you reach for something like a friend declaration, naming a specific UserFactory and handing it total access to User's internals — every private property and method — just to reach the constructor.

A surface gives you a lighter, more honest tool:

final class User {
    surface Creation;

    surface[Creation] function __construct(
        public readonly string $name,
        public readonly string $email,
    ) {}
}

Now new User(...) from ordinary code is a compile error. The factory — which can live anywhere, in any class, in any file — declares the role and builds:

final class UserFactory {
    public function fromRow(array $row): User {
        use User with surface[Creation];
        return new User($row['name'], $row['email']);
    }
}

Notice what this doesn't do. It doesn't hand the factory the keys to the whole object the way a friend declaration would. It grants exactly one capability — the ability to construct — to any code willing to declare that it's doing construction work. The role grants the access, not the location, and not a hand-picked list of trusted classes. If someone wants to call new User(...) directly somewhere else, they still can — but they have to write the grant to do it, and that grant sits right there in the diff for a reviewer to see. I'll come back to why that last part matters more than it looks.

What surfaces are, and aren't

I've been careful with a word this whole time, and it's time to say it plainly. Surfaces are not access control. A caller can always grant itself any surface a class declares. There is no gate, no permission check, no trusted-caller list.

If your instinct is that this makes the whole thing pointless — that a boundary anyone can cross isn't a boundary — I'd gently point you back to where we started. protected never stopped anyone either. A subclass can widen it to public in one line. We kept protected anyway, for decades, across every major language, because its value was never the enforcement. It was the signal. Surfaces are the same bargain, extended past "subclasses" to audiences we've never had a word for: a way to say who a member is for, backed by a compiler that holds you to your word exactly as far as making you say it.

It's worth sitting with how far down that goes, because the objection quietly assumes that enforced access control is the floor — and it isn't. Compile the program and the modifiers evaporate. At the level where the machine actually runs there is no private; there are symbols, addresses, and bytes, and the only real boundary is whether a symbol was exported at all. C has known this the whole time. Marking a function static keeps it out of the export table, and that is the one true wall; everything above it is a matter of which declarations a caller happened to pull into scope. The Linux kernel — millions of lines of about the most safety-critical code any of us depend on — has no member access modifiers at all. What keeps it correct is convention and review.

I don't raise that to argue private is worthless; I reach for it constantly. Enforced visibility is a genuine convenience, and it catches honest mistakes early. But it was never the foundation the word "protection" implies. The systems that truly cannot afford to be wrong have always rested on people reading the code, not on the compiler refusing to emit a call. So surfaces don't ask you to surrender a guarantee you had. They do openly what the ladder of modifiers was quietly doing underneath the whole time — express intent, and trust the people reading to honor it.

There's one more reflex to answer — "isn't this just interfaces?" Interfaces record a consumer's intent only when the author predefined exactly the right interface and the consumer chose to depend on it instead of the concrete class. Hold the concrete object, as the analytics library forced me to, and the interfaces are a suggestion you're free to ignore, because every method is still right there. Surfaces put the declaration at the point of use and don't let you skip it: to touch a role, you name the role. The intent gets recorded whether or not anyone planned an interface for it.

Why this belongs in the language

The most common objection I hear is that this belongs in a static analyzer, not the language. PHPStan and Psalm already do this sort of thing with annotations, the argument goes, so why grow the engine?

Because a surface is a visibility level, and no one argues that visibility belongs in a linter. private, protected, and public are answers to the question the engine already asks on every member access: is this allowed here? A surface adds a fourth kind of answer to that same question. A static analyzer, to enforce it, would have to reimplement the engine's own visibility logic in a parallel dialect that every consumer would have to opt into and no library could rely on. Put it in the language and the answer is universal — every reader, every tool, every consumer sees the same contract, the same way they all see private. It's the same reason typed properties and enums and readonly migrated out of docblocks and into PHP itself. The declaration was always the point, and a declaration only works if everyone is bound by it.

There are honest limits, and I won't pretend otherwise. Surfaces live in typed code; a surface member reached through an untyped variable can't be checked, because the check needs to know the type. Dynamic calls and reflection slip past them, exactly as they slip past private today. These aren't nothing — but they aren't unique to a language feature, either. A static analyzer labors under the identical blind spots, because it has the same information and no more. The honest scope of the claim is that surfaces make intent legible in typed code, which is most of the code that matters.

The part I didn't expect to care about

I want to close on the benefit that has quietly become the one I value most, because it isn't really about the person writing the code at all. It's about the person reading it.

When a change adds use SomeClass with surface[Extension];, that one line tells a reviewer something precise: this code has taken on a dependency on the extension role of that class. It's greppable. It's diffable. It shows up in the pull request as an explicit statement of intent, right next to the code that acts on it — instead of being something the reviewer has to reconstruct by reading every method call and knowing, from experience, which ones are load-bearing.

That has always been useful. Lately it's become urgent. More and more of the code we review was written by an AI agent, and agent-written code has a particular character: voluminous, locally plausible, and confidently wrong in ways that don't announce themselves. The reviewer's job shifts from "is each line correct" toward "is this reaching further than the task asked for." Surfaces make that reach visible. An agent can't quietly wander into the extension API, or the construction path, or the test seam, without leaving a declared grant behind — a line a human, or another tool, can catch. I've come to build most of my own work spec-first, letting an agent implement against a finished design, and the thing I most want back from that process is a legible diff. Surfaces give the machine a place to write down its intentions where I can read them.

Where this leaves us

Swift was right. protected didn't protect anything, and adding it back would have been installing a lock with no bolt. Where the conversation went wrong — where I went wrong, for a good long while — was in concluding that because the lock was fake, the modifier was worthless. It was never a lock. It was a label. And we have only ever had three labels.

Surfaces are what happens when you take that seriously: let a class label its members with the audiences they're actually for, and let a caller say which audience it's speaking to. Not to stop anyone — you were never really stopping anyone — but so that the intent, which was there all along, finally has somewhere to live.

I didn't only write this up. There's a working implementation in a branch of PHP on my own machine — the fastest way I know to find out whether an idea holds together is to build it and watch what breaks, and this one held. I've put it forward as one of four pieces of a larger case for what PHP 9.0 could be, and whether the appetite is there for any of it, I honestly can't say yet.

But I'm fairly sure this gap, at least, is real — because I've been standing in it, on both sides, for about a decade.