Transfer funds between two accounts atomically with PDO
Implement Ledger::transfer. It must debit one account and credit another so that both rows change or neither does, and it must not hide a failure — the caller has to learn that the transfer did not happen.
Requirements: use the injected PDO handle, bind every value as a parameter, and leave no transaction open on any path.
<?php
class Ledger {
public function __construct(private PDO $pdo) {}
public function transfer(int $fromId, int $toId, int $amountCents): void {
// your code here
}
}
Write the implementation.
Wrap both UPDATEs in beginTransaction() … commit(), and in a catch call rollBack() and rethrow so the caller learns the transfer failed. With PDO::ERRMODE_EXCEPTION a failing statement throws by itself. Bind $fromId, $toId and the amount as parameters, never interpolate them.
- ✗Skipping the transaction and 'fixing' a failed debit with a compensating write
- ✗Catching the exception and returning, so the caller believes the transfer succeeded
- ✗Interpolating the ids and the amount into the SQL instead of binding them
- →What does
PDO::inTransaction()protect you from whentransfer()may be called from an outer transaction? - →How would you make the transfer refuse to overdraw the source account inside the same transaction?
The transaction must be closed on every path: beginTransaction() opens it, commit() closes it on success, and the catch rolls back and rethrows — if it swallows the exception the caller will believe the transfer went through.
<?php
class Ledger {
public function __construct(private PDO $pdo) {}
public function transfer(int $fromId, int $toId, int $amountCents): void {
$this->pdo->beginTransaction();
try {
$debit = $this->pdo->prepare(
'UPDATE accounts SET balance = balance - :amt WHERE id = :id AND balance >= :amt'
);
$debit->execute(['amt' => $amountCents, 'id' => $fromId]);
if ($debit->rowCount() !== 1) {
throw new RuntimeException('Insufficient funds'); // ❌ rolls back
}
$credit = $this->pdo->prepare(
'UPDATE accounts SET balance = balance + :amt WHERE id = :id'
);
$credit->execute(['amt' => $amountCents, 'id' => $toId]);
$this->pdo->commit(); // ✅ both rows
} catch (Throwable $e) {
$this->pdo->rollBack();
throw $e; // ⚠️ never swallow
}
}
}
Three details an interviewer looks for:
statement throws on its own, so no manual return-value checks are needed.
makes the balance check part of the write itself, and 0 affected rows means "not enough funds".
straight injection hole; PDO does not escape anything "by itself".
PDO::ERRMODE_EXCEPTION(set when thePDOis constructed) — a failingrowCount()after a conditionalUPDATE—balance >= :amtin theWHERE- Bound parameters only. Interpolating
$fromIdinto the query string is a