DEV Community

A0mineTV
A0mineTV

Posted on

Building a Clean BankAccount with PHP 8.4 — Property Hooks & Asymmetric Visibility

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 
Enter fullscreen mode Exit fullscreen mode

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 💡

  1. Add a ledger (Transaction[]) to keep an audit trail.
  2. Swap exceptions for business‑specific ones (InvalidAmountException, InsufficientFundsException).
  3. Unit tests with Pest:
 it('rejects negative deposit', function () { $acc = new BankAccount('FR7630006000011234567890189', 'Bob'); expect(fn() => $acc->deposit(-10)) ->toThrow(ValueError::class); }); 
Enter fullscreen mode Exit fullscreen mode
  1. Hook in events — publish MoneyDeposited inside deposit() to notify users via an async queue.
  2. 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)