Laravel Queues in Action (2nd edition) is now available!

Better Management of Database Transactions in Laravel 8

Updated: Feb 19, 2021 — 3 min Read#databases #queues #notifications

Using database transactions is a powerful way to ensure data integrity. You group multiple database queries into a single transaction, these queries will only take effect if they all succeed. Consider the following code:

$user = User::create([...]);

Team::create([
    'owner_id' => $user->id,
    ...
]);

If the team creation fails, a user will be left in your system with no team. To prevent this from happening, you can wrap the block inside a transaction:

DB::transaction(function(){
    $user = User::create([...]);

    Team::create([
        'owner_id' => $user->id,
        ...
    ]);
});

Now if the team creation fails, the entire transaction will be rolled back, including the query that creates the user. Laravel takes care of everything behind the scenes so you can ensure your data integrity with just a few lines of code.

However, database transactions wrap only database queries and execute them as a single unit of work. Any other code you may include inside the transaction will be executed right away and will not wait for after the transaction has committed:

DB::transaction(function(){
    $user = User::create([...]);

    Mail::to($user)->send(new WelcomeEmail());

    Team::create([
        'owner_id' => $user->id,
        ...
    ]);
});

In the example above, the welcome email will be sent to the user even if the team creation fails and the user record was not stored in the database.

An obvious fix for this issue is to extract the mail sending code outside the transaction:

DB::transaction(function(){
    $user = User::create([...]);

    Team::create([
        'owner_id' => $user->id,
        ...
    ]);
});

Mail::to($user)->send(new WelcomeEmail());

Now if the transaction fails, an exception will be thrown and the mail will not be sent. However, in many cases code that runs around database queries is not directly invoked. For example, the email sending could be inside a listener to the UserCreated event that fires after User::create(). In that case, the email sending will happen right after the user is created and before the transaction has committed.

Starting Laravel v8.19.0, you can wrap any code inside a closure that'll only get invoked after all transactions have committed. So inside the event listener that sends the email, you can do this:

class SendWelcomeEmail{
    public function handle()
    {
        DB::afterCommit(function(){
            Mail::to($user)->send(new WelcomeEmail());
        });
    }    
}

Now when the user gets created and the event is fired, the listener will call the afterCommit method which will put the mail sending logic in a local cache and will only execute it after any database transaction that may be open has committed.

But let's agree, having to wrap code inside a closure doesn't make it look any nicer. For that reason, Laravel introduces another way to ensure your listeners are only executed when database transactions have committed. Let's take the SendWelcomeEmail listener as an example:

class SendWelcomeEmail{
    public $afterCommit = true;

    public function handle()
    {
        Mail::to($user)->send(new WelcomeEmail());
    }    
}

Using the $afterCommit property, we can instruct laravel to only run the handle() method after any open transactions have committed. If no transactions are open, the code will run right away like normal.

Another scenario in which this comes in handy is dispatching a queued job, mail, notification, broadcasted event, or listener from inside a transaction. The workers may pick the work before the transaction commits and the code will run on a database state where the records modified by the transaction are still in their old state. For example:

DB::transaction(function(){
    $user = User::create([...]);

    SendWelcomeEmail::dispatch($user);

    Team::create([
        'owner_id' => $user->id,
        ...
    ]);
});

The SendWelcomeEmail job will be dispatched to the queue before the transaction commits. If a worker picks it right away, a ModelNotFound exception will be thrown because the User model passed to the job hasn't been stored in the database yet.

Setting the $afterCommit property to true in the job class will ensure the job only gets dispatched if the transaction has committed. You can also set after_commit to true in the queue connection configuration inside the queue.php configuration file:

'redis' => [
    'driver' => 'redis',
    'connection' => 'default',
    // ...
    'after_commit' => true
],

Now all jobs dispatched through the redis queue connection will wait until any open transaction has committed.

Alternatively, you can decide the behaviour while dispatching the job:

SendWelcomeEmail::dispatch($user)->afterCommit();
// OR
SendWelcomeEmail::dispatch($user)->beforeCommit();

You can use the $afterCommit property on mailables, notifications, jobs, listeners, model observers, and broadcasted events.

If you want to learn more about Laravel's queue system, make sure to check Laravel Queues in Action! I've put everything I know about the queue system in an eBook format along with several real-life uses cases. Check it out for a crash course, a cookbook, a guide, and a reference.

Hey! 👋 If you find this content useful, consider sponsoring me on GitHub.

You can also follow me on Twitter, I regularly post about all things Laravel including my latest video tutorials and blog posts.

By Mohamed Said

Hello! I'm a former Laravel core team member & VP of Engineering at Foodics. In this publication, I share everything I know about Laravel's core, packages, and tools.

You can find me on Twitter and Github.

This site was built using Wink. Follow the RSS Feed.