1- Why another “BankAccount” tutorial ? 🚀
I keep bumping into examples that:
- still store money in a floatwithout 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 >= 0and 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 $initialcalls 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 MoneyDepositedinsidedeposit()to notify users via an async queue.
- Persist the entity with Doctrine (store $balanceas 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)