====== PHP RFC: Namespace-Scoped Visibility for Methods and Properties ======
* Version: 1.0
* Date: 2025-11-07
* Author: Rob Landers
* Status: Draft
* 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 everywhere
* ''%%protected%%'': Accessible within class hierarchy (inheritance-based)
* ''%%private%%'': Accessible only within the same class
There is 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()) {
// ...
}
}
}
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
===== 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) 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();
// Error: Cannot access namespace-private method
$session->checkExpiry();
}
}
==== Scoping Rules ====
**Rule 1: Exact namespace match required**
Sub-namespaces are considered different namespaces:
namespace App\Auth;
private(namespace) function helper() {}
namespace App\Auth\OAuth;
helper(); // Error: Different namespace
**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**
''%%private(namespace)%%'' can be used as a set visibility modifier, following PHP’s asymmetric visibility pattern:
// Valid: Get is less restrictive than set
public private(namespace)(set) string $prop;
protected private(namespace)(set) string $prop;
// Invalid: Set is less restrictive than get
private(namespace) public(set) string $prop; // Compile error
private(namespace) protected(set) string $prop; // Compile error
**Visibility hierarchy:**
public < protected < private(namespace) < private
(least restrictive → most restrictive)
**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;
// Error: Different namespace
$s = new \App\Auth\Session();
$s->foo();
The namespace context is tracked at compile-time.
==== Inheritance Behavior ====
''%%private(namespace)%%'' members are inherited (like ''%%private%%'' members), but visibility is enforced based on the **namespace where they were declared**:
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() {
// Error - Different is in App\Other, but method was declared in App\Auth
$this->helper();
}
}
$diff = new Different();
$diff->callHelper(); // Still works! Parent method in App\Auth can call it
==== Liskov Substitution Principle (LSP) Considerations ====
The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of a subclass without breaking the application. How does ''%%private(namespace)%%'' affect LSP?
**Short answer**: ''%%private(namespace)%%'' does NOT violate LSP because it’s not part of the public contract.
=== Public API Perspective (External Code) ===
From outside the namespace, ''%%private(namespace)%%'' members are invisible. The public API remains unchanged, so LSP is preserved:
namespace App\Auth;
class SessionManager {
private(namespace) function validateToken() { /* ... */ }
public function login(): bool {
return $this->validateToken();
}
}
class AdvancedSessionManager extends SessionManager {
// Public API unchanged - fully substitutable
}
namespace App\Controllers;
function authenticate(SessionManager $session): bool {
// LSP satisfied - AdvancedSessionManager works identically here
// Only public methods are accessible, private(namespace) is hidden
return $session->login();
}
$basic = new SessionManager();
$advanced = new AdvancedSessionManager();
authenticate($basic); // Works
authenticate($advanced); // Works identically - LSP preserved
=== Namespace API Perspective (Same Namespace) ===
Within the namespace, ''%%private(namespace)%%'' members ARE part of the contract. A child class in a **different namespace** is intentionally NOT substitutable for namespace-internal operations:
namespace App\Auth;
class SessionManager {
private(namespace) function validateToken() { /* ... */ }
}
class SessionStore {
public function check(SessionManager $session): bool {
// Works with SessionManager
return $session->validateToken();
}
}
namespace App\Other;
class CustomSession extends \App\Auth\SessionManager {
// Inherits validateToken() but in different namespace
}
namespace App\Auth;
$custom = new \App\Other\CustomSession();
$store = new SessionStore();
// Breaks LSP within namespace - but this is INTENTIONAL
$store->check($custom); // Error: Cannot access namespace-private method
**Design rationale**: This is intentional! The child class is in a different namespace, so it’s outside the namespace boundary. The namespace API contract only applies to classes within the same namespace. This enforces encapsulation at the namespace level.
=== Recommendations ===
- **Public interfaces**: Design your public methods to not depend on callers being in the same namespace.
- **Namespace cohesion**: Classes that share ''%%private(namespace)%%'' members should remain in the same namespace to maintain substitutability within that namespace.
- **Cross-namespace inheritance**: If a child class is in a different namespace, ensure it doesn’t break the public API contract (LSP still applies to public methods).
- **Documentation**: Document which methods are part of the "namespace API" vs "public API" to help maintainers understand the intended usage.
**Bottom line**: ''%%private(namespace)%%'' preserves LSP for public APIs while allowing controlled LSP violations within namespace boundaries. This is analogous to package-private access in Java or internal visibility in C#/Kotlin.
==== 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 are 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) {
// Error - OtherController is in App\Controllers
// (different from SessionManager's namespace App\Auth)
$session->validateToken();
}
}
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
===== 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 SAPIs ====
None
==== To Existing Extensions ====
None
==== To Opcache ====
No changes needed. Visibility checks happen at call-site, not in opcache.
==== Performance ====
Minimal impact. One additional flag check at method/property access time (already checking visibility flags).
===== Future Scope =====
This RFC intentionally excludes:
* **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 =====
None at this time.
===== 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
* Swift access control: https://docs.swift.org/swift-book/documentation/the-swift-programming-language/accesscontrol/
* Rust visibility: https://doc.rust-lang.org/reference/visibility-and-privacy.html
===== Changelog =====
* 1.0: Initial version