This RFC proposes extending the private(namespace) visibility modifier to allow access from all namespaces that share a matching prefix with the declaring namespace. Under this proposal, a member declared in App\Auth\OAuth would be accessible from App, App\Auth, App\Auth\OAuth, App\Auth\Session, and any other namespace beginning with App\Auth\.
This is a relaxation of the exact namespace matching rule introduced in the base private(namespace) RFC, enabling broader collaboration within namespace hierarchies while maintaining encapsulation boundaries.
The original private(namespace) RFC requires an exact namespace match for access:
namespace App\Auth; class SessionManager { private(namespace) function validateToken(): bool { return true; } } namespace App\Auth\OAuth; class OAuthProvider { public function login(SessionManager $session): void { // Fatal error: Cannot access private(namespace) method // App\Auth\SessionManager::validateToken() from scope App\Auth\OAuth $session->validateToken(); } }
While this provides strong encapsulation, it creates artificial barriers within logically related code:
App\Auth\OAuth and App\Auth\Session are conceptually part of the same authentication module, but cannot share internal APIspublic to share them across sub-namespaces, polluting the public APIConsider an authentication module with multiple sub-components:
namespace App\Auth; class AuthManager { private(namespace) function createSession(User $user): Session { // Core session creation logic return new Session($user); } } namespace App\Auth\OAuth; class OAuthProvider { public function authenticate(string $token): Session { $user = $this->fetchUserFromToken($token); $auth = new \App\Auth\AuthManager(); // ERROR: Cannot access createSession() - different namespace return $auth->createSession($user); } } namespace App\Auth\Session; class SessionStore { public function refresh(Session $session): void { $auth = new \App\Auth\AuthManager(); // ERROR: Cannot access createSession() - different namespace return $auth->createSession($session->getUser()); } }
The workarounds are all problematic:
App\Auth - loses organizationprotected + inheritance: Forces artificial class hierarchiesprivate(namespace): Forces code duplication or architectural compromises
Extend private(namespace) to allow access from all namespaces that share a matching prefix with the declaring namespace.
New Rule: Prefix matching instead of exact matching
A private(namespace) member declared in namespace N is accessible from:
NN (moving up the hierarchy)N (moving down the hierarchy)N
In formal terms, namespace C can access a member declared in namespace D if and only if:
C starts with D\, ORD starts with C\, ORC == D
This creates a namespace tree where all descendants of a namespace can access each other's private(namespace) members.
namespace App\Auth; class SessionManager { private(namespace) function validateToken(): bool { return true; } }
Accessible from same namespace (unchanged):
namespace App\Auth; class SessionStore { public function check(SessionManager $session): void { $session->validateToken(); // ✓ OK - exact match } }
Accessible from child namespaces (NEW):
namespace App\Auth\OAuth; class OAuthProvider { public function login(SessionManager $session): void { $session->validateToken(); // ✓ OK - App\Auth\OAuth starts with App\Auth\ } } namespace App\Auth\Session\Storage; class DatabaseStorage { public function validate(SessionManager $session): void { $session->validateToken(); // ✓ OK - App\Auth\Session\Storage starts with App\Auth\ } }
Accessible from parent namespaces (NEW):
namespace App\Auth\OAuth; class OAuthSession { private(namespace) function refreshToken(): void { /* ... */ } } namespace App\Auth; class AuthManager { public function refresh(OAuthSession $session): void { $session->refreshToken(); // ✓ OK - App\Auth is parent of App\Auth\OAuth } } namespace App; class Bootstrap { public function init(OAuthSession $session): void { $session->refreshToken(); // ✓ OK - App is parent of App\Auth\OAuth } }
Not accessible from unrelated namespaces (unchanged):
namespace App\Billing; class PaymentProcessor { public function process(SessionManager $session): void { $session->validateToken(); // ✗ Fatal error: Cannot access private(namespace) method // App\Auth\SessionManager::validateToken() from scope App\Billing } } namespace Other; class ExternalCode { public function test(SessionManager $session): void { $session->validateToken(); // ✗ Fatal error: Cannot access private(namespace) method // App\Auth\SessionManager::validateToken() from scope Other } }
Consider this namespace hierarchy:
App\
├── Auth\
│ ├── SessionManager (declares private(namespace) validateToken())
│ ├── OAuth\
│ │ └── OAuthProvider
│ └── Session\
│ └── SessionStore
├── Billing\
│ └── PaymentProcessor
└── Controllers\
└── LoginController
With exact matching (original RFC):
App\Auth\* can access validateToken()App\Auth\OAuth\* cannot accessApp\Auth\Session\* cannot accessApp\Billing\* cannot accessWith prefix matching (this RFC):
App\Auth\* can access validateToken()App\Auth\OAuth\* can access (child namespace)App\Auth\Session\* can access (child namespace)App\* can access (parent namespace)App\Billing\* cannot access (different branch)App\Controllers\* cannot access (different branch)The namespace visibility check becomes:
bool is_namespace_accessible(const char *caller_ns, const char *member_ns) { if (caller_ns == NULL || member_ns == NULL) { return false; } size_t caller_len = strlen(caller_ns); size_t member_len = strlen(member_ns); // Exact match if (caller_len == member_len && strcmp(caller_ns, member_ns) == 0) { return true; } // Caller is child of member namespace (member_ns is prefix of caller_ns) // e.g., caller="App\Auth\OAuth", member="App\Auth" if (caller_len > member_len && strncmp(caller_ns, member_ns, member_len) == 0 && caller_ns[member_len] == '\\') { return true; } // Caller is parent of member namespace (caller_ns is prefix of member_ns) // e.g., caller="App\Auth", member="App\Auth\OAuth" if (member_len > caller_len && strncmp(member_ns, caller_ns, caller_len) == 0 && member_ns[caller_len] == '\\') { return true; } return false; }
Global namespace:
namespace App\Auth; class Session { private(namespace) function foo() {} } namespace { $s = new \App\Auth\Session(); $s->foo(); // ✗ Fatal error: Global namespace does not match App\Auth }
The global namespace (empty string) does not match any namespaced code. This prevents accidental global access.
Single-level namespace:
namespace App; class Base { private(namespace) function helper() {} } namespace App\Auth; class Child extends Base { public function test() { $this->helper(); // ✓ OK - App\Auth starts with App\ } }
Namespace separator edge case:
namespace App; class Foo { private(namespace) function test() {} } namespace Application; class Bar { public function run(Foo $foo) { $foo->test(); // ✗ Fatal error: "Application" does not start with "App\" } }
The check requires a full namespace segment match - Application is not treated as a child of App.
The following behaviors from the original RFC remain identical:
private(namespace)(set) remain the samepublic/protected membersOnly one change: The namespace matching algorithm.
Before (exact match):
caller_namespace == member_namespace
After (prefix match):
caller_namespace == member_namespace || caller_namespace starts with member_namespace\ || member_namespace starts with caller_namespace\
The LSP analysis from the original RFC still applies, with one important extension:
Original RFC: A child class in a different namespace breaks LSP for namespace-internal operations.
This RFC: A child class in a different namespace branch breaks LSP for namespace-internal operations.
namespace App\Auth; class SessionManager { private(namespace) function validateToken() { /* ... */ } } class SessionStore { public function check(SessionManager $session): bool { return $session->validateToken(); // Expects access } } namespace App\Auth\OAuth; // ✓ OK - LSP preserved (same namespace tree) class OAuthSession extends SessionManager {} $store = new SessionStore(); $store->check(new OAuthSession()); // Works! namespace App\Billing; // ✗ Breaks LSP (different namespace branch) class BillingSession extends SessionManager {} $store = new SessionStore(); $store->check(new BillingSession()); // Fatal error: Cannot access private(namespace) method
Design rationale: Classes within the same namespace tree (e.g., App\Auth\*) form a cohesive module. Classes outside that tree (e.g., App\Billing\*) are external and should not be substitutable for namespace-internal operations.
Java's package-private visibility (no modifier) allows access within the same package and sub-packages:
package com.app.auth; class SessionManager { void validateToken() {} // package-private } package com.app.auth.oauth; class OAuthProvider { void login() { SessionManager session = new SessionManager(); session.validateToken(); // ✓ OK - same package tree } }
This RFC brings PHP's private(namespace) closer to Java's semantics.
C# internal is assembly-scoped, but nested namespaces within an assembly can access internal members:
namespace App.Auth { internal class SessionManager { internal void ValidateToken() {} } } namespace App.Auth.OAuth { class OAuthProvider { void Login() { var session = new SessionManager(); session.ValidateToken(); // ✓ OK - same assembly } } }
C# doesn't restrict by namespace within an assembly - all namespaces in the assembly can access internal members.
Rust has fine-grained visibility control:
mod app { mod auth { pub(super) fn validate_token() {} // Accessible to parent (app) pub(crate) fn other_function() {} // Accessible to entire crate mod oauth { pub fn login() { super::validate_token(); // ✓ OK - can access pub(super) } } } }
This RFC's prefix matching is similar to Rust's pub(in path) where path is the declaring namespace.
| Language | Package/Module Hierarchy | Access Scope |
|---|---|---|
| Java | com.app.auth.oauth | Package + sub-packages (prefix match) |
| C# | App.Auth.OAuth | Entire assembly (no namespace restriction) |
| Kotlin | Module-scoped | Entire module (no namespace restriction) |
| Rust | app::auth::oauth | Configurable (pub(super), pub(crate), pub(in path)) |
| PHP (this RFC) | App\Auth\OAuth | Namespace tree (prefix match) |
PHP with this RFC would be most similar to Java, with namespace hierarchies defining access boundaries.
This RFC is not backward compatible with the original private(namespace) RFC if both are adopted.
Code that relied on exact namespace matching for security boundaries will now allow broader access:
namespace App\Auth; class CriticalSecurity { private(namespace) function destroyAllSessions(): void { // DANGEROUS OPERATION } } namespace App\Auth\Public; // Under original RFC: Cannot access (different namespace) // Under this RFC: CAN access (child namespace) class PublicController { public function logout(CriticalSecurity $security): void { $security->destroyAllSessions(); // Now allowed! } }
If the original private(namespace) RFC ships first, this RFC would need to either:
private(namespace:tree) vs private(namespace:exact))
All impacts are identical to the original private(namespace) RFC, as the implementation change is limited to the namespace matching function.
The only change needed is in zend_check_private_namespace_access() (or equivalent):
// Before (exact match) if (strcmp(caller_ns, member_ns) == 0) { return true; } // After (prefix match) if (is_namespace_accessible(caller_ns, member_ns)) { return true; }
Negligible impact. The prefix matching adds two additional string comparisons in the worst case:
strncmp)strncmp)Total overhead: ~10-20 CPU cycles per visibility check (measured in nanoseconds on modern CPUs).
Pros:
Cons:
Add a separate modifier for prefix matching:
private(namespace:tree) function foo() {} // Prefix match private(namespace:exact) function bar() {} // Exact match (explicit) private(namespace) function baz() {} // Defaults to... tree or exact?
Pros:
Cons:
Allow specifying how many namespace levels to include:
private(namespace:1) function foo() {} // Current namespace + 1 level private(namespace:*) function bar() {} // All levels (prefix match)
Pros:
Cons:
Adopt prefix matching as the default behavior for private(namespace). This provides the best balance of:
This RFC proposes that parent namespaces can access child namespace members:
namespace App\Auth\OAuth; class OAuthSession { private(namespace) function refresh() {} } namespace App\Auth; class Manager { public function test(OAuthSession $session) { $session->refresh(); // Should this work? } }
Arguments for allowing:
pub(super) + pub(crate) combined behaviorArguments against:
Current proposal: Allow parent access (as specified above). This can be revisited during discussion.
Should code in the global namespace have access to private(namespace) members in namespaced code if they're in the “ same file”?
// file.php namespace App\Auth; class Session { private(namespace) function foo() {} } namespace { $s = new \App\Auth\Session(); $s->foo(); // Should this work? }
Current proposal: No - global namespace does not match any namespaced code. This is consistent and prevents accidental global access.
private(namespace:exact): Add explicit exact-match syntax if both behaviors are desiredprotected(namespace): Combine namespace and inheritance scopingconst declarationsprivate(namespace) class Foo {}
Change private(namespace) visibility to use prefix matching instead of exact matching? Yes/No (2/3 majority required)
The implementation requires minimal changes to the existing private(namespace) implementation:
zend_API.c or wherever visibility checks occurExample implementation of the matching function is provided in the “Algorithm” section above.