
Database consistency is one of the most important aspects of modern web applications. Whether you are building a financial platform, e-commerce system, healthcare application, or enterprise dashboard, handling concurrent database operations safely is critical.
In Laravel, database transactions help ensure that multiple operations either succeed together or fail together. But as your application scales, you may also encounter deadlocks, race conditions, and inconsistent data.
In this guide, we will explore:
- What database transactions are
- How transactions work in Laravel
- Common transaction mistakes
- How deadlocks happen
- Strategies to avoid deadlocks
- Retry mechanisms
- Performance optimization tips
- Real-world Laravel examples
What Is a Database Transaction?
A database transaction is a group of database operations executed as a single unit.
A transaction follows the ACID principles:
- Atomicity β all operations succeed or fail together
- Consistency β database remains valid
- Isolation β transactions do not interfere incorrectly
- Durability β committed changes are permanent
Example:
When a user purchases a product:
- Reduce product stock
- Create order record
- Save payment transaction
- Generate invoice
If one operation fails, everything should rollback.
Without transactions, partial data can corrupt your system.
Basic Laravel Transaction Example
Laravel provides a clean transaction API.
phpuse Illuminate\Support\Facades\DB; DB::transaction(function () { Order::create([ 'user_id' => auth()->id(), 'total' => 250000, ]); Product::where('id', 1)->decrement('stock'); });
If any query fails, Laravel automatically rolls back the transaction.
Manual Transaction Control
Sometimes you need more control.
phpDB::beginTransaction(); try { $order = Order::create([ 'user_id' => auth()->id(), 'total' => 500000, ]); Payment::create([ 'order_id' => $order->id, 'status' => 'paid', ]); DB::commit(); } catch (\Throwable $e) { DB::rollBack(); report($e); throw $e; }
Manual transactions are useful when:
- Complex exception handling is required
- External services are involved
- You need conditional commits
- Multiple transaction scopes exist
Understanding Deadlocks
Deadlocks occur when two transactions wait for each other indefinitely.
Example scenario:
Transaction A:
- Locks row A
- Wants row B
Transaction B:
- Locks row B
- Wants row A
Both transactions become stuck.
The database detects this and kills one transaction.
Typical MySQL deadlock error:
textSQLSTATE[40001]: Serialization failure: 1213 Deadlock found
Real Laravel Deadlock Example
Imagine a wallet transfer system.
phpDB::transaction(function () use ($sender, $receiver, $amount) { $senderWallet = Wallet::find($sender); $receiverWallet = Wallet::find($receiver); $senderWallet->decrement('balance', $amount); $receiverWallet->increment('balance', $amount); });
If two users transfer money simultaneously in opposite directions, deadlocks may occur.
Preventing Deadlocks in Laravel
1. Lock Rows in Consistent Order
Always access rows in the same order.
Bad:
php$walletA = Wallet::find($sender); $walletB = Wallet::find($receiver);
Better:
php$ids = collect([$sender, $receiver])->sort(); $wallets = Wallet::whereIn('id', $ids) ->lockForUpdate() ->get();
Consistent locking order significantly reduces deadlocks.
Using lockForUpdate()
Laravel supports pessimistic locking.
phpDB::transaction(function () { $product = Product::where('id', 1) ->lockForUpdate() ->first(); if ($product->stock > 0) { $product->decrement('stock'); } });
This prevents other transactions from modifying the row until the transaction finishes.
Useful for:
- Inventory systems
- Financial balances
- Seat booking systems
- Ticket reservations
Shared Locks with sharedLock()
Sometimes you only need read consistency.
php$product = Product::where('id', 1) ->sharedLock() ->first();
This allows multiple readers while preventing writes.
Automatic Deadlock Retries
Laravel transactions support retry attempts.
phpDB::transaction(function () { // critical operation }, 5);
Laravel retries the transaction up to 5 times if deadlocks occur.
This is extremely useful for high-concurrency applications.
Keeping Transactions Small
Large transactions increase lock duration.
Avoid this:
phpDB::transaction(function () { // 1000 queries // external API calls // file uploads // slow operations });
Better:
php// external API first $response = Http::post(...); DB::transaction(function () use ($response) { // only database operations });
Short transactions improve:
- Performance
- Concurrency
- Scalability
- Deadlock prevention
Avoid External Calls Inside Transactions
Never perform slow operations inside a transaction.
Bad:
phpDB::transaction(function () { Order::create([...]); Http::post('https://payment-gateway.com/pay'); });
If the external API is slow, database locks remain active.
Instead:
php$order = DB::transaction(function () { return Order::create([...]); }); Http::post('https://payment-gateway.com/pay');
Queue Jobs After Commit
Laravel provides afterCommit support.
phpProcessInvoice::dispatch($order) ->afterCommit();
This ensures jobs only run after successful transactions.
Without this, queued jobs may execute before data is committed.
Nested Transactions in Laravel
Laravel supports nested transactions using savepoints.
phpDB::transaction(function () { User::create([...]); DB::transaction(function () { Profile::create([...]); }); });
However, excessive nesting can complicate debugging.
Keep transaction architecture simple whenever possible.
Isolation Levels
Databases support different isolation levels.
Common levels:
- READ UNCOMMITTED
- READ COMMITTED
- REPEATABLE READ
- SERIALIZABLE
MySQL default:
textREPEATABLE READ
PostgreSQL default:
textREAD COMMITTED
Higher isolation improves consistency but reduces concurrency.
Detecting Transaction Bottlenecks
Common symptoms:
- Slow queries
- High database CPU
- Frequent deadlocks
- Lock wait timeout errors
- API latency spikes
Useful MySQL commands:
sqlSHOW ENGINE INNODB STATUS;
Useful PostgreSQL monitoring:
sqlSELECT * FROM pg_locks;
Optimizing High-Concurrency Laravel Systems
Use Indexes Properly
Missing indexes increase lock scanning.
Example:
sqlCREATE INDEX orders_user_id_index ON orders(user_id);
Avoid Table Scans
This query is dangerous under heavy load:
phpOrder::where('status', 'pending') ->update(['status' => 'processing']);
It may lock many rows.
Process records in chunks instead.
phpOrder::where('status', 'pending') ->chunkById(100, function ($orders) { foreach ($orders as $order) { $order->update([ 'status' => 'processing' ]); } });
Atomic Updates in Laravel
Atomic queries reduce race conditions.
Instead of:
php$product->stock -= 1; $product->save();
Use:
phpProduct::where('id', $id) ->decrement('stock');
This executes safely at the database level.
Building a Safe Wallet Transfer System
Example production-ready pattern:
phpDB::transaction(function () use ($fromId, $toId, $amount) { $wallets = Wallet::whereIn('id', [$fromId, $toId]) ->orderBy('id') ->lockForUpdate() ->get() ->keyBy('id'); $sender = $wallets[$fromId]; $receiver = $wallets[$toId]; if ($sender->balance < $amount) { throw new Exception('Insufficient balance'); } $sender->decrement('balance', $amount); $receiver->increment('balance', $amount); Transaction::create([ 'from_wallet_id' => $fromId, 'to_wallet_id' => $toId, 'amount' => $amount, ]); }, 5);
This implementation:
- Prevents race conditions
- Prevents negative balances
- Reduces deadlocks
- Retries automatically
- Maintains consistency
Common Laravel Transaction Mistakes
Using Transactions Everywhere
Not every operation needs a transaction.
Transactions introduce:
- Lock overhead
- Reduced concurrency
- Longer query time
Use them only when consistency matters.
Long-Running Loops Inside Transactions
Bad:
phpDB::transaction(function () { foreach ($largeData as $item) { // slow processing } });
Process data outside transactions whenever possible.
Forgetting Retry Logic
Deadlocks are normal in high-concurrency systems.
Your application should handle them gracefully.
Database Choice Matters
MySQL
Strengths:
- Excellent Laravel compatibility
- Good transactional support
- Mature ecosystem
Weaknesses:
- Gap locks can surprise developers
- Deadlocks under heavy writes
PostgreSQL
Strengths:
- Better concurrency handling
- Advanced locking
- Strong consistency guarantees
Weaknesses:
- Slightly more complex tuning
For enterprise Laravel systems, PostgreSQL is increasingly popular.
Final Thoughts
Laravel makes database transactions simple, but building reliable concurrent systems requires deeper understanding.
As your application grows:
- Concurrency increases
- Lock contention rises
- Deadlocks become inevitable
- Query efficiency matters more
The key principles are:
- Keep transactions short
- Lock rows consistently
- Use atomic operations
- Retry deadlocks automatically
- Avoid unnecessary locks
- Move slow operations outside transactions
Mastering transactions and deadlock prevention is essential for building scalable Laravel applications that remain stable under real-world traffic.