Polyscope - The agent-first dev environment for Laravel

Building Transaction-Safe Multi-Document Operations in Laravel

Last updated on by

Building Transaction-Safe Multi-Document Operations in Laravel image

Learn how to use MongoDB's multi-document ACID transactions in Laravel to ensure data consistency across collections when atomic operations aren't enough, with practical examples of handling rollbacks and failures.

What you'll learn

  • When atomic operations aren't sufficient for data consistency
  • How to use Laravel's DB::transaction() with MongoDB
  • Understanding transaction rollbacks, commits, and failure scenarios
  • Best practices for keeping transactions fast and reliable

Introduction

In our previous article, we solved race conditions using MongoDB's atomic operators like $inc. Our wallet debits and inventory updates became race-condition-free. Victory, right?

Not quite. While testing failure scenarios, I discovered a new problem: what happens if my application crashes after debiting the wallet but before creating the order? The customer loses $80 with no purchase to show for it.

Atomic operations guarantee single-document consistency, but our checkout flow touches three collections: wallets, products, and orders. We need a way to ensure that either all three updates succeed together, or none of them happen.

This is where MongoDB's multi-document ACID transactions come in—and Laravel makes them remarkably simple to use.

The Partial Failure Problem

Let me show you exactly what can go wrong. Here's our checkout flow from the previous article:

public function checkout(Request $request)
{
// ... validation ...
 
$product = Product::findOrFail($productId);
$amount = $product->price * $quantity;
 
// Step 1: Debit wallet atomically
$walletResult = Wallet::raw(function ($collection) use ($userId, $amount) {
return $collection->updateOne(
['user_id' => $userId, 'balance' => ['$gte' => $amount]],
['$inc' => ['balance' => -$amount]]
);
});
 
if ($walletResult->getModifiedCount() === 0) {
return response()->json(['error' => 'Insufficient funds'], 400);
}
 
// 💥 WHAT IF THE APP CRASHES HERE? 💥
 
// Step 2: Decrease inventory atomically
$inventoryResult = Product::raw(function ($collection) use ($productId, $quantity) {
return $collection->updateOne(
['_id' => new ObjectId($productId), 'stock' => ['$gte' => $quantity]],
['$inc' => ['stock' => -$quantity]]
);
});
 
// Step 3: Create order
$order = Order::create([
'user_id' => $userId,
'product_id' => $productId,
'quantity' => $quantity,
'amount' => $amount
]);
 
return response()->json(['order' => $order]);
}

A note on search per IDs: The Product::findOrFail($productId) method (Eloquent) automatically handles the string-to-ObjectId conversion. Inside the raw query, we must explicitly create a new ObjectId($productId) because we are working directly with the MongoDB driver. The Laravel MongoDB package provides this seamless conversion for its higher-level APIs.

Let's simulate a crash scenario with a test:

public function test_partial_failure_leaves_inconsistent_state()
{
$product = Product::create([
'name' => 'Laptop',
'price' => 1000.00,
'stock' => 5
]);
 
$wallet = Wallet::create([
'user_id' => 'user-1',
'balance' => 1500.00
]);
 
// Modify controller to crash after wallet debit
try {
// Debit wallet
Wallet::raw(function ($collection) {
return $collection->updateOne(
['user_id' => 'user-1', 'balance' => ['$gte' => 1000]],
['$inc' => ['balance' => -1000]]
);
});
 
// Simulate crash
throw new \Exception('Simulated server crash');
 
// These never execute
Product::raw(function ($collection) { ... });
Order::create([...]);
 
} catch (\Exception $e) {
// App crashed
}
 
// Check database state
$wallet->refresh();
$product->refresh();
$order = Order::where('user_id', 'user-1')->first();
 
dump('=== AFTER CRASH ===');
dump('Wallet balance: ' . $wallet->balance); // $500 (debited!)
dump('Product stock: ' . $product->stock); // 5 (unchanged!)
dump('Order exists: ' . ($order ? 'Yes' : 'No')); // No!
 
$this->assertEquals(1500, $wallet->balance,
'Wallet should not be debited if order was not created');
}

Result:

=== AFTER CRASH ===
Wallet balance: 500
Product stock: 5
Order exists: No

The customer lost $1,000 but has no order. The inventory wasn't touched. Our data is inconsistent.

Each operation was atomic (no race conditions), but together they're not—there's no guarantee they'll all succeed or all fail together.

Understanding Database Invariants

An invariant is a condition that must always be true across your data. In our e-commerce system, we have several critical invariants:

Invariant 1: Money Conservation

Sum of all wallet debits = Sum of all order amounts

If we debit $1,000 from wallets but only create $800 worth of orders, money has vanished from the system.

Invariant 2: Inventory Accuracy

Physical stock = Database stock - Sum of order quantities

If we create orders without decreasing inventory, we'll oversell. If we decrease inventory without orders, we're losing track of stock.

Invariant 3: Wallet-Order Consistency

If wallet is debited, corresponding order must exist
If order exists, wallet must be debited

Every debit needs a receipt, and every receipt needs a debit.

When operations span multiple documents or collections, atomic operations alone cannot maintain these invariants. We need transactions.

Introduction to MongoDB Multi-Document Transactions

MongoDB's multi-document transactions work just like transactions in traditional relational databases like MySQL or PostgreSQL. They provide ACID guarantees:

  • Atomicity: All operations succeed together or none do
  • Consistency: Database moves from one valid state to another
  • Isolation: Concurrent transactions don't interfere with each other
  • Durability: Committed changes persist even after crashes

As the MongoDB documentation states:

"Multi-document transactions make MongoDB the only database to combine the ACID guarantees of traditional relational databases and the speed, flexibility, and power of the document model, with an intelligent distributed systems design to scale-out and place data where you need it. Through snapshot isolation, transactions provide a consistent view of data and enforce all-or-nothing execution to maintain data integrity".

Through snapshot isolation, transactions provide a consistent view of data and enforce all-or-nothing execution to maintain data integrity.

How Transactions Work in Laravel

Laravel's DB::transaction() method works seamlessly with MongoDB through the Laravel MongoDB package. Here's the basic syntax:

use Illuminate\Support\Facades\DB;
 
DB::connection('mongodb')->transaction(function () {
// All operations here are part of the transaction
// If any operation fails, everything rolls back
}, 5); // Retry up to 5 times on deadlock

Important note: Laravel's built-in retry mechanism only handles deadlock exceptions. As you'll see in the "When Transactions Fail (And That's OK)" section later in this article, MongoDB transactions can fail for various reasons beyond deadlocks - including network interruptions, replica set elections, and transient transaction errors. For comprehensive error handling of all MongoDB-specific transaction errors, you'll need the manual retry logic with exponential backoff that we'll cover later.

The beauty of Laravel's transaction API is that it works identically whether you're using MongoDB, MySQL, or PostgreSQL. The same familiar syntax.

Implementing Our First Transaction

Let's refactor our checkout to use transactions:

<?php
 
namespace App\Http\Controllers;
 
use App\Models\Wallet;
use App\Models\Product;
use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use MongoDB\BSON\ObjectId;
 
class CheckoutController extends Controller
{
public function checkout(Request $request)
{
$validated = $request->validate([
'user_id' => 'required|string',
'product_id' => 'required|string',
'quantity' => 'required|integer|min:1',
]);
 
$userId = $validated['user_id'];
$productId = $validated['product_id'];
$quantity = $validated['quantity'];
 
try {
$order = DB::connection('mongodb')->transaction(function () use ($userId, $productId, $quantity) {
 
// Get product price
$product = Product::findOrFail($productId);
$amount = $product->price * $quantity;
 
// Step 1: Debit wallet atomically
$walletResult = Wallet::raw(function ($collection) use ($userId, $amount) {
return $collection->updateOne(
[
'user_id' => $userId,
'balance' => ['$gte' => $amount]
],
[
'$inc' => ['balance' => -$amount]
]
);
});
 
if ($walletResult->getModifiedCount() === 0) {
throw new \Exception('Insufficient funds');
}
 
// Step 2: Decrease inventory atomically
$inventoryResult = Product::raw(function ($collection) use ($productId, $quantity) {
return $collection->updateOne(
[
'_id' => new ObjectId($productId),
'stock' => ['$gte' => $quantity]
],
[
'$inc' => ['stock' => -$quantity]
]
);
});
 
if ($inventoryResult->getModifiedCount() === 0) {
throw new \Exception('Insufficient stock');
}
 
// Step 3: Create order
$order = Order::create([
'user_id' => $userId,
'product_id' => $productId,
'quantity' => $quantity,
'amount' => $amount,
'status' => 'completed',
'created_at' => now()
]);
 
return $order;
});
 
return response()->json([
'success' => true,
'order' => $order
]);
 
} catch (\Exception $e) {
return response()->json([
'error' => $e->getMessage()
], 400);
}
}
}

The key changes:

  1. Wrap all operations in DB::connection('mongodb')->transaction()
  2. Return the order from the closure so we can access it outside
  3. Throw exceptions for business logic failures (insufficient funds, out of stock)
  4. Catch exceptions to return proper HTTP responses

Now all three operations are atomic together. If any step fails—insufficient funds, out of stock, or even a server crash—MongoDB automatically rolls back all changes.

Testing Transaction Rollback

Let's verify transactions actually work. Create comprehensive tests:

<?php
 
namespace Tests\Feature;
 
use Tests\TestCase;
use App\Models\Wallet;
use App\Models\Product;
use App\Models\Order;
 
class TransactionTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
 
Wallet::truncate();
Product::truncate();
Order::truncate();
}
 
public function test_successful_checkout_commits_all_changes()
{
$product = Product::create([
'name' => 'Laptop',
'price' => 1000.00,
'stock' => 5
]);
 
$wallet = Wallet::create([
'user_id' => 'user-1',
'balance' => 1500.00
]);
 
$response = $this->postJson('/api/checkout', [
'user_id' => 'user-1',
'product_id' => $product->id,
'quantity' => 1
]);
 
$response->assertStatus(200);
 
// All changes should be committed
$wallet->refresh();
$product->refresh();
 
$this->assertEquals(500.00, $wallet->balance, 'Wallet debited');
$this->assertEquals(4, $product->stock, 'Inventory decreased');
$this->assertEquals(1, Order::count(), 'Order created');
 
$order = Order::first();
$this->assertEquals('user-1', $order->user_id);
$this->assertEquals(1000.00, $order->amount);
}
 
public function test_insufficient_funds_rolls_back_entire_transaction()
{
$product = Product::create([
'name' => 'Laptop',
'price' => 1000.00,
'stock' => 5
]);
 
$wallet = Wallet::create([
'user_id' => 'user-1',
'balance' => 500.00 // Not enough!
]);
 
$response = $this->postJson('/api/checkout', [
'user_id' => 'user-1',
'product_id' => $product->id,
'quantity' => 1
]);
 
$response->assertStatus(400);
$response->assertJson(['error' => 'Insufficient funds']);
 
// Nothing should change
$wallet->refresh();
$product->refresh();
 
$this->assertEquals(500.00, $wallet->balance, 'Wallet unchanged');
$this->assertEquals(5, $product->stock, 'Inventory unchanged');
$this->assertEquals(0, Order::count(), 'No order created');
}
 
public function test_out_of_stock_rolls_back_wallet_debit()
{
$product = Product::create([
'name' => 'Laptop',
'price' => 1000.00,
'stock' => 0 // Out of stock!
]);
 
$wallet = Wallet::create([
'user_id' => 'user-1',
'balance' => 1500.00
]);
 
$response = $this->postJson('/api/checkout', [
'user_id' => 'user-1',
'product_id' => $product->id,
'quantity' => 1
]);
 
$response->assertStatus(400);
$response->assertJson(['error' => 'Insufficient stock']);
 
// Wallet should NOT be debited
$wallet->refresh();
 
$this->assertEquals(1500.00, $wallet->balance,
'Wallet should not be debited when out of stock');
$this->assertEquals(0, Order::count(), 'No order created');
}
 
public function test_simulated_crash_rolls_back_transaction()
{
$product = Product::create([
'name' => 'Laptop',
'price' => 1000.00,
'stock' => 5
]);
 
$wallet = Wallet::create([
'user_id' => 'user-1',
'balance' => 1500.00
]);
 
// Simulate a crash by throwing an exception mid-transaction
try {
DB::connection('mongodb')->transaction(function () use ($wallet, $product) {
// Debit wallet
Wallet::raw(function ($collection) {
return $collection->updateOne(
['user_id' => 'user-1', 'balance' => ['$gte' => 1000]],
['$inc' => ['balance' => -1000]]
);
});
 
// Simulate crash
throw new \Exception('Simulated crash');
});
} catch (\Exception $e) {
// Transaction aborted
}
 
// Check that everything was rolled back
$wallet->refresh();
$product->refresh();
 
$this->assertEquals(1500.00, $wallet->balance,
'Transaction should rollback on crash');
$this->assertEquals(5, $product->stock, 'Stock unchanged');
$this->assertEquals(0, Order::count(), 'No order created');
}
}

Run these tests:

php artisan test --filter=TransactionTest

All tests should pass, confirming:

  • ✅ Successful checkouts commit all changes
  • ✅ Insufficient funds rolls back everything
  • ✅ Out of stock rolls back wallet debit
  • ✅ Crashes roll back partial changes

When Transactions Fail (And That's OK)

Transactions don't eliminate failures—they change how failures behave. According to the MongoDB documentation, transactions can fail for various reasons:

  • Network interruptions - Connection drops between application and database
  • Replica set elections - MongoDB cluster elects a new primary during the transaction
  • Write conflicts - Another transaction modified the same documents
  • Timeout - Transaction exceeded the maximum allowed time (default: 60 seconds)

When a transaction fails, MongoDB guarantees:

  1. Automatic abort - The transaction is terminated
  2. Complete rollback - All operations within the transaction are reversed
  3. Consistent state - Database returns to pre-transaction state
  4. Error returned - Your application receives an exception

Let's handle these scenarios properly:

public function checkout(Request $request)
{
// ... validation ...
 
$maxAttempts = 3;
$attempt = 0;
 
while ($attempt < $maxAttempts) {
$attempt++;
 
try {
$order = DB::connection('mongodb')->transaction(function () use ($userId, $productId, $quantity) {
// ... transaction logic ...
});
 
return response()->json([
'success' => true,
'order' => $order
]);
 
} catch (\MongoDB\Driver\Exception\RuntimeException $e) {
// Transient transaction error - might succeed on retry
if (str_contains($e->getMessage(), 'TransientTransactionError')) {
if ($attempt < $maxAttempts) {
\Log::warning('Transient transaction error, retrying', [
'attempt' => $attempt,
'error' => $e->getMessage()
]);
 
// Wait before retry (exponential backoff)
usleep(100000 * $attempt); // 100ms, 200ms, 300ms
continue;
}
}
 
// Non-transient error or max attempts reached
\Log::error('Transaction failed', [
'error' => $e->getMessage(),
'attempts' => $attempt
]);
 
return response()->json([
'error' => 'Transaction failed, please try again'
], 500);
 
} catch (\Exception $e) {
// Business logic error (insufficient funds, out of stock)
return response()->json([
'error' => $e->getMessage()
], 400);
}
}
}

This implementation:

  • Distinguishes between transient and business errors - Retries transient errors, immediately fails business errors
  • Uses exponential backoff - Waits progressively longer between retries
  • Limits retry attempts - Gives up after 3 tries to avoid infinite loops
  • Logs failures - Helps with debugging and monitoring

MongoDB's driver has built-in retry logic, but understanding how to handle different error types makes your application more robust.

Laravel's Automatic Transaction Retry

Laravel actually provides a simpler way to handle retries. You can specify a retry count directly:

DB::connection('mongodb')->transaction(function () {
// Transaction logic
}, 5); // Retry up to 5 times on deadlock

However, this only retries on deadlocks. For comprehensive error handling including transient transaction errors, use the manual retry loop shown above.

Performance: Keeping Transactions Fast

After implementing transactions, I ran a performance benchmark and noticed checkout was averaging 450ms—much slower than before. Looking at the code, I realized I was doing too much inside the transaction.

Here's the mistake:

// ❌ BAD: Transaction doing too much
DB::connection('mongodb')->transaction(function () use ($order, $user) {
// Database operations
Wallet::raw(...);
Product::raw(...);
Order::create(...);
 
// External services - DON'T DO THIS IN A TRANSACTION!
Mail::to($user->email)->send(new OrderConfirmation($order));
 
Http::post('https://shipping-api.com/create-label', [
'order_id' => $order->id
]);
 
// Analytics
Analytics::track('purchase', $order->toArray());
});

The transaction holds database locks while waiting for:

  • Email server to respond
  • Shipping API to respond
  • Analytics service to respond

This is terrible for performance and blocks other operations.

Here's the fix:

// ✅ GOOD: Transaction only for database operations
public function checkout(Request $request)
{
// ... validation ...
 
// Transaction: ONLY atomic database changes
$order = DB::connection('mongodb')->transaction(function () use ($userId, $productId, $quantity) {
$product = Product::findOrFail($productId);
$amount = $product->price * $quantity;
 
// Atomic wallet debit
$walletResult = Wallet::raw(function ($collection) use ($userId, $amount) {
return $collection->updateOne(
['user_id' => $userId, 'balance' => ['$gte' => $amount]],
['$inc' => ['balance' => -$amount]]
);
});
 
if ($walletResult->getModifiedCount() === 0) {
throw new \Exception('Insufficient funds');
}
 
// Atomic inventory decrease
$inventoryResult = Product::raw(function ($collection) use ($productId, $quantity) {
return $collection->updateOne(
['_id' => new ObjectId($productId), 'stock' => ['$gte' => $quantity]],
['$inc' => ['stock' => -$quantity]]
);
});
 
if ($inventoryResult->getModifiedCount() === 0) {
throw new \Exception('Insufficient stock');
}
 
// Create order
return Order::create([
'user_id' => $userId,
'product_id' => $productId,
'quantity' => $quantity,
'amount' => $amount,
'status' => 'completed'
]);
});
 
// AFTER transaction commits, do the slow stuff
// These can fail without affecting data consistency
 
try {
$user = User::find($userId);
Mail::to($user->email)->send(new OrderConfirmation($order));
} catch (\Exception $e) {
\Log::error('Email failed', [
'order_id' => $order->id,
'error' => $e->getMessage()
]);
// Queue for retry, but order is already committed
}
 
try {
Http::post('https://shipping-api.com/create-label', [
'order_id' => $order->id,
'address' => $user->address
]);
} catch (\Exception $e) {
\Log::error('Shipping label failed', [
'order_id' => $order->id,
'error' => $e->getMessage()
]);
// Queue for retry
}
 
// Analytics can fail silently
try {
Analytics::track('purchase', $order->toArray());
} catch (\Exception $e) {
\Log::warning('Analytics tracking failed', ['error' => $e->getMessage()]);
}
 
return response()->json([
'success' => true,
'order' => $order
]);
}

After this refactor, checkout dropped to 150ms average—a 67% improvement.

The golden rule: Keep transactions focused on just the atomic database operations. Everything else happens after commit.

Optimizing with Database Indexes

To further improve transaction performance, create indexes on frequently queried fields. As recommended in the MongoDB Laravel documentation, structure your data and indexes to optimize transaction performance:

<?php
 
namespace App\Console\Commands;
 
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
 
class CreateMongoIndexes extends Command
{
protected $signature = 'mongo:create-indexes';
protected $description = 'Create MongoDB indexes for transaction optimization';
 
public function handle()
{
$db = DB::connection('mongodb')->getMongoDB();
 
// Index on wallet user_id for fast lookups during checkout
$db->wallets->createIndex(['user_id' => 1], ['unique' => true]);
$this->info('✓ Created index on wallets.user_id');
 
// Compound index on wallet for conditional updates
$db->wallets->createIndex(['user_id' => 1, 'balance' => 1]);
$this->info('✓ Created compound index on wallets');
 
// Index on product stock for inventory checks
$db->products->createIndex(['stock' => 1]);
$this->info('✓ Created index on products.stock');
 
// Compound index on orders for user queries
$db->orders->createIndex(['user_id' => 1, 'created_at' => -1]);
$this->info('✓ Created compound index on orders');
 
// Index on order status for filtering
$db->orders->createIndex(['status' => 1]);
$this->info('✓ Created index on orders.status');
 
$this->info("\n✅ All indexes created successfully!");
$this->info('Transaction performance optimized.');
}
}

Register this command and run it:

php artisan mongo:create-indexes

As noted in the documentation, creating indexes on fields used in transaction queries significantly improves performance and reliability.

When NOT to Use Transactions

Transactions solve specific problems, but they're not always necessary. Here's when to skip them:

1. Single-document operations (use atomic operators instead):

// ❌ Don't use transaction
DB::connection('mongodb')->transaction(function () {
Product::raw(fn($c) => $c->updateOne(
['_id' => $productId],
['$inc' => ['views' => 1]]
));
});
 
// ✅ Just use atomic operation
Product::raw(fn($c) => $c->updateOne(
['_id' => $productId],
['$inc' => ['views' => 1]]
));

2. Eventual consistency is acceptable:

// ❌ Don't use transaction for analytics
DB::connection('mongodb')->transaction(function () {
UserActivity::create([
'user_id' => $userId,
'action' => 'viewed_product',
'product_id' => $productId
]);
});
 
// ✅ Just create directly
UserActivity::create([
'user_id' => $userId,
'action' => 'viewed_product',
'product_id' => $productId
]);

3. High-volume operations where performance is critical:

// ❌ Don't use transaction for logs
DB::connection('mongodb')->transaction(function () {
Log::create(['message' => 'User logged in']);
});
 
// ✅ Just insert
Log::create(['message' => 'User logged in']);

Use transactions when:

  • Operations span multiple documents or collections
  • Partial failure would create inconsistent state (e.g., wallet debit without order)
  • Financial operations require guaranteed consistency
  • Business invariants must be maintained (e.g., inventory matching orders)

Real-World Example: Booking System

Let's look at another practical example. Similar to the MongoDB Learning Byte on Laravel transactions, imagine a hotel booking system:

<?php
 
namespace App\Http\Controllers;
 
use App\Models\Rental;
use App\Models\Booking;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use MongoDB\BSON\ObjectId;
 
class BookingController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'rental_id' => 'required|string',
'user_id' => 'required|string',
'check_in' => 'required|date',
'check_out' => 'required|date|after:check_in',
]);
 
try {
$booking = DB::connection('mongodb')->transaction(function () use ($validated) {
 
// Update rental availability
$rentalResult = Rental::raw(function ($collection) use ($validated) {
return $collection->updateOne(
[
'_id' => new ObjectId($validated['rental_id']),
'available' => true
],
[
'$set' => ['available' => false]
]
);
});
 
if ($rentalResult->getModifiedCount() === 0) {
throw new \Exception('Rental not available');
}
 
// Create booking record
$booking = Booking::create([
'rental_id' => $validated['rental_id'],
'user_id' => $validated['user_id'],
'check_in' => $validated['check_in'],
'check_out' => $validated['check_out'],
'status' => 'confirmed'
]);
 
return $booking;
});
 
return response()->json([
'success' => true,
'booking' => $booking
]);
 
} catch (\Exception $e) {
return response()->json([
'error' => $e->getMessage()
], 400);
}
}
}

If both actions succeed, the transaction commits. If not, it rolls back, ensuring data consistency. The rental's availability and the booking record stay synchronized.

Testing Transaction Performance

Create a benchmark to measure transaction overhead:

public function test_transaction_performance()
{
// Setup test data
for ($i = 0; $i < 100; $i++) {
Product::create([
'name' => "Product {$i}",
'price' => 50.00,
'stock' => 100
]);
 
Wallet::create([
'user_id' => "user-{$i}",
'balance' => 1000.00
]);
}
 
$start = microtime(true);
 
// Run 100 transactional checkouts
for ($i = 0; $i < 100; $i++) {
$product = Product::skip($i)->first();
 
$this->postJson('/api/checkout', [
'user_id' => "user-{$i}",
'product_id' => $product->id,
'quantity' => 1
]);
}
 
$duration = (microtime(true) - $start) * 1000;
$average = $duration / 100;
 
dump("100 transactional checkouts in {$duration}ms");
dump("Average: {$average}ms per checkout");
 
// With optimized transactions (no external calls), should be under 200ms average
$this->assertLessThan(200, $average,
'Transactional checkout should average under 200ms');
}

Target: Under 200ms per checkout with properly scoped transactions.

Key Takeaways

  • Atomic operations solve single-document consistency, transactions solve multi-document consistency - Know which tool to use for each scenario
  • Laravel's DB::transaction() works identically with MongoDB and SQL databases - Same familiar syntax you already know
  • Transactions provide all-or-nothing execution - Either all operations succeed, or all are rolled back
  • Keep transaction scope minimal - Only include critical database operations; move external API calls outside
  • Handle transient errors with retry logic - Network issues and cluster elections can cause temporary failures
  • Test both success and failure scenarios - Verify rollbacks work correctly when operations fail
  • Create indexes on fields used in transaction queries - Dramatically improves performance and lock contention
  • Monitor transaction duration - Keep transactions under 100ms when possible by avoiding slow operations inside them

FAQs

What's the difference between atomic operations and transactions in MongoDB?

Atomic operations like $inc modify a single document in one indivisible step—fast and simple. Transactions coordinate multiple operations across multiple documents or collections, ensuring they all succeed or all fail together. Use atomic operations for single-document updates (like incrementing a counter), and use transactions when you need consistency across multiple documents (like debiting a wallet while creating an order).

How long can a MongoDB transaction run in Laravel?

MongoDB has a default 60-second timeout for transactions. If your transaction exceeds this limit, MongoDB automatically aborts it. In practice, you should keep transactions much shorter—under 100ms ideally. Long-running transactions hold locks, blocking other operations and reducing throughput. Only include critical atomic database operations in your transaction closure, and move everything else (emails, API calls) outside.

What happens if my transaction times out or the server crashes?

MongoDB guarantees complete rollback. If a transaction times out, crashes, or encounters any error, MongoDB automatically aborts it and reverses all operations within that transaction. The database returns to its exact pre-transaction state—no partial updates. Your application receives an exception, which you can catch and handle appropriately (retry, return error to user, etc.).

Can I nest transactions in Laravel MongoDB?

No, MongoDB doesn't support nested transactions. If you call DB::transaction() inside another DB::transaction(), Laravel will treat them as the same transaction. All operations belong to the outermost transaction. This is standard behavior across most databases—transactions are flat, not nested. Design your code to use a single transaction scope for related operations.

How do I handle transaction errors in production?

Distinguish between transient and business errors. Transient errors (network issues, cluster elections) should be retried automatically with exponential backoff. Business errors (insufficient funds, out of stock) should fail immediately with a meaningful message to the user. Always log transaction failures with context (user ID, operation details) for debugging. Monitor retry rates and alert if they're unusually high, as this indicates infrastructure issues.

What's Next

We've built a robust transactional checkout system that maintains data consistency across multiple collections. But there's one more critical problem to solve: what happens when a transaction succeeds on the database but the response never reaches the client? The frontend retries, and suddenly the customer is charged twice.

In the next article, "Making Laravel MongoDB Operations Idempotent: Safe Retries for Financial Transactions," we'll implement idempotency keys to make our checkout operations safe to retry, even when network failures create uncertainty.

References

Arthur Ribeiro photo

Delbridge Consultant

Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

image
Laravel Cloud

Easily create and manage your servers and deploy your Laravel applications in seconds.

Visit Laravel Cloud
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell
Get expert guidance in a few days with a Laravel code review logo

Get expert guidance in a few days with a Laravel code review

Expert code review! Get clear, practical feedback from two Laravel devs with 10+ years of experience helping teams build better apps.

Get expert guidance in a few days with a Laravel code review
PhpStorm logo

PhpStorm

The go-to PHP IDE with extensive out-of-the-box support for Laravel and its ecosystem.

PhpStorm
Laravel Cloud logo

Laravel Cloud

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Cloud
Acquaint Softtech logo

Acquaint Softtech

Acquaint Softtech offers AI-ready Laravel developers who onboard in 48 hours at $3000/Month with no lengthy sales process and a 100 percent money-back guarantee.

Acquaint Softtech
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Harpoon: Next generation time tracking and invoicing logo

Harpoon: Next generation time tracking and invoicing

The next generation time-tracking and billing software that helps your agency plan and forecast a profitable future.

Harpoon: Next generation time tracking and invoicing
Lucky Media logo

Lucky Media

Get Lucky Now - the ideal choice for Laravel Development, with over a decade of experience!

Lucky Media
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Multi-tenant Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit

The latest

View all →
Building Transaction-Safe Multi-Document Operations in Laravel image

Building Transaction-Safe Multi-Document Operations in Laravel

Read article
Ship AI with Laravel: Building Your First Agent with Laravel 13's AI SDK image

Ship AI with Laravel: Building Your First Agent with Laravel 13's AI SDK

Read article
OG Kit: Generate Dynamic Open Graph Images with HTML and CSS image

OG Kit: Generate Dynamic Open Graph Images with HTML and CSS

Read article
Prism Workers AI — A Cloudflare Workers AI Provider for Prism PHP image

Prism Workers AI — A Cloudflare Workers AI Provider for Prism PHP

Read article
Take the Pain Out of Data Imports with Laravel Ingest image

Take the Pain Out of Data Imports with Laravel Ingest

Read article
Liminal: A Browser-Based IDE for Laravel Powered by WebAssembly image

Liminal: A Browser-Based IDE for Laravel Powered by WebAssembly

Read article