Budowa lekkiego frameworka PHP na komponentach Symfony
Pełne frameworki takie jak Symfony czy Laravel są bardzo potężne, ale w wielu projektach okazują się zbyt ciężkie. Jeśli potrzebujemy prostego backendu do niewielkiej aplikacji, mikroserwisu lub narzędzia wewnętrznego, dobrym rozwiązaniem jest stworzenie lekkiego frameworka opartego na komponentach Symfony.
W tym artykule pokażę jak zbudować mini-framework, który:
- korzysta z komponentów Symfony
- używa czystego PHP w widokach
- posiada konfigurację w PHP
- wspiera routing
- obsługuje kontener DI
- posiada middleware
Całość pozostaje bardzo lekka i łatwa do rozszerzania.
Założenia architektury
Framework będzie oparty na kilku kluczowych komponentach Symfony:
- symfony/http-foundation – obsługa Request i Response
- symfony/routing – system routingu
- symfony/dependency-injection – kontener usług
Dzięki temu wykorzystujemy sprawdzone elementy ekosystemu Symfony bez konieczności instalowania całego frameworka.
Struktura projektu
Proponowana struktura katalogów:
project/
│
├─ public/
│ └─ index.php
│
├─ config/
│ ├─ routes.php
│ ├─ services.php
│ └─ middleware.php
│
├─ src/
│ ├─ Controller/
│ ├─ Middleware/
│ └─ Core/
│ └─ Middleware/
│
├─ templates/
│
└─ vendor/
Najważniejsze elementy aplikacji znajdują się w katalogu src/Core.
Punkt wejścia aplikacji
Plik public/index.php uruchamia aplikację.
require __DIR__.'/../vendor/autoload.php';
use App\Core\App;
$app = new App(__DIR__.'/../config');
$response = $app->handle();
$response->send();
To tutaj tworzona jest instancja aplikacji oraz obsługiwany jest request.
Klasa aplikacji
Główna logika znajduje się w klasie App.
Jej zadaniem jest:
- utworzenie kontenera DI
- dopasowanie trasy
- uruchomienie middleware
- wywołanie kontrolera
Schemat działania wygląda następująco:
Request
↓
Router
↓
Middleware
↓
Controller
↓
Response
Kod src/Core/App.php:
namespace App\Core;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use App\Core\Middleware\MiddlewareDispatcher;
class App
{
private $container;
private $router;
public function __construct(private string $configPath)
{
$this->container = ContainerFactory::create($configPath);
$this->router = new Router(require $configPath.'/routes.php');
}
public function handle(): Response
{
$request = Request::createFromGlobals();
$route = $this->router->match($request);
$controller = $this->container->get($route['controller']);
$controllerCallable = function (Request $request) use ($controller, $route) {
return call_user_func([$controller, $route['method']], $request);
};
$globalMiddleware = require $this->configPath.'/middleware.php';
$routeMiddleware = $route['middleware'] ?? [];
$middlewareClasses = array_merge(
$globalMiddleware,
$routeMiddleware
);
$middlewares = array_map(
fn($m) => $this->container->get($m),
$middlewareClasses
);
$dispatcher = new MiddlewareDispatcher($middlewares);
return $dispatcher->handle($request, $controllerCallable);
}
}
Routing
Trasy definiujemy w pliku config/routes.php.
use App\Controller\HomeController;
use App\Middleware\AuthMiddleware;
return [
'home' => [
'path' => '/',
'controller' => HomeController::class,
'method' => 'index',
'middleware' => [
AuthMiddleware::class
]
]
];
Każda trasa zawiera:
- ścieżkę URL
- klasę kontrolera
- metodę kontrolera
- opcjonalną listę middleware
Router wykorzystuje komponent Symfony Routing, który dopasowuje request do odpowiedniej trasy.
Kod routera src/Core/Router.php:
namespace App\Core;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
use Symfony\Component\Routing\Matcher\UrlMatcher;
use Symfony\Component\Routing\RequestContext;
class Router
{
private UrlMatcher $matcher;
public function __construct(array $routes)
{
$collection = new RouteCollection();
foreach ($routes as $name => $r) {
$collection->add($name, new Route(
$r['path'],
[
'controller' => $r['controller'],
'method' => $r['method'],
'middleware' => $r['middleware'] ?? [],
]
));
}
$context = new RequestContext();
$this->matcher = new UrlMatcher($collection, $context);
}
public function match(Request $request): array
{
$this->matcher->getContext()->fromRequest($request);
return $this->matcher->match($request->getPathInfo());
}
}
Kontener Dependency Injection
Konfiguracja usług znajduje się w config/services.php.
use Symfony\Component\DependencyInjection\Reference;
return [
App\Controller\HomeController::class => [
'public' => true,
'arguments' => [
new Reference(App\Core\View::class)
]
],
App\Core\View::class => [
'arguments' => [
__DIR__.'/../templates'
]
],
App\Middleware\AuthMiddleware::class => [
'public' => true
],
];
Kontener pozwala na łatwe wstrzykiwanie zależności pomiędzy komponentami aplikacji.
Kod Container Factory z src/Core/ContainerFactory.php:
namespace App\Core;
use Symfony\Component\DependencyInjection\ContainerBuilder;
class ContainerFactory
{
public static function create(string $configPath)
{
$container = new ContainerBuilder();
$services = require $configPath.'/services.php';
foreach ($services as $id => $config) {
$def = $container->register($id);
if (!empty($config['arguments'])) {
$def->setArguments($config['arguments']);
}
if (!empty($config['public'])) {
$def->setPublic(true);
}
}
$container->compile();
return $container;
}
}
System widoków
Widoki są napisane w czystym PHP, bez dodatkowego silnika templatingowego.
Przykładowy plik widoku:
<h1>Hello <?= htmlspecialchars($name) ?></h1>
Renderowanie odbywa się w klasie View, która ładuje plik template i przekazuje dane.
To rozwiązanie jest bardzo szybkie i nie wymaga dodatkowych bibliotek.
Kod klasy View z src/Core/View.php:
namespace App\Core;
class View
{
public function __construct(
private string $path
) {}
public function render(string $template, array $data = []): string
{
extract($data);
ob_start();
require $this->path.'/'.$template.'.php';
return ob_get_clean();
}
}
Kontrolery
Kontroler obsługuje logikę konkretnej trasy.
Przykład:
namespace App\Controller;
use App\Core\View;
use Symfony\Component\HttpFoundation\Response;
class HomeController
{
public function __construct(
private View $view
) {}
public function index()
{
return new Response(
$this->view->render('home', [
'name' => 'World'
])
);
}
}
Kontroler otrzymuje zależności z kontenera DI.
Middleware
Middleware pozwala przechwycić request przed kontrolerem.
Przykładowy middleware:
class AuthMiddleware implements MiddlewareInterface
{
public function process(Request $request, callable $next): Response
{
if (!$request->headers->get('X-AUTH')) {
return new Response('Unauthorized', 401);
}
return $next($request);
}
}
Middleware może:
- zatrzymać request
- zmodyfikować request
- zmodyfikować response
Kod src/Code/Middleware/MiddlewareInterface.php:
namespace App\Core\Middleware;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
interface MiddlewareInterface
{
public function process(Request $request, callable $next): Response;
}
Kod src/Core/Middleware/MiddlewareDispatcher.php:
namespace App\Core\Middleware;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class MiddlewareDispatcher
{
private array $middlewares;
public function __construct(array $middlewares)
{
$this->middlewares = $middlewares;
}
public function handle(Request $request, callable $controller): Response
{
$next = $controller;
foreach (array_reverse($this->middlewares) as $middleware) {
$next = function (Request $request) use ($middleware, $next) {
return $middleware->process($request, $next);
};
}
return $next($request);
}
}
Pipeline middleware
Request przechodzi przez wszystkie middleware w kolejności:
Request
↓
RequestLogger
↓
AuthMiddleware
↓
Controller
↓
Response
Framework obsługuje dwa poziomy middleware:
- globalne – dla całej aplikacji
- per-route – tylko dla wybranej trasy
Zalety takiego podejścia
Budowa własnego mikro-frameworka daje kilka korzyści:
- bardzo szybkie uruchamianie aplikacji
- pełna kontrola nad architekturą
- brak zbędnych zależności
- możliwość stopniowego rozwoju frameworka
Rozmiar całego kodu frameworka to zazwyczaj kilkaset linii.
Możliwe rozszerzenia
Jeśli projekt zacznie rosnąć, framework można łatwo rozbudować o:
- autowiring kontenera
- routing przez atrybuty PHP
- generowanie URL
- middleware groups
- system eventów
- CLI (symfony/console)
Dzięki temu stopniowo możemy uzyskać architekturę zbliżoną do pełnego frameworka, zachowując jednocześnie lekkość rozwiązania.
Podsumowanie
Komponenty Symfony pozwalają bardzo szybko zbudować własny framework dopasowany do potrzeb projektu.
Dzięki wykorzystaniu HttpFoundation, Routing oraz DependencyInjection otrzymujemy solidne fundamenty, a jednocześnie unikamy złożoności pełnego frameworka.
Takie podejście świetnie sprawdza się w:
- małych aplikacjach webowych
- mikroserwisach
- narzędziach wewnętrznych
- projektach edukacyjnych
Jednocześnie pozostawia dużą swobodę w dalszym rozwijaniu architektury.
Pobierz kod źródłowy mini frameworka.