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

Modules: A Front Door for Your Library

Here is the dirty secret of PHP namespaces: they're a filing system, not a boundary. Every public symbol in every namespace is globally reachable by its fully qualified name. When a library author writes @internal on a class, they're posting a sign, not locking a door — PHPStan and your IDE will nag you about it, but the engine will new that class for anyone who asks.

The result is familiar to anyone who's ever shipped a package: your actual API is three classes, your codebase is forty, and you can't refactor the other thirty-seven without someone, somewhere, having coupled to them.

PHP 9.0 puts the door in the language. A module is a named group of classes — spanning as many namespaces as it likes — with a deliberately chosen public surface.

Membership is declared bottom-up, one line per file:

namespace Acme\Http\Routing;

module Acme\Http\Kernel;      // this class belongs to the Kernel module

class Router
{
    public function dispatch(Request $r): Response { /* ... */ }

    internal function compileTable(): array { /* module-only */ }
}

The public surface is declared top-down, once:

namespace Acme\Http;

module Kernel {
    export Acme\Http\Request;
    export Acme\Http\Response;
    export Acme\Http\Routing\Router;
    // RouteCompiler is a member but NOT exported → module-internal
}

And consumers come in through the front:

namespace App;

use module Acme\Http\Kernel;

class Application
{
    public function handle(Kernel:>Request $request): Kernel:>Response
    {
        $router = new Kernel:>Router();
        return $router->dispatch($request);
        // new Kernel:>RouteCompiler()  → error: not exported
        // $router->compileTable()      → error: internal member
    }
}

The three moving parts

internal joins public, protected, and private. It scopes a method, property, or constant to the module — any class sharing the same module name can call it; nobody else can. It's the visibility level PHP has been missing: "shared among these collaborating classes, invisible beyond them." Notice there's no internal class modifier — a class is internal simply by being a member that isn't exported. One axis for whole classes (the export list), one for members (the modifier).

The export block is the API. Not a roster — membership lives in each class's own file — but the published surface. A member that isn't exported can't be instantiated, extended, or reached by static access from outside. At all. Even by its bare fully qualified name:

new \Acme\Http\Routing\Router();   // from outside: error — module mismatch
new Kernel:>Router();              // the only door

That last part deserves a beat. Once a class joins a module, outside code reaches it only through a use module import and the :> prefix. Which means adding module Acme\Http\Kernel; to a published class is a breaking change for its consumers — deliberately. Modularizing a library is a major-version event, the moment you finally get to say "this was never API" and have the engine agree.

:> is resolve-time sugar. Kernel:>Request::class evaluates to "Acme\Http\Request". get_class() returns the canonical name; serialization, containers, and instanceof see one class, however it was reached. Modules never fork type identity — the class table doesn't know they exist.

Hoisting a member to the root

The Kernel:> prefix is explicit by design — you can always see which door a name came through — but a file that leans on one member over and over shouldn't have to repeat it. A member import pulls a single exported class into the file's own root, so you can drop the prefix entirely:

use module Acme\Http\Kernel;
use Kernel:>Request as Req;      // hoist one member, renamed
use Kernel:>Response;           // hoist under its own short name

class Application
{
    public function handle(Req $request): Response
    {
        return (new Kernel:>Router())->dispatch($request);
    }
}

The as clause is optional. Omit it and the member arrives under its own short name (Response above); spell it and you rename on the way in — the same escape valve class use has always offered for colliding short names. Once hoisted, Req and Response read like any imported class; the :> form stays available for everything you didn't hoist.

Two things keep it honest. It's the same resolve-time sugar as :> itself, just landing in the root instead of behind a prefix: Req still resolves to Acme\Http\Request, so identity, get_class(), and instanceof are all unmoved. And the boundary holds — a member import resolves against the export surface exactly as :> does, so use Kernel:>RouteCompiler; fails for the very same reason new Kernel:>RouteCompiler() does. An unexported member never enters your file's namespace by any spelling.

One ordering note: the prefix has to exist first. A member import reaches through the module local name that a use module established, so use Kernel:>Request; is only meaningful once use module Acme\Http\Kernel; is in scope above it.

Observe freely, acquire through the door

The enforcement rule compresses to one line: outside code can observe a module-internal class; it cannot acquire one.

If an exported method returns an internal object, or throws an internal exception, that value is legitimately in your hands — you can type against it, instanceof it, and catch it (imagine catching internal exceptions only as \Throwable — no). What you can't do is new the class, extend it, or reach into its statics. Observation is not coupling; construction is. As a bonus, this keeps the module layer entirely off the engine's hot paths — type checks and instanceof never pay for it.

A discipline, not a fortress

Honesty clause: membership is open and self-asserted. Any file can declare itself into your module, and Reflection pierces internal exactly as it pierces private. That's the same trust model protected and namespaces have always had, and it's the right one — the enemy here was never a hostile actor inside your process. It was the well-meaning teammate at 5 pm coupling to RouteCompiler because autocomplete offered it.

Modules make @internal a fact instead of a request. Accidental coupling becomes a hard error; intentional piercing stays possible, visible, and greppable — which is exactly where PHP has always drawn this line.

Sources

  • PHP Module Pattern — working draft / pre-RFC
  • Prior art: Java's JPMS, C# internal, Rust pub(crate) — this proposal is closest to Java's exports plus C#'s internal, adapted to PHP's runtime