Każdy projekt w pewnym momencie się rozrasta. Nowe funkcjonalności, nowe wymagania, nowe edge case’y – to nieuniknione. Problem pojawia się wtedy, gdy każda taka zmiana wymaga otwierania i modyfikowania klas, które już działają poprawnie. Właśnie przed tym chroni nas zasada OCP.

Osobiście, kiedy zaczynam od małego projektu, często czuję pokusę w stylu – „o, tutaj to zrobię ze dwa if’y albo switcha i wszystko będzie super”. Na początku owszem, jest to mały „grzeszek”, jednak jak uczy nas choćby teoria chaosu, niewielki błąd na początku prac może w przyszłości doprowadzić do katastrofy. W dzisiejszych rozważaniach znów pochylę się nad aplikacją budżetową, w której znajduje się specjalna klasa Router, wykorzystująca middleware.

Dla uproszczenia załóżmy, że nasz Router sprawdza, czy na danym urządzeniu jest zalogowany użytkownik (co w PHP oznacza obecność obiektu user w supertablicy $_SESSION) i w zależności od tego będzie kierował na inną trasę (ang. route):

<?php

declare(strict_types=1);

namespace Framework;

// ❌ Router musi teraz znać szczegóły implementacji każdego middleware
use App\Exceptions\SessionException;

class Router
{
    private array $routes = [];
    private array $middlewares = [];
    private $errorHandler;

    // -------------------------------------------------------------------------
    // ❌ NARUSZENIE OCP: logika SessionMiddleware wbudowana w Router
    // -------------------------------------------------------------------------

    private function handleSession()
    {
        // ❌ Router zna szczegóły zarządzania sesją
        if (session_status() === PHP_SESSION_ACTIVE) {
            throw new SessionException("Session already started.");
        }

        if (headers_sent($filename, $line)) {
            throw new SessionException(
                "Headers already sent. Consider enabling output buffering. 
                Data outputted from {$filename}, - Line: {$line}"
            );
        }

        session_set_cookie_params([
            'secure'   => $_ENV['APP_ENV'] === 'production',
            'httponly' => true,
            'samesite' => 'lax'
        ]);

        session_start();
    }

    // -------------------------------------------------------------------------
    // ❌ NARUSZENIE OCP: logika AuthRequiredMiddleware wbudowana w Router
    // -------------------------------------------------------------------------

    private function handleAuth()
    {
        // ❌ Router zna szczegóły autoryzacji i reguły biznesowe
        if (empty($_SESSION['user'])) {
            redirectTo('/');
        }
    }

    // -------------------------------------------------------------------------
    // ❌ NARUSZENIE OCP: zamiast pętli po interfejsie – switch po nazwie
    // -------------------------------------------------------------------------

    private function processMiddleware(string $middlewareType)
    {
        // ❌ Każde nowe middleware = obowiązkowa modyfikacja tej metody
        switch ($middlewareType) {
            case 'session':
                $this->handleSession();
                break;
            case 'auth':
                $this->handleAuth();
                break;
            default:
                throw new \RuntimeException(
                    // ❌ Router musi znać listę wszystkich dostępnych middleware
                    "Nieznany typ middleware: {$middlewareType}. 
                    Dostępne typy: session, auth"
                );
        }
    }

    public function add(string $method, string $path, array $controller)
    {
        $path = $this->normalizePath($path);
        $regexPath = preg_replace('#{[^/]+}#', '([^/]+)', $path);

        $this->routes[] = [
            'path'        => $path,
            'method'      => strtoupper($method),
            'controller'  => $controller,
            // ❌ Middleware to nie klasy, a hardcodowane stringi
            'middlewares' => [],
            'regexPath'   => $regexPath
        ];
    }

    private function normalizePath(string $path): string
    {
        $path = trim($path, '/');
        $path = "/{$path}/";
        $path = preg_replace('#[/]{2,}#', '/', $path);

        return $path;
    }

    public function dispatch(string $path, string $method, Container $container = null)
    {
        $path   = $this->normalizePath($path);
        $method = strtoupper($_POST['_METHOD'] ?? $method);

        // ❌ Sesja uruchamiana bezpośrednio w dispatch zamiast przez middleware
        $this->handleSession();

        foreach ($this->routes as $route) {
            if (
                !preg_match("#^{$route['regexPath']}$#", $path, $paramValues) ||
                $route['method'] !== $method
            ) {
                continue;
            }

            array_shift($paramValues);
            preg_match_all('#{([^/]+)}#', $route['path'], $paramKeys);
            $paramKeys = $paramKeys[1];
            $params    = array_combine($paramKeys, $paramValues);

            [$class, $function] = $route['controller'];

            $controllerInstance = $container ?
                $container->resolve($class) :
                new $class;

            // ❌ Auth sprawdzany przez switch zamiast przez interfejs
            foreach ($route['middlewares'] as $middlewareType) {
                $this->processMiddleware($middlewareType);
            }

            $action = fn() => $controllerInstance->{$function}($params);

            // ❌ Globalne middleware też przez switch
            foreach ($this->middlewares as $middlewareType) {
                $this->processMiddleware($middlewareType);
            }

            $action();

            // ❌ Zamknięcie sesji hardcodowane w dispatch
            session_write_close();

            return;
        }

        $this->dispatchNotFound($container);
    }

    public function addMiddleware(string $middleware)
    {
        // ❌ Przyjmuje string 'session' lub 'auth' zamiast klasy
        $this->middlewares[] = $middleware;
    }

    public function addRouteMiddleware(string $middleware)
    {
        $lastRouteKey = array_key_last($this->routes);
        // ❌ Przyjmuje string 'session' lub 'auth' zamiast klasy
        $this->routes[$lastRouteKey]['middlewares'][] = $middleware;
    }

    public function setErrorHandler(array $controller)
    {
        $this->errorHandler = $controller;
    }

    public function dispatchNotFound(?Container $container)
    {
        [$class, $function] = $this->errorHandler;

        $controllerInstance = $container ?
            $container->resolve($class) :
            new $class;

        $action = fn() => $controllerInstance->$function();

        foreach ($this->middlewares as $middlewareType) {
            // ❌ Nawet obsługa błędów 404 musi przechodzić przez switch
            $this->processMiddleware($middlewareType);
        }

        $action();
    }
}

Dodatkowo, rejestrujemy ścieżki Routera w osobnym pliku:

// ❌ Zamiast klas – magiczne stringi bez autouzupełniania i type-hintów
$app->addMiddleware('session');

$app->get('/summary', [SummaryController::class, 'summaryViewCurrentMonth'])
    ->add('auth');

$app->get('/login', [AuthController::class, 'loginView'])
    ->add('guest');

Jak widzimy, Router zna całą logikę polegającą na zarządzaniu sesją oraz własnoręcznie sprawdza, czy jest zalogowany użytkownik. Teraz rodzi się pytanie, co jeśli zechcemy dodać middleware obsługujące komunikaty flash:

private function handleFlash()
{
    // ❌ Router bezpośrednio manipuluje danymi widoku
    $this->view->addGlobal('errors', $_SESSION['errors'] ?? []);
    unset($_SESSION['errors']);
    $this->view->addGlobal('oldFormData', $_SESSION['oldFormData'] ?? []);
    unset($_SESSION['oldFormData']);
}

Aby powyższe zadziałało, musimy dodatkowo zmodyfikować konstruktor Router’a:

// ❌ Router musi teraz znać TemplateEngine – zależność, która tu nie pasuje
public function __construct(private TemplateEngine $view) {}

Ne zapominajmy o switch’u:

private function processMiddleware(string $middlewareType)
{
    switch ($middlewareType) {
        case 'session':
            $this->handleSession();
            break;
        case 'auth':
            $this->handleAuth();
            break;
        // ❌ Kolejny case = kolejna modyfikacja Routera
        case 'flash':
            $this->handleFlash();
            break;
        default:
            throw new \RuntimeException(
                // ❌ Lista rośnie z każdym nowym middleware
                "Nieznany typ middleware: {$middlewareType}. 
                Dostępne typy: session, auth, flash"
            );
    }
}

Na koniec, rejestracja w pliku ze string’ami:

// ❌ Kolejny magiczny string dołączony do listy
function registerMiddleware(App $app)
{
    $app->addMiddleware('auth');
    $app->addMiddleware('session');
    $app->addMiddleware('flash');  // ❌ Musi być dokładnie 'flash', inaczej RuntimeException
}

Jak widzimy, dodanie jednej małej funkcjonalności zmusza nas do zmodyfikowania łącznie 4 miejsc w już istniejącym kodzie, co jest sprzeczne z zasadą OCP – klasy są otwarte na rozszerzanie, ale zamknięte na modyfikację. Oto cała nasza klasa Router:

<?php

declare(strict_types=1);

namespace Framework;

use App\Exceptions\SessionException;
use Framework\TemplateEngine;

class Router
{
    private array $routes = [];
    private array $middlewares = [];
    private $errorHandler;

    // ❌ Router musi znać TemplateEngine tylko ze względu na Flash
    public function __construct(private TemplateEngine $view) {}

    // -------------------------------------------------------------------------
    // ❌ NARUSZENIE OCP: logika SessionMiddleware wbudowana w Router
    // -------------------------------------------------------------------------

    private function handleSession()
    {
        if (session_status() === PHP_SESSION_ACTIVE) {
            throw new SessionException("Session already started.");
        }

        if (headers_sent($filename, $line)) {
            throw new SessionException(
                "Headers already sent. Consider enabling output buffering. 
                Data outputted from {$filename}, - Line: {$line}"
            );
        }

        session_set_cookie_params([
            'secure'   => $_ENV['APP_ENV'] === 'production',
            'httponly' => true,
            'samesite' => 'lax'
        ]);

        session_start();
    }

    // -------------------------------------------------------------------------
    // ❌ NARUSZENIE OCP: logika AuthRequiredMiddleware wbudowana w Router
    // -------------------------------------------------------------------------

    private function handleAuth()
    {
        if (empty($_SESSION['user'])) {
            redirectTo('/');
        }
    }

    // -------------------------------------------------------------------------
    // ❌ NARUSZENIE OCP: logika FlashMiddleware wbudowana w Router
    // ❌ Router musi teraz znać TemplateEngine i wiedzieć co to 'errors'
    // ❌ Router musi wiedzieć o strukturze sesji (klucze 'errors', 'oldFormData')
    // -------------------------------------------------------------------------

    private function handleFlash()
    {
        $this->view->addGlobal('errors', $_SESSION['errors'] ?? []);
        unset($_SESSION['errors']);
        $this->view->addGlobal('oldFormData', $_SESSION['oldFormData'] ?? []);
        unset($_SESSION['oldFormData']);
    }

    // -------------------------------------------------------------------------
    // ❌ NARUSZENIE OCP: każde nowe middleware = modyfikacja tej metody
    // -------------------------------------------------------------------------

    private function processMiddleware(string $middlewareType)
    {
        switch ($middlewareType) {
            case 'session':
                $this->handleSession();
                break;
            case 'auth':
                $this->handleAuth();
                break;
            case 'flash':
                $this->handleFlash();
                break;
            default:
                throw new \RuntimeException(
                    "Nieznany typ middleware: {$middlewareType}. 
                    Dostępne typy: session, auth, flash"
                );
        }
    }

    public function add(string $method, string $path, array $controller)
    {
        $path = $this->normalizePath($path);
        $regexPath = preg_replace('#{[^/]+}#', '([^/]+)', $path);

        $this->routes[] = [
            'path'        => $path,
            'method'      => strtoupper($method),
            'controller'  => $controller,
            'middlewares' => [],
            'regexPath'   => $regexPath
        ];
    }

    private function normalizePath(string $path): string
    {
        $path = trim($path, '/');
        $path = "/{$path}/";
        $path = preg_replace('#[/]{2,}#', '/', $path);

        return $path;
    }

    public function dispatch(string $path, string $method, Container $container = null)
    {
        $path   = $this->normalizePath($path);
        $method = strtoupper($_POST['_METHOD'] ?? $method);

        // ❌ Kolejność middleware hardcodowana w dispatch
        // ❌ Sesja musi być pierwsza, bo Flash zależy od sesji –
        //    ale Router sam musi o tym wiedzieć i pilnować kolejności
        $this->handleSession();
        $this->handleFlash();

        foreach ($this->routes as $route) {
            if (
                !preg_match("#^{$route['regexPath']}$#", $path, $paramValues) ||
                $route['method'] !== $method
            ) {
                continue;
            }

            array_shift($paramValues);
            preg_match_all('#{([^/]+)}#', $route['path'], $paramKeys);
            $paramKeys = $paramKeys[1];
            $params    = array_combine($paramKeys, $paramValues);

            [$class, $function] = $route['controller'];

            $controllerInstance = $container ?
                $container->resolve($class) :
                new $class;

            foreach ($route['middlewares'] as $middlewareType) {
                // ❌ Auth sprawdzany przez switch zamiast przez interfejs
                $this->processMiddleware($middlewareType);
            }

            $action = fn() => $controllerInstance->{$function}($params);

            foreach ($this->middlewares as $middlewareType) {
                $this->processMiddleware($middlewareType);
            }

            $action();

            // ❌ Zamknięcie sesji hardcodowane – nie można tego wyłączyć
            //    bez modyfikacji Routera
            session_write_close();

            return;
        }

        $this->dispatchNotFound($container);
    }

    public function addMiddleware(string $middleware)
    {
        $this->middlewares[] = $middleware;
    }

    public function addRouteMiddleware(string $middleware)
    {
        $lastRouteKey = array_key_last($this->routes);
        $this->routes[$lastRouteKey]['middlewares'][] = $middleware;
    }

    public function setErrorHandler(array $controller)
    {
        $this->errorHandler = $controller;
    }

    public function dispatchNotFound(?Container $container)
    {
        [$class, $function] = $this->errorHandler;

        $controllerInstance = $container ?
            $container->resolve($class) :
            new $class;

        $action = fn() => $controllerInstance->$function();

        foreach ($this->middlewares as $middlewareType) {
            $this->processMiddleware($middlewareType);
        }

        $action();
    }
}

Zawiera dopiero 3 middleware’y, a już jest ogromny, a co jeszcze z zabezpieczeniami przez CSRF, blokadą dla niezalogowanych, walidacją itp.? Co więcej, nasz Router narusza już nie tylko OCP, ale także SRP, czyli pierwszą literkę z SOLID (pewnie jeszcze kilka innych).

Jak to zatem uporządkować? Posłużymy się interfejsami. Na początku tworzymy interfejs MiddlewareInterface:

<?php

declare(strict_types=1);

namespace Framework\Contracts;

interface MiddlewareInterface
{
      public function process(callable $next);
}

Następnie całą logikę każdego middleware przeprowadzamy do osobnej klasy, poniżej przykłady AuthRequiredMiddleware:

<?php

declare(strict_types=1);

namespace App\Middleware;

use Framework\Contracts\MiddlewareInterface;

class AuthRequiredMiddleware implements MiddlewareInterface
{
      public function process(callable $next)
      {
            if (empty($_SESSION['user'])) {
                  redirectTo('/');
            }

            $next();
      }
}

SessionMiddleware:

<?php

declare(strict_types=1);

namespace App\Middleware;

use Framework\Contracts\MiddlewareInterface;
use App\Exceptions\SessionException;

class SessionMiddleware implements MiddlewareInterface
{
      public function process($next)
      {

            if (session_status() === PHP_SESSION_ACTIVE) {
                  throw new SessionException("Session already started.");
            }

            if (headers_sent($filename, $line)) {
                  throw new SessionException("Headers already sent. Consider enabling output buffering. Data outputted from {$filename}, - Line: {$line}");
            }

            session_set_cookie_params(([
                  'secure' => $_ENV['APP_ENV'] === 'production',
                  'httponly' => true,
                  'samesite' => 'lax'
            ]));

            session_start();

            $next();

            session_write_close();
      }
}

FlashMiddleware:

<?php

declare(strict_types=1);

namespace App\Middleware;

use Framework\Contracts\MiddlewareInterface;
use Framework\TemplateEngine;

class FlashMiddleware implements MiddlewareInterface
{
      public function __construct(private TemplateEngine $view) {}

      public function process(callable $next)
      {
            $this->view->addGlobal('errors', $_SESSION['errors'] ?? []);
            unset($_SESSION['errors']);
            $this->view->addGlobal('oldFormData', $_SESSION['oldFormData'] ?? []);
            unset($_SESSION['oldFormData']);
            $next();
      }
}

Jak widzimy, każda klasa middleware otrzymała dokładnie jedną odpowiedzialność i implementuje ten sam interfejs MiddlewareInterface. To właśnie on jest kluczem do rozwiązania problemu – Router nie musi już wiedzieć, co kryje się w środku każdego middleware. Wie tylko tyle, że może wywołać metodę process(), a resztą zajmie się już konkretna klasa. Co więcej, zależność od TemplateEngine przeniosła się tam, gdzie jest jej miejsce – z Routera do FlashMiddleware. Każda klasa zna tylko to, czego naprawdę potrzebuje.

Dzięki temu nasz Router wygląda teraz następująco:

<?php

declare(strict_types=1);

namespace Framework;

class Router
{

      private array $routes = [];
      private array $middlewares = [];
      private $errorHandler;

      public function add(string $method, string $path, array $controller)
      {
            $path = $this->normalizePath($path);

            $regexPath = preg_replace('#{[^/]+}#', '([^/]+)', $path);

            $this->routes[] = [
                  'path' => $path,
                  'method' => strtoupper($method),
                  'controller' => $controller,
                  'middlewares' => [],
                  'regexPath' => $regexPath
            ];
      }

      private function normalizePath(string $path): string
      {
            $path = trim($path, '/');
            $path = "/{$path}/";
            $path = preg_replace('#[/]{2,}#', '/', $path);

            return $path;
      }

      public function dispatch(string $path, string $method, Container $container = null)
      {
            $path = $this->normalizePath($path);
            $method = strtoupper($_POST['_METHOD'] ?? $method);

            foreach ($this->routes as $route) {
                  if (
                        !preg_match("#^{$route['regexPath']}$#", $path, $paramValues) ||
                        $route['method'] !== $method
                  ) {
                        continue;
                  }

                  array_shift($paramValues);

                  preg_match_all('#{([^/]+)}#', $route['path'], $paramKeys);

                  $paramKeys = $paramKeys[1];

                  $params = array_combine($paramKeys, $paramValues);

                  [$class, $function] = $route['controller'];

                  $controllerInstance = $container ?
                        $container->resolve($class) :
                        new $class;


                  $action = fn() => $controllerInstance->{$function}($params);

                  $allMiddleware = [...$route['middlewares'], ...$this->middlewares];

                  foreach ($allMiddleware as $middleware) {
                        $middlewareInstance = $container ? $container->resolve($middleware) : new $middleware;
                        $action = fn() => $middlewareInstance->process($action);
                  }

                  $action();

                  return;
            }

            $this->dispatchNotFound($container);
      }

      public function addMiddleware(string $middleware)
      {
            $this->middlewares[] = $middleware;
      }

      public function addRouteMiddleware(string $middleware)
      {
            $lastRouteKey = array_key_last($this->routes);
            $this->routes[$lastRouteKey]['middlewares'][] = $middleware;
      }

      public function setErrorHandler($controller)
      {
            $this->errorHandler = $controller;
      }

      public function dispatchNotFound(?Container $container)
      {
            [$class, $function] = $this->errorHandler;

            $controllerInstance = $container ? $container->resolve($class) : new $class;

            $action = fn() => $controllerInstance->$function();

            foreach ($this->middlewares as $middleware) {
                  $middlewareInstance = $container ? $container->resolve($middleware) : new $middleware;
                  $action = fn() => $middlewareInstance->process($action);
            }

            $action();
      }
}

Teraz dodanie każdego nowego middleware nie modyfikuje Router’a, tworzymy nową klasę i rejestrujemy je w pliku Middleware.php:

<?php

declare(strict_types=1);

namespace App\Config;

use Framework\App;
use App\Middleware\{
      TemplateDataMiddleware,
      ValidationExceptionMiddleware,
      SessionMiddleware,
      FlashMiddleware,
      CsrfTokenMiddleware,
      CsrfGuardMiddleware
};

function registerMiddleware(App $app)
{
      $app->addMiddleware(CsrfGuardMiddleware::class);
      $app->addMiddleware(CsrfTokenMiddleware::class);
      $app->addMiddleware(ValidationExceptionMiddleware::class);
      $app->addMiddleware(FlashMiddleware::class);
      $app->addMiddleware(TemplateDataMiddleware::class);
      $app->addMiddleware(SessionMiddleware::class);
}

Na koniec wypiszmy sobie problemy, jakie napotkaliśmy przy modyfikowaniu klasy Router:

KrokCo dodanoCo trzeba było zmienić w Router
Startbrak middlewarenic
SessionMiddlewarehandleSession()Konstruktor, processMiddleware(), dispatch()
AuthMiddlewarehandleAuth()processMiddleware()
FlashMiddlewarehandleFlash()Konstruktor
(nowa zależność!), processMiddleware(), dispatch()
Następne middlewarehandleX()processMiddleware() – za każdym razem

Dzięki naszym poprawkom, powyższe problemy już nie istnieją.

Powyższy przykład pokazuje, że zasady SOLID nie są czymś, co obowiązuje w odosobnieniu, często reguły te przeplatają się, więc zła konstrukcja klasy potrafi złamać ich kilka na raz. W następnym wpisie skoncentrujemy się na literce L – Liskov Substitution Principle.