Using Database Transactions to Write Safer Laravel Code
Introduction
In web development, data integrity and accuracy is really important. So, making sure that we write code that stores, updates and deletes data in our databases in a safe way is imperative.
In this article, we're going to look at what database transactions are, why they're important, and how to get started with using them in Laravel. We're also going to look at a common "gotcha" involving queued jobs and database transactions.
What are Database Transactions?
Before we get started with looking at database transactions in Laravel, let's take a look at what they are and how they can are beneficial.
There's a lot of technical, complicated-sounding explanations for what a database transaction is. But, for a large majority of us, as web developers, we just need to know that transactions are way of completing a unit of work as a whole in a database.
To understand what this actually means, let's take a look at a basic example that will give a little bit of context.
Let's imagine that we have an application that lets users register. Whenever a user register, we want to create a new account for them and then assign them a default role of 'general'.
Our code might look something like this:
$user = User::create([
'email' => $request->email,
]);
$user->roles()->attach(Role::where('name', 'general')->first());
At first glance, it might seem like this code is completely fine. But, when we take a closer look we can see that there's actually something that could go wrong. It's possible that we could create the user but not assign them the role. This could be caused by many different things, such as a bug in the code that assigns the roles, or even a hardware problem that stops us from reaching the database.
As a result of this happening, this would mean that we would have a user in the system that doesn't have a role. As you can imagine, this will likely cause exceptions and bugs in other places across your application because you would always be making an assumption that a user has a role (and rightly so).
So, to solve this issue, we can use database transactions. By using transaction, it would ensure that if anything goes wrong when executing the code, any changes to the database from inside that transaction would be rolled back. For example, if the user was inserted into the database but the query to assign the role failed for any reason, the transaction would be rolled back and the user's row would be removed. By doing this, it means that we wouldn't be able to create a user without an assigned role.
In other words, it's "all or nothing".
Using Database Transactions in Laravel
Now that we have a brief idea on what transactions are and what they achieve, let's take a look at how to use them in Laravel.
In Laravel, it's actually really to easy to get started with using transactions thanks to the transaction()
method that we can access on the DB
facade. Sticking with our example code from earlier, let's take a look at how we could use a transaction when creating a user and assigning them a role.
use Illuminate\Support\Facades\DB;
DB::transaction(function () use ($user, $request): void {
$user = User::create([
'email' => $request->email,
]);
$user->roles()->attach(Role::where('name', 'general')->first());
});
Now that our code is wrapped in a database transaction, if an exception is thrown at any point inside it, any changes to the database will be returned to how they were before the transaction started.
Manually Using Database Transactions in Laravel
There may be times when you want to have more granular control over your transactions. For example, let's imagine that you're integrating with a third-party service; such as Mailchimp or Xero. And we'll say that whenever you create a new user, you also want to make a HTTP request to their API to create them as a user in that system too.
We might want to update our code so that if we can't create the user in our own system and the third-party system, neither of them should be created. It's possible that if you're interacting with a third-party system that you might have a class that you can use for making requests. Or, it's possible that there might be an package that you can use. Sometimes, the classes making the request might throw an exception when certain requests can't be complete. However, some of them may silence the errors and instead just return false
from the method you called and place the errors in an field on the class.
So, let's imagine that we have the following basic, example class that makes a call to the API:
class ThirdPartyService
{
private $errors;
public function createUser($userData)
{
$request = $this->makeRequest($userData);
if ($request->successful()) {
return $request->body();
}
$errors = $request->errors();
return false;
}
public function getErrors()
{
return $this->errors;
}
}
Of course, the code above for the request class is incomplete and my code example below isn't very clean, but it should give you the general idea of the point that I'm trying to make. So let's use this request class and add it to our previous code example:
use Illuminate\Support\Facades\DB;
use App\Services\ThirdPartyService;
DB::beginTransaction();
$thirdPartyService = new ThirdPartyService();
$userData = [
'email' => $request->email,
];
$user = User::create($userData);
$user->roles()->attach(Role::where('name', 'general')->first());
if ($thirdPartyService->createUser($userData)) {
DB::commit();
return;
}
DB::rollBack();
report($thirdPartyService->getErrors());
Looking at the code above, we can see that we start a transaction, create the user and assign them a role, and then we make a call to the third-party service. If the user is successfully created in the external service, we can safely commit our database changes knowing that everything has been created correctly. However, if the user wasn't created in the external service, we rollback the changes in our database (remove the user and their role assignment) and then report the errors.
Tips for Interacting with Third-Party Services
As a bonus tip, I would usually recommend putting any code that affects any third-party systems, file storage or caches after your database calls.
To understand this a little bit more, let's take the code example from above. Notice how we made all of our changes to our database first before making the request to the third-party service. This means that if any errors were returned from the third-party request, the user and role assignment in our own database would be rolled back.
However, if we had done this the other way around and we made the request before making our database changes, this would not be the case. If, for any reason, we had any errors when creating our user in our database, we would have created a new user in the third-party system but not our own. As you can imagine, this could potentially lead to more issues. It would be possible to reduce the severity of this issue by writing a clean-up method that deletes the user from the third-party system. But, as you can imagine, this would likely cause more problems and would lead to more code to write, maintain and test.
So, I'd always recommend trying to put your database calls before your API calls. However, this isn't always possible. There might be times when you need to save a value in your database that's returned from a third-party request. If this is the case, this is totally fine as long as you ensure you have some code in place to handle any failures.
Using Automatic or Manual Transactions
It's also worth noting that because our original example, using the DB::transaction()
method, rolls back transactions if an exception is thrown, we could also use that approach for making requests to our third-party service. Instead we could update our class to do something like this:
use Illuminate\Support\Facades\DB;
use App\Services\ThirdPartyService;
DB::transaction(function () use ($user, $request): void {
$user = User::create([
'email' => $request->email,
]);
$user->roles()->attach(Role::where('name', 'general')->first());
if (! $thirdPartyService->createUser($userData)) {
throw new \Exception('User could not be created');
}
});
This is definitely a viable solution and would successfully rollback the transaction as expected. In fact, in terms of my personal preference, I actually prefer the way this looks than manually using the transactions. I think it looks much simpler to read and understand.
However, exception handling can be expensive in terms of time and performance in comparison to using an 'if' statement like when we are manually committing or rolling back the transactions.
So, as an example, if this code was being used for something like importing a CSV file with 10,000 users' data, you might find that throwing the exception will slow down the import considerably.
However, if it was just being used inside a simple web request where a user can register, you would likely be okay with throwing the exception. Of course, this comes down to the size of your application, and how much performance is a key factor; so this is something that you'd need to decide on a case-by-case basis.
Dispatching Queued Jobs inside Database Transactions
Whenever you are working with jobs inside transactions, there's a "gotcha" that you need to be aware of.
To give a bit of context, let's stick with our code example from earlier. We'll imagine that after we've created our user that we want to run a job that alerts an admin to inform them of a new sign up and sends a welcome email to the new user. We'll do this by dispatching a queued job called AlertNewUser
like so:
use Illuminate\Support\Facades\DB;
use App\Jobs\AlertNewUser;
use App\Services\ThirdPartyService;
DB::transaction(function () use ($user, $request): void {
$user = User::create([
'email' => $request->email,
]);
$user->roles()->attach(Role::where('name', 'general')->first());
AlertNewUser::dispatch($user);
});
When you begin a transaction and make changes to any data inside it, those changes are only available to the request/process that the transaction is running in. For any other requests or processes to access the data you've changed, the transaction will first have to be committed. Therefore, this means that if we dispatch any queued jobs, event listeners, mailables, notifications, or broadcast events from inside our transaction, our data changes might not be available inside them due to a race condition.
This can happen if the queue worker starts to process the queued code before the transaction was committed. Therefore, this can lead to your queued code potentially trying to access data that doesn't exist yet and may cause errors. In our case, if the queue AlertNewUser
job is run before the transaction is committed, our job will try accessing a user that's not actually stored in the database yet. As you can expect, this will cause the job to fail.
To prevent this race condition from happening, we can make some changes to our code and/or our config to ensure that the jobs are only dispatched after the transactions are successfully committed.
We can make updates to our config/queue.php
and add the after_commit
field. Let's imagine that we are using the redis
queue driver, we could update our config like so:
<?php
return [
// ...
'connections' => [
// ...
'redis' => [
'driver' => 'redis',
// ...
'after_commit' => true,
],
// ...
],
// ...
];
By making this change, if we try and dispatch a job inside a transaction, the job will wait for the transaction to be committed before actually dispatching the job. The handy thing is, if the transaction is rolled back, it will also prevent the job from being dispatched.
However, there may be a reason that you don't want to set this option globally in the config. If this is the case, Laravel still provides some nice helper methods that we can use on a case-by-case basis.
If we wanted to update the code in our transaction to only dispatch the job after it's committed, we could use the afterCommit()
method like so:
use Illuminate\Support\Facades\DB;
use App\Jobs\AlertNewUser;
use App\Services\ThirdPartyService;
DB::transaction(function () use ($user, $request): void {
$user = User::create([
'email' => $request->email,
]);
$user->roles()->attach(Role::where('name', 'general')->first());
AlertNewUser::dispatch($user)->afterCommit();
});
Laravel also provides another handy beforeCommit()
method that we can use. We can use this if we have set the global after_commit => true
in our queue config but don't care about waiting for the transaction to be committed. To do this we can simply update our code like so:
use Illuminate\Support\Facades\DB;
use App\Jobs\AlertNewUser;
use App\Services\ThirdPartyService;
DB::transaction(function () use ($user, $request): void {
$user = User::create([
'email' => $request->email,
]);
$user->roles()->attach(Role::where('name', 'general')->first());
AlertNewUser::dispatch($user)->beforeCommit();
});
Conclusion
Hopefully this article should have given you an overview of what database transactions are and how to get started with using them in Laravel. It should have also shown you how to avoid the "gotcha" when dispatching queued jobs from inside transactions.
If this post helped you out, I'd love to hear about it. Likewise, if you have any feedback to improve this post, I'd also love to hear that too.
If you're interested in getting updated each time I publish a new post, feel free to sign up for my newsletter.
Keep on building awesome stuff! 🚀
shivam-687, ash-jc-allen, faissaloux, geovanek, ronald169, akhmatovalexander, rsmsp, yvan-burrie, django23, peterfox and more liked this article
Other articles you might like
Laravel Custom Query Builders Over Scopes
Hello 👋 Alright, let's talk about Query Scopes. They're awesome, they make queries much easier to r...
Access Laravel before and after running Pest tests
How to access the Laravel ecosystem by simulating the beforeAll and afterAll methods in a Pest test....
🍣 Sushi — Your Eloquent model driver for other data sources
In Laravel projects, we usually store data in databases, create tables, and run migrations. But not...
The Laravel portal for problem solving, knowledge sharing and community building.
The community