01 · THE IDEAREAD → TRY → LEARN
Why visibility matters
An object is more than a bag of values. It is a value plus the rules for changing that value. If every property is open to any code, those rules become suggestions. Visibility lets a class decide which of its members are part of its public contract and which are internal details.
PHP has three keywords for properties, methods and constants:
public: reachable from anywhere. A member with no keyword is public.protected: reachable inside the class and in inheriting (and parent) classes.private: reachable only inside the class that declares it.
The usual habit is to start with private and widen access only when there is a reason.
Private state behind methods
In the next example the balance-like number is private. Outside code cannot read or write it directly, so it has to go through methods that you control. Trying to touch it from the global scope throws an Error.
class Wallet
{
private int $cents = 0;
public function add(int $amount): void
{
$this->cents += $amount;
}
public function total(): int
{
return $this->cents;
}
}
$wallet = new Wallet();
$wallet->add(250);
echo $wallet->total(), "\n";
try {
echo $wallet->cents;
} catch (Error $e) {
echo $e->getMessage(), "\n";
}250 Cannot access private property Wallet::$cents
The class can later change how it stores money, or add validation to add(), without breaking any caller. That freedom is the point of encapsulation.
Protected versus private
Use protected when a subclass legitimately needs a member. A subclass can use a protected member but outside code cannot. A private member belongs to the defining class alone. If a child class declares a property with the same name as a parent's private property, it creates a separate property; it does not change the parent's one.
Asymmetric visibility in PHP 8.4
Sometimes you want everyone to read a value but only the class to change it. PHP 8.4 supports this with a separate set visibility, written like public private(set).
class Book
{
public function __construct(
public private(set) string $title,
public protected(set) string $author,
) {}
public function retitle(string $title): void
{
$this->title = $title;
}
}
$book = new Book('Draft', 'Ada');
$book->retitle('Final');
echo $book->title, "\n";
try {
$book->title = 'Hack';
} catch (Error $e) {
echo $e->getMessage(), "\n";
}Final Cannot modify private(set) property Book::$title from global scope
Rules to remember:
- Only typed properties can have a separate set visibility.
- The set visibility must be the same as the read visibility or stricter.
protected public(set)is a syntax error. - Write
private(set)with no spaces inside the parentheses. - A
private(set)property is implicitly final, so a child class cannot redeclare it. - Taking a reference to a property, or writing into an array property, follows the set visibility, because both can modify the value.
Visibility is per class, not per object
Code inside a class may access private and protected members of other instances of the same class. That is useful for comparisons and copying.
class Point
{
private int $x;
public function __construct(int $x)
{
$this->x = $x;
}
public function sameAs(Point $other): bool
{
return $this->x === $other->x;
}
}
var_dump((new Point(3))->sameAs(new Point(3)));
var_dump((new Point(3))->sameAs(new Point(4)));bool(true) bool(false)
Worked design: an account
Before writing the exercise, plan the class. The state is a balance and a count of rejected transactions. Both are private. A single method, apply(int $amount), owns the rules: an amount of zero is rejected, and a negative amount that would take the balance below zero is rejected. A summary() method exposes a snapshot as an array. Because callers can never assign to the balance directly, the rule the balance never goes negative holds for the lifetime of every object.
Exercise contract
Implement applyTransactions(int $opening, array $transactions): array. Create an object that keeps its balance and rejected count in private properties, then apply each integer in order. Positive numbers are deposits and negative numbers are withdrawals. Reject zero, and reject any withdrawal larger than the current balance. Rejected items do not change the balance. Return ['balance' => int, 'rejected' => int].
Watch the boundaries: an empty list, a withdrawal that empties the account exactly (allowed), and a withdrawal one unit too large (rejected).
Recap
- Default to
private, and expose behaviour through methods. protectedis for deliberate extension by subclasses.- PHP 8.4 lets you separate read access from write access with
private(set)orprotected(set). - Visibility is checked by class scope, so instances of the same class can reach each other's internals.
Functions · Arrays · Types
- Encapsulate local object state behind public methods
- Implement and test a deterministic PHP 8.4 function