Not every PHP project needs a full framework. Sometimes you just need a clean way to handle a handful of API endpoints. Without pulling in Laravel or Symfony, configuration files and without a dependency manager. Just a class you can drop in and use.
Over the years, working on various web applications, I’ve run into a recurring problem: routing solutions that are either too opinionated for small projects or too bare-bones to handle dynamic URLs properly. So I built a minimal PHP router class that hits the middle ground. Clean URLs, HTTP method handling, dynamic parameters, controller dispatch, all in a single self-contained file.
This article walks through how it works, step by step.
The complete class is designed to be copied directly into any PHP project. No external dependencies, no configuration required.
The problem with traditional PHP URLs
The traditional PHP approach to request handling relies on individual files per endpoint. A client calls something like:
http://www.my-simple-api-service.com/get-product-data.php?id=57As the number of parameters grows, this becomes unmanageable – very quickly:
http://www.my-simple-api-service.com/get-product-data.php?id=57&number_of_items=100&startpage=5&start_timestamp=123456...This puts a burden on both sides of the request. The API consumer has to construct long, fragile URLs. The backend has to parse a growing list of query parameters and build increasingly complex logic around them.
Modern frameworks solve this with URL rewriting and routing. The same request becomes:
http://www.my-simple-api-service.com/products/57/100/123456The URL is cleaner, the parameters are positional, and a routing layer maps the request to the right controller method automatically.
The mechanism behind this is URL rewriting: instead of loading a specific .php file for every request, the server redirects everything to a single entry point (typically index.php), and the router takes it from there.
You don’t always need Laravel. This guide walks through building a minimal PHP router class from scratch. One you can drop into any project with no configuration and no external dependencies.
Building the router class
Here’s how the class is structured, section by section.
1. Defining valid HTTP methods
The first thing the router needs to know is which HTTP methods it supports. I kept it to the four most common ones:
const VALID_METHODS = ['GET', 'POST', 'PUT', 'DELETE'];This constant is used later to validate incoming route definitions and reject anything unsupported early, rather than letting an invalid method cause a silent failure downstream.
2. Storing routes
Routes are held in a static array on the class. Each entry stores the route string, HTTP method, controller name, action name, and the compiled regex pattern:
private static array $routes = [];Using a static array keeps the class self-contained and avoids any dependency on a database or external state.
3. Adding routes
The addRoute() method is the core of route registration. It validates the HTTP method, checks that the controller action is in the expected Controller@action format, and stores the compiled route:
public static function addRoute(string $routeString, string $httpMethod, string $controllerAction): void
{
if (!in_array($httpMethod, static::VALID_METHODS)) {
throw new \InvalidArgumentException("Invalid HTTP method: $httpMethod");
}
if (!str_contains($controllerAction, '@')) {
throw new \InvalidArgumentException("Invalid route action format. Expected 'Controller@method'.");
}
[$controller, $action] = explode('@', $controllerAction, 2);
static::$routes[] = [
"routeString" => $routeString,
"httpMethod" => $httpMethod,
"controllerName" => $controller,
"actionName" => $action,
"regex" => static::compileRouteRegex($routeString),
];
}Throwing exceptions on invalid input means misconfigurations surface immediately during development rather than producing unexpected behaviour in production.
4. Helper methods for common HTTP methods
To keep route definitions readable, the class exposes convenience methods for each HTTP verb. Instead of calling addRoute() directly, you can use:
public static function get(string $routeString, string $controllerAction)
{
static::addRoute($routeString, 'GET', $controllerAction);
}
public static function post(string $routeString, string $controllerAction)
{
static::addRoute($routeString, 'POST', $controllerAction);
}The same pattern applies to put() and delete(). These are thin wrappers, but they make route definitions in index.php much easier to read.
5. Handling incoming requests
This is where the routing actually happens. The handleRequest() method normalises the incoming URI, loops through stored routes, matches against the compiled regex, and dispatches to the correct controller action:
public static function handleRequest(string $requestUri, string $requestHttpMethod): mixed
{
$normalizedUri = trim($requestUri, '/');
$normalizedUri = strtok($normalizedUri, '?') ?: '';
foreach (self::$routes as $route) {
if ($route['httpMethod'] !== $requestHttpMethod) continue;
$params = static::matches($route['regex'], $normalizedUri);
if ($params !== false) {
foreach ($params as &$param) {
if (is_numeric($param)) $param = (int) $param;
}
if (!class_exists($route['controllerName'])) {
throw new \Exception('Controller not found: ' . $route['controllerName']);
}
if (!method_exists($route['controllerName'], $route['actionName'])) {
throw new \Exception("Method '{$route['actionName']}' not found in controller " . $route['controllerName']);
}
$controller = new($route['controllerName']);
return $controller->{$route['actionName']}(...$params);
}
}
return null; // No matching route — handle 404 externally
}A few things worth noting here. The URI is stripped of leading/trailing slashes and query strings before matching, so the regex patterns stay simple. Numeric parameters are cast to integers automatically. If the controller class or method doesn’t exist, an exception is thrown rather than failing silently.
6. Compiling route patterns to regex
Dynamic route parameters like {id} need to be converted into named regex capture groups. The compileRouteRegex() method handles this:
private static function compileRouteRegex(string $routePattern): string
{
$routePattern = trim($routePattern, '/');
$parts = explode('/', $routePattern);
$patternParts = array_map(function ($part) {
if (preg_match('/^\{(.+?)(?::(.+?))?\}$/', $part, $matches)) {
$paramName = $matches[1];
$paramPattern = $matches[2] ?? '[a-zA-Z0-9\-\_]+';
return "(?P<$paramName>$paramPattern)";
}
return preg_quote($part, '/');
}, $parts);
return '^' . implode('/', $patternParts) . '$';
}For example, the route /product/{id} compiles to the regex ^product/(?P<id>[a-zA-Z0-9\-\_]+)$. The named capture group makes it straightforward to extract the id value from the URI and pass it directly to the controller method.
7. Matching the request URI
The actual regex matching is handled by a separate private method that keeps handleRequest() clean:
private static function matches(string $routeRegex, string $requestUri): array|bool
{
return (preg_match("~{$routeRegex}~", $requestUri, $matches))
? array_filter($matches, fn($key) => !is_int($key), ARRAY_FILTER_USE_KEY)
: false;
}If the match succeeds, only the named captures (the actual parameter values) are returned and the numeric keys from preg_match are filtered out. If there’s no match, the method returns false.
8. Missing routes
When no route matches, handleRequest() returns null. This keeps the router itself simple and leaves 404 handling to the application layer. For a web application that means a custom 404 page; for an API it means returning the correct HTTP response code.
Server configuration: the .htaccess file
The router only works if all requests are directed to index.php first. On Apache, that means a .htaccess file in the project root with URL rewriting rules:
RewriteEngine On
# Send all requests to index.php (skip real files and directories)
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [QSA,L]What each rule does:
- RewriteEngine On enables Apache’s mod_rewrite module
- RewriteCond %{REQUEST_FILENAME} !-f skips rewriting if the request points to a real file (images, CSS, JS). These are served directly
- RewriteCond %{REQUEST_FILENAME} !-d skips rewriting if the request points to a real directory
- RewriteRule ^(.*)$ index.php [QSA,L] rewrites everything else to index.php. The [QSA] flag preserves any query parameters; [L] marks this as the final rule
This is the same rewriting pattern used by Laravel, WordPress and most other PHP frameworks. If you’ve deployed any of them on Apache, this will be familiar.
Using the router
1. Define your routes
In your index.php or a dedicated routes file:
require_once 'Router.php';
Router::get('/product/{id}', 'ProductController@show');
Router::post('/product', 'ProductController@create');
Router::put('/product/{id}', 'ProductController@update');
Router::delete('/product/{id}', 'ProductController@delete');2. Create a controller
class ProductController
{
public function show($id)
{
echo 'Displaying product with ID: ' . $id;
}
public function create()
{
echo 'Creating a new product.';
}
public function update($id)
{
echo 'Updating product with ID: ' . $id;
}
public function delete($id)
{
echo 'Deleting product with ID: ' . $id;
}
}3. Handle the incoming request
In index.php:
require_once 'Router.php';
require_once 'ProductController.php';
$requestUri = $_SERVER['REQUEST_URI'];
$requestMethod = $_SERVER['REQUEST_METHOD'];
$response = Router::handleRequest($requestUri, $requestMethod);
if ($response === null) {
// For web: render a 404 page
// For API: http_response_code(404); echo json_encode(['error' => 'Not found']);
echo '404 Not Found';
}A GET request to /product/57 will match the first route, instantiate ProductController, call show(57), and return the result.
Strengths and limitations
What this PHP router class does well
- No dependencies. Copy the file into any project and it works. No Composer, no configuration, no setup
- Clean dynamic routing. Named capture groups in the regex make parameter extraction readable and reliable
- Explicit validation. Invalid HTTP methods and malformed controller actions fail loudly at route registration time, not at request time
- Easy to extend. Adding middleware support, route groups or additional HTTP methods (e.g. PATCH) requires minimal changes to the existing structure
Current limitations of the PHP router class
- Integer parameters only. The automatic type casting currently handles numeric strings as integers. String parameters are passed through as-is
- Linear route matching. All routes are iterated on every request. For applications with a large number of routes, grouping routes by HTTP method (separate arrays per method) would reduce the lookup cost
- No built-in 404 handling. Returning null keeps the class flexible, but it means the application layer needs to handle missing routes explicitly. This is a deliberate trade-off, not an oversight
- Apache-specific rewriting. The provided configuration assumes Apache with mod_rewrite. Nginx requires a different approach, a location block with try_files instead of .htaccess.
Conclusion
This PHP router class will not replace a full framework. It is not designed to. What it does is give you a clean, readable routing layer for projects where a framework would be excessive, like a small internal API, a lightweight microservice or a quick prototype.
The complete class is around 80 lines. It handles dynamic routes, validates input, dispatches to controllers and stays entirely self-contained. For the right project, that’s exactly what you need.
If you extend it with middleware support, route grouping or Nginx configuration, I’d be glad to hear how you approached it.
Now that you’re up to speed with PHP router class, maybe you’re interested in this:
