PHP: What is the difference between public, private and protected?

You find the table for this in every tutorial, and it is correct. But it suggests one thing that is wrong: that private applies to the individual object. It does not, and that is a good thing.

The basics

<?php
class Basis {
  public    string $oeffentlich = 'public';
  protected string $geschuetzt  = 'protected';
  private   string $privat      = 'private';
}
?>

From outside:

$b->oeffentlich : 'public'
$b->geschuetzt  : Error: Cannot access protected property Basis::$geschuetzt
$b->privat      : Error: Cannot access private property Basis::$privat

From inside the class everything works:

vonInnen() : ["public","protected","private"]

Note that this is an Error, not a warning. So the access aborts — unlike with a property that does not exist at all.

In a derived class

And here it already gets more interesting:

Warning: Undefined property: Kind::$privat
ausDerAbleitung() : {"oeffentlich":"public","geschuetzt":"protected","privat":null}

protected works, private does not. So far as expected. What is interesting is the kind of failure: it is not an access error but a Warning: Undefined property.

The difference matters. private is not inherited — the property simply does not exist in Kind. So you do not get an Error that stops your script, but a warning and a null that gets happily carried forward.

That is the more dangerous of the two cases.

Now for the part that surprises

private applies to the class, not to the object. Which means: an object may access the private properties of another object of the same class.

<?php
class Konto {
  public function __construct(private int $stand) {}

  public function vergleiche(Konto $anderes): string {
    return $this->stand . ' gegen ' . $anderes->stand;   // works!
  }
}
?>
$a1->vergleiche($a2) : 100 gegen 250

$anderes->stand is a private property of a foreign object, and the access is permitted.

Seeing this for the first time it looks like a hole in the encapsulation. But it is the precondition for being able to write sensible classes at all. Every equals() method, every comparison, every add() method that combines two objects of the same class needs exactly this:

<?php
class Money {
  public function __construct(private int $cents) {}

  public function plus(Money $other): self {
    return new self($this->cents + $other->cents);
  }

  public function equals(Money $other): bool {
    return $this->cents === $other->cents;
  }
}
?>

Without this rule Money would need a public getter for $cents — and then the encapsulation would be gone for good.

The same applies to protected, all the way up to the common base class:

LinksKind reads Eltern instance     : 'ererbt'
LinksKind reads RechtsKind instance : 'ererbt'

So a class may also access protected properties of a sibling class, as long as both descend from the same base.

The case worth knowing about: __get()

This is the subtlest point in this article.

<?php
class Magisch {
  private array $daten = ['a' => 1];

  public function __get(string $n): mixed {
    return $this->daten[$n] ?? null;
  }
}
?>
$m->a     : 1        [__get('a') was called]
$m->daten : NULL     [__get('daten') was called]

$m->daten is private. Normally access from outside would be an Error. But because a __get() exists, PHP calls that instead — and here it returns null.

A loud error turns into a quiet wrong value. This is exactly why magic methods are so unpleasant when debugging: a typo in a property name does not lead to a crash but to a null that causes trouble somewhere further down.

If you use __get(), have it throw an exception for unknown names rather than returning null:

<?php
  public function __get(string $n): mixed {
    if (!array_key_exists($n, $this->data)) {
      throw new InvalidArgumentException("Unknown property: $n");
    }
    return $this->data[$n];
  }
?>

Widening yes, narrowing no

When overriding, visibility may become wider but not narrower:

<?php
class Basis { public function machWas(): void {} }
class Kind extends Basis {
  protected function machWas(): void {}   // Fatal Error
}
?>
Fatal error: Access level to Kind::machWas() must be public
(as in class Basis) in /verstoss.php on line 8

That makes sense: whoever receives a Basis object relies on machWas() being callable. If narrowing were permitted, a derived class could break that promise.

The other way round works: protected may become public in a derived class.

What constructor promotion gives you

Since PHP 8.0 all of this can be shortened into the constructor:

<?php
class Server {
  public function __construct(
    public readonly string $name,
    private int $portNr = 22,
  ) {}

  public function port(): int { return $this->portNr; }
}
?>
$s->name   : web01
$s->port() : 22
$s->portNr : Error: Cannot access private property Server::$portNr
$s->name = "neu" : Error: Cannot modify readonly property Server::$name

readonly is the interesting addition here: the property is readable from outside but cannot be changed after the constructor. So for immutable values you no longer need a getter — public readonly is enough.

What is visible from outside

To check when you are unsure:

get_object_vars from outside : ["oeffentlich"]
get_object_vars from inside  : ["oeffentlich","geschuetzt","privat"]

get_object_vars() shows what is reachable from the respective place in the code. The same applies to json_encode(), which only takes the public properties along — something that regularly surprises people when an API response is suddenly empty.

What I do in practice

  • Properties private by default. If a derived class needs one, it becomes protected — but only then.
  • Methods public when they belong to the interface, otherwise private.
  • public readonly instead of getters for immutable values.
  • protected sparingly. It is a promise to every future derived class and therefore almost as binding as public.

The rule of thumb behind it: anything that is not private you cannot easily change later. So you start narrow and open up when it becomes necessary.

Summary

  • public everywhere, protected in the class and its derived classes, private only in the class itself.
  • private is class-scoped, not object-scoped: an object sees the private properties of other instances of the same class.
  • private is not inherited — access from a derived class is a warning with null, not an error.
  • An existing __get() replaces the access error with a silent return value.
  • Visibility can only be widened when overriding.
  • public readonly replaces the classic getter.

About Netcup (advertisement)

The German host Netcup offers, among other things, affordable and powerful web hosting packages, KVM-based root servers and dedicated servers. With our voucher codes you can save even more (6€ off your first order, 30% off all KVM-based root servers, ...).