====== 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