In the early days of PHP development, handling different pages was often a messy affair. You might remember the era of index.php?page=contact or, even worse, having a separate physical .php file for every single URL on your website. As applications grew in complexity, these “spaghetti” approaches became impossible to maintain. This led to the rise of the “Front Controller” pattern, where a single entry point (usually index.php) handles every request and decides which code to execute based on the URL.
Today, frameworks like Laravel and Symfony have popularized “Attribute-based routing.” Instead of defining routes in a massive configuration file, you can simply add a metadata tag directly above your controller methods. But have you ever wondered how this works under the hood? Understanding the mechanics of routing—specifically using the Reflection API and Attributes introduced in PHP 8.0—is a rite of passage for intermediate developers looking to reach senior status.
In this comprehensive guide, we will build a production-grade, extensible PHP router. We won’t just write code; we will explore the architectural decisions, the performance implications, and the “why” behind every line. By the end, you will have a deep understanding of how modern PHP frameworks handle request orchestration.
Why Build Your Own Router?
While using a library like FastRoute or the Symfony Routing component is standard for production, building your own provides several benefits:
- Deep Architectural Understanding: You learn how the request-response lifecycle actually works.
- Zero Dependencies: For small microservices, a custom router can reduce the overhead of heavy vendor folders.
- Tailored Features: You can implement specific logic (like automatic API versioning or custom permission checks) directly into the routing engine.
- Mastering PHP 8+ Features: It is the perfect project to practice using Attributes, Union Types, and the Reflection API.
Prerequisites
To follow this tutorial, you should have:
- PHP 8.1 or higher installed (we will use readonly properties and attributes).
- A basic understanding of Object-Oriented Programming (OOP) in PHP.
- Composer installed for PSR-4 autoloading (essential for modern PHP projects).
- A local web server (like Apache, Nginx, or PHP’s built-in server).
The Architecture of an Attribute-Based Router
Our router will consist of four main components:
- The Attribute (
Route): A simple class used to flag controller methods. - The Controller: Classes where our business logic lives.
- The Route Resolver: The engine that uses Reflection to find attributes and map them to URLs.
- The Dispatcher: The part that executes the controller method and passes in any URL parameters.
Step 1: Setting Up the Project Structure
First, let’s create a clean directory structure. Open your terminal and run:
mkdir custom-router
cd custom-router
mkdir src
mkdir public
Now, initialize Composer to handle class loading. Create a composer.json file:
{
"name": "yourname/custom-router",
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"require": {}
}
Run composer install to generate the vendor folder and the autoloader.
Step 2: Defining the Route Attribute
Attributes are a native way to add metadata to your code. In older versions of PHP, we used “DocBlocks” (comments starting with /**), which were slow to parse and error-prone. Attributes are first-class citizens.
Create src/Attributes/Route.php:
<?php
namespace App\Attributes;
use Attribute;
/**
* The #[Attribute] marker tells PHP this class can be used as metadata.
* We restrict it to methods only using Attribute::TARGET_METHOD.
*/
#[Attribute(Attribute::TARGET_METHOD)]
class Route
{
public function __construct(
public string $path,
public string $method = 'GET',
public string $name = ''
) {}
}
This simple class stores the URL path, the HTTP method (GET, POST, etc.), and an optional name for the route. The public function __construct uses Constructor Property Promotion, a PHP 8.0 feature that reduces boilerplate.
Step 3: Creating a Sample Controller
Let’s create a controller that uses our new attribute. Create src/Controllers/UserController.php:
<?php
namespace App\Controllers;
use App\Attributes\Route;
class UserController
{
#[Route('/users', method: 'GET')]
public function index(): void
{
echo "Listing all users...";
}
#[Route('/users/create', method: 'POST')]
public function store(): void
{
echo "Saving a new user...";
}
#[Route('/users/(\d+)', method: 'GET')]
public function show(int $id): void
{
echo "Showing user with ID: " . $id;
}
}
Notice the third route: /users/(\d+). We are using a Regular Expression (Regex) capture group to handle dynamic IDs. Our router will need to parse this later.
Step 4: The Core Router Logic (The Reflection Engine)
This is where the magic happens. We need a class that can scan our controllers, find methods with the #[Route] attribute, and build a map of URLs.
Create src/Router.php. We will build this class incrementally because it involves complex logic.
4.1 Basic Structure and Route Discovery
<?php
namespace App;
use App\Attributes\Route;
use ReflectionClass;
use ReflectionMethod;
class Router
{
private array $routes = [];
/**
* Register an array of controller classes
*/
public function registerControllers(array $controllers): void
{
foreach ($controllers as $controller) {
$reflection = new ReflectionClass($controller);
foreach ($reflection->getMethods() as $method) {
// Get the #[Route] attributes from the method
$attributes = $method->getAttributes(Route::class);
foreach ($attributes as $attribute) {
$route = $attribute->newInstance();
// We store the path, the HTTP method, the controller class, and the method name
$this->routes[] = [
'path' => $route->path,
'method' => $route->method,
'handler' => [$controller, $method->getName()]
];
}
}
}
}
}
How the Reflection API works here: ReflectionClass allows us to “inspect” a class at runtime. We call getMethods() to loop through every function inside that class. Then, getAttributes(Route::class) filters out everything except our custom Route metadata. Finally, newInstance() converts that metadata into an actual object of our Route class.
4.2 Resolving and Dispatching
Now we need a method to handle the incoming request. This method must compare the current URL and the HTTP method against our $routes array.
public function resolve(string $requestUri, string $requestMethod)
{
// Strip query strings (e.g., /users?id=1 becomes /users)
$path = parse_url($requestUri, PHP_URL_PATH);
foreach ($this->routes as $route) {
// Check if HTTP method matches
if ($route['method'] !== $requestMethod) {
continue;
}
// Convert our path into a valid Regex
$pattern = "#^" . $route['path'] . "$#";
if (preg_match($pattern, $path, $matches)) {
// Remove the full match from the beginning of the array
array_shift($matches);
[$controller, $methodName] = $route['handler'];
$controllerInstance = new $controller();
// Call the method with captured regex groups as arguments
return call_user_func_array([$controllerInstance, $methodName], $matches);
}
}
// No route found
http_response_code(404);
echo "404 Not Found";
}
The use of preg_match is vital here. By wrapping our route path in #^...$#, we ensure that the entire URL must match the pattern. If our route was /users/(\d+) and the URL was /users/42, $matches would contain ['/users/42', '42']. We use array_shift to remove the first element, leaving just the ID 42 to be passed into our controller method.
Step 5: Putting It All Together (The Entry Point)
Create the file public/index.php. This is what your web server will serve. We need to instantiate our router, tell it about our controllers, and resolve the request.
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use App\Router;
use App\Controllers\UserController;
$router = new Router();
// Register your controllers here
$router->registerControllers([
UserController::class
]);
// Resolve the current request
$router->resolve($_SERVER['REQUEST_URI'], $_SERVER['REQUEST_METHOD']);
Real-World Example: Testing Your Router
To test this without setting up Nginx, you can use the built-in PHP server. Run this in your project root:
php -S localhost:8000 -t public/
Now, open your browser and visit:
http://localhost:8000/users→ Should display “Listing all users…”http://localhost:8000/users/55→ Should display “Showing user with ID: 55”http://localhost:8000/something-else→ Should display “404 Not Found”
Handling Advanced Concepts: Middleware
In a real application, you often need to run code before the controller executes—for example, to check if a user is logged in. This is called Middleware.
Let’s modify our Attribute to support middleware. Update src/Attributes/Route.php:
#[Attribute(Attribute::TARGET_METHOD)]
class Route
{
public function __construct(
public string $path,
public string $method = 'GET',
public array $middlewares = [] // Add this
) {}
}
Now, in your Router.php, you can add logic to instantiate and run these middleware classes before the call_user_func_array line. This allows you to build a sophisticated request pipeline similar to what you find in Laravel.
Common Mistakes and How to Fix Them
1. Forgeting PSR-4 Autoloading
The Problem: You get a Fatal error: Class "App\Router" not found.
The Fix: Ensure your composer.json namespaces match your directory structure exactly (case-sensitive!). Always run composer dump-autoload after changing the autoload section.
2. Improper Regex Escaping
The Problem: You use a path like /api/v1/user but the router fails because of forward slashes.
The Fix: In our resolve method, we used # as a delimiter: #^...$#. This is safer than / because it doesn’t conflict with URL paths. If you use /, you must escape it: preg_quote($path, '/').
3. Reflection Performance Issues
The Problem: On every single request, the router is scanning every class and method. On a site with 100 controllers, this becomes slow.
The Fix: In a production environment, you should cache the results of the reflection process. On the first request, generate the $routes array and save it as a JSON or a PHP array file. On subsequent requests, simply load that file instead of using Reflection.
4. Not Handling Trailing Slashes
The Problem: /users works, but /users/ returns a 404.
The Fix: Normalize your URI inside the resolve() method using rtrim($path, '/') before matching.
Performance Optimization: The Cached Router
To take this to the “Expert” level, let’s look at how we can implement a simple caching mechanism. Reflection is powerful but computationally expensive because PHP has to parse the internal metadata of the classes.
public function getRouteMap(): array {
return $this->routes;
}
// In index.php
$cacheFile = __DIR__ . '/../cache/routes.php';
if (file_exists($cacheFile) && !DEBUG_MODE) {
$router->loadRoutes(require $cacheFile);
} else {
$router->registerControllers([...]);
$routes = $router->getRouteMap();
file_put_contents($cacheFile, '<?php return ' . var_export($routes, true) . ';');
}
Using var_export generates a valid PHP array that can be included instantly by the OpCache, making your custom router nearly as fast as static code.
Summary and Key Takeaways
- Attributes: A clean, modern way to store metadata directly on classes and methods (PHP 8.0+).
- Reflection API: A powerful tool that allows your code to “look at itself” to automate tasks like route discovery.
- Front Controller: All requests should flow through one entry point (index.php) for centralized management.
- Regex: Essential for handling dynamic URLs like
/user/123. - Caching: Critical for production apps to avoid the performance overhead of Reflection on every request.
Frequently Asked Questions
1. Is this router secure?
The routing logic itself is secure as long as you properly validate the data passed into the controller. However, you should always sanitize any regex input and ensure your web server configuration (like .htaccess) doesn’t allow direct access to your src folder.
2. Can I use this for a REST API?
Yes! This is perfect for REST APIs. You can easily expand the Route attribute to handle JSON response headers and different HTTP verbs like PUT, PATCH, and DELETE.
3. Why not just use Symfony Routing?
Symfony Routing is excellent and feature-rich. However, learning to build your own helps you understand the “magic” happening in Symfony. It also gives you a lightweight alternative for projects where you want minimal dependencies.
4. How do I handle named routes?
You can add a name property to the Route attribute. In your Router class, store these in a separate associative array. This allows you to generate URLs in your templates by calling $router->generate('user_profile', ['id' => 5]).
5. Does this work with PHP 7.4?
No. Attributes were introduced in PHP 8.0. For PHP 7.4, you would need to use a library like doctrine/annotations to parse DocBlocks, which is significantly more complex and slower.
Building a router from scratch is one of the most rewarding exercises in PHP development. It bridges the gap between writing scripts and engineering software. By leveraging the modern features of PHP 8.x, you can create a system that is both elegant and highly performant.
