PHP public, private, and protected

In PHP object-oriented programming, public, private, and protected are visibility modifiers (also called access modifiers) that control which parts of your code are allowed to read or call a class’s properties and methods. They are the primary mechanism behind encapsulation, one of the core pillars of OOP, letting a class decide exactly what it exposes to the outside world versus what stays internal implementation detail. Getting visibility right turns your classes into safe, predictable building blocks; getting it wrong lets any code anywhere in the application reach in and corrupt an object’s internal state.

Overview: What Visibility Modifiers Do

public members can be read or called from anywhere: inside the class itself, from subclasses, and from completely unrelated code outside the class. protected members can only be reached from inside the declaring class or from a class that extends it, at any depth in the inheritance chain. private members are the strictest: only code written inside the exact class that declared them can touch them, and subclasses are locked out entirely, even though they inherit the member. If you omit a visibility keyword on a property or method, PHP silently treats it as public. This default exists for historical reasons going back to PHP 4’s untyped, always-public properties, but relying on it today is considered sloppy, since it means encapsulation happens by accident rather than by design.

Internally, the Zend Engine (PHP’s runtime) treats visibility as part of a class member’s identity, not just a label. Every property and method is compiled with a record of its declaring class and its visibility. When your code executes an expression like $object->property, the engine does not just look up the property by name, it also compares the class your currently executing code belongs to against the class that declared the property. For private properties, PHP goes further at the storage level: each class layer that declares a private property with a given name gets its own separate, internally mangled storage slot for it. That means a parent class and a child class can both declare a private property called $id without any conflict. They are two completely independent pieces of storage, and each class can only see its own.

Visibility is not limited to properties and instance methods. It applies just as consistently to class constants (since PHP 7.1), to static properties and static methods, and to constructor-promoted properties (the private readonly string $x shorthand introduced in PHP 8.0/8.1). Anywhere a class can expose a member, it can also restrict who is allowed to see it.

Syntax

Visibility keywords are written immediately before a property or method declaration, replacing (or alongside) the type declaration:

<?php

class Example
{
    public const MAX_ITEMS = 100;
    protected const DEFAULT_LABEL = "N/A";
    private const SECRET_KEY = "internal-only";

    public string $publicProp;
    protected int $protectedProp = 0;
    private array $privateProp = [];

    public function publicMethod(): void {}
    protected function protectedMethod(): void {}
    private function privateMethod(): void {}
}
Modifier Accessible From Typical Use
public Anywhere: inside the class, subclasses, and any external code The class’s public API, the methods and properties other code is meant to use
protected The declaring class and any class that extends it, at any depth Internal state that subclasses need to read or extend, but outside code should not touch
private Only the exact class that declared it, not even subclasses Implementation details that must never leak, even to child classes

Examples

Example 1: Public, Protected, and Private on One Class

This example puts all three modifiers on a single class so you can see the difference side by side. Only public members are touched directly from outside the class; the private $ssn is only ever read through a controlled, public method.

<?php

class Person
{
    public string $name;
    protected int $age;
    private string $ssn;

    public function __construct(string $name, int $age, string $ssn)
    {
        $this->name = $name;
        $this->age = $age;
        $this->ssn = $ssn;
    }

    public function introduce(): string
    {
        return "Hi, I'm {$this->name}, age {$this->age}.";
    }

    private function maskedSsn(): string
    {
        return substr($this->ssn, -4);
    }

    public function getMaskedSsn(): string
    {
        return "***-**-" . $this->maskedSsn();
    }
}

$person = new Person("Ava Chen", 29, "123-45-6789");

echo $person->name . PHP_EOL;
echo $person->introduce() . PHP_EOL;
echo $person->getMaskedSsn() . PHP_EOL;

Output:

Ava Chen
Hi, I'm Ava Chen, age 29.
***-**-6789

$person->name works because $name is public. The private maskedSsn() method can only be called from inside Person, so it is wrapped by the public getMaskedSsn() method, which acts as a safe, controlled doorway to the private data.

Example 2: Protected Properties and Inheritance

Here Employee extends Person and reads the protected $age property directly, something that would be impossible if $age were private.

<?php

class Person
{
    public string $name;
    protected int $age;
    private string $ssn;

    public function __construct(string $name, int $age, string $ssn)
    {
        $this->name = $name;
        $this->age = $age;
        $this->ssn = $ssn;
    }
}

class Employee extends Person
{
    public function __construct(
        string $name,
        int $age,
        string $ssn,
        private readonly string $department,
    ) {
        parent::__construct($name, $age, $ssn);
    }

    public function summary(): string
    {
        return "{$this->name} ({$this->age}) works in {$this->department}.";
    }
}

$emp = new Employee("Marcus Lee", 34, "987-65-4321", "Engineering");
echo $emp->summary() . PHP_EOL;

Output:

Marcus Lee (34) works in Engineering.

Employee::summary() reaches $this->age, which was declared protected in the parent Person class. Because Employee extends Person, the calling scope (code running inside Employee) is allowed to access it. If $age had been private, this same line would fail, since private members are invisible even to subclasses.

Example 3: Encapsulation in a Real BankAccount Class

This example shows why encapsulation matters in practice. The balance is private so it can only change through validated methods, while a protected accessor lets a SavingsAccount subclass build new behavior on top without exposing the raw balance publicly.

<?php

class BankAccount
{
    private float $balance;

    public function __construct(
        protected readonly string $owner,
        float $openingBalance = 0.0,
    ) {
        $this->balance = $openingBalance;
    }

    public function deposit(float $amount): void
    {
        if ($amount <= 0) {
            throw new InvalidArgumentException("Deposit must be positive.");
        }
        $this->balance += $amount;
    }

    public function withdraw(float $amount): void
    {
        if ($amount > $this->balance) {
            throw new RuntimeException("Insufficient funds.");
        }
        $this->balance -= $amount;
    }

    protected function getBalance(): float
    {
        return $this->balance;
    }

    public function statement(): string
    {
        return "{$this->owner}'s balance: $" . number_format($this->getBalance(), 2);
    }
}

class SavingsAccount extends BankAccount
{
    public function applyInterest(float $rate): void
    {
        $interest = $this->getBalance() * $rate;
        $this->deposit($interest);
    }
}

$savings = new SavingsAccount("Priya Nair", 1000.0);
$savings->applyInterest(0.05);
echo $savings->statement() . PHP_EOL;

Output:

Priya Nair's balance: $1,050.00

SavingsAccount::applyInterest() calls the protected getBalance() method to read the balance without ever touching the private $balance property directly, and it deposits new interest through the same validated deposit() method every caller uses. Outside code can never set $balance to an arbitrary value; it can only go through deposit() and withdraw(), which enforce the account’s rules.

How PHP Enforces Visibility Under the Hood

Every time your code accesses a property or calls a method through the arrow operator, PHP performs a scope check before allowing it:

  1. When PHP compiles a method body, it records which class that code belongs to. This is the calling scope.
  2. When an expression like $obj->prop or $obj->method() executes, the engine looks up the class that originally declared prop or method. This is the declaring scope.
  3. If the member is public, access is always allowed, regardless of the calling scope.
  4. If it’s protected, PHP checks whether the calling scope is the declaring class or is related to it through inheritance, either an ancestor or a descendant. This check is based on the relationship between classes, not on which specific object instance is being touched, so a method inside ClassA can read a protected property on any ClassA (or subclass) instance, not only through $this.
  5. If it’s private, the calling scope must be exactly the declaring class. Subclasses are excluded even though they technically inherited the member.
  6. If the check fails, PHP throws an Error object (which implements Throwable) with a message describing exactly which property or method was blocked. Because it is a normal object, it can be caught with try/catch like any other exception.
  7. At the storage level, each class layer that declares a private property with a given name owns a distinct slot for it, so a parent’s private property and a child’s identically named private property never collide.

Common Mistakes

Mistake 1: Accessing a private property directly from outside the class. New PHP developers often treat properties like public data fields by default, then get surprised when access fails.

<?php

class Wallet
{
    private float $balance = 0.0;

    public function deposit(float $amount): void
    {
        $this->balance += $amount;
    }
}

$wallet = new Wallet();
$wallet->deposit(50);

try {
    echo $wallet->balance;
} catch (\Error $e) {
    echo "Error: " . $e->getMessage();
}

Output:

Error: Cannot access private property Wallet::$balance

This fails because $balance is private, and the code trying to read it is running outside the Wallet class entirely. The fix is to expose a public accessor method instead of the raw property:

<?php

class Wallet
{
    private float $balance = 0.0;

    public function deposit(float $amount): void
    {
        $this->balance += $amount;
    }

    public function getBalance(): float
    {
        return $this->balance;
    }
}

$wallet = new Wallet();
$wallet->deposit(50);

echo $wallet->getBalance();

Output:

50

Mistake 2: Assuming protected means accessible to any related-looking class. A common misconception is that protected just means slightly more open than private, accessible to any class that happens to work with the object. It does not. It is strictly limited to the declaring class and its actual subclasses.

<?php

class Account
{
    protected float $balance = 100.0;
}

class Auditor
{
    public function inspect(Account $account): float
    {
        return $account->balance;
    }
}

$account = new Account();
$auditor = new Auditor();

try {
    echo $auditor->inspect($account);
} catch (\Error $e) {
    echo "Error: " . $e->getMessage();
}

Output:

Error: Cannot access protected property Account::$balance

Auditor does not extend Account, so it has no inheritance relationship with it, and the protected property remains off limits. The fix is the same pattern as before: add a public (or protected, if only subclasses need it) accessor method.

<?php

class Account
{
    protected float $balance = 100.0;

    public function getBalance(): float
    {
        return $this->balance;
    }
}

class Auditor
{
    public function inspect(Account $account): float
    {
        return $account->getBalance();
    }
}

$account = new Account();
$auditor = new Auditor();

echo $auditor->inspect($account);

Output:

100

Best Practices

  • Default to private for properties, and only widen to protected or public when you have an actual reason. It’s far easier to loosen access later than to tighten it once other code depends on it.
  • Expose behavior, not state: instead of a public property, provide focused public methods (getters, setters, or domain-specific methods like deposit()) so the class can validate and control changes to its own data.
  • Use protected deliberately for the extension points you actually want subclasses to use. Treat it as a documented contract with future subclasses, not a lazy alternative to private.
  • Always write the visibility keyword explicitly. Omitting it defaults to public, which is easy to do by accident and quietly defeats encapsulation.
  • Apply visibility to constants and promoted constructor properties too, such as private readonly string $id. Modern PHP supports it everywhere a class member can appear.
  • Don’t reach for protected just to make testing more convenient. Prefer well-designed public methods or dependency injection so tests exercise the same API real callers use.
  • Remember that visibility is an engine-level guarantee against accidental misuse, not a security boundary against an attacker who already has code-execution access inside your process.

Practice Exercises

  1. Write a Temperature class with a private float $celsius property. Add public methods toFahrenheit(): float and toKelvin(): float that compute conversions from the private value. Try accessing $temperature->celsius directly from outside the class and confirm you get an error.
  2. Create a Shape class with a protected function area(): float method that returns 0.0, and two subclasses, Circle and Square, that override area() with real formulas. Add a public function describe(): string in Shape that calls $this->area(), and verify it correctly calls each subclass’s version.
  3. Build a Vehicle class with a private array $maintenanceLog = [] and a protected function logService(string $note): void method that appends to it. In a Car subclass, add a public method that calls logService(). Then think through why a second private property with the same name declared in the subclass would not conflict with the parent’s version.

Summary

  • public members are reachable from anywhere; protected members are reachable from the declaring class and its subclasses; private members are reachable only from the exact declaring class.
  • Omitting a visibility keyword makes a member public by default, so always state it explicitly.
  • Visibility checks are based on the calling scope, meaning which class the executing code belongs to, not on which specific object instance is being accessed.
  • Violating visibility throws a catchable Error, not a silent failure.
  • Visibility applies uniformly to properties, methods, class constants, and promoted constructor properties.
  • Good encapsulation defaults to private, exposes behavior through public methods, and reserves protected for deliberate extension points meant for subclasses.