Encapsulation: public fields to guarded state
Make one class own its data, so every change follows the same rules.
Watch the narrated video
Every step
Problem
Steps 1–3Refactor
Steps 4–9Payoff
Steps 10–11Recap
Steps 12–13The code: before and after
Before
declare(strict_types=1);
final class GiftCard
{
public function __construct(
public string $code,
public int $balance,
) {
}
}
final class Checkout
{
public function pay(GiftCard $card, int $amount): void
{
if ($card->balance >= $amount) {
$card->balance -= $amount;
}
}
}
final class Refunds
{
public function refund(GiftCard $card, int $amount): void
{
$card->balance += $amount;
}
}
final class Support
{
public function cancel(GiftCard $card): void
{
$card->balance = 0;
}
}
$card = new GiftCard('GC-7', 50);
(new Checkout())->pay($card, 30);
(new Refunds())->refund($card, 10);
(new Support())->cancel($card);
echo $card->balance, "\n";After
declare(strict_types=1);
final class GiftCard
{
/** @var list<string> */
private array $history = [];
public function __construct(
public string $code,
private int $balance,
) {
}
public function getBalance(): int
{
return $this->balance;
}
/** @return list<string> */
public function history(): array
{
return $this->history;
}
public function charge(int $amount): bool
{
if ($this->balance >= $amount) {
$this->balance -= $amount;
$this->history[] = "paid {$amount}";
return true;
}
return false;
}
public function credit(int $amount): void
{
$this->balance += $amount;
$this->history[] = "refunded {$amount}";
}
public function cancel(): void
{
$this->history[] = "cancelled {$this->balance}";
$this->balance = 0;
}
}
final class Checkout
{
public function pay(GiftCard $card, int $amount): void
{
$card->charge($amount);
}
}
final class Refunds
{
public function refund(GiftCard $card, int $amount): void
{
$card->credit($amount);
}
}
final class Support
{
public function cancel(GiftCard $card): void
{
$card->cancel();
}
}
$card = new GiftCard('GC-7', 50);
(new Checkout())->pay($card, 30);
(new Refunds())->refund($card, 10);
(new Support())->cancel($card);
echo $card->getBalance(), "\n";
echo implode(', ', $card->history()), "\n";Transcript, step by step
Problem
Step 1: A shop's gift cards Before
A gift card here holds a code and a balance, and both are public properties, set in the constructor. Checkout takes money off when there's enough, Refunds puts money back, and Support cancels a card by setting the balance to zero. The script at the bottom starts the card at 50, pays 30, gets 10 back, and cancels it, so it prints 0.
Step 2: Finance wants a history Code smell
Finance now needs a history of every balance change, for audits. The quick way is a public history array on the card, plus a line in pay and a line in refund that record each change. The script then prints the balance, followed by the history.
Step 3: Every class writes the balance Code smell
A public property lets any class change the balance, so the rule for changing it is spread over every class that writes it. Checkout writes the balance here, Refunds here, and Support here. Support's cancel takes 30 off the card, and the history never hears of it, so the audit shows 30 left on a card that holds 0. And the funds check lives in Checkout only, so Refunds and Support write the balance with no check at all.
Refactor
Step 4: Refactor first Back it out
Let's take the history out again. We'll refactor first, so that adding the history becomes a small change, and then we'll add it.
Step 5: Hide the balance Encapsulate Field
First, the balance becomes private, with a getter and a setter on Gift Card. Checkout, Refunds and Support now call set balance, and the script echoes get balance, because PHP refuses access to a private property from outside the class. The property is hidden, but any class can still set any number, so this alone guards nothing.
Step 6: Charge moves into the card Move Method
The funds check and the subtraction move into Gift Card, as a method called charge. Charge returns true when the card had enough, and false when it didn't. Checkout now calls charge on the card with the amount, and the card still ends at 0.
Step 7: Credit moves into the card Move Method
The refund's addition moves into Gift Card the same way, as a method called credit. Refunds now calls credit on the card, and it no longer reads or writes the balance itself.
Step 8: Cancel moves into the card Move Method
Support's zeroing moves in last, as cancel, a method with no parameters. Support calls cancel on the card, so no class outside Gift Card calls set balance any more.
Step 9: Delete the setter Remove Setting Method
Nothing calls set balance now, so the setter goes, and only the getter stays for the script to echo. The balance now changes only inside Gift Card, through charge, credit and cancel. That is encapsulation, where an object keeps its fields private and offers methods for the changes it allows, so its state can only change in one place.
Payoff
Step 10: Add the history again Payoff
Now the history goes back in, as a private array inside Gift Card. Charge, credit and cancel each add one line to it. History returns the array, and PHP hands arrays over by value, so callers get a copy they can read but can't add to the card's own list. That move is called Encapsulate Collection, and it keeps the history as private as the balance. Checkout, Refunds and Support didn't change, and the script only gains a line that echoes the history.
Step 11: One owner for the balance Payoff
The three writes are gone from the callers, so Checkout calls charge here, Refunds calls credit here, and Support calls cancel here. Cancel now records cancelled 30 before it zeroes the card, so the history and the balance agree. And the funds check sits next to the only code that writes the balance.
Recap
Step 12: Where we started, where we ended Before / after
On the left, the balance is a public property, and Checkout, Refunds and Support each write it themselves. On the right, the balance is private, and only the card's own methods, like charge and cancel, change it. A caller like Checkout makes one call, and the card decides what happens to its money.
Step 13: When to reach for it Takeaways
Reach for it when several classes change the same value and a rule must hold every time it changes. Skip it when a class only carries data with no rule, like a DTO for one view, because accessors there add lines and protect nothing. You'll spot the need by the same property assigned in many classes, or by a check copied before each write.