Laravel middleware: WithTransaction

ยท

3 min read

Laravel middleware: WithTransaction

Hey guys,

I've used the Transaction Wrapper middleware for my action APIs for a while. It comes in pretty handy.

For the action APIs where multiple DB write operations can happen, best to wrap them in a transaction for the data consistency.

The good'ol way

Likely in the Laravel Controller, we usually use the 2 options, let's check them out below:

The Options

Start - Try Commit - Catch Rollback

Legends know this haha ๐Ÿ˜Ž

use Illuminate\Support\Facades\DB;
use Illuminate\Http\JsonResponse;

class UserController
{
    public function update(UpdateUserRequest $request, User $user): JsonResponse
    {
        DB::beginTransaction();

        try {
            $user->update($request->getUpdatableField());
            $user->updateProfilePicture($request->getProfilePic());

            DB::commit();

            return new JsonResponse(['outcome' => 'SUCCESS']);
        } catch (Throwable) {
            DB::rollBack();

            return new JsonResponse(['outcome' => 'FAILED_TO_UPDATE']);
        }
    }
}

Within the new Laravel version, you'd be able to use the rescue HoF:

use Illuminate\Support\Facades\DB;
use Illuminate\Http\JsonResponse;

class UserController
{
    public function update(UpdateUserRequest $request, User $user): JsonResponse
    {
        DB::beginTransaction();

        return rescue(
            function () use ($request, $user) {
                $user->update($request->getUpdatableField());
                $user->updateProfilePicture($request->getProfilePic());

                DB::commit();

                return new JsonResponse(['outcome' => 'SUCCESS']);
            },
            function () {
                DB::rollBack();

                return new JsonResponse(['outcome' => 'FAILED_TO_UPDATE']);
            }
        );
    }
}

Use DB::transaction HoF

Instead of using rescue and also do the beginTransaction , you can do something better & shorter:

use Illuminate\Support\Facades\DB;
use Illuminate\Http\JsonResponse;

class UserController
{
    public function update(UpdateUserRequest $request, User $user): JsonResponse
    {
        return DB::transaction(function () use ($request, $user) {
            $user->update($request->getUpdatableField());
            $user->updateProfilePicture($request->getProfilePic());

            return new JsonResponse(['outcome' => 'SUCCESS']);
        });
    }
}

The problem with the above options

  • Isn't reusable.

  • Your code is always inside a scope level, doesn't look so cool.

So why wouldn't we create something that is reusable, and get rid of a scoped level?

That's the reason for this article hehe ๐Ÿ˜Ž.

WithTransaction middleware

The goal of this middleware is to wrap a whole Controller's action inside a middleware, and rollback when there is an exception. Also reusable, we just simply register the middleware in the routes

Route::post('/users/{user}', [UserController::class, 'update'])
    ->middleware(['withTransaction']);

The simple Middleware

<?php

namespace App\Http\Middleware;

use App\Http\JsonResponseFactory;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
use Throwable;

class WithTransaction
{
    public function handle(Request $request, Closure $next): Response
    {
        return DB::transaction(function () use ($request, $next) {
            $result = $next($request);

            if (isset($result->exception)) {
                throw $result->exception;
            }

            return $result;
        });
    }
}

Laravel has its Error Response layer, after triggering the $next, we won't get any exceptions.

If there is an exception, the result will have the exception property. That's why we'll need to check & throw to commit or roll back the transaction.

This would do the job, but I don't like this way because we'll expose the internal error to the client side, therefore I came up with another better solution.

The improved version

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
use Throwable;

class WithTransaction
{
    public function handle(Request $request, Closure $next): Response
    {
        return rescue(
            fn () => DB::transaction(function () use ($request, $next) {
                $result = $next($request);

                if (isset($result->exception)) {
                    throw $result->exception;
                }

                return $result;
            }),
            function (Throwable $e) use ($request) {
                // report($e); // you can also do this
                Log::error('Rolled back transaction on error', [
                    'error' => [
                        'msg' => $e->getMessage(),
                        'traces' => $e->getTrace(),
                    ],
                    'url' => $request->fullUrl(),
                ]);

                return new JsonResponse([
                    'outcome' => 'ACTION_FAILED',
                    ...(config('app.debug')
                        ? ['msg' => $e->getMessage()]
                        : []),
                ], 400);
            },
        );
    }
}

With this improved way, I'll have:

  • Internal error log - would be really useful for staging & production environments.

  • Standardized error response

Notes

  • If your endpoint is purely read, DO NOT use it, for better performance.

  • If your action has only a single write/update operation, you don't have to use it either.

Conclusion

I hope that this Middleware can bring some joy to your Controller ๐Ÿฅฐ and get rid of the tedious code.

Happy friyay!

ย