In this lesson, we will finalize our big goal of shortening the OrderController and removing the other try-catch methods.
app/Http/Controllers/Api/V1/OrderController.php
public function store(StoreOrderRequest $request){ Gate::authorize('create', Order::class); try { $order = $this->orderService->createOrderHandleProducts($request); return response()->json(new OrderResource($order), Response::HTTP_CREATED); } catch (QueryException $eQueryException) { DB::rollback(); // Rollback transaction on database error return $this->responseDbError(); } catch (Throwable $eTh) { DB::rollback(); // Rollback transaction on any other error return $this->responseUnexpectedError(); }} public function update(UpdateOrderRequest $request, Order $order){ Gate::authorize('update', $order); try { $this->orderService->updateOrderHandleProducts($request, $order); return response()->json(new OrderResource($order), Response::HTTP_OK); } catch (QueryException $eQueryException) { DB::rollback(); // Rollback transaction on database error return $this->responseDbError(); } catch (Throwable $eTh) { DB::rollback(); // Rollback transaction on any other error return $this->responseUnexpectedError(); }}
Those try-catch blocks "clutter" the Controller, trying to catch Exceptions, which we can move to the bootstrap/app.php
, like we did in the previous lessons with 404 and 403 status code Exceptions.
As a result, I hope to also entirely eliminate two Trait methods: responseDbError()
and responseUnexpectedError()
. Or maybe even remove that Trait altogether. We'll see.
Exceptions from Controller to bootstrap/app.php File
First, let's remove those from the Controller.
The methods are simplified to just THIS:
app/Http/Controllers/Api/V1/OrderController.php
public function store(StoreOrderRequest $request){ Gate::authorize('create', Order::class); $order = $this->orderService->createOrderHandleProducts($request); return response()->json(new OrderResource($order), Response::HTTP_CREATED);} public function update(UpdateOrderRequest $request, Order $order){ Gate::authorize('update', $order); $this->orderService->updateOrderHandleProducts($request, $order); return response()->json(new OrderResource($order), Response::HTTP_OK);}
Then, we handle those Exceptions in the same place as other Exceptions, globally.
bootstrap/app.php:
use Illuminate\Database\QueryException;use Illuminate\Validation\ValidationException; // ... ->withExceptions(function (Exceptions $exceptions) { $exceptions->renderable(function (Throwable $e) { if ($e instanceof AuthorizationException) { return response()->json([ 'errors' => 'You are not authorized.', 'status' => Response::HTTP_FORBIDDEN, ], Response::HTTP_FORBIDDEN); } $previous = $e->getPrevious(); if ($previous instanceof ModelNotFoundException) { return response()->json([ 'errors' => str($previous->getModel())->afterLast('\\') . ' not found', 'status' => Response::HTTP_NOT_FOUND, ], Response::HTTP_NOT_FOUND); } if ($e instanceof AccessDeniedHttpException) { return response()->json([ 'errors' => 'You are not authorized.', 'status' => Response::HTTP_FORBIDDEN, ], Response::HTTP_FORBIDDEN); }; if ($e instanceof QueryException) { return response()->json([ 'errors' => 'Database error.', 'status' => Response::HTTP_INTERNAL_SERVER_ERROR, ], Response::HTTP_INTERNAL_SERVER_ERROR); }; if (! $e instanceof ValidationException) { return response()->json([ 'errors' => 'An unexpected error occurred.', 'status' => Response::HTTP_INTERNAL_SERVER_ERROR, ], Response::HTTP_INTERNAL_SERVER_ERROR); } });})->create();
I've made a few changes here:
- Added two highlighted blocks: to catch the
QueryException
, and to catch all other Exceptions except ValidationException, because that should be handled by the Form Request validation. - Re-grouped it all under one group of the
Throwable
interface, inside just checking$e instanceof
class with if-statements.
See, now we have a complete list of Exceptions and their handling, all in one place, no matter which project Controller or other file they come from.
Here's the GitHub commit for this change so far.
Wait, but you removed DB::rollback()?
You may be wondering about the DB::rollback()
method that I removed.
try { $this->orderService->updateOrderHandleProducts($request, $order); return response()->json(new OrderResource($order), Response::HTTP_OK);} catch (QueryException $eQueryException) { DB::rollback(); // ...}
It's supposed to rollback the DB transaction started inside of that $this->orderService->updateOrderHandleProducts()
method:
app/Services/V1/OrdersService.php:
public function createOrderHandleProducts(BaseOrderRequest $request){ DB::beginTransaction(); // Start transaction // Create new order $order = Order::create($request->mappedAttributes()); // Get products from request $incomingProducts = collect($request->input('data.relationships.products', []))->keyBy('id'); foreach ($incomingProducts as $productId => $productData) { $quantity = $productData['quantity']; $price = $productData['price']; // Lock the product row for update to prevent race conditions $lockedProduct = Product::where('id', $productId)->lockForUpdate()->first(); // Attach product to order with quantity and price $order->products()->attach($productId, ['quantity' => $quantity, 'price' => $price]); // Decrease stock $lockedProduct->decrement('stock', $quantity); } DB::commit(); // Commit transaction if everything succeeds return $order;}
But this is again a problem with the single responsibility principle. Why is the successful transaction handled in the Service, but the failed transaction is rolled back in Controller?
And again, there's a better "Laravel way" to handle it automatically.
Instead of manual DB::commit()
, we will combine it and change the Transaction mechanism to DB::transaction()
.
app/Services/V1/OrdersService.php:
public function createOrderHandleProducts(BaseOrderRequest $request): Order{ return DB::transaction(function () use ($request) { // Create new order $order = Order::create($request->mappedAttributes()); // Get products from request $incomingProducts = collect($request->input('data.relationships.products', []))->keyBy('id'); foreach ($incomingProducts as $productId => $productData) { $quantity = $productData['quantity']; $price = $productData['price']; // Lock the product row for update to prevent race conditions $lockedProduct = Product::where('id', $productId)->lockForUpdate()->first(); // Attach product to order with quantity and price $order->products()->attach($productId, ['quantity' => $quantity, 'price' => $price]); // Decrease stock $lockedProduct->decrement('stock', $quantity); } return $order; });}
A few things to note here:
- If something wrong happens inside of that
DB::transaction()
callback function, the transaction will be rolled back automatically. That's why we can safely remove thatDB::rollback()
from the Controller. - You can also
return
the result fromDB::transaction()
, which I use here as a result of the entire method, without temporary variables.
I performed the same refactoring for the other two methods inside the same Service: updateOrderHandleProducts()
and deleteOrderHandleProducts()
.
Here's the GitHub commit for this Transaction refactoring.
OrderController: Final Cleanup
There are a few small pieces we can shorten in this Controller:
- Remove the unused classes from the
use
section - Remove the unnecessary
response()->json()
in a few more methods
app/Http/Controllers/Api/V1/OrderController.php
use Illuminate\Database\QueryException; use Illuminate\Http\Response;use Illuminate\Support\Facades\DB;use Illuminate\Support\Facades\Gate;use Throwable; // ... class OrderController extends ApiController{ // ... public function store(StoreOrderRequest $request) { Gate::authorize('create', Order::class); $order = $this->orderService->createOrderHandleProducts($request); return response()->json(new OrderResource($order), Response::HTTP_CREATED); return new OrderResource($order); } public function update(UpdateOrderRequest $request, Order $order) { Gate::authorize('update', $order); $this->orderService->updateOrderHandleProducts($request, $order); return response()->json(new OrderResource($order), Response::HTTP_OK); return new OrderResource($order); }}
Just to make sure... the tests are still green!
This is the GitHub commit for this cleanup.
Finished OrderController: BEFORE and AFTER
Finally, I think I've completed the refactoring of OrderController
, simplifying it as much as possible, removing unnecessary parts, and moving them elsewhere.
Look at what we had in the very beginning: 159 lines of code.
BEFORE:
<?php namespace App\Http\Controllers\Api\V1; use App\Http\Filters\V1\OrderFilter;use App\Http\Requests\Api\V1\ReplaceOrderRequest;use App\Http\Requests\Api\V1\StoreOrderRequest;use App\Http\Requests\Api\V1\UpdateOrderRequest;use App\Http\Resources\V1\OrderCollection;use App\Http\Resources\V1\OrderResource;use App\Models\Order;use App\Policies\V1\OrderPolicy;use App\Services\V1\OrdersService;use Illuminate\Auth\Access\AuthorizationException;use Illuminate\Database\Eloquent\ModelNotFoundException;use Illuminate\Database\QueryException;use Illuminate\Http\Response;use Illuminate\Support\Facades\DB;use Throwable; class OrderController extends ApiController{ protected $policyClass = OrderPolicy::class; public function __construct( protected OrdersService $orderService ) {} /** * Display a listing of the resource. */ public function index(OrderFilter $filter) { try { return response()->json(new OrderCollection( Order::filter($filter)->paginate() ), Response::HTTP_OK); } catch (AuthorizationException $eAuthorizationException) { return $this->notAuthorized(); } } /** * Display the specified resource. */ public function show($order_id) { try { $order = Order::findOrFail($order_id); $this->isAble('view', $order); // policy if ($this->include('user')) { $order->load('user'); } $order->load('products'); return response()->json(new OrderResource($order), Response::HTTP_OK); } catch (ModelNotFoundException $eModelNotFound) { return $this->notFound('Order not found'); } catch (AuthorizationException $eAuthorizationException) { return $this->notAuthorized(); } } /** * Store a newly created resource in storage. */ public function store(StoreOrderRequest $request) { try { $order = $this->orderService->createOrderHandleProducts($request); return response()->json(new OrderResource($order), Response::HTTP_CREATED); } catch (QueryException $eQueryException) { DB::rollback(); // Rollback transaction on database error return $this->dbError(); } catch (Throwable $eTh) { DB::rollback(); // Rollback transaction on any other error return $this->unexpectedError(); } } /** * Update the specified resource in storage. * PATCH */ public function update(UpdateOrderRequest $request, $order_id) { try { $order = Order::findOrFail($order_id); $this->isAble('update', $order); // policy $this->orderService->updateOrderHandleProducts($request, $order); return response()->json(new OrderResource($order), Response::HTTP_OK); } catch (ModelNotFoundException $eModelNotFound) { return $this->notFound('Order not found'); } catch (AuthorizationException $eAuthorizationException) { return $this->notAuthorized(); } catch (QueryException $eQueryException) { DB::rollback(); // Rollback transaction on database error return $this->dbError(); } catch (Throwable $eTh) { DB::rollback(); // Rollback transaction on any other error return $this->unexpectedError(); } } /** * Replace the specified resource in storage. * PUT */ public function replace(ReplaceOrderRequest $request, $order_id) { try { $order = Order::findOrFail($order_id); $this->isAble('update', $order); // policy $this->orderService->updateOrderHandleProducts($request, $order); return response()->json(new OrderResource($order), Response::HTTP_OK); } catch (ModelNotFoundException $eModelNotFound) { return $this->notFound('Order not found'); } catch (AuthorizationException $eAuthorizationException) { return $this->notAuthorized(); } catch (QueryException $eQueryException) { DB::rollback(); // Rollback transaction on database error return $this->dbError(); } catch (Throwable $eTh) { DB::rollback(); // Rollback transaction on any other error return $this->unexpectedError(); } } /** * Remove the specified resource from storage. */ public function destroy($order_id) { try { $order = Order::findOrFail($order_id); $this->isAble('delete', $order); // policy $this->orderService->deleteOrderHandleProducts($order); return $this->ok('Order deleted successfully', [ 'status' => Response::HTTP_OK, ]); } catch (ModelNotFoundException $eModelNotFound) { return $this->notFound('Order not found'); } catch (AuthorizationException $eAuthorizationException) { return $this->notAuthorized(); } }}
Look at what we have now: 84 lines of code. Almost 2x shorter.
AFTER:
<?php namespace App\Http\Controllers\Api\V1; use App\Http\Filters\V1\OrderFilter;use App\Http\Requests\Api\V1\StoreOrderRequest;use App\Http\Requests\Api\V1\UpdateOrderRequest;use App\Http\Resources\V1\OrderCollection;use App\Http\Resources\V1\OrderResource;use App\Models\Order;use App\Services\V1\OrdersService;use Illuminate\Support\Facades\Gate; class OrderController extends ApiController{ public function __construct( protected OrdersService $orderService ) {} /** * Display a listing of the resource. */ public function index(OrderFilter $filter) { Gate::authorize('view-any', Order::class); return new OrderCollection( Order::filter($filter)->paginate() ); } /** * Display the specified resource. */ public function show(Order $order) { Gate::authorize('view', $order); if ($this->include('user')) { $order->load('user'); } $order->load('products'); return new OrderResource($order); } /** * Store a newly created resource in storage. */ public function store(StoreOrderRequest $request) { Gate::authorize('create', Order::class); $order = $this->orderService->createOrderHandleProducts($request); return new OrderResource($order); } /** * Update the specified resource in storage. * PATCH */ public function update(UpdateOrderRequest $request, Order $order) { Gate::authorize('update', $order); $this->orderService->updateOrderHandleProducts($request, $order); return new OrderResource($order); } /** * Remove the specified resource from storage. */ public function destroy(Order $order) { Gate::authorize('delete', $order); $this->orderService->deleteOrderHandleProducts($order); return $this->responseSuccess('Order deleted successfully'); }}
No comments yet…