Laravel Queues in Action is now available!

Better Management of Database Transactions in Laravel 8

Updated: Dec 22, 2020 — 2 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.

Hey! 👋 If you want to receive updates on what I'm up to, I host a newsletter on my website themsaid.com and would love to have you.

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 full-stack web developer working at Laravel. 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.