1- Why another “BankAccount” tutorial ? 🚀
I keep bumping into examples that:
- still store money in a
float
without any safeguard ❌ - ship a forest of getters/setters nobody reads ❌
- completely ignore what’s new in PHP 8.4 ❌.
But with just two fresh language features—Property Hooks and Asymmetric Visibility—we can craft something simpler, safer, and easier to reason about.
2- What’s new in PHP 8.4 ? 🔥
Feature | Mini syntax | Why you should care |
---|---|---|
Property Hooks | public int $x { get => …; set($v){ … } } | Centralise all validation, transformation or logging inside the property declaration. No boilerplate. |
Asymmetric Visibility | public private(set) float $balance | Public read, restricted write—makes illegal states unrepresentable. |
✨ If those names are new to you, read the Property Accessors RFC. It’s short and crystal‑clear.
3- The finished code 🧑💻
<?php declare(strict_types=1); final class NegativeBalanceException extends DomainException {} final class BankAccount { /* Immutable identity */ public readonly string $iban; public readonly string $owner; /* Current balance — public read, private write */ public private(set) float $balance = 0.0 { // Always expose a 2‑decimals value get => round($this->balance, 2); // Prevent negatives + normalise before storage set(float $value) { if ($value < 0) { throw new NegativeBalanceException('Balance cannot be negative'); } $this->balance = round($value, 2); } } public function __construct(string $iban, string $owner, float $initial = 0.0) { if (!self::isValidIban($iban)) { throw new InvalidArgumentException('Invalid IBAN format'); } $this->iban = $iban; $this->owner = $owner; $this->balance = $initial; // goes through the private setter } /* ------------ Public API ------------ */ public function deposit(float $amount): void { if ($amount <= 0) { throw new ValueError('Deposit must be positive'); } $this->balance += $amount; // triggers hook logic } public function withdraw(float $amount): void { if ($amount <= 0) { throw new ValueError('Withdrawal must be positive'); } $this->balance -= $amount; // will throw if result < 0 } /* ------------ Internals ------------ */ private static function isValidIban(string $iban): bool { // Extremely naive check — replace by a real IBAN validator in prod return preg_match('/^[A-Z0-9]{15,34}$/', $iban) === 1; } } // ---------- Quick demo ---------- $account = new BankAccount('FR7630006000011234567890189', 'Alice', 100); $account->deposit(50); print($account->balance . PHP_EOL); // 150.00 $account->withdraw(75); print($account->balance . PHP_EOL); // 75.00
4- Line‑by‑line walkthrough 🔍
4.1- $balance
property hook
Aspect | What happens | Why it matters |
---|---|---|
Getter | round($this->balance, 2) | Guarantees consumers always see two decimals; presentation logic in one place. |
Setter | Validates \$value >= 0 and re‑rounds internally | No negative balances can slip in, even from inside the class. |
Visibility | private(set) | No external class can assign directly—only our logic can. |
4.2- Constructor safety net
- IBAN goes through a quick regex (good enough for demo; use a real lib in production).
- Setting
$initial
calls the setter → negative default immediately triggers the domain exception.
4.3- Deposit / Withdraw
- Validate positive input first.
- The
+=
/-=
operations still land in the hook → invariant remains unbroken.
🧠 Takeaway: the domain rule “balance can’t be negative” lives exactly once, enforced everywhere automatically.
5- What about floats 🤔
Yes, we’re still using float
. For real money apps, swap it with brick/money
(immutable, integer‑based). The hook signature becomes set(Money $value)
and arithmetic switches to $this->balance = $this->balance->plus($amount)
.
6- Extending the example 💡
- Add a ledger (
Transaction[]
) to keep an audit trail. - Swap exceptions for business‑specific ones (
InvalidAmountException
,InsufficientFundsException
). - Unit tests with Pest:
it('rejects negative deposit', function () { $acc = new BankAccount('FR7630006000011234567890189', 'Bob'); expect(fn() => $acc->deposit(-10)) ->toThrow(ValueError::class); });
- Hook in events — publish
MoneyDeposited
insidedeposit()
to notify users via an async queue. - Persist the entity with Doctrine (store
$balance
as DECIMAL(12,2) or JSON if you switch to Money VO).
7- Conclusion ✅
With less than 70 lines of code we achieved:
- Zero duplicated getter/setter boilerplate, 100 % type‑hinted.
- Domain invariants expressed exactly once and enforced everywhere.
- A codebase ready for real‑world extensions—log, events, persistence.
Top comments (0)