Leveraging SOLID Principles To Refactor A Laravel Controller
Have you ever experienced a situation in your Laravel or any MVC application where it just doesn’t feel right to have a controller for certain models because of how they are interacted with?
Context
I have this Laravel application for managing my church and there are these models, Bacenta
, Chapel
and Minstry
which are not really meant to change often and even if they are to change a user with administrative rights will be the one to effect the change. In the initial stages of the app it was assumed that the Chapel
model will never change and from the developer perspective Bacenta
and Minstry
chapels did almost the same thing so I thought to myself it will be okay to have a SettingsController
that handles the basic CRUD actions and probably one if
statement to handle the minor differences that might occur.
For 3 years, this was all that I needed until the app reached a point where the Chapel
model also required CRUD actions. I thought to myself, “That’s not a problem! I will just add another if statement“ and pass some parameters from the client which will tell me what model to use and how to respond.
Later, I realized that the table structures were not the same for all 3 models so I had to also pass what columns to query from the client. As if that was not enough, I realized some models could depend on more than one column when searching so I added extra params to dynamically determine which columns to use and it was at this point that I realized how much I have deviated from the original intention of the controller and how much the controller was begging to be refactored.
This is the initial SettingsController
I ended up with before the refactor
<?php
namespace App\Http\Controllers;
use App\Http\Requests\SettingsRequest;
use App\Models\Bacenta;
use App\Models\Ministry;
use Exception;
use Symfony\Component\HttpFoundation\Response;
class SettingsController extends Controller
{
public function index () {
$Model = 'App\\Models\\'.request()->model;
$searchTerm = request()->searchTerm;
$searchKey = request()->searchKey;
$secondaryKey = request()->secondaryKey ?? 'uid';
if(is_null($searchTerm)) {
$list = $Model::orderBy($searchKey)->get();
return response()->json(['list' => $list], Response::HTTP_OK);
}
$list = $Model::where($searchKey, 'like', $searchTerm.'%')->orWhere($secondaryKey, 'like', $searchTerm.'%')->limit(10)->get();
return response()->json(['list' => $list], Response::HTTP_OK);;
}
public function store(SettingsRequest $request) {
try {
$isChapelModel = request()->model == 'Chapel';
$Model = 'App\\Models\\'.request()->model;
$payload = $request->validated();
if ($isChapelModel) {
unset($payload['chapel_id']);
} else {
$lastUid = $Model::latest()->limit(1)->value('uid');
$uid = is_null($lastUid) ? 1001 : substr($lastUid, 1)+1;
$prefix = substr(request()->model, 0, 1);
$payload = array_merge($request->validated(), ['uid' => $prefix.$uid]);
}
$Model::create($payload);
return response()->json(['message' => 'Entity created successfully'], Response::HTTP_CREATED);
} catch (Exception $e) {
return response()->json([
'message' => 'Unable to create entity',
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function update(SettingsRequest $request) {
try {
$Model = 'App\\Models\\'.request()->model;
$validated = $request->validated();
$payload = [
'name' => $validated['name'],
'chapel_id' => $validated['chapel_id']
];
$isChapelModel = request()->model == 'Chapel';
if ($isChapelModel) {
unset($payload['chapel_id']);
}
$Model::whereId($validated['id'])->update($payload);
return response()->json(['message' => 'Entity updated successfully'], Response::HTTP_CREATED);
} catch (Exception $e) {
return response()->json([
'message' => 'Unable to update entity',
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
My Approach To Refactoring
When I looked at the code I had ended up with I thought to myself, why not allow each model to decide uniquely how to handle each of the user requests without cluttering it with unrelated logic as well as avoiding the need to create a new controller for each model because in this context, it just didn’t feel right.
Here are the 3 things that came to mind
Use some kind of class to service the controller
The service class should then delegate the implementation of the actual logic to whichever model that is responsible for it
All models involved should have some strict agreement to implement certain required methods
Just by reading the above you should be able to deduce the solution but I will share what I arrived at anyways
- A
SettingsService
class whose responsibility it is to delegate to various model implementations
<?php namespace App\Services; use Illuminate\Http\Request; class SettingsService { public function index (Request $request) { $model = 'App\\Models\\'.$request->model; return (new $model)->index($request); } public function store (Request $request) { $model = 'App\\Models\\'.$request->model; return (new $model)->store($request->validated()); } public function update (Request $request) { $model = 'App\\Models\\'.$request->model; return (new $model)->modify($request->validated()); } }
2. A
SettingsInterface
which enforces the strict implementation of the methods invoked in the service class<?php namespace App\Interfaces; use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\Request; interface SettingsInterface { public function index(Request $request) : Collection; public function store(array $paload); public function modify(array $paload); public function remove(int $id); }
3. Lastly, the individual models implementing the various methods in their unique ways
Bacenta
Model<?php namespace App\Models; use App\Interfaces\SettingsInterface; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; class Bacenta extends Model implements SettingsInterface { public function index(Request $request) : Collection { $searchTerm = $request->searchTerm; if(is_null($searchTerm)) { return $this::orderBy('name')->get(); } return $this::where('name', 'like', $searchTerm.'%')->orWhere('uid', 'like', $searchTerm.'%')->limit(10)->get(); } public function store(array $payload) { $lastUid = $this::latest()->limit(1)->value('uid'); $uid = is_null($lastUid) ? 1001 : substr($lastUid, 1)+1; $payload = array_merge($payload, ['uid' => 'B'.$uid]); $this->create($payload); } public function modify(array $payload) { $this::whereId($payload['id'])->update($payload); } public function remove(int $id) { // TO BE IMPLEMENTED } }
Ministry
model<?php namespace App\Models; use App\Interfaces\SettingsInterface; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; class Ministry extends Model implements SettingsInterface { public function index(Request $request) : Collection { $searchTerm = $request->searchTerm; if(is_null($searchTerm)) { return $this::orderBy('name')->get(); } return $this::where('name', 'like', $searchTerm.'%')->orWhere('uid', 'like', $searchTerm.'%')->limit(10)->get(); } public function store(array $payload) { $lastUid = $this::latest()->limit(1)->value('uid'); $uid = is_null($lastUid) ? 1001 : substr($lastUid, 1)+1; $payload = array_merge($payload, ['uid' => 'M'.$uid]); $this->create($payload); } public function modify(array $payload) { $this::whereId($payload['id'])->update($payload); } public function remove(int $id) { // TO BE IMPLEMENTED } }
Chapel
model<?php namespace App\Models; use App\Interfaces\SettingsInterface; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\Request; class Chapel extends Model implements SettingsInterface { public function index(Request $request) : Collection { $searchTerm = $request->searchTerm; if(is_null($searchTerm)) { return $this::orderBy('name')->get(); } return $this::where('name', 'like', $searchTerm.'%')->limit(10)->get(); } public function store(array $payload) { unset($payload['chapel_id']); $this::create($payload); } public function modify(array $payload) { unset($payload['chapel_id']); $this::whereId($payload['id'])->update($payload); } public function remove(int $id) { // TO BE IMPLEMENTED } }
Finally, lets look at the new SettingsController
<?php
namespace App\Http\Controllers;
use App\Http\Requests\SettingsRequest;
use App\Services\SettingsService;
use Exception;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SettingsController extends Controller
{
public $settingsService;
public function __construct(SettingsService $service)
{
$this->settingsService = $service;
}
public function index (Request $request) {
$list = $this->settingsService->index($request);
return response()->json(['list' => $list], Response::HTTP_OK);;
}
public function store(SettingsRequest $request) {
try {
$this->settingsService->store($request);
return response()->json(['message' => 'Entity created successfully'], Response::HTTP_CREATED);
} catch (Exception $e) {
return response()->json([
'message' => $e->getMessage(),
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function update(SettingsRequest $request) {
try {
$this->settingsService->update($request);
return response()->json(['message' => 'Entity updated successfully'], Response::HTTP_CREATED);
} catch (Exception $e) {
return response()->json([
'message' => 'Unable to update entity',
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
The immediate benefit I derived was that I no more depended on the client to tell me which columns to select. All I needed to know was which model the user is interacting with on the client and that’s how I refactored my code.
Final Note
Remember, the point of this article is not to flex my design pattern or SOLID skills but to show how I used it to solve my unique problem in my code.
Subscribe to my newsletter
Read articles from Reuben Frimpong directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Reuben Frimpong
Reuben Frimpong
A Software engineer with passion for learning and experimenting. I love to build products and try out my business and marketing skills as well.