Table of Contents

PHP RFC: Namespace-Scoped Visibility for Methods and Properties

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:

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:

  1. Make everything public: Pollutes API, no encapsulation
  2. Use protected + inheritance: Forces artificial inheritance hierarchies
  3. Put everything in one class: Creates god objects
  4. 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

  1. Public interfaces: Design your public methods to not depend on callers being in the same namespace.
  2. Namespace cohesion: Classes that share private(namespace) members should remain in the same namespace to maintain substitutability within that namespace.
  3. 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).
  4. 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:

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:

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

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:

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

Changelog