Table of Contents

PHP RFC: Namespace Prefix-Based Visibility for private(namespace)

Introduction

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.

Motivation

The Problem with Exact Matching

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:

  1. Fragmented module boundaries: Sub-namespaces like App\Auth\OAuth and App\Auth\Session are conceptually part of the same authentication module, but cannot share internal APIs
  2. Forced public exposure: Developers must make methods public to share them across sub-namespaces, polluting the public API
  3. Organizational inflexibility: Teams must either flatten their namespace structure (losing organization) or accept weaker encapsulation
  4. Inconsistent with package semantics: In most languages (Java, C#, Kotlin), package-private visibility applies to the package and all sub-packages

Real-World Example

Consider 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:

  1. Flatten namespaces: Put everything in App\Auth - loses organization
  2. Make methods public: Exposes internal APIs to the entire application
  3. Use protected + inheritance: Forces artificial class hierarchies
  4. Current exact-match private(namespace): Forces code duplication or architectural compromises

Proposal

Extend private(namespace) to allow access from all namespaces that share a matching prefix with the declaring namespace.

Scoping Rules

New Rule: Prefix matching instead of exact matching

A private(namespace) member declared in namespace N is accessible from:

  1. The exact namespace N
  2. Any parent namespace of N (moving up the hierarchy)
  3. Any child namespace of N (moving down the hierarchy)
  4. Any sibling namespace at any level below N

In formal terms, namespace C can access a member declared in namespace D if and only if:

This creates a namespace tree where all descendants of a namespace can access each other's private(namespace) members.

Examples

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

Visual Representation

Consider this namespace hierarchy:

App\
├── Auth\
│   ├── SessionManager (declares private(namespace) validateToken())
│   ├── OAuth\
│   │   └── OAuthProvider
│   └── Session\
│       └── SessionStore
├── Billing\
│   └── PaymentProcessor
└── Controllers\
    └── LoginController

With exact matching (original RFC):

With prefix matching (this RFC):

Algorithm

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;
}

Edge Cases

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.

Impact on Original RFC Semantics

Unchanged Behaviors

The following behaviors from the original RFC remain identical:

  1. Asymmetric visibility: All rules for private(namespace)(set) remain the same
  2. Inheritance: Members are inherited, visibility is based on declaring namespace
  3. Visibility reduction: Cannot reduce visibility of inherited public/protected members
  4. Traits: Trait members use the receiver class's namespace
  5. Reflection: Reflection still bypasses namespace visibility
  6. Closures and callables: All callable semantics remain unchanged
  7. eval(): Namespace context rules remain the same

Changed Behavior

Only 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\

Liskov Substitution Principle (LSP) Considerations

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.

Comparison with Other Languages

Java (package-private)

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)

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 (pub(crate) and pub(super))

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.

Summary

Language Package/Module Hierarchy Access Scope
Java com.app.auth.oauthPackage + 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.

Backward Compatibility

This RFC is not backward compatible with the original private(namespace) RFC if both are adopted.

Breaking Change

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!
    }
}

Migration Path

If the original private(namespace) RFC ships first, this RFC would need to either:

  1. Replace the behavior entirely (breaking change, requires major version)
  2. Add a new syntax (e.g., private(namespace:tree) vs private(namespace:exact))
  3. Use an INI setting to control matching behavior (not recommended)

Proposed PHP Version

RFC Impact

All impacts are identical to the original private(namespace) RFC, as the implementation change is limited to the namespace matching function.

To Core

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;
}

Performance

Negligible impact. The prefix matching adds two additional string comparisons in the worst case:

  1. Exact match check (fast path)
  2. Child namespace check (one strncmp)
  3. Parent namespace check (one strncmp)

Total overhead: ~10-20 CPU cycles per visibility check (measured in nanoseconds on modern CPUs).

Alternatives Considered

Alternative 1: Keep Exact Matching (Status Quo)

Pros:

Cons:

Alternative 2: New Syntax for Prefix Matching

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:

Alternative 3: Configurable Namespace Depth

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:

Recommendation

Adopt prefix matching as the default behavior for private(namespace). This provides the best balance of:

Open Issues

Issue 1: Should parent namespaces have access?

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:

Arguments against:

Current proposal: Allow parent access (as specified above). This can be revisited during discussion.

Issue 2: Global namespace edge case

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.

Future Scope

Potential Extensions

  1. private(namespace:exact): Add explicit exact-match syntax if both behaviors are desired
  2. protected(namespace): Combine namespace and inheritance scoping
  3. Namespace visibility for constants: Extend to const declarations
  4. Namespace visibility for classes: private(namespace) class Foo {}

Proposed Voting Choices

Change private(namespace) visibility to use prefix matching instead of exact matching? Yes/No (2/3 majority required)

Implementation Considerations

The implementation requires minimal changes to the existing private(namespace) implementation:

  1. Modify namespace matching function in zend_API.c or wherever visibility checks occur
  2. Update error messages to reflect “namespace tree” instead of “exact namespace”
  3. Update tests to cover prefix matching scenarios

Example implementation of the matching function is provided in the “Algorithm” section above.

References

Changelog