Skip to content
beginner Phase 4 · Object-Oriented Programming Foundations

Public, Private, and Protected Members

Encapsulate internal state with visibility modifiers.

40m
0 problems
Topic Progress 0%

Why public Everywhere Breaks Tomorrow: The Private Line in the Sand

A Wallet class ships with every property public. A caller discovers it can do this:

$wallet = new Wallet(500);
$wallet->cents = -9000;          // no error — the class never guarded this
$wallet->log = 'discarded';      // stomps an internal field it was never meant to see

Nothing crashes. The invariant that a wallet's balance is never negative was never enforced, because the balance was never owned — it was a suggestion any caller could rewrite. Visibility modifiers turn that suggestion into a rule the language enforces:

final class Wallet
{
    private int $cents;
    private array $ledger = [];

    public function __construct(int $cents)
    {
        if ($cents < 0) {
            throw new InvalidArgumentException('balance cannot be negative');
        }
        $this->cents = $cents;
    }
}

$wallet->cents;   // Error: Cannot access private property Wallet::$cents

Private is the default you should reach for. A private property or method is reachable only inside the class that declares it. That is the whole trick of encapsulation: callers cannot bypass your constructor's validation because they cannot touch the field at all. They interact through the methods you chose to expose, and those methods are free to keep the object consistent.

The rule has a detail interviews love to test: private is per-class, not per-instance. A method of Wallet may read the private members of any Wallet, not only $this:

public function merge(Wallet $other): void
{
    $this->cents += $other->cents;   // legal — $other is the same class
    $other->cents = 0;               // also legal
}

Because both instances were built by the same code with the same invariants, letting one inspect another breaks no promise. A subclass, however, does not inherit private members in any usable way — a parent's private property exists inside child instances but the child's code cannot name it. That boundary is what makes protected a different concept rather than a synonym.

Private methods deserve the same habit. Helpers that exist only to support the public API — validateEmail(), recomputeLedger() — should be private, because every additional reachable method is another contract you must keep stable across refactors. The smaller the public surface, the more freedom you keep to change the inside. In Classes and Objects the Customer class exposed everything with promotion's public shorthand; visibility is the decision of which of those promoted properties deserve a wider door — and which deserve protected instead, which is exactly what Inheritance and the extends Keyword needs next.

The engine enforces visibility eagerly, so violations surface as predictable errors rather than undefined behavior: reading a private property from outside throws Error: Cannot access private property, and the same message pattern applies to methods and statics. A related enforcement often mistaken for visibility is the PHP 8.2 dynamic-property deprecation — visibility governs the members you declared, while creating brand-new undeclared properties is deprecated for everyone. Note also that visibility cuts no slack in the other direction: a public method of a child class cannot widen its reach into a parent's private members, and a parent's private methods remain callable only by that parent's own code. The design consequence is the argument you will make in reviews whenever a data class ships with twelve public fields: the smaller the door, the larger the freedom to rename, retype, or recompute what sits behind it.

Protected: the Inheritance Surface, Not a Weaker Private

protected is often described as "private, but for subclasses". Precisely: a protected member is reachable from the declaring class and from any class that extends it, but from nowhere else:

class Wallet
{
    protected int $cents;

    public function __construct(int $cents) { $this->cents = $cents; }

    protected function fee(int $amount): int
    {
        return (int) ceil($amount * 0.02);
    }
}

final class CryptoWallet extends Wallet
{
    public function withdraw(int $amount): void
    {
        $total = $amount + $this->fee($amount);   // protected: child may use it
        $this->cents -= $total;                   // protected property: reachable
    }
}

$w = new CryptoWallet(1000);
$w->cents;              // Error: Cannot access protected property
$w->fee(100);           // Error: Cannot access protected method

The child gets the member; outside code does not. That makes protected the inheritance surface: everything a subclass is allowed to build on. Choosing it is a design statement — "subclasses may depend on this" — and dependencies are liabilities, because you can no longer refactor that member without touching every descendant.

The sharp comparison question: a parent's private member is invisible to the child's code even though child instances physically carry it. If Wallet::$cents were private, CryptoWallet::withdraw() could not write $this->cents at all; it would have to go through a public or protected method of the parent. So the ladder is strict:

Modifier Declaring class Child class Everyone else
public yes yes yes
protected yes yes no
private yes no no

The strategic rule that keeps hierarchies healthy: start private and widen only under pressure. A frequent move is keeping the property private and adding a protected method that gives subclasses a controlled seam — say protected function credit(int $amount): void that validates before mutating. The parent keeps its invariant (only validated writes), the child gets a supported extension point, and the raw field never leaves the class. Widening a private member to protected later is a small refactor; narrowing a protected one is a breaking change for every subclass you shipped.

One trap while we are here: final on a class does not make its members private — it only forbids extending the class. And final public methods are allowed on non-final classes to stop overriding. Visibility answers "who can reach it"; finality answers "who can replace the behavior" — you will use both when overriding rules arrive in Polymorphism and Method Overriding.

A practical audit sharpens the whole ladder: count the public members of any class — that number is the maintenance surface every future refactor must honor. Framework code demonstrates the range deliberately. Value objects like Money ship readonly promoted properties, public by name and immutable by type, so access control comes from immutability rather than hiding. Entities keep fields private with accessors on every side because their state must evolve. And base classes in deep hierarchies maintain the protected-seam discipline: a small set of documented protected methods, each treated as a promise. When you cannot decide between private and protected, ask whether you would accept a subclass you have never met depending on that member tomorrow. If the honest answer makes you wince, it is private.

Getters, Setters, and readonly: Controlled Access Instead of Raw Fields

Making everything private and stopping there produces a useless class: callers cannot read a thing. The replacement for a public property is not nothing — it is an accessor pair that lets you intercept reads and writes:

final class Customer
{
    public function __construct(
        private string $email,
        private int $credits,
    ) {}

    public function email(): string
    {
        return $this->email;
    }

    public function changeEmail(string $email): void
    {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException("Invalid email: $email");
        }
        $this->email = $email;
    }

    public function credits(): int
    {
        return $this->credits;
    }
}

$c->changeEmail('not-an-email');   // rejected at the boundary, by the class that owns the rule

Notice what the write path bought: the validation that used to live in every caller (or in no one) now lives in one method named after the intent — changeEmail, not setEmail. Good PHP style drops the get/set prefixes when the method is a pure read (email()), and keeps a verb for anything that mutates, because the call site then reads as an event. Every write is a chance to enforce an invariant; every read is a chance to derive or normalize a value. That is the whole economic argument for accessors over fields — a bare public property gets zero such chances.

When the invariant is "written once and never again", PHP 8.1 offers a stronger tool than a guarded setter: readonly:

final class Money
{
    public function __construct(
        public readonly int $cents,
        public readonly string $currency,
    ) {}
}

$m = new Money(500, 'EUR');
$m->cents = 1;        // Error: Cannot modify readonly property Money::$cents

Readonly properties may be initialized exactly once — in the constructor, or from a static factory that calls it — and any later write is an Error at the language level. There is no setter to get wrong and no validation race to reason about: the object is born valid and stays valid. Prefer readonly for value objects (Money, coordinates, DTOs), and prefer the validated-setter pattern when the state must legitimately evolve, like the wallet balance. (A subtle readonly rule worth knowing: each instance may initialize its own readonly properties, so a clone can reinitialize them inside __clone in PHP 8.3+.)

One escape hatch exists for reads on classes you do not control or cannot edit: the magic __get/__set methods intercept access to inaccessible properties, so a private field still routes through your code if you define them. They are interception tools, not access modifiers — the mechanics deserve Magic Methods like __get and __toString, while the subclass-facing half of this ladder is consumed by Inheritance and the extends Keyword.

One naming debate worth settling before interviews: get and set prefixes versus bare reads. Modern PHP style prefers $customer->email() for a pure getter and an intent verb for mutation — changeEmail(), deactivate(), transferTo() — because the call site then reads like a sentence and reviewers can see that a write is happening. Whatever spelling you choose, keep it consistent across the codebase, and keep one more rule: a setter that silently accepts anything is worse than the public property it replaced, because it advertises validation it does not perform. An accessor earns its existence only when it does something a field cannot — validate, normalize, lazily compute, or forbid.

Quiz

1. A method inside class Wallet reads $other->cents where $other is another instance of Wallet and $cents is private. What happens?

Question 1 options

2. What is the difference between a parent's private and protected member from a child class's point of view?

Question 2 options

3. Which statement best describes when a protected member is the right choice?

Question 3 options

4. What does `public readonly int $cents` (PHP 8.1) guarantee?

Question 4 options

Flashcards

Question

Who can reach a private, a protected, and a public member?

Answer

Private: only code in the declaring class (any instance of it — private is per-class, not per-instance). Protected: the declaring class plus subclasses. Public: everyone. A parent's private member exists in child instances but child code cannot name it.

Question

Why widen a private member to protected instead of declaring it protected from the start?

Answer

Protected is a promise subclasses may depend on, which makes the member expensive to refactor. Starting private and widening only when a real subclass needs the seam keeps the surface minimal; narrowing protected later is a breaking change for every shipped subclass.

Question

What do accessors buy you over a public property?

Answer

Every write is a validation point and every read a normalization point, both owned by the class. An intent-named mutator (changeEmail) documents the event at the call site and enforces invariants that a public field lets any caller bypass.

Question

What does a readonly property (PHP 8.1) allow and forbid?

Answer

It may be initialized exactly once (constructor or a static factory calling it); every later assignment throws Error. It is the tool for value objects whose state should never evolve; use a validated setter when state must change legitimately.

Question

Do visibility modifiers affect static members, and does final relate to visibility?

Answer

Yes, static properties and methods take visibility like instance members (a protected static is usable by subclasses via static::). final is orthogonal: it forbids overriding a method or extending a class; it says nothing about who can reach a member.

Revision Notes

Key Takeaways

  • 1. Private is the default: callers cannot bypass invariants they cannot reach, and private is per-class, so same-class instances may inspect each other
  • 2. Protected is the inheritance surface — a documented promise to subclasses — so widen to it only when a real subclass needs the seam
  • 3. A parent's private member is present in child instances but invisible to child code; that is the strict line between private and protected
  • 4. Accessors convert reads into normalization points and writes into validation points; intent-named mutators document events at the call site
  • 5. readonly (8.1) makes write-once a language guarantee — the right tool for value objects, with validated setters for state that must evolve

Interview Tips

  • • "Why not just make everything public?" — every public field is an unguarded invariant: any caller can store invalid state, and the class loses the chance to validate or normalize. Encapsulation keeps the rules next to the data
  • • "Can a private method call another object's private method?" — yes, if that object is the same class: visibility is per-class, not per-instance. This is exactly how merge() or comparison helpers work
  • • "Protected or private + getter?" — prefer private; expose protected only as a supported extension point. Protected members couple you to every subclass, so each one needs a justification
  • • "How do you make an object immutable in PHP?" — readonly properties (8.1), promoted in the constructor; later writes are Errors. For whole-object immutability, readonly class (8.2) marks every property readonly at once

Cheat Sheet

Visibility: public, private, protected

Access matrix:

Member Declaring class Child class Everyone else
public yes yes yes
protected yes yes no
private yes no (even though child instances carry it) no

Default posture: private first; widen to protected only when a subclass genuinely builds on it.

class Wallet
{
    private int $cents;                      // hidden state
    protected int $tier;                     // inheritance surface

    public function merge(Wallet $o): void   // per-class private access
    {
        $this->cents += $o->cents;           // legal: same class
    }
}

Accessors over fields: private + intent-named mutator (changeEmail() validates) beats setEmail(); pure reads drop the get prefix.

Immutability (8.1):

final class Money
{
    public function __construct(
        public readonly int $cents,
        public readonly string $currency,
    ) {}
}
$m->cents = 1;   // Error: Cannot modify readonly property

Readonly = initialized once per object, later writes are Errors. final (class or method) is orthogonal to visibility: it blocks extension/overriding, not access.

Continue Learning

Before You Start

Related Reading