Back to blog

Auditing namespaces in pext: a partial overhaul of the slot-based hierarchy

Namespaces in PHP look simple from the top: namespace Foo\Bar; at the top of a file, use Vendor\Thing; below it, and from there on Thing resolves to a class somewhere in the vendor tree. The simplicity is a thin layer. Under it sits a long list of resolution rules that depend on whether you wrote Thing(), new Thing, Thing::method, or just Thing, and on whether Thing happens to also be the name of a function, a constant, or even another namespace. Pext models all of that at runtime, not by moving files around, and this week the model went through a partial audit. This post walks through what changed and why.

Why pext keeps namespaces as a map-based hierarchy

There is a short and destructive way to handle PHP namespaces on the JavaScript side: presume PSR-4, walk the source tree, rename files into directories that match the namespace, and rewrite every use as a relative import. It would work for a slice of well-behaved code and break everything else: non-PSR-4 projects, files that declare more than one namespace, classes that share a file with helpers, classes loaded by a custom autoloader, anything dynamic. It also throws away the one property that makes converted code reviewable, the 1:1 mapping between a PHP file and the JavaScript file pext emits next to it.

Pext takes the other path. Each namespace is a node in a runtime tree (Pspace), and every class, function, and constant declared inside it is stored as a slot on that node. The tree is the only source of truth for resolution. When transpiled code references Foo\Bar\Baz, it walks the tree from the root, and the slot it lands on tells it whether Baz is a class, a function, a constant, or another namespace. Files stay where they are, the JS output sits beside the PHP, and the namespace declarations the PHP author wrote keep meaning what they meant.

Case-insensitive lookups for slots and aliases

PHP namespace resolution is case-insensitive for namespaces, classes, and functions. new foo\BAR() and new Foo\Bar() resolve to the same class. Constants are the exception (they are case-sensitive). The original Pspace implementation got most of this right by case-folding the children map at navigation time, but the rule had to be threaded carefully through every code path that touches a slot: lookups from use, lookups from a fully qualified call site, lookups during autoload, and the materialisation step that ultimately invokes the registered loader.

The audit walked every one of those paths and made the case-folding rule explicit at each step. Slot reads always case-fold the segment; constant slots opt out of the fold at the final segment only. The change is invisible from PHP source, which is the point. The risky part was confirming that no code path was relying on the older behaviour, where the first vivified casing won and later writes silently went to the same canonical slot. That older behaviour is also why some long-standing autoloader edge cases were hard to reproduce: real projects all use canonical casing at call sites, so the gap never surfaced in production, but the rule is now correct rather than accidentally correct.

One name, three kinds: resolving by usage context

PHP keeps separate symbol tables for functions, classes, and constants. The same identifier can refer to all three at once inside the same namespace, and the syntax tells the resolver which table to look at. date() with parentheses is the function. DATE as a bare identifier in an expression is the constant. new Date is the class. They are three independent slots that just happen to share a name after case-folding.

OpenSourcePOS hit this in the wild. The application defines a DATE constant at the top of one of its config files, and the same scope also uses PHP's built-in date() function. Before the audit, the slot for the constant and the slot for the function were not always disambiguated cleanly, and an expression like array(DATE) could end up reading the function slot or vice versa. The fix routes the kind decision through the call site shape: a parenthesised invocation reads the function slot, a bare identifier in a value-position expression reads the constant slot, and the :: resolution operator reads the class slot. The transpiler always knew which form it was emitting; the runtime now respects that signal end-to-end.

Canonical FQN through use aliases

When a file writes use A\B; and then references B::class, PHP returns the string "A\B". The literal at the source position is B, but the language treats the alias as transparent: ::class reveals the canonical fully qualified name, not the local alias path.

The pre-audit runtime had a sharp edge here. Inside an enclosing namespace X;, an aliased use A\B; followed by B::class sometimes returned "X\B", the current-namespace path that the alias would have taken if it were not actually aliased. The cause was that Pspace.__name, the slot that reports the canonical FQN, was being read from the vivified child path rather than recursing through the alias target. The fix makes __name follow the alias to its registered source, so ::class always returns the FQN the symbol was registered under, regardless of which scope dereferences it. This had downstream consequences for CodeIgniter 4 services that pass class names through ::class as identifiers (database group names, service keys), where a wrong FQN silently looks up the wrong configuration entry.

Namespace-versus-class collisions on the same slot

A namespace and a class can share a name. A\B can be both a namespace (containing A\B\C) and a class (with methods on A\B). PHP is fine with this; pext has to be too. The transpiled emission uses a dot-chained traversal from the namespace root, written as _.A.B in the generated JavaScript. The root _ is the global scope; each segment is a child lookup; the final segment can be a class, a function, a constant, or another namespace, depending on what the call site wanted.

The poor resolution case was deciding whether the last segment is the namespace B or the class B. Before the audit, the slot lookup returned whichever was registered first, and follow-up access (a method call, a static read, a further navigation) had to reach back and correct it. That worked for the common cases but produced surprising failures when the call site itself was ambiguous, for example _.A.B as the receiver of __name for a class registered alongside a same-named subnamespace. The audit clarifies the rule: the final segment carries both registrations as separate slot kinds, and the access form chooses one. A constructor or static access reaches the class slot, a further dot-chain reaches the namespace slot, and __name on the slot picks the registration that the access mode just resolved.

Performance: faster cold path for use-heavy scripts

Large PHP files often start with twenty or thirty use statements. Each one registers an alias on the file's local namespace context. The original implementation registered each alias eagerly and walked the namespace tree for every registration, so the cold path of loading a heavy file was visibly slower than it needed to be. The audit replaced the eager registration with an on-demand resolution that defers the tree walk until the alias is actually read, and the per-call resolution itself was tightened so the slot lookup short-circuits at the first segment when the local context already has a binding.

The combined effect is a measurable drop in cold-start time for files with many use statements, and a smaller but still real win on the steady-state path through deeply nested namespace navigation. The math is unchanged, only the dispatch overhead. The result generalises: any project with a wide vendor tree (Symfony, Laravel, CodeIgniter 4) benefits without needing any change to its source.

What is still on the audit list

This is a partial overhaul. The remaining items, in rough order of impact: the transpiler still accepts code before namespace or declare(strict_types) that real PHP rejects (a documented gap with four skipped tests); same-named class and function in one namespace produces a JS-level identifier collision (two test fixtures excluded today); and the autoloader receives the canonicalised casing rather than the caller's exact case, which is correct for every real project we have converted but is not strictly PHP-faithful. Each of those is scoped on the conversion-core side; none surface in production code we ship through pext today.

The audit closed enough long-standing gaps to unblock the remaining OpenSourcePOS regressions tied to namespace resolution, and the cleaner rules give the next round of work a stable surface to build on. The current namespace status and the supported features sit on the features page, alongside the rest of the runtime model.