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)