SRP says a class should have one reason to change. That does not mean one method per class or one line. It means one job. If a database change, a new JSON format, and a new validation rule all force you to edit the same class, that class has too many responsibilities.
Important Note : SRP does not mean "one method per class"
SRP doesn't mean that every class should contain only one method. A class can have several methods as long as they belong to the same responsibility and have the same reason to change.
The Problem
Consider the following class :
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Wallet;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class WalletBalanceController extends Controller
{
/**
* Return the authenticated user's current balance.
*/
public function __invoke(Request $request): JsonResponse
{
$wallet = $request->user()->wallet()->firstOrCreate([], [
'balance' => 0,
'currency' => Wallet::CURRENCY,
]);
return ApiResponse::make([
'balance' => $wallet->balance,
'currency' => $wallet->currency,
]);
}
}
First let's see what the WalletBalanceController does :
The
WalletBalanceControllercontroller is an invokable controller, so Laravel calls__invoke()when this endpoint is requested.firstOrCreate()method looks for the user's wallet and creates one with a zero balance if it doesn't exist.ApiResponseis a project-specific helper used here to standarddize API responses.
The WalletBalanceController controller is responsible for handling the HTTP request, but it also contains the wallet's creation rule. If that rule changes, the controller needs to change too.
The fix: one job per class
1. The controller handles only HTTP
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Wallet;
use App\Services\WalletService;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class WalletBalanceController extends Controller
{
/**
* Return the authenticated user's current balance.
*/
public function __invoke(Request $request, WalletService $wallets): JsonResponse
{
$wallet = $wallets->findOrCreate($request->user());
return ApiResponse::make([
'balance' => $wallet->balance,
'currency' => $wallet->currency ?? Wallet::CURRENCY,
]);
}
}
The controller now reads the request, gets the wallet from a service, and returns the response. Nothing else.
2. A service owns the business rules
<?php
namespace App\Services;
use App\Exceptions\InsufficientFundsException;
use App\Models\LedgerEntry;
use App\Models\User;
use App\Models\Wallet;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class WalletService
{
/**
* Return the user's wallet, creating an empty rial wallet when one does not exist.
*/
public function findOrCreate(User $user): Wallet
{
return $user->wallet()->firstOrCreate([], [
'balance' => 0,
'currency' => Wallet::CURRENCY,
]);
}
WalletService gets the user's wallet and creates an empty wallet if no wallet exists.
A Quick Test
Describe the class in one sentence without the word "and". "This controller returns the user's balance" passes. "This controller validates the transfer and updates the wallets and formats the response" fails.
Conclusion
With these responsibilities separated, the controllers stay small, the wallet rules live in one place, and each part can be tested on its own. How do you apply SRP in your Laravel projects?
Top comments (0)