Мини-роутер без фреймворка
Фреймворки классные, но мини-роутер полезно написать хотя бы раз - чтобы понимать, что происходит «под капотом». Если не знаком с методами и кодами - сначала кросс-урок про 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']);
Это работает, но быстро превращается в спагетти. Давай сделаем лучше.
Шаг 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';
// Диспетчер (код выше)
Обработка ошибок в роутере
Глобальный 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']);
}
Что дальше?
Наш роутер - учебный. В реальных проектах используют библиотеки: 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