Updated June 2026. Tested on Laravel 13 and PHP 8.4. The
afterCommitbehaviour has been part of Laravel since 8.19 and works the same way today.
A database transaction groups several queries into one all or nothing unit. Either every query succeeds and the changes stick, or one of them fails and the whole lot is rolled back as if nothing happened. It is the simplest, most reliable tool you have for keeping related records consistent. The catch, and the reason for this post, is that a transaction only rolls back database queries. The email you sent or the job you dispatched in the middle of it does not roll back, and that gap causes some genuinely confusing bugs.
Why you want a transaction
Take a sign up flow that creates a user and then a team that belongs to them.
$user = User::create([...]);
Team::create([
'owner_id' => $user->id,
...
]);
If the team creation throws, you are left with a user who has no team: a half finished record sitting in your database. Wrap both queries in a transaction and that cannot happen.
use Illuminate\Support\Facades\DB;
DB::transaction(function () {
$user = User::create([...]);
Team::create([
'owner_id' => $user->id,
...
]);
});
Now if the team insert fails, the user insert is rolled back too. Laravel opens the transaction, runs your closure, commits on success, and rolls back automatically if an exception is thrown.
Two extras worth knowing. You can ask Laravel to retry the whole transaction a few times if it hits a deadlock, by passing a second argument.
DB::transaction(function () {
// ...
}, attempts: 3);
And if you need to manage the boundaries yourself, the manual API is there.
DB::beginTransaction();
try {
// queries...
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
The trap: only queries roll back
A transaction wraps database queries. Anything else inside the closure is ordinary code that runs the moment it is reached, and it has no idea a transaction is even open. Look what happens when you slip an email into the middle.
DB::transaction(function () {
$user = User::create([...]);
Mail::to($user)->send(new WelcomeEmail()); // runs immediately
Team::create([
'owner_id' => $user->id,
...
]);
});
If the team creation fails, the user insert rolls back and the user never existed. But the welcome email has already gone out. You have emailed a user who, as far as the database is concerned, was never created.
The obvious fix is to keep side effects out of the transaction entirely. Do the data work inside, and only send the mail once the transaction has committed.
DB::transaction(function () {
$user = User::create([...]);
Team::create([
'owner_id' => $user->id,
...
]);
});
Mail::to($user)->send(new WelcomeEmail());
If the transaction throws, the exception propagates and we never reach the mail line. Clean.
When the side effect is not in your hands
Moving code out works when you can see it. The trouble is that the side effect is often triggered indirectly. Suppose User::create() fires a UserCreated event, and a listener on that event sends the welcome mail. The event fires the instant the user is inserted, which is still inside the open transaction, so the listener runs and the mail goes out before the transaction has committed. You cannot just lift it out, because you are not the one calling it.
Laravel gives you two ways to handle this.
The explicit one is DB::afterCommit(). Wrap the side effect in a closure and Laravel holds it until any open transaction commits. If no transaction is open, it runs straight away.
class SendWelcomeEmail
{
public function handle(UserCreated $event): void
{
DB::afterCommit(function () use ($event) {
Mail::to($event->user)->send(new WelcomeEmail());
});
}
}
Wrapping things in a closure is a bit noisy though. The tidier way is the $afterCommit property. Set it on the listener and Laravel defers the whole handle() method until the transaction commits, with no closure needed.
class SendWelcomeEmail
{
public $afterCommit = true;
public function handle(UserCreated $event): void
{
Mail::to($event->user)->send(new WelcomeEmail());
}
}
Same rule applies: if no transaction is open, it just runs normally.
Queued jobs dispatched inside a transaction
This one bites people on production with a bug that will not reproduce locally. Dispatch a queued job from inside a transaction and you have a race.
DB::transaction(function () {
$user = User::create([...]);
SendWelcomeEmail::dispatch($user);
Team::create([
'owner_id' => $user->id,
...
]);
});
The job is pushed onto the queue before the transaction commits. If a worker is fast and grabs it before the commit lands, it goes looking for that user in the database, does not find it yet, and throws ModelNotFoundException. On your machine the worker is usually slower than the commit, so it looks fine. Under load it fails intermittently, which is the worst kind of bug.
The fix is the same afterCommit idea, and you can apply it three ways.
Set the property on the job so it is never dispatched until the transaction commits.
class SendWelcomeEmail implements ShouldQueue
{
public $afterCommit = true;
}
Turn it on for a whole queue connection in config/queue.php, so every job on that connection waits for commit.
'redis' => [
'driver' => 'redis',
'connection' => 'default',
// ...
'after_commit' => true,
],
Or decide per dispatch with the fluent methods, which override the connection setting either way.
SendWelcomeEmail::dispatch($user)->afterCommit();
// or, to force the opposite
SendWelcomeEmail::dispatch($user)->beforeCommit();
Where afterCommit applies
The $afterCommit property and the afterCommit() helper are not just for jobs. The same deferral works for queued jobs, mailables, notifications, event listeners, model observers, and broadcasted events. Anywhere a side effect might be triggered from inside a transaction, you can tell Laravel to hold it until the data is actually committed.
A reasonable default for a lot of apps is to turn after_commit on at the queue connection level, so dispatching from inside a transaction is safe by default, and only reach for ->beforeCommit() in the rare case where you genuinely want the job to fire before the commit.
Wrapping up
Transactions give you all or nothing safety for your database writes, and that is exactly as far as they reach. Emails, events, jobs and notifications fired inside a transaction run immediately and will not roll back with it, which leaves you sending mail for records that were never saved, or queueing jobs that race the commit. Keep side effects outside the transaction when you can see them, and when you cannot, lean on DB::afterCommit(), the $afterCommit property, or the after_commit queue setting to hold them until the data is real.
Have you been bitten by the dispatch inside a transaction race? Share it in the comments.
All comments ()
No comments yet
Be the first to leave a comment on this post.