Laravel DB transaction and events
Stefan Bogdanescu
Founder & Senior Architect · 2026-06-29
Mastering Atomicity: Firing Events Reliably After Database Transactions in Laravel
As senior developers working with Laravel, we constantly grapple with balancing data integrity—ensuring that a set of related operations either all succeed or all fail—with the need to trigger side effects, such as emitting events. When you update multiple records, ensuring an event is fired only after the entire transaction has successfully committed is crucial for maintaining application consistency.
This post dives into how to correctly manage database transactions and asynchronous events in Laravel, providing a robust pattern for scenarios like updating product prices.
The Necessity of Database Transactions
When performing multiple database operations (like updating several products), we must rely on database transactions. A transaction groups several SQL statements into a single, indivisible unit of work. If any single statement fails (e.g., a constraint violation or a network error), the entire group is rolled back, preventing partial, inconsistent data from being saved.
Laravel provides the DB::transaction() method to manage this complexity elegantly. It automatically handles the BEGIN, COMMIT, and ROLLBACK logic. However, simply wrapping your code inside DB::transaction() doesn't automatically handle firing an event after the commit; you need a mechanism to hook into that successful completion.
The Challenge: Coupling Logic with Side Effects
The goal is: "If any of the products are updated, fire an event once the transaction finishes."
A common mistake is attempting to fire an event immediately after the loop inside the transaction closure. While this seems straightforward, if the process fails midway, you risk firing an event based on partial, invalid data, or worse, failing to fire it entirely when it should have been triggered.
The solution lies in decoupling the transactional logic from the event notification logic. We need a mechanism that executes only upon successful commitment.
The Best Practice: Decoupling with Model Events and Listeners
The most idiomatic Laravel approach is to let the models themselves handle their state changes via events. When you update a model, you fire an event (e.g., ProductUpdated). Registered listeners then react to this event after the save operation has successfully completed within its own transaction scope.
Step-by-Step Implementation
Here is how we structure the process to ensure reliability:
1. Define the Event:
First, define an event that signifies a product update occurred.
// app/Events/ProductPriceUpdated.php
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ProductPriceUpdated
{
use Dispatchable, SerializesModels;
public $product;
public $oldPrice;
public $newPrice;
public function __construct($product, $oldPrice, $newPrice)
{
$this->product = $product;
$this->oldPrice = $oldPrice;
$this->newPrice = $newPrice;
}
}
2. Register the Listener:
Create a listener that handles the side effect (e.g., sending an email, updating analytics).
// app/Listeners/SendPriceUpdateNotification.php
namespace App\Listeners;
use App\Events\ProductPriceUpdated;
use Illuminate\Support\Facades\Mail;
class SendPriceUpdateNotification
{
public function handle(ProductPriceUpdated $event)
{
// This code only runs if the event was successfully dispatched,
// which happens after the model save/commit.
Mail::to($event->product->email)->send(new PriceUpdatedMail($event));
}
}
3. Update the Model:
The Product model should be responsible for firing the event upon a successful update.
// app/Models/Product.php
use App\Events\ProductPriceUpdated;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
protected $fillable = ['price'];
protected static function booted()
{
static::updating(function ($model) {
// Optional: Add pre-save logic if needed
});
static::updated(function ($model) {
// Fire the event ONLY when a record is successfully updated.
if ($model->wasChanged('price')) {
event(new ProductPriceUpdated($model, $model->getOriginal('price'), $model->price));
}
});
}
}
Executing the Transaction Safely
Now, when you execute your bulk update, the transaction ensures data integrity, and the model's event system ensures that the side effects are triggered reliably upon successful commit.
use Illuminate\Support\Facades\DB;
use App\Models\Product;
// Assume $products is an array of IDs or models you want to update
try {
DB::transaction(function () use ($products) {
foreach ($products as $productId) {
$product = Product::findOrFail($productId);
// Perform the update within the transaction
$product->price = 2;
$product->save();
// The 'updated' event is automatically fired here if the save succeeds.
}
});
// If we reach here, the entire batch succeeded, and all related events have been processed.
echo "All product prices updated successfully.";
} catch (\Exception $e) {
// If any part of the transaction failed, Laravel automatically rolled back everything.
// No event was dispatched because the save operation failed before commit.
throw $e;
}
Conclusion
By leveraging Laravel's built-in features—specifically database transactions for atomicity and Eloquent model events for decoupled side effects—we achieve a clean, reliable, and maintainable architecture. We move away from tightly coupling transactional logic with event firing and instead rely on the framework's established patterns. For complex operations involving many records, always prioritize data integrity via transactions, and use events as the asynchronous mechanism to react to those verified state changes. This approach aligns perfectly with the principles of robust application design found within the Laravel ecosystem.