Мини-роутер без фреймворка

Фреймворки классные, но мини-роутер полезно написать хотя бы раз - чтобы понимать, что происходит «под капотом». Если не знаком с методами и кодами - сначала кросс-урок про HTTP.

Идея

Мы хотим, чтобы:

  • GET / показывал «OK»
  • GET /tasks возвращал список задач
  • POST /tasks создавал задачу
  • GET /tasks/42 возвращал задачу по ID

Шаг 1: Простейший роутер на if/else

public/index.php:

<?php
declare(strict_types=1);

$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';

header('Content-Type: application/json; charset=utf-8');

if ($method === 'GET' && $path === '/') {
    echo json_encode(['ok' => true]);
    exit;
}

if ($method === 'GET' && $path === '/tasks') {
    echo json_encode(['tasks' => []]);
    exit;
}

http_response_code(404);
echo json_encode(['error' => 'Not Found']);
`$_SERVER['REQUEST_URI']` содержит и путь, и query-строку (`/tasks?page=2`). `parse_url(..., PHP_URL_PATH)` отрезает `?query=...`, оставляя только путь.

Это работает, но быстро превращается в спагетти. Давай сделаем лучше.

Шаг 2: Роутер как массив

<?php
declare(strict_types=1);

$routes = [
    'GET /'       => fn() => ['ok' => true],
    'GET /tasks'  => fn() => ['tasks' => listTasks()],
    'POST /tasks' => function () {
        $body = json_decode(file_get_contents('php://input'), true, 512, JSON_THROW_ON_ERROR);
        $title = $body['title'] ?? '';
        if ($title === '') {
            http_response_code(400);
            return ['error' => 'title is required'];
        }
        return ['id' => createTask($title), 'ok' => true];
    },
];

$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$key = "$method $path";

header('Content-Type: application/json; charset=utf-8');

if (isset($routes[$key])) {
    $result = $routes[$key]();
    echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
} else {
    http_response_code(404);
    echo json_encode(['error' => 'Not Found']);
}

Уже лучше - роуты отделены от логики диспетчеризации.

Шаг 3: Динамические сегменты

Как обработать /tasks/42? Нужны параметры в URL:

<?php
declare(strict_types=1);

$routes = [];

function route(string $method, string $pattern, callable $handler): void {
    global $routes;
    $routes[] = [
        'method'  => $method,
        'pattern' => $pattern,
        'handler' => $handler,
    ];
}

function matchRoute(string $method, string $path): ?array {
    global $routes;

    foreach ($routes as $route) {
        if ($route['method'] !== $method) {
            continue;
        }

        // Превращаем /tasks/{id} в регулярку /tasks/([^/]+)
        $regex = preg_replace('#\{(\w+)\}#', '([^/]+)', $route['pattern']);
        $regex = '#^' . $regex . '$#';

        if (preg_match($regex, $path, $matches)) {
            array_shift($matches); // убираем полное совпадение
            return ['handler' => $route['handler'], 'params' => $matches];
        }
    }

    return null;
}

Регистрация роутов:

<?php
route('GET', '/', fn() => ['ok' => true]);

route('GET', '/tasks', fn() => ['tasks' => listTasks()]);

route('GET', '/tasks/{id}', function (string $id) {
    $task = findTask((int)$id);
    if ($task === null) {
        http_response_code(404);
        return ['error' => 'Task not found'];
    }
    return $task;
});

route('POST', '/tasks', function () {
    $body = json_decode(file_get_contents('php://input'), true, 512, JSON_THROW_ON_ERROR);
    return ['id' => createTask($body['title'] ?? ''), 'ok' => true];
});

route('DELETE', '/tasks/{id}', function (string $id) {
    deleteTask((int)$id);
    http_response_code(204);
    return null;
});

Диспетчер:

<?php
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?: '/';

header('Content-Type: application/json; charset=utf-8');

$match = matchRoute($method, $path);

if ($match !== null) {
    $result = ($match['handler'])(...$match['params']);
    if ($result !== null) {
        echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
    }
} else {
    http_response_code(404);
    echo json_encode(['error' => 'Not Found']);
}

Настройка веб-сервера

Чтобы все запросы попадали в index.php, нужна перезапись URL.

Встроенный сервер PHP (для разработки):

php -S localhost:8000 -t public/

Встроенный сервер PHP уже направляет все запросы к несуществующим файлам на index.php.

Для Nginx:

location / {
    try_files $uri /index.php$is_args$args;
}

Структура проекта

project/
├── public/
│   └── index.php       # точка входа (роутер)
├── src/
│   ├── routes.php      # определения роутов
│   ├── handlers.php    # обработчики (функции)
│   └── db.php          # подключение к БД
└── config.php          # настройки

public/index.php:

<?php
declare(strict_types=1);

require __DIR__ . '/../config.php';
require __DIR__ . '/../src/db.php';
require __DIR__ . '/../src/handlers.php';
require __DIR__ . '/../src/routes.php';

// Диспетчер (код выше)
Только `public/` доступен из веба. Файлы `src/` и `config.php` лежат выше - их нельзя запросить через браузер. Это защита от утечки кода и конфигов.

Обработка ошибок в роутере

Глобальный try/catch с разными типами исключений:

<?php
try {
    $match = matchRoute($method, $path);

    if ($match !== null) {
        $result = ($match['handler'])(...$match['params']);
        if ($result !== null) {
            echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
        }
    } else {
        http_response_code(404);
        echo json_encode(['error' => 'Not Found']);
    }
} catch (\JsonException $e) {
    http_response_code(400);
    echo json_encode(['error' => 'Invalid JSON: ' . $e->getMessage()]);
} catch (\PDOException $e) {
    error_log('DB error: ' . $e->getMessage());
    http_response_code(500);
    echo json_encode(['error' => 'Internal Server Error']);
} catch (\Throwable $e) {
    error_log('Unhandled: ' . $e->getMessage());
    http_response_code(500);
    echo json_encode(['error' => 'Internal Server Error']);
}
`$e->getMessage()` может содержать SQL-запрос, путь к файлу, стек вызовов. В продакшне всегда логируй в файл, а пользователю показывай общее сообщение.

Что дальше?

Наш роутер - учебный. В реальных проектах используют библиотеки: nikic/fast-route (минималистичный), league/route, или полноценные фреймворки (Laravel, Symfony). Но принцип тот же: метод + паттерн → обработчик. Полноценное REST-API с валидацией и статус-кодами - в уроке 21.

Типичные ошибки

  • Сравнение REQUEST_URI без parse_url. Запрос /tasks?page=2 не совпадёт с роутом /tasks - в строке остался ?page=2. Всегда вытаскивай путь через parse_url($uri, PHP_URL_PATH).
  • Регулярка без anchors ^ и $. Паттерн /users/(\d+) совпадёт с /admin/users/42/delete. Оборачивай в #^...$#, как в Шаге 3.
  • Регистрозависимое сравнение путей. /Users и /users для роутера - разные роуты, а для пользователя - один и тот же. Приводи путь к нижнему регистру через strtolower() перед матчингом, либо документируй case-sensitive поведение.
  • Метод из $_SERVER['REQUEST_METHOD'] без normalization. Прокси и тесты иногда шлют get или Get. Делай strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'), иначе валидные запросы упадут в 404.
  • Trailing slash как два разных роута. /users и /users/ обычно один ресурс, но матчатся раздельно. Нормализуй: $path = rtrim($path, '/') ?: '/'; - иначе клиенты ловят 404 на пустом месте.

Best practices

  • Различай 404 (нет пути) и 405 (метод не подходит): если путь нашёлся, но метод другой - отвечай 405 Method Not Allowed и заголовком Allow: GET, POST.
  • Нормализуй путь один раз в диспетчере: parse_url + rtrim + strtolower - дальше роуты пишутся без сюрпризов.
  • Регистрируй роуты декларативно (массив или билдер), а не через if/elseif - так проще тестировать и логировать 404.
  • В продакшне бери nikic/fast-route или Symfony Routing с атрибутом #[Route('/tasks/{id}', methods: ['GET'])] - свой роутер хорош как учебный, но не как фундамент API.
  • Покрой роутинг unit-тестами: матчинг паттернов и извлечение параметров - чистая функция без БД, тестируется за минуты и ловит регрессии при добавлении новых роутов.

Мини-задание

  • Добавь роут GET /health{ "status": "ok" }
  • Добавь роут POST /echo → возвращает полученный JSON обратно
  • Реализуй роут GET /tasks/{id} с динамическим параметром
  • Добавь обработку ошибок (try/catch с разными типами исключений)
  • Вынеси роуты в отдельный файл src/routes.php

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.