Introduction: Moving Beyond Controller-Centric Code
In the early stages of a web project, placing all database queries, validation rules, business calculations, and third-party notifications directly inside controller actions feels fast and productive. A controller handles an incoming HTTP request, fetches an Eloquent model, mutates state, and returns a view.
However, as business requirements expand—adding webhooks, scheduled background workers, API mobile endpoints, and complex scoring rules—this "Fat Controller" approach quickly degrades into tightly coupled, un-testable spaghetti code.
Modern PHP 8.3 OOP architecture provides clean, battle-tested design patterns that decouple business domain rules from HTTP transport layers.
In this guide, we explore how to implement Service Layers, the Strategy Pattern, the Repository Pattern, and Dependency Injection without falling into the trap of over-engineering.
1. The Strategy Pattern for Interchangeable Algorithms
The Strategy Pattern is an essential behavioral design pattern that defines a family of interchangeable algorithms, encapsulates each one into an isolated class, and makes them swappable at runtime.
Real-World Use Case: Dynamic Quiz Scoring Engines
Different quiz categories (e.g., Speed Rounds, Streak Tournaments, Standard Practice) may calculate final points using different scoring formulas:QuizSubmission ➔ ScoringStrategyContext ➔ Selects [SpeedScoringStrategy | StreakScoringStrategy] ➔ Evaluates Score
Step 1: Define the Contract Interface
namespace App\Contracts;
interface ScoringStrategyInterface
{
public function calculate(int $basePoints, float $timeElapsed, int $currentStreak): int;
}
Step 2: Implement Concrete Strategy Classes
namespace App\Services\Scoring;
use App\Contracts\ScoringStrategyInterface;
class StandardScoringStrategy implements ScoringStrategyInterface
{
public function calculate(int $basePoints, float $timeElapsed, int $currentStreak): int
{
return $basePoints;
}
}
class SpeedScoringStrategy implements ScoringStrategyInterface
{
public function calculate(int $basePoints, float $timeElapsed, int $currentStreak): int
{
$speedBonus = (int) max(0, (15 - $timeElapsed) * 2);
return $basePoints + $speedBonus;
}
}
class StreakMultiplierStrategy implements ScoringStrategyInterface
{
public function calculate(int $basePoints, float $timeElapsed, int $currentStreak): int
{
$multiplier = 1.0 + ($currentStreak * 0.1); // +10% per consecutive streak
return (int) ($basePoints * $multiplier);
}
}
Step 3: Implement the Strategy Factory & Resolver
namespace App\Services\Scoring;
use App\Contracts\ScoringStrategyInterface;
use InvalidArgumentException;
class ScoringStrategyFactory
{
public static function make(string $gameMode): ScoringStrategyInterface
{
return match ($gameMode) {
'speed' => new SpeedScoringStrategy(),
'streak' => new StreakMultiplierStrategy(),
'standard' => new StandardScoringStrategy(),
default => throw new InvalidArgumentException("Unsupported game mode: {$gameMode}"),
};
}
}
Using the Strategy Pattern, adding a new tournament scoring rule in the future requires creating a single isolated class without modifying existing controller or model code (adhering strictly to the Open/Closed Principle).
2. The Service Layer: Decoupling Domain Logic from HTTP
Controllers should be lean coordinators responsible solely for:
- Validating incoming HTTP request schemas.
- Delegating domain execution to an injected Service Class.
- Returning an HTTP response (HTML Blade view, Inertia prop array, or JSON payload).
Implementing a Clean QuizRoundService
namespace App\Services;
use App\Models\User;
use App\Models\Question;
use App\Services\Scoring\ScoringStrategyFactory;
use Illuminate\Support\Facades\DB;
class QuizRoundService
{
public function completeRound(User $user, array $answers, string $gameMode): array
{
$strategy = ScoringStrategyFactory::make($gameMode);
return DB::transaction(function () use ($user, $answers, $strategy) {
$totalPointsEarned = 0;
$correctCount = 0;
foreach ($answers as $item) {
$question = Question::findOrFail($item['question_id']);
$isCorrect = $question->correct_option_id === $item['selected_option_id'];
if ($isCorrect) {
$correctCount++;
$points = $strategy->calculate(
$question->base_points,
(float) $item['elapsed_seconds'],
$user->active_streak
);
$totalPointsEarned += $points;
}
}
// Update user stats atomically
$user->increment('lifetime_points', $totalPointsEarned);
$user->update([
'active_streak' => $correctCount === 5 ? $user->active_streak + 1 : 0,
]);
return [
'total_points' => $totalPointsEarned,
'correct_count' => $correctCount,
'new_lifetime_score' => $user->lifetime_points,
];
});
}
}
The Controller Stays Ultra-Thin:
namespace App\Http\Controllers;
use App\Http\Requests\SubmitRoundRequest;
use App\Services\QuizRoundService;
use Illuminate\Http\JsonResponse;
class QuizSubmissionController extends Controller
{
public function __construct(
private readonly QuizRoundService $quizService
) {}
public function __invoke(SubmitRoundRequest $request): JsonResponse
{
$result = $this->quizService->completeRound(
$request->user(),
$request->validated('answers'),
$request->validated('game_mode', 'standard')
);
return response()->json($result);
}
}
3. Practical Repository Pattern for Non-Database Data
The Repository Pattern abstracts the source of truth for retrieving entities. While putting Eloquent behind an abstract repository can sometimes add unnecessary boilerplate for simple CRUD, it is the ideal pattern for flat-file data sources, external APIs, and caching layers.
For example, our flat-file Markdown blog system in QuizRush uses App\Services\BlogRepository:
namespace App\Services;
use Illuminate\Support\Facades\File;
class BlogRepository
{
protected string $storagePath;
public function __construct()
{
$this->storagePath = base_path('content/blogs');
}
public function getAllArticles(): array
{
$files = File::glob($this->storagePath . '/*.md');
$articles = [];
foreach ($files as $file) {
$articles[] = $this->parseFile($file);
}
// Sort by publication date descending
usort($articles, fn($a, $b) => strcmp($b['date'], $a['date']));
return $articles;
}
public function findBySlug(string $slug): ?array
{
$filePath = $this->storagePath . '/' . $slug . '.md';
return File::exists($filePath) ? $this->parseFile($filePath) : null;
}
}
4. Constructor Property Promotion & Dependency Injection in PHP 8.3
PHP 8.3 makes Dependency Injection exceptionally concise using Constructor Property Promotion:
namespace App\Services;
use App\Contracts\LeaderboardLedgerInterface;
use App\Contracts\NotificationServiceInterface;
use Psr\Log\LoggerInterface;
class TournamentEngine
{
// Constructor Property Promotion with readonly type enforcement
public function __construct(
private readonly LeaderboardLedgerInterface $ledger,
private readonly NotificationServiceInterface $notifier,
private readonly LoggerInterface $logger
) {}
public function finalizeTournament(int $seasonId): void
{
$this->logger->info("Finalizing season {$seasonId}");
$podium = $this->ledger->calculateMonthlyWinners($seasonId);
$this->notifier->broadcastWinners($podium);
}
}
Modern PHP Architecture Checklist
- Enforce the Single Responsibility Principle (SRP): Keep controllers focused on HTTP; delegate business rules to Services.
- Use Strategy Pattern for Variant Logic: Encapsulate dynamic formulas and scoring variants into isolated strategy classes.
- Rely on Constructor Promotion: Inject dependencies with
public readonlyorprivate readonlytyping. - Apply Database Transactions: Wrap multi-table state mutations inside
DB::transaction()closures.