Skip to content

Tier 2b · Chapter 4: Data Consistency in Laravel

PHP/Laravel Track · ~30 min read + exercise

A note on sources: The consistency principles come from Foundations Chapter 3 and Designing Data-Intensive Applications; the Eloquent and transaction mechanics are Laravel-specific (Laravel documentation), flagged where relevant.


Why this chapter exists

Foundations Chapter 3 taught consistency as an architectural idea; the JS/TS track made persistence concrete in Node; this chapter makes it concrete in Laravel, where Eloquent's conveniences create specific ways to corrupt your data if you don't understand what's happening underneath. Laravel makes data access so pleasant that it's easy to write code that looks correct and quietly violates consistency — a multi-step update with no transaction, a check-then-act with a race, a domain rule enforced in PHP that the database doesn't know about. This chapter is about keeping data correct in a Laravel app: using transactions where atomicity matters, understanding where Eloquent helps and where it hides danger, and deciding where domain rules should actually be enforced.

The throughline: Eloquent makes data access easy, which makes consistency mistakes easy — correctness comes from understanding transactions, concurrency, and where rules are truly enforced. The convenience that lets you write $model->save() without thinking is the same convenience that lets you write three saves that should be atomic but aren't, so a failure between them leaves the data half-updated. The discipline is knowing when "easy" is hiding a consistency decision you should be making deliberately.

What this chapter covers

  • Transactions — wrapping multi-step changes so they're all-or-nothing (ACID from the foundations, in Laravel).
  • Concurrency and race conditions — the check-then-act bug and how locking addresses it.
  • Where rules are enforced — application vs. database, and why the database is the last line of defense.
  • Eloquent consistency traps — the conveniences that hide consistency problems.

Transactions

Foundations Chapter 3 introduced ACID transactions as the single-database tool for keeping multi-step changes consistent — all-or-nothing, so a failure can't leave the data half-changed. Laravel gives you transactions simply, and the architectural discipline is recognizing when you need one, because the danger is the multi-step change written without a transaction, which works in testing and corrupts data the first time a step fails in production.

The rule: any operation that changes multiple things which must stay consistent with each other belongs in a transaction. Resolving a ticket that updates the ticket, writes a resolution record, and adjusts the expert's workload count is three writes that must all happen or all not happen — if the process dies after the first two, the expert's count is wrong forever. Wrap it:

php
use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($ticket, $resolution) {
    $ticket->resolve($resolution);          // update the ticket
    ResolutionRecord::create([/* ... */]);  // write the record
    $ticket->expert->decrementWorkload();   // adjust the count
    // All three commit together, or none do if any throws.
});

If any step throws, the whole transaction rolls back and the data is untouched — atomicity, the foundations' "A" in ACID, delivered. (Flagged: DB::transaction is Laravel's mechanism; the ACID principle is from the foundations and Kleppmann.)

Two disciplines make transactions reliable rather than a false comfort. Keep transactions short, holding them open only for the database work that must be atomic — never wrap a slow external API call (a payment, an email) inside a transaction, because the transaction holds locks for the duration, and a slow call inside one means held locks, blocked other requests, and the cascading contention from the foundations' reliability chapter. Do the external call outside the transaction; keep inside it only the database writes that must commit together. And let exceptions propagate so the rollback actually happens — catching and swallowing an exception inside a transaction defeats it, committing a partial change. The transaction's safety depends on the failure being allowed to abort it.


Concurrency and race conditions

The subtlest consistency bugs in a Laravel app come from concurrency — two requests acting on the same data at the same time — and they're invisible in single-user testing, which is why they reach production. The classic is the check-then-act race, and it appears constantly:

php
// RACE: two simultaneous requests can both pass the check before either acts.
if ($customer->tickets()->where('status', 'open')->count() < 5) {
    Ticket::create([/* ... */]);   // both requests create — customer ends with 6 open
}

Two requests arrive at once; both run the count, both see 4, both pass the check, both create — and the customer now has 6 open tickets, violating the rule. Nothing in the code is obviously wrong; the bug lives in the gap between the check and the act, where another request can interleave. This is a real, common, hard-to-reproduce class of bug, and recognizing the check-then-act shape is the first defense.

The tools to address it, from the foundations' consistency lessons applied to concurrent writes:

Database transactions with locking. Wrapping the check-and-act in a transaction and acquiring a lock on the relevant rows (lockForUpdate() in Eloquent, a pessimistic lock) forces concurrent requests to serialize — the second waits until the first commits, then sees the updated state. This closes the race at the cost of some contention (requests wait), appropriate when the operation is critical and conflicts are plausible. (Flagged: lockForUpdate is Eloquent's mechanism; pessimistic/optimistic locking are general database concepts.)

Optimistic locking for lower-contention cases: a version column that's checked on write, so a write fails if the row changed since you read it, and you retry. Less blocking, better when conflicts are rare.

Atomic database operations where possible: rather than read-modify-write in PHP (which has the race), use a single atomic database operation (increment(), a conditional UPDATE ... WHERE) that the database performs atomically. Pushing the operation into a single database statement eliminates the gap the race lives in — the foundations' lesson that the database is often the right place for the operation.

Unique constraints as a backstop: a database unique constraint makes a duplicate physically impossible regardless of application races — which is the next section's point about the database being the last line of defense.


Where rules are enforced

A recurring architectural question with a frequently-wrong answer: where is a rule actually enforced? It's tempting to enforce everything in the application — in the rich entities from the last chapter, in service logic — and feel done. But application-level enforcement has a gap: it only protects against the paths that go through your application code. A rule enforced only in PHP is violated the moment data is changed by a different path — a second application, a database migration, a manual fix, a future service, a concurrent request that raced past the check. The database is the last line of defense, and the rules that must never be violated belong there too, not only in the application.

The principle is defense in depth applied to data integrity, layering enforcement:

Application-level rules — in entities and services — are where rich business logic lives, give good error messages, and handle the common case. The open-ticket limit, the lifecycle rules: these belong in the domain, as the last chapter argued. This is the primary, user-facing enforcement.

Database-level constraintsNOT NULL, UNIQUE, foreign keys, CHECK constraints — are the guarantees that hold regardless of how data is changed. A unique constraint on a payment idempotency key makes a double-charge physically impossible even if the application's check races. A foreign key makes an orphaned ticket impossible even if application code has a bug. These don't give pretty error messages and aren't the place for complex logic, but they're the floor beneath which data cannot fall, whatever the application does.

The architectural judgment is layering them deliberately: rich, friendly rules in the application for the normal path and good UX; hard, inviolable constraints in the database for the invariants that must hold no matter what. A great many "how did the data get into this impossible state?" incidents trace to a rule that was enforced only in the application, with no database constraint as backstop, so some path around the application corrupted it. The foundations' point that data outlives code applies here: the constraint in the database outlives every application bug and every alternate access path.


Eloquent consistency traps

Eloquent's conveniences hide a few specific consistency hazards worth naming, because each looks fine and bites later. (Flagged: these are Laravel/Eloquent-specific behaviors from the framework docs and common experience.)

Mass operations skip model events and rules. Ticket::where(...)->update([...]) is efficient but runs as a direct database UPDATE — it does not load models, fire model events, or run the behavior you put on the entity. If your consistency rules live in model events or entity methods (the rich-model approach from the last chapter), a mass update silently bypasses them. The convenience of the bulk operation is exactly the bypassing of the rules, so use mass operations only where no model-level rule needs to run, and be aware that they're a hole in application-level enforcement (another argument for database constraints as backstop).

Lazy loading hides N+1 and inconsistent reads. The N+1 problem (Foundations Chapter 3, where consistency and read patterns are covered) appears in Eloquent through lazy-loaded relationships accessed in a loop — invisible in the code, a query per iteration in reality. Beyond performance, lazy loads happen at different moments, so a loop reading related data across many lazy loads can observe data that changed mid-loop. Eager loading (with()) fetches related data in one consistent query — fixing both the N+1 and the read-consistency issue.

The default isn't always transactional. A sequence of Eloquent saves is not automatically a transaction — each save() commits independently. The multi-step change that must be atomic needs the explicit DB::transaction wrapper; assuming Eloquent's convenience implies atomicity is exactly the trap this chapter opened with.

The meta-lesson: Eloquent optimizes for ease, and ease can hide consistency decisions. The fluency to use it well is knowing what each convenient operation actually does underneath — whether it's transactional, whether it fires your rules, whether it reads consistently — so you make the consistency decision deliberately instead of inheriting whatever the convenient path happened to do.

That's data consistency in Laravel: wrap multi-step changes in short transactions, recognize and close check-then-act races with locking or atomic operations, enforce inviolable rules in the database as well as the application, and understand the Eloquent conveniences well enough that they don't hide a consistency decision from you.


Practice

Budget about ninety minutes.

Exercise 1: Find the unprotected multi-step change (20 min)

In a Laravel app you work on, find an operation that makes several related database changes without a transaction. Trace what happens if the process dies between two of the steps — what inconsistent state does the data end up in? That's a latent corruption bug.

Exercise 2: Close a race (35 min)

Find a check-then-act in your code (count-then-create, find-or-create, check-balance-then-deduct). Trace how two concurrent requests could both pass the check. Then fix it — with a transaction plus lockForUpdate, an atomic database operation, or a unique constraint backstop — and explain why the race is now closed. Decide which fix fits the contention level.

Exercise 3: Add the database backstop (35 min)

Find a rule your app enforces only in PHP that must never be violated (uniqueness, a required relationship, a non-negative quantity). Add the database constraint that enforces it regardless of access path, and confirm the application handles the constraint violation gracefully. Note what path around your application could previously have violated the rule.

Deliverable

A short consistency hardening for one feature: a multi-step change wrapped in a transaction, one check-then-act race closed (with the chosen mechanism and why), and one application-only rule given a database constraint backstop — each with a sentence on the corruption it now prevents.

Check yourself

  • Explain when an operation needs a transaction, and why transactions must be short and let exceptions propagate.
  • Identify a check-then-act race and explain how locking or an atomic operation closes it.
  • Explain why the database is the last line of defense and how application + database enforcement layer as defense in depth.
  • Name the Eloquent traps (mass-operation bypass, lazy-load issues, non-atomic saves) and how each hides a consistency decision.

What a strong answer demonstrates: you can spot the check-then-act shape and explain exactly where the race lives — in the gap between checking and acting — then pick a fix sized to the contention (lock, atomic operation, or unique-constraint backstop) rather than reaching for one reflexively. The deepest point to land is defense in depth: a rule enforced only in PHP is bypassed by any other access path, so inviolable invariants belong in the database as well, because, as the chapter puts it, data outlives code. A strong answer shows you treat Eloquent's conveniences as hiding consistency decisions you should be making deliberately, not as guarantees.

Going deeper (optional)

  • Designing Data-Intensive Applications (Kleppmann) — the transactions and consistency chapters are the deep source for the isolation, locking, and concurrency theory underneath this chapter's Laravel mechanics.
  • Primary source: Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002) — the concurrency chapter is the canonical naming of the patterns this chapter applies: Unit of Work (the transaction boundary), and Optimistic Offline Lock vs. Pessimistic Offline Lock (the optimistic/pessimistic locking choice for the check-then-act race).
  • Primary source: Jim Gray & Andreas Reuter, Transaction Processing: Concepts and Techniques (Morgan Kaufmann, 1992) — the foundational text behind ACID and isolation; Gray's earlier work originated the formal notion of transaction isolation levels that "keep transactions short" and locking trade-offs ultimately rest on.
  • The Laravel documentation on database transactions, pessimistic locking, and Eloquent relationships for the framework specifics. (Flagged: all Eloquent/Laravel mechanics here are from Laravel docs and practice; the consistency principles are from the foundations and Kleppmann.)

Key takeaways

  • Wrap multi-step changes in transactions (ACID atomicity in Laravel), keep them short (no slow external calls inside), and let exceptions propagate so rollback works.
  • Check-then-act races corrupt data under concurrency and are invisible in single-user testing — close them with locking, atomic database operations, or unique-constraint backstops.
  • The database is the last line of defense: enforce inviolable rules with constraints as well as in the application (defense in depth), because application-only rules are bypassed by any other access path.
  • Eloquent's conveniences hide consistency decisions — mass operations skip your rules, lazy loads hide N+1 and inconsistent reads, and saves aren't automatically transactional. Fluency is knowing what each does underneath.

Next: Laravel Async Patterns — queues, jobs, and events for background work, making the foundations' async communication and reliability concrete in Laravel.