Проектируем фреймворк на PHP: от идеи до реализации

Раздел: Архитектура приложений -> Архитектура PHP-приложений

В этой статье рассматривается процесс создания собственного PHP-фреймворка. Основное внимание уделяется архитектурным решениям, которые помогают строить гибкие, поддерживаемые приложения. Приводится несколько подходов, от простого маршрутизатора до полноценного MVC-фреймворка с контейнером зависимостей. Каждый вариант сопровождается вопросом, на который он отвечает, и типичными проблемами.

Подходы к созданию фреймворка

Как организовать минимальную маршрутизацию без сторонних библиотек?

Первый вариант: микрофреймворк на замыканиях. Всё строится вокруг простого роутера, который сопоставляет URI с анонимными функциями. Такой подход подходит для очень маленьких проектов или прототипов, где не нужна сложная логика.


// index.php
$routes = [];
$routes['GET']['/'] = function () { echo 'Главная'; };
$routes['GET']['/user/{id}'] = function ($id) { echo "Пользователь $id"; };

$method = $_SERVER['REQUEST_METHOD'];
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

foreach ($routes[$method] ?? [] as $pattern => $handler) {
    $pattern = preg_replace('/\{([a-z]+)\}/', '(?P<$1>[^/]+)', $pattern);
    if (preg_match('#^' . $pattern . '$#', $uri, $matches)) {
        $handler(...array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY));
        exit;
    }
}
http_response_code(404);
echo '404';

Php создание фреймворка (создание фреймворка php)

Проблемы: отсутствие поддержки middleware, нет DI-контейнера, сложно расширять. Ошибка: если не экранировать слэши в регулярном выражении, роутинг сломается. Решение: использовать rawurldecode и экранирование.

Как реализовать обработку запросов через цепочку middleware на основе PSR-15?

Второй вариант: фреймворк с использованием PSR-7 (HTTP messages) и PSR-15 (HTTP handlers). Позволяет строить приложения в виде цепочки middleware.


use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

class RouterMiddleware implements RequestHandlerInterface
{
    public function handle(ServerRequestInterface $request): \Psr\Http\Message\ResponseInterface
    {
        // разбор URI, выбор обработчика
        $handler = $routes[$request->getMethod()][$path] ?? null;
        if (!$handler) {
            return new Response(404, [], 'Not Found');
        }
        return $handler($request);
    }
}

// Входная точка
$request = \Laminas\Diactoros\ServerRequestFactory::fromGlobals();
$pipeline = new \Middlewares\Utils\Stack($router, [
    new ErrorMiddleware(),
    new AuthMiddleware()
]);
$response = $pipeline->handle($request);
(new \Laminas\HttpHandlerRunner\Emitter\SapiEmitter())->emit($response);

Проблема: выбор совместимых пакетов (например, laminas-diactoros для Request/Response). Типичная ошибка: забыть реализовать интерфейс RequestHandlerInterface и получить фатальную ошибку. Решение: всегда проверять сигнатуры методов.

Как построить полноценный MVC-фреймворк с контейнером зависимостей?

Основное и наиболее эффективное решение - создание фреймворка с поддержкой внедрения зависимостей (DI), маршрутизации, контроллеров и шаблонизатора. Это даёт максимальную гибкость и тестируемость.

Шаги реализации:

  1. Создать файловую структуру: app/Controllers, app/Models, app/Views, core (ядро), public/index.php.
  2. Реализовать автозагрузку через Composer или собственный spl_autoload_register.
  3. Создать класс Router, который хранит маршруты и умеет вызывать контроллеры.
  4. Создать Container с поддержкой autowiring или простого разрешения зависимостей через Reflection.
  5. Подключить шаблонизатор (например, самописный простой php-шаблонизатор).
  6. Обработка ошибок и исключений.

Пример простого контейнера:


class Container
{
    private array $bindings = [];
    private array $instances = [];

    public function set(string $abstract, callable $factory): void
    {
        $this->bindings[$abstract] = $factory;
    }

    public function get(string $abstract)
    {
        if (isset($this->instances[$abstract])) {
            return $this->instances[$abstract];
        }
        if (!isset($this->bindings[$abstract])) {
            // autowiring через Reflection
            $ref = new ReflectionClass($abstract);
            $constructor = $ref->getConstructor();
            if (!$constructor) {
                $this->instances[$abstract] = $ref->newInstance();
                return $this->instances[$abstract];
            }
            $params = [];
            foreach ($constructor->getParameters() as $param) {
                $type = $param->getType();
                if ($type && !$type->isBuiltin()) {
                    $params[] = $this->get($type->getName());
                }
            }
            $this->instances[$abstract] = $ref->newInstanceArgs($params);
            return $this->instances[$abstract];
        }
        $this->instances[$abstract] = call_user_func($this->bindings[$abstract], $this);
        return $this->instances[$abstract];
    }
}

Пример использования в контроллере:


class UserController {
    private $userRepository;
    public function __construct(UserRepository $userRepository) {
        $this->userRepository = $userRepository;
    }
    public function show(int $id) {
        $user = $this->userRepository->find($id);
        // рендеринг шаблона
    }
}

Проблемы и ошибки:

  • Циклические зависимости (например, A зависит от B, B от A). Решение: внедрить интерфейсы и использовать абстракции.
  • Reflection может быть медленным. Решение: кешировать зависимости (например, в файл при development, в память при production).
  • Отсутствие чёткой маршрутизации (например, /user/1 и /user/create конфликтуют). Решение: использовать именованные параметры и порядок маршрутов.

Как сделать фреймворк на основе готовых компонентов (Symfony Components)?

Четвёртый вариант: собрать фреймворк из отдельных библиотек Symfony (HttpKernel, Routing, DependencyInjection). Это компромисс между написанием всего с нуля и использованием монолитного Symfony.


use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
use Symfony\Component\Routing\Matcher\UrlMatcher;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\HttpFoundation\Request;

$routes = new RouteCollection();
$routes->add('home', new Route('/', ['_controller' => 'App\Controller\HomeController::index']));
$routes->add('user_show', new Route('/user/{id}', ['_controller' => 'App\Controller\UserController::show']));

$request = Request::createFromGlobals();
$context = new RequestContext();
$context->fromRequest($request);
$matcher = new UrlMatcher($routes, $context);

try {
    $parameters = $matcher->match($request->getPathInfo());
    list($class, $method) = explode('::', $parameters['_controller']);
    $controller = new $class();
    echo $controller->$method($parameters['id'] ?? null);
} catch (\Symfony\Component\Routing\Exception\ResourceNotFoundException $e) {
    echo '404';
}

Проблемы: необходимость загружать много зависимостей, высокая сложность конфигурации. Ошибка: забыть установить пакет symfony/routing. Решение: всегда проверять composer.json.

Расширенные примеры реализации компонентов фреймворка

Реализация маршрутизатора с поддержкой групп и middleware

Ниже показан более продвинутый роутер, который поддерживает группы маршрутов с общим middleware.

Пример

class Router
{
    private array $routes = [];
    private array $groupStack = [];

    public function group(array $attributes, callable $callback): void
    {
        $this->groupStack[] = $attributes;
        call_user_func($callback, $this);
        array_pop($this->groupStack);
    }

    public function add(string $method, string $path, callable $handler, array $middleware = []): void
    {
        $prefix = '';
        $groupMiddleware = [];
        foreach ($this->groupStack as $group) {
            $prefix .= $group['prefix'] ?? '';
            $groupMiddleware = array_merge($groupMiddleware, $group['middleware'] ?? []);
        }
        $fullPath = $prefix . $path;
        $this->routes[] = [
            'method' => $method,
            'path' => $fullPath,
            'handler' => $handler,
            'middleware' => array_merge($groupMiddleware, $middleware)
        ];
    }

    public function dispatch(string $method, string $uri): void
    {
        foreach ($this->routes as $route) {
            if ($route['method'] !== $method) continue;
            $pattern = preg_replace('/\{([a-z]+)\}/', '(?P<$1>[^/]+)', $route['path']);
            if (preg_match('#^' . $pattern . '$#', $uri, $matches)) {
                $params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
                // Выполнение middleware
                $stack = $route['middleware'];
                $handler = $route['handler'];
                $next = function ($request) use ($handler, $params) {
                    return $handler($request, ...$params);
                };
                while ($middleware = array_pop($stack)) {
                    $next = function ($request) use ($middleware, $next) {
                        return $middleware($request, $next);
                    };
                }
                $next(['uri' => $uri, 'method' => $method]);
                return;
            }
        }
        throw new \RuntimeException('Route not found');
    }
}

// Использование
$router = new Router();
$router->group(['prefix' => '/admin', 'middleware' => ['AuthMiddleware']], function ($r) {
    $r->add('GET', '/dashboard', function ($req) { echo 'Admin dashboard'; });
});
$router->add('GET', '/', function ($req) { echo 'Home'; });
try {
    $router->dispatch('GET', '/admin/dashboard');
} catch (\RuntimeException $e) {
    echo '404';
}

Результат: при запросе GET /admin/dashboard будет выполнен AuthMiddleware, затем обработчик. Без авторизации можно выдать перенаправление.

Результат (при отсутствии авторизации):
// Допустим, AuthMiddleware перенаправляет на /login
// Вывод может быть пустым или 302 редирект

Создание контейнера с поддержкой фабрик и псевдонимов

Расширенная версия DI-контейнера включает регистрацию фабрик, общих экземпляров (singleton) и псевдонимов.

Пример

class Container
{
    private array $definitions = [];
    private array $shared = [];
    private array $aliases = [];

    public function set(string $id, $definition, bool $shared = false): void
    {
        if (is_string($definition) && class_exists($definition)) {
            $definition = function (Container $c) use ($definition) {
                return $c->build($definition);
            };
        }
        $this->definitions[$id] = ['factory' => $definition, 'shared' => $shared];
    }

    public function alias(string $alias, string $original): void
    {
        $this->aliases[$alias] = $original;
    }

    public function get(string $id)
    {
        $id = $this->aliases[$id] ?? $id;
        if (isset($this->shared[$id])) {
            return $this->shared[$id];
        }
        if (!isset($this->definitions[$id])) {
            return $this->build($id); // autowire
        }
        $obj = call_user_func($this->definitions[$id]['factory'], $this);
        if ($this->definitions[$id]['shared']) {
            $this->shared[$id] = $obj;
        }
        return $obj;
    }

    private function build(string $class)
    {
        $ref = new ReflectionClass($class);
        $constructor = $ref->getConstructor();
        if (!$constructor) {
            return $ref->newInstance();
        }
        $params = [];
        foreach ($constructor->getParameters() as $param) {
            $type = $param->getType();
            if ($type && !$type->isBuiltin()) {
                $params[] = $this->get($type->getName());
            } elseif ($param->isDefaultValueAvailable()) {
                $params[] = $param->getDefaultValue();
            } else {
                throw new \RuntimeException("Cannot resolve parameter \${$param->getName()} for $class");
            }
        }
        return $ref->newInstanceArgs($params);
    }
}

// Пример использования
$container = new Container();
$container->set(LoggerInterface::class, function () {
    return new FileLogger('/var/log/app.log');
}, shared: true);
$container->alias('logger', LoggerInterface::class);

$service = $container->get('logger'); // получит один экземпляр
Результат: объект FileLogger, при повторном вызове get вернётся тот же экземпляр.

Реализация PSR-7 middleware pipeline

Продемонстрируем собственный pipeline для обработки HTTP запросов через middleware.

Пример

use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;

interface MiddlewareInterface
{
    public function process(ServerRequestInterface $request, callable $next): ResponseInterface;
}

class Pipeline
{
    private array $middlewares = [];

    public function add(MiddlewareInterface $middleware): void
    {
        $this->middlewares[] = $middleware;
    }

    public function run(ServerRequestInterface $request, callable $coreHandler): ResponseInterface
    {
        $stack = array_reverse($this->middlewares);
        $next = $coreHandler;
        foreach ($stack as $middleware) {
            $next = function (ServerRequestInterface $req) use ($middleware, $next) {
                return $middleware->process($req, $next);
            };
        }
        return $next($request);
    }
}

// Пример middleware
class AuthMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, callable $next): ResponseInterface
    {
        if (!isset($request->getCookieParams()['token'])) {
            return new Response(401, [], 'Unauthorized');
        }
        return $next($request);
    }
}

// Использование
$pipeline = new Pipeline();
$pipeline->add(new AuthMiddleware());
$request = new \Laminas\Diactoros\ServerRequest();
$response = $pipeline->run($request, function ($req) {
    return new Response(200, [], 'OK');
});
Результат: если нет cookie token, ответ 401, иначе 200.

Создание фреймворка PHP - comments

En
Php создание фреймворка (php)