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:
| Krok | Co dodano | Co trzeba było zmienić w Router |
| Start | brak middleware | nic |
| SessionMiddleware | handleSession() | Konstruktor, processMiddleware(), dispatch() |
| AuthMiddleware | handleAuth() | processMiddleware() |
| FlashMiddleware | handleFlash() | Konstruktor (nowa zależność!), processMiddleware(), dispatch() |
| Następne middleware | handleX() | 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.

