Laravel gives you transactions. MySQL decides whether each SELECT sees a fresh world or the first one it touched.

The strangest database bugs are the ones where every query is technically correct.
An approval flow reads a row, checks a balance, reads again 200 milliseconds later and still misses the change another request just committed. Nobody forgot the transaction, nobody fat-fingered the SQL and the application still failed because it assumed that “inside one transaction” means “up to date enough.” In MySQL, that sentence is incomplete.
Laravel does not choose the reality your reads live in. MySQL does. More specifically, the transaction isolation level does. If you never set it, InnoDB uses REPEATABLE READ by default. That one choice decides whether two plain SELECT statements inside the same transaction see one stable snapshot or two different committed moments in time.
This is why two engineers can describe the same bug in completely different ways: one says the transaction was consistent, the other says it was stale and they can both be right.
The Default Your ORM Never Mentions
Laravel’s DB::transaction helper is exactly what it claims to be: a transaction wrapper that begins the transaction, commits on success and rolls back on exception. Useful, necessary, still not magic.
What it does not do is tell MySQL how reads inside that transaction should behave.
In InnoDB, the default isolation level is REPEATABLE READ. The important part is not the name. The important part is the behavior: plain nonlocking SELECT statements inside the same transaction keep reading from the snapshot established by the first consistent read. If you expected the second SELECT to notice another request’s commit, you already lost the argument with the database.
That sentence is where most ORM-heavy explanations stop being useful. They talk about atomicity and rollbacks, then they talk about “wrapping things in a transaction” as if a transaction were one setting. It is not. A transaction is a boundary and the isolation level decides what that boundary feels like.
There is another subtle point here. Plain SELECT is not the same as a locking read. If you need to stop another transaction from changing the row you are about to act on, a stable snapshot is not enough by itself. You need an explicit locking read or a write path that acquires the lock for you. Otherwise you are only looking at history more consistently.
What Repeatable Read Actually Buys You
REPEATABLE READ is not a bad default. It solves a real problem.
Sometimes one transaction needs to reason over a stable view of the data while it performs several reads. Think about a reconciliation job, an eligibility calculation, or a multi-step validation where every query should agree on the same moment in time. If the second query could quietly observe a newer commit than the first, your own transaction could contradict itself halfway through.
That is what REPEATABLE READ protects.
It is easier to see with code:
<?php
use Illuminate\Support\Facades\DB;
// Both reads come from the same snapshot under MySQL's default REPEATABLE READ.
DB::transaction(function () use ($productId) {
$first = DB::select(
'select stock from products where id = ?',
[$productId]
);
// Another request commits a stock change here.
$second = DB::select(
'select stock from products where id = ?',
[$productId]
);
// $first and $second can still agree because the snapshot did not move.
});That behavior is perfect when the business rule is “make every decision in this transaction against one consistent picture.” It is dangerous when the business rule is “re-check before I act because another request may have changed the row.”
That difference is the whole article.
A lot of production bugs start with somebody mixing those two intentions inside one transaction. The code reads like a second check for freshness. The database reads it as another glance at the same snapshot.
There is one more wrinkle. InnoDB’s consistent reads can show your own earlier changes inside the transaction while still hiding newer commits from other sessions. So you can update one row, read it back and feel confident while another row in the same query result is still being shown from an older snapshot. That is the kind of detail nobody mentions in cheerful ORM tutorials because it makes the room uncomfortable. It should.
What Read Committed Changes
READ COMMITTED changes the question from “what did this transaction first see?” to “what is committed right now?”
Under READ COMMITTED, each consistent read gets its own fresh snapshot. The second plain SELECT inside the transaction is allowed to see a newer committed version than the first. If your workflow genuinely needs to observe outside commits between reads, this is often the right trade.
It also changes lock behavior during update scans. In MySQL’s documentation, READ COMMITTED lets InnoDB release record locks for rows it read but did not end up modifying and it uses what the docs call a semi-consistent read during certain UPDATE flows. That is not trivia. It affects concurrency when your update condition touches many rows but only changes a few.
Here is the application-level pattern:
<?php
use Illuminate\Support\Facades\DB;
// This applies to the next transaction only, so it must happen before DB::transaction starts.
DB::statement('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
DB::transaction(function () use ($invoiceId) {
$before = DB::select(
'select status from invoices where id = ?',
[$invoiceId]
);
// Another request commits a status change here.
$after = DB::select(
'select status from invoices where id = ?',
[$invoiceId]
);
// Under READ COMMITTED, $after can observe the newer committed status.
});That does not make READ COMMITTED “safer.” It makes it fresher.
Freshness has a cost. Once every consistent read can see a newer committed state, your own transaction can no longer assume that two identical reads mean the same thing unless you lock what matters. If your code reads a value, branches on it, reads again and expects nothing to move, READ COMMITTED will happily prove you wrong.
So the real decision is not “which isolation level is best?” The real decision is “do I need a stable snapshot or a fresh one for this exact transaction?” Those are different jobs.
The Bug Usually Isn’t in the Transaction
When teams debug isolation problems, they often stare at the transaction wrapper first. Wrong target.
The wrapper is rarely the bug. The bug is usually the mental model behind the read sequence:
- “I read it twice, so I must have checked the latest value.”
- “I used a transaction, so nobody could have changed anything that matters.”
- “The second query should have seen the update because it happened before commit.”
None of those statements is automatically true, not in MySQL and not because Laravel is weird, but because transaction isolation is doing exactly what it was designed to do.
This matters most in boring backend workflows: approval queues, wallet deductions, inventory reservation, permission checks, any place where a request reads first and mutates second. The code often looks innocent because the ORM hides the timing details. Timing details are the whole thing.
If your transaction is trying to answer “what did the world look like when I started reasoning?”, default REPEATABLE READ is usually on your side.
If your transaction is trying to answer “what is the world like right now, just before I commit this decision?”, READ COMMITTED may be a better fit.
If your transaction is trying to guarantee “nobody changes this row while I decide,” neither plain consistent read solves it on its own. That is when you move from isolation level selection to locking strategy.
Read Committed vs Repeatable Read in Real Work
You do not need a whitepaper to choose between them. You need a sharper question.
Stay on REPEATABLE READ when:
- One transaction needs a stable picture across several plain reads.
- Doing the same calculation twice against one snapshot matters more than seeing the newest external commit.
- A stale second read is acceptable because the transaction is intentionally reasoning over one moment in time.
Prefer READ COMMITTED when:
- The second read is supposed to notice what another committed request just changed.
- Long transactions or admin flows should not cling to an old snapshot after the world has moved on.
- Update scans are holding more locks than the business rule actually requires.
Do not expect either isolation level to replace locking when:
- You are reading a row specifically to decide whether to mutate it.
- Another concurrent request must not make the same decision at the same time.
- If the rule is “once I inspect this row, everyone else must wait,” say so with locks.
That last case is where many teams wave the word “transaction” around and hope it means “serialized.” It does not. A plain read can still be plain.
The Laravel Pattern That Keeps the Choice Explicit
The cleanest pattern is also the most boring one: decide the isolation level before the transaction starts, then keep the transaction body honest about what kind of reads it is performing.
Laravel gives you the raw pieces you need:
<?php
use Illuminate\Support\Facades\DB;
DB::statement('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
DB::transaction(function () use ($orderId) {
$order = DB::select(
'select id, status, reserved_stock from orders where id = ?',
[$orderId]
);
// Make the decision that needs fresh committed reads.
});If the rule is not just “see fresh data” but “nobody else gets to change this row while I decide,” switch to a locking read:
SELECT id, status, reserved_stock
FROM orders
WHERE id = 42
FOR UPDATE;That is a different promise. A stronger one.
Also remember the MySQL scope rules. SET TRANSACTION ISOLATION LEVEL … without SESSION or GLOBAL applies to the next transaction only, which is exactly what you want for a single request with unusual needs. It also means you cannot issue it after the transaction has already begun and expect MySQL to change the current one, because MySQL rejects that and silent behavior there would be worse.
And yes, deadlocks still happen. Isolation level choice does not erase them. Laravel’s transaction helper lets you retry a transaction when deadlocks occur:
<?php
use Illuminate\Support\Facades\DB;
DB::transaction(function () {
DB::update('update inventory set reserved = reserved + 1 where sku = ?', ['ABC-123']);
}, attempts: 5);That is not an excuse to ignore lock ordering. It is just the part mature systems add after they stop pretending concurrency is optional.
A Rule of Thumb You Can Defend in Code Review
If you want one rule that survives production, use this:
- Start with REPEATABLE READ when the transaction is mostly about internal consistency across several reads.
- Use READ COMMITTED when the transaction must observe the most recent committed state between reads.
- Add locking reads when the business rule depends on preventing concurrent changes, not just observing them.
- Keep the isolation choice local to the transaction that needs it. Do not change connection-wide behavior because one workflow got weird.
That last point saves a lot of pain. Global isolation changes are the kind of fix that makes one endpoint behave and three unrelated ones start lying in new ways a week later.
The ORM is not hiding something malicious from you. It is hiding database vocabulary so you can move faster most of the time. Fine. But transaction isolation is one of those places where senior backend work starts exactly where framework convenience stops.
If two identical SELECTs inside one transaction are supposed to mean two different moments, your bug is not in Laravel. It is in the isolation level you never chose.


