Choose the business boundary
A transaction should protect one invariant: creating an order and its lines, moving a balance between accounts, or updating a record together with its audit event. Network calls and user interaction should normally happen outside that boundary because they make locks live longer and cannot be rolled back by the database.
Write down what must be true before and after the operation. That statement determines which reads and writes belong inside the transaction.
Handle failure paths deliberately
Catch only where the application can add context, retry safely, or translate the failure. Always roll back an active transaction before returning control. Preserve the original exception as the cause so operational logs retain the database error.
Retries are appropriate only for transient failures and idempotent operations. Use bounded attempts with jitter, and make sure a repeated request cannot create a duplicate business event.
- Set PDO to throw exceptions.
- Check transaction state before rollback.
- Distinguish constraint failures from transient conflicts.
- Give externally retried operations an idempotency key.
Test concurrency, not only success
Two individually correct requests can violate an invariant when they overlap. Build a test that pauses one transaction after its read while another performs the conflicting update. Decide whether row locks, a unique constraint, an atomic update, or a higher isolation level provides the simplest durable protection.
Prefer database constraints for facts the database can enforce. Application checks improve error messages, but they do not close the race between checking and writing.
Force an exception after the first write, confirm the rollback removes every partial change, then run two concurrent copies and verify the invariant still holds.