rfc:namespace_visibility

Namespace-Scoped Visibility for Methods and Properties

Introduction

This RFC proposes adding namespace-scoped visibility to methods and properties in PHP, using the syntax private(namespace). This allows classes within the same namespace to access each other’s internal APIs without exposing them publicly to the entire codebase.

Motivation

The Problem

PHP currently has three visibility levels:

  • public: Accessible everywhere
  • protected: Accessible within class hierarchy (inheritance-based)
  • private: Accessible only within the same class

There’s no way to share code between classes in the namespace without making it public to the entire application.

Real-World Example

namespace App\Auth;
 
class SessionManager {
    public function validateSession(): bool {
        return $this->checkExpiry() && $this->verifySignature();
    }
 
    // PROBLEM: Must be public to be called from SessionStore,
    // but we don't want these exposed to the entire codebase
    public function checkExpiry(): bool { /* ... */ }
    public function verifySignature(): bool { /* ... */ }
}
 
class SessionStore {
    public function refresh(SessionManager $session): void {
        // Needs to call internal SessionManager methods
        if (!$session->checkExpiry()) {
            // ...
        }
    }
}

Why this is dangerous

In the example above, SessionManager exposes two public methods: checkExpiry() and verifySignature(). To someone reading the class, they look like safe, standalone API calls.

But they’re not. They’re partial checks of a single security invariant. Calling only one is incorrect:

// Looks harmless... but it isn't
if ($session->checkExpiry()) {
    // Session may still be forged
}

Teams have to rely on documentation and social convention to avoid this mistake. Code review can easily miss it. New developers won’t know the coupling. Static analysis can’t fully enforce it.

This turns internal implementation details into false public API surface, which can:

  • introduce subtle authentication bypasses,
  • degrade performance (devs calling both checks unnecessarily),
  • create broken invariants that only appear under load.

private(namespace) solves this cleanly: SessionStore can call internal APIs, other namespaces can’t. The correct usage becomes enforced, not implied.

The workarounds are all problematic:

  1. Make everything public: Pollutes API, no encapsulation
  2. Use protected + inheritance: Forces artificial inheritance hierarchies
  3. Put everything in one class: Creates god objects
  4. Use @internal docblock: Not enforced, easily violated
  5. Reflection: Can bypass visibility, but requires awkward code and awkward reviews
  6. debug_backtrace (as used in Symfony): Check caller namespace manually, fragile, and a performance cost

Proposal

Add private(namespace) visibility for methods and properties. Members with this visibility are accessible to all code within the same namespace but not to code in other namespaces.

Syntax

namespace App\Auth;
 
class SessionManager {
    private(namespace) static string $internalState;
 
    // Asymmetric visibility: publicly readable, namespace-writable
    public private(namespace)(set) int $sessionCount = 0;
 
    private(namespace) function checkExpiry(): bool {
        return time() < $this->internalState;
    }
 
    private(namespace) static function validateToken(string $token): bool {
        return hash_equals($token, self::$internalState);
    }
}
 
class SessionStore {
    public function refresh(SessionManager $session): void {
        // OK - same namespace
        $session->checkExpiry();
 
        // OK - same namespace, can write to asymmetric property
        $session->sessionCount++;
    }
}
namespace App\Controllers;
 
use App\Auth\SessionManager;
 
class LoginController {
    public function login(): void {
        $session = new SessionManager();
        $session->checkExpiry();
        // Fatal error: Uncaught Error: Call to private(namespace) method
        // App\Auth\SessionManager::checkExpiry() from scope App\Controllers
    }
}

private(namespace) restricts access to code whose lexical namespace exactly matches the member’s declaring namespace. Applies to methods and properties (including static). Enforced for direct calls, callables (at creation or invocation as specified), property reads/writes, and property hooks as ordinary method calls. Traits use the receiver class’s namespace.

Inheritance behavior is similar to protected: child classes inherit private(namespace) members and must maintain signature compatibility when redeclaring them. However, visibility enforcement is based on the declaring namespace rather than the class hierarchy. You can’t transition between protected and private(namespace) as they operate on different axes (inheritance vs namespace).

Scoping Rules

Rule 1: Exact namespace match required

Sub-namespaces are considered different namespaces:

namespace App\Auth;
 
class Foo {
  private(namespace) function helper() { /* */ }
}
 
namespace App\Auth\OAuth;
new Foo()->helper();
// Fatal error: Uncaught Error: Call to private(namespace) method
// App\Auth\helper() from scope App\Auth\OAuth

Rule 2: Works with static and instance members

private(namespace) function instanceMethod() {}
private(namespace) static function staticMethod() {}
private(namespace) string $instanceProperty;
private(namespace) static string $staticProperty;

Rule 3: Asymmetric visibility for properties

The write visibility mustn’t allow a broader set of callers than the read visibility. If it would, it’s a compile-time error.

Important: protected and private(namespace) operate on different axes (inheritance vs namespace) and can’t be combined in asymmetric visibility. These combinations are compile-time errors:

  • protected private(namespace)(set) - Error: protected operates on inheritance axis while private(namespace) on a namespace axis
  • private(namespace) protected(set) - Error: same reason

Valid asymmetric combinations include:

  • public private(namespace)(set) - Publicly readable, namespace-writable
  • private(namespace) private(set) - Namespace-readable, class-writable
  • protected protected(set) - Inheritance-readable, inheritance-writable (already allowed)
  • public protected(set) - Publicly readable, inheritance-writable (already allowed)

Here’s an example of the error you’ll get when mixing axes:

namespace App\Auth;
 
class SessionManager {
    // Error: Cannot mix inheritance and namespace axes
    protected private(namespace)(set) int $sessionCount = 0;
    // Fatal error: Property App\Auth\SessionManager::$sessionCount has incompatible visibility modifiers:
    // protected and private(namespace) operate on different axes (inheritance vs namespace)
    // and cannot be combined in asymmetric visibility
}

Rule 4: Visibility checked at call-site

The namespace of the calling code is compared to the namespace where the member was declared.

Rule 5: Works from top-level code

Namespace-private members can be accessed from top-level code within the same namespace:

namespace App\Auth;
 
class Session {
    private(namespace) function foo() {}
}
 
// OK: Top-level code in the same namespace
$s = new Session();
$s->foo(); 
namespace App\Other;
 
$s = new \App\Auth\Session();
$s->foo();
// Fatal error: Uncaught Error: Call to private(namespace) method
// App\Auth\Session::foo() from scope App\Other

The namespace context is tracked at compile-time.

Inheritance Behavior

private(namespace) members behave like protected members in terms of inheritance: They’re inherited by child classes, and child classes can redeclare them only if they follow compatibility rules. However, visibility is enforced based on the namespace where they were declared, not on the inheritance hierarchy:

namespace App\Auth;
 
class Base {
    private(namespace) function helper() {
        echo "Base::helper\n";
    }
 
    public function callHelper() {
        // Always works - method declared in App\Auth, called from App\Auth
        $this->helper();
    }
}
 
class Child extends Base {
    public function test() {
        // OK - Child is in same namespace as declaration (App\Auth)
        $this->helper();
    }
}
 
$child = new Child();
$child->callHelper(); // Works - parent method calls via $this
namespace App\Other;
 
class Different extends \App\Auth\Base {
    public function test() {
        $this->helper();
        // Fatal error: Uncaught Error: Call to private(namespace) method
        // App\Auth\Base::helper() from scope App\Other\Different
    }
}
 
$diff = new Different();
$diff->callHelper(); // Still works! Parent method in App\Auth can call it

Preventing Visibility Reduction and Incompatible Redeclaration

Just like with protected members, redefining an inherited method with incompatible visibility is an error.

protected and private(namespace) operate on different axes and are fundamentally incompatible. You can’t transition between them in either direction:

  • Cannot widen from private(namespace) to protected
  • Cannot narrow from protected to private(namespace)

This prevents soundness violations where code in the declaring namespace could call a private(namespace) method, but if a child class changes it to protected, that call would fail at runtime.

You also can’t reduce visibility from public to private(namespace):

namespace App\Auth;
 
class Base {
    public function helper() {}
}
 
class Child extends Base {
    private(namespace) function helper() {}
    // Fatal error: Access level to Child::helper() must be public (as in class Base)
}

Soundness Example: Why protected ↔ private(namespace) is Forbidden

Here’s why transitioning between protected and private(namespace) would break type safety:

namespace App\Auth;
 
class P {
    private(namespace) function x(): void {
        echo "P::x()\n";
    }
}
 
class C extends P {
    // ERROR: Cannot change from private(namespace) to protected
    // If this were allowed, the following would break:
    protected function x(): void {
        echo "C::x()\n";
    }
    // Fatal error: Access level to C::x() must be private(namespace) (as in class P) or weaker
}
 
function f(P $p): void {
    // This is legal because f() is in the same namespace as P
    // and P::x() is private(namespace)
    $p->x();
}
 
// If C::x() were allowed to be protected, this would fail at runtime:
// f(new C()); // Would error: Call to protected method C::x() from global scope

The reverse is also forbidden:

namespace App\Auth;
 
class Base {
    protected function helper() {}
}
 
class Child extends Base {
    // ERROR: Cannot change from protected to private(namespace)
    private(namespace) function helper() {}
    // Fatal error: Access level to Child::helper() must be protected (as in class Base) or weaker
}

This would break child classes in other namespaces that expect to access the method via protected inheritance.

Child classes in different namespaces can’t redeclare private(namespace) methods unless the signature is compatible (follows the same inheritance rules as protected methods):

namespace App\Auth;
 
class Base {
    private(namespace) function helper(string $arg): void {}
}
namespace App\Other;
 
class Child extends \App\Auth\Base {
    // This is an error: incompatible signature
    private(namespace) function helper(int $arg): void {}
    // Fatal error: Declaration of App\Other\Child::helper(int $arg): void
    // must be compatible with App\Auth\Base::helper(string $arg): void
}

Liskov Substitution Principle (LSP) Considerations

The Liskov Substitution Principle applies to the public contract of a class. A subclass must be usable anywhere its parent is expected, using only the parent’s public API.

private(namespace) doesn't alter the public API at all. Substitutionally, for external callers, it is unchanged.

Important inheritance note: While private(namespace) members are inherited like protected members, a key difference is that visibility is checked against the declaring namespace rather than the calling class’s namespace. This means:

  • Child classes in the same namespace can access inherited private(namespace) members.
  • Child classes in different namespaces can’t access inherited private(namespace) members.
  • However, child classes in different namespaces must still provide compatible signatures if they redeclare the method, just like with protected methods.

Public Callers (outside the namespace)

For callers in a different namespace, private(namespace) members aren’t accessible. Both parent and child present the same public surface:

namespace App\Auth;
 
class SessionManager {
    private(namespace) function validateToken() { /* ... */ }
 
    public function login(): bool {
        return $this->validateToken();
    }
}
 
class AdvancedSessionManager extends SessionManager {
    // public API unchanged
}
 
// in a different file:
namespace App\Controllers;
 
function authenticate(SessionManager $session): bool {
    // same public API for SessionManager and AdvancedSessionManager
    return $session->login();
}

Callers inside the declaring namespace

Inside the declaring namespace, private(namespace) members are accessible and therefore part of an internal API. A subclass defined in a different namespace does not expose that same internal API.

This isn’t a violation of LSP, because LSP only requires substitutability via the public contract. The internal namespace-level API is intentionally not part of the public contract.

Practical Guidance

  • The public API of a class shouldn’t depend on callers being in the same namespace.
  • Classes that share internal APIs should be kept in the same namespace.
  • Subclasses in other namespaces remain fully substitutable through public methods.

Trait Behavior

When a trait defines a private(namespace) member, the visibility is checked against the receiver class’s namespace, not the trait’s namespace. This is consistent with how traits work in PHP—they’re essentially copied into the class that uses them.

namespace App\Traits;
 
trait SessionHelper {
    private(namespace) function validateToken() {
        return true;
    }
}
namespace App\Auth;
 
use App\Traits\SessionHelper;
 
class SessionManager {
    use SessionHelper;
 
    public function login() {
        // OK - uses SessionManager's namespace (App\Auth)
        // NOT the trait's namespace (App\Traits)
        $this->validateToken();
    }
}
namespace App\Auth;
 
class LoginController {
    public function test(SessionManager $session) {
        // OK - LoginController is in App\Auth (same as SessionManager)
        $session->validateToken();
    }
}
namespace App\Controllers;
 
class OtherController {
    public function test(\App\Auth\SessionManager $session) {
        $session->validateToken();
        // Fatal error: Uncaught Error: Call to private(namespace) method
        // App\Auth\SessionManager::validateToken() from scope App\Controllers\OtherController
    }
}

In short:

  • Trait members behave as if they were declared directly in the class
  • Different classes using the same trait may have different namespace access to the same trait method
  • This is the least surprising behavior and consistent with trait semantics

eval() and Dynamic Code

Code executed via eval() can access private(namespace) members if it declares an explicit namespace that matches the target member’s namespace:

eval() with Explicit Namespace Declaration

namespace App\Auth;
 
class SessionManager {
    private(namespace) int $sessionCount = 0;
 
    private(namespace) function validateToken(): bool {
        return true;
    }
}
 
$session = new SessionManager();
 
// OK - eval code with explicit namespace declaration
eval('
namespace App\Auth;
$count = $session->sessionCount;
echo "Count: $count\n";
$session->validateToken();
');

eval() without Namespace Declaration

When eval() is called without an explicit namespace declaration, the code runs in the global namespace and can’t access private(namespace) members:

namespace App\Auth;
 
$session = new SessionManager();
 
// Error - eval code without namespace runs in global scope
eval('
$count = $session->sessionCount;
');
// Fatal error: Cannot access private(namespace) property
// App\Auth\SessionManager::$sessionCount from scope {main}

Functions Defined in eval()

Functions defined within eval() inherit the namespace context from the evaluated code:

namespace App\Auth;
 
eval('
namespace App\Auth;
 
function helperFunction(SessionManager $session): int {
    // OK - function defined in App\Auth namespace
    return $session->sessionCount * 2;
}
');
 
$result = \App\Auth\helperFunction($session);

Closures and Callables

private(namespace) methods can be used with all callable forms in PHP. Namespace visibility is checked using the lexical namespace where the callable is created or invoked.

call_user_func(), call_user_func_array(), and array_map() check namespace visibility using the caller’s lexical namespace:

namespace App\Auth;
 
class SessionManager {
    private(namespace) function validateToken(): bool {
        return true;
    }
}
namespace App\Auth;
 
$session = new SessionManager();
// OK - call_user_func called from App\Auth namespace
call_user_func([$session, 'validateToken']);
namespace App\Controllers;
 
$session = new \App\Auth\SessionManager();
call_user_func([$session, 'validateToken']);
// Fatal error: Uncaught TypeError: call_user_func(): Argument #1 ($callback)
// must be a valid callback, cannot access private(namespace) method
// App\Auth\SessionManager::validateToken()

First-Class Callables

First-class callable syntax ($fn = $obj->method(...)) checks namespace visibility at the creation site:

namespace App\Auth;
 
$session = new SessionManager();
// OK - Callable created in App\Auth namespace
$fn = $session->validateToken(...);
$fn(); // Works
namespace App\Controllers;
 
$session = new \App\Auth\SessionManager();
$fn = $session->validateToken(...);
// Fatal error: Uncaught Error: Call to private(namespace) method
// App\Auth\SessionManager::validateToken() from scope App\Controllers

Closure::fromCallable()

Closure::fromCallable() follows the same rules as first-class callables - namespace visibility is checked at the conversion site:

namespace App\Auth;
 
$session = new SessionManager();
// OK - Closure created in App\Auth namespace
$closure = Closure::fromCallable([$session, 'validateToken']);
$closure(); // Works
namespace App\Controllers;
 
$session = new \App\Auth\SessionManager();
$closure = Closure::fromCallable([$session, 'validateToken']);
// Fatal error: Uncaught Error: class@anonymous::__invoke(): Argument #1 ($callback)
// must be a valid callback, can't access private(namespace) method
// App\Auth\SessionManager::validateToken()

Variable Function Calls

Direct invocation of callable variables (e.g., $callable()) checks namespace visibility at the invocation site, just like with other visibility modifiers:

namespace App\Auth;
 
$session = new SessionManager();
$callable = [$session, 'validateToken'];
// OK - Invoked from App\Auth namespace
$callable();
namespace App\Controllers;
 
$session = new \App\Auth\SessionManager();
$callable = [$session, 'validateToken'];
$callable();
// Fatal error: Uncaught Error: Call to private(namespace) method
// App\Auth\SessionManager::validateToken() from global scope

Reflection

Reflection bypasses namespace visibility, consistent with how it handles private:

$method = new ReflectionMethod(SessionManager::class, 'checkExpiry');
$method->invoke($session); // Works regardless of caller namespace

New Reflection methods:

$method->isNamespacePrivate(): bool
$property->isNamespacePrivate(): bool

Precedent in Other Languages

Scoped visibility beyond class boundaries is standard in modern languages:

LanguageSyntax Scope
C# internal Assembly
Kotlin internal Module
Swift internal (default!)Module
Rust pub(crate) Crate
Java (no modifier) Package

PHP is an outlier in not having this feature.

Backward Incompatible Changes

None. This is a purely additive feature:

  • New syntax that was previously a parse error
  • No changes to existing visibility behavior
  • Fully opt-in

Proposed PHP Version

Next minor version (PHP 8.6 or 9.0)

RFC Impact

To the Ecosystem

This feature provides a new encapsulation mechanism that’ll benefit library and framework developers:

Benefits

  1. Cleaner Internal APIs: Frameworks like Symfony, Laravel, and Doctrine can better encapsulate internal helper methods that need to be shared between classes in a namespace without exposing them as public API.
  2. Better Type Safety: Static analysis tools (PHPStan, Psalm) can understand namespace-scoped visibility and provide more accurate warnings about API misuse. Attempting to call a namespace-scoped member results in a compile-time error.
  3. Reduced Public API Surface: Libraries can reduce their public API surface, making it easier to evolve internal implementations without breaking backwards compatibility.
  4. Package Organization: Large packages with multiple classes working together can organize code more naturally without artificial design patterns (such as excessive inheritance) to share internal state.

Migration Path

This is a purely additive feature—existing code continues to work unchanged:

  • No Breaking Changes: Existing visibility modifiers (public, protected, private) continue to work exactly as before
  • Opt-in Adoption: Developers can gradually adopt private(namespace) in new code or when refactoring
  • No Forced Upgrades: Libraries don’t need to update unless they want to use the feature

Static Analysis Tool Support

Static analysis tools will need to understand the new visibility modifier:

  • PHPStan: Will need to add support for private(namespace) visibility checks
  • Psalm: Will need to add support for private(namespace) visibility checks
  • PHP-CS-Fixer/PHP_CodeSniffer: May want to add rules for consistent usage of private(namespace)
  • IDEs (PhpStorm, VS Code with Intelephense): Will need to understand the syntax and provide proper code completion and error highlighting

The implementation provides Reflection methods (ReflectionMethod::isNamespacePrivate(), ReflectionProperty::isNamespacePrivate()) that tools can use to inspect namespace visibility.

Symfony:

  • Internal authentication checks (like the SessionManager example in this RFC)
  • Event dispatcher internal methods
  • Container internal service resolution helpers

Laravel:

  • Eloquent model internal query building methods
  • Collection internal transformation helpers
  • Routing internal matching algorithms

Doctrine:

  • UnitOfWork internal state management
  • EntityManager internal hydration methods
  • Query builder internal optimization passes

Potential Breaking Changes for Reflection Users

Code that uses Reflection to introspect classes will see new private(namespace) members:

// This will now include private(namespace) methods
$methods = (new ReflectionClass(SessionManager::class))->getMethods();

However, this is consistent with how Reflection already works—it shows all members regardless of visibility.

To SAPIs

None

To Existing Extensions

Extensions that interact with op_arrays directly will need to be aware of the new namespace_name field. Most extensions shouldn’t be affected.

To Opcache

Opcache’s persistence layer must handle the new namespace_name field in zend_op_array:

  1. String interning: The namespace_name string must be interned into shared memory, similar to function_name
  2. Reference counting: Proper refcount management when persisting and reusing cached op_arrays
  3. Translation table: The old namespace_name pointer must be registered in the translation table for proper cleanup
  4. Memory management: When reusing cached op_arrays, the original namespace_name string must be released

To Core

The compiler needed modifications to prevent memory leaks when tokenizing code to track the current namespace to support private(namespace) and private(namespace)(set)

Performance

Runtime performance: Minimal impact. One additional flag check at method/property access time (already checking visibility flags).

Memory cost: 8 bytes per zend_op_array (64-bit systems) for the namespace_name field:

  • Main script op_arrays: Always allocated (one per file)
  • Function op_arrays: One per function/method
  • For a typical application with 1000 functions, this adds ~8KB of memory

Opcache performance: Minor overhead for namespace_name string interning and refcount management during cache persistence. This occurs once per script during compilation, not on every request.

Future Scope

This RFC intentionally excludes:

  • Anonymous classes:
    • Anonymous class names don’t contain namespace information (class@anonymous... vs Foo\Bar\ClassName)
    • A future RFC could address this, potentially alongside reconsidering anonymous class naming conventions
    • For now, anonymous classes are treated as being in the global namespace for visibility checks
  • Class-level visibility: private class Foo {}
    • This would require class resolution changes and is significantly more complex
    • Can be proposed separately
  • Constants: private(namespace) const X = 1;
    • Constants have compile-time resolution complexity
  • Protected(namespace): Combination of namespace and inheritance scoping
    • Can be added later if desired

Open Issues

  1. Syntax: The syntax private(namespace) doesn't follow asymmetric visibility (AViz) semantics and is somewhat verbose. However, it clearly communicates the intent and follows the pattern established by other visibility modifiers.

Proposed Voting Choices

Accept private(namespace) visibility for methods and properties? Yes/No (2/3 majority required)

Patches and Tests

References

Changelog

  • 1.0: Initial version
  • 1.1: Address issue with protected private(namespace) asymmetric visibility
  • 1.2: Clarified inheritance behavior (behaves like protected, not private), added soundness requirement preventing transitions between protected and private(namespace), expanded ecosystem impact section
rfc/namespace_visibility.txt · Last modified: by withinboredom