Please wait while the page loads.
Skip to main content
PHP

Modern PHP OOP Patterns for Scalable Web Apps

Sophia Chen
Aug 07, 2026
11 min read

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:

  1. Validating incoming HTTP request schemas.
  2. Delegating domain execution to an injected Service Class.
  3. 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 readonly or private readonly typing.
  • Apply Database Transactions: Wrap multi-table state mutations inside DB::transaction() closures.

Frequently Asked Questions (FAQ)

What is the difference between a Service and a Repository?

A Repository handles data retrieval and persistence (abstracting where and how data is stored). A Service contains business logic, orchestrates transactions, applies calculations, and coordinates multiple repositories and notification channels.

Does using the Strategy Pattern affect runtime performance?

No. Instantiating a small strategy object in PHP memory has negligible overhead ($<0.01\text{ms}$) while providing massive architectural flexibility and testability.

Why should I avoid placing business logic in Eloquent Models?

While models should define relationships, scopes, and simple attribute mutators, embedding complex multi-model business workflows inside models leads to "Fat Models" that are difficult to isolate and test independently.

How does Dependency Injection improve automated testing?

By injecting interfaces into service constructors, you can easily mock dependencies (such as external payment gateways or notification dispatchers) during PHPUnit and Pest test runs.
Sophia Chen

Software Architecture Consultant — Passionate about trivia strategy, speed mechanics, and competitive player rankings.

Keep Reading

Related Guides & Tips

Platform 10 min read

Understanding Monthly Leaderboard & Level Unlocks

A comprehensive breakdown of how monthly points reset, how the dual-ledger leaderboard functions, rank multipliers, and how to unlock exclusive level badges.