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)