Namespace-Scoped Visibility for Methods and Properties
- Version: 1.2
- Date: 2025-11-10
- Author: Rob Landers
- Status: Under Discussion
- Implementation: https://github.com/php/php-src/pull/20421
- First Published at: http://wiki.php.net/rfc/namespace_visibility
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 everywhereprotected: 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:
- Make everything public: Pollutes API, no encapsulation
- Use protected + inheritance: Forces artificial inheritance hierarchies
- Put everything in one class: Creates god objects
- Use @internal docblock: Not enforced, easily violated
- Reflection: Can bypass visibility, but requires awkward code and awkward reviews
- 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 axisprivate(namespace) protected(set)- Error: same reason
Valid asymmetric combinations include:
public private(namespace)(set)- Publicly readable, namespace-writableprivate(namespace) private(set)- Namespace-readable, class-writableprotected 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)toprotected - Cannot narrow from
protectedtoprivate(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
protectedmethods.
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() and Related Functions
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:
| Language | Syntax | 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
- 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.
- 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.
- Reduced Public API Surface: Libraries can reduce their public API surface, making it easier to evolve internal implementations without breaking backwards compatibility.
- 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.
Use Cases in Popular Frameworks
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:
- String interning: The
namespace_namestring must be interned into shared memory, similar tofunction_name - Reference counting: Proper refcount management when persisting and reusing cached op_arrays
- Translation table: The old namespace_name pointer must be registered in the translation table for proper cleanup
- 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...vsFoo\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
- 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
Implementation: https://github.com/php/php-src/pull/20421
References
- C# internal modifier: https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/internal
- Kotlin internal visibility: https://kotlinlang.org/docs/visibility-modifiers.html
- Rust visibility: https://doc.rust-lang.org/reference/visibility-and-privacy.html
- Previous discussion on this topic: https://externals.io/message/111876#111876
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