Attribute routing without Symfony: a take-home case study
- #php
- #routing
- #attributes
- #case-study
Build a minimal PHP router that discovers controllers via PHP 8 attributes; no annotations, no config files.
The assignment was simple: ship an app with no frameworks. That meant inventing the parts frameworks usually leave lurking in the dark - including routing. This is the attribute-based router I built for that take-home, stripped to the parts that matter.
Disclaimer: I tried to make it as simple as possible by removing unrelated code.
Without further ado let’s start with a simple controller:
src/Controller/IndexController.php
<?php
declare(strict_types=1);
namespace Framework\Controller;
// We will write this class in a moment
use Framework\Model\Attribute\Route;
// The class below is not covered in this case study.
// To avoid this import you might want to change the action to
// return 'My response string';
use Framework\Model\Http\Message\Response\TemplateResponse;
class IndexController
{
#[Route(method: 'GET', path: '/', name: 'root')]
public function index(): mixed
{
return new TemplateResponse('templateName');
}
}
As you see from the attribute our route has the following properties: method, path and name.
methodis for HTTP methodpathis for the path component of the requested URInameis something that we can use to produce the link to this route.
At this point we have enough info to implement the attribute itself:
src/Model/Attribute/Route.php
<?php
declare(strict_types=1);
namespace Framework\Model\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Route
{
public function __construct(
public string $method,
public string $path,
public string $name
) {
}
}
Note Attribute::IS_REPEATABLE – it allows to have multiple attributes for the same method.
Like this:
#[Route(method: 'GET', path: '/route', name: 'route')]
#[Route(method: 'GET', path: '/alias', name: 'alias')]
public function sampleMultirouteAction(): mixed
{
// do the stuff
}
That’s it, now it’s time to create a router:
src/Router.php
<?php
declare(strict_types=1);
namespace Framework;
use Framework\Model\Attribute\Route;
class Router
{
public function registerRoutes(): Router
{
foreach ($this->getControllers() as $controller) {
$reflectionController = new \ReflectionClass($controller);
foreach ($reflectionController->getMethods() as $method) {
$attributes = $method->getAttributes(Route::class, \ReflectionAttribute::IS_INSTANCEOF);
foreach ($attributes as $attribute) {
$route = $attribute->newInstance();
$this->register($route->method, $route->path, [$controller, $method->getName()], $route->name);
}
}
}
return $this;
}
}
Pretty easy:
- Get all the controller classes
- Build a reflection for every controller class
- Search for the attributes matching our Route class
- Register every attribute as a new route
However, getControllers and register methods in the code above still beg implementation… So here we go.
Get all the controller classes
src/Router.php (addition)
private const string CONTROLLER_NS = 'Framework\Controller';
// ...
private function getControllers(): array
{
// Something that returns an absolute path to the 'src' directory
// You might want to inject it into the Router constructor method
$baseDir = App::getBaseDir();
$controllerDirectory = "{$baseDir}/Controller";
$files = scandir($controllerDirectory);
$namespace = self::CONTROLLER_NS;
$classes = array_map(function ($file) use ($namespace) {
$className = str_replace('.php', '', $file);
return $namespace . '\\' . $className;
}, $files);
$controllers = array_filter($classes, function ($possibleClass) {
return class_exists($possibleClass);
});
return $controllers;
}
This part is generously sponsored by PSR-4 standard. There is nothing special here – we:
- load all filenames from the
src/Controllerdirectory into the array - transform these filenames into the class names by removing the
.phpextension - prepend every class name with the common controller namespace
- filter the result by leaving only those classes that actually exist. Validation of the existence handed off to the autoloader.
Register a route
src/Router.php (addition)
private array $routes = [];
// ...
private function routeExists(string $requestMethod, string $path): bool
{
return isset($this->routes[$requestMethod][$path]);
}
private function register(string $requestMethod, string $path, array $action, string $name): self
{
if ($this->routeExists($requestMethod, $path)) {
throw new \LogicException(
"The method {$requestMethod} and path {$path} are already registered"
);
}
if ($this->getPathByName($name) !== '') {
throw new \LogicException(
"Duplicated route name '{$name}' for {$requestMethod} and path {$path}"
);
}
$this->routes[$requestMethod][$path] = [
'action' => $action,
'name' => $name
];
return $this;
}
Yes, registering a route as easy as assigning a value to the array item. In this case matching the route is accessing the array item.
Note that we are going slightly further here by doing 2 sanity checks:
- validate that the route name is unique
- validate that the pair (method, path) is unique
That’ll save us from a headache due to copy/paste route errors.
I’m not happy enough with the register method signature but I leave the room for the improvement to you, my dear reader.
For the sanity check #1 we will need getPathByName method implementation:
src/Router.php (addition)
public function getPathByName(string $name): string
{
foreach ($this->routes as $routePart) {
foreach ($routePart as $routePath => $routeData) {
if ($name === $routeData['name']) {
return $routePath;
}
}
}
// Throw 'route not found' exception at this point
// if you can afford throwing exceptions
return '';
}
getPathByName is made public intentionally.
This is our secret weapon – we can wrap it with a function and make this function available in the templates later when we start to write our own template engine. (We will certainly do. Maybe. Someday).
Once again, this logic could be cleaner, e.g. we can combine all routes in a single array regardless of the HTTP method before searching through them. Or change the $routes property to have a different structure.
Consider this refactoring to be your homework ;-)
Dispatch the request
At this point we are ready to dispatch the request:
src/Router.php (addition)
use Framework\Controller\NotFoundController;
// ...
public function dispatch(Request $request): void
{
/**
* Given that it’s not a tutorial on PSR-7 compatible requests
* and responses I stripped the Request implementation
**/
$requestMethod = $request->getMethod();
// Strip '/index.php' from path (if any)
$path = preg_replace('#/index.php#', '', $request->getUri()->getPath());
// Consider an empty path to be the same as root
$path = $path !== '' ? $path : '/';
if ($this->routeExists($requestMethod, $path)) {
$class = $this->routes[$requestMethod][$path]['action'][0];
$method = $this->routes[$requestMethod][$path]['action'][1];
} else {
$class = NotFoundController::class;
$method = 'index';
}
ob_start();
// Magic happens here:
// we create a controller instance and call the respective method
$response = (new $class())->$method();
/**
* Given that it’s not a tutorial on PSR-7 compatible requests
* and responses I stripped the response processing
**/
// This is the place where the headers and body are usually sent
$response = $response->send();
// And now we send it at once
ob_end_flush();
}
NotFoundController could be similar to IndexController:
src/Controller/NotFoundController.php
<?php
declare(strict_types=1);
namespace Framework\Controller;
use Framework\Model\Http\Message\Response\TemplateResponse;
class NotFoundController extends Controller
{
public function index(): mixed
{
return new TemplateResponse('404');
}
}
I believe you have your own Application singleton so it’s high time to write the following lines there:
somewhere in your App:
use Framework\Router;
// …
try {
(new Router())->registerRoutes()->dispatch($request);
} catch (\Exception $e) {
echo 'Mayday, Mayday, Mayday';
}
Thanks for reading and good luck!
TL;DR
You can find the complete implementation below:
src/Controller/IndexController.php
<?php
declare(strict_types=1);
namespace Framework\Controller;
// We will write this class in a moment
use Framework\Model\Attribute\Route;
// The class below is not covered in this case study.
// To avoid this import you might want to change the action to
// return 'My response string';
use Framework\Model\Http\Message\Response\TemplateResponse;
class IndexController
{
#[Route(method: 'GET', path: '/', name: 'root')]
public function index(): mixed
{
return new TemplateResponse('templateName');
}
}
src/Controller/NotFoundController.php
<?php
declare(strict_types=1);
namespace Framework\Controller;
use Framework\Model\Http\Message\Response\TemplateResponse;
class NotFoundController extends Controller
{
public function index(): mixed
{
return new TemplateResponse('404');
}
}
src/Model/Attribute/Route.php
<?php
declare(strict_types=1);
namespace Framework\Model\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Route
{
public function __construct(
public string $method,
public string $path,
public string $name
) {
}
}
src/Router.php
<?php
declare(strict_types=1);
namespace Framework;
use Framework\Controller\NotFoundController;
use Framework\Model\Attribute\Route;
class Router
{
private const string CONTROLLER_NS = 'Framework\Controller';
private array $routes = [];
public function dispatch(Request $request): void
{
/**
* Given that it’s not a tutorial on PSR-7 compatible requests
* and responses I stripped the Request implementation
**/
$requestMethod = $request->getMethod();
// Strip '/index.php' from path (if any)
$path = preg_replace('#/index.php#', '', $request->getUri()->getPath());
// Consider an empty path to be the same as root
$path = $path !== '' ? $path : '/';
if ($this->routeExists($requestMethod, $path)) {
$class = $this->routes[$requestMethod][$path]['action'][0];
$method = $this->routes[$requestMethod][$path]['action'][1];
} else {
$class = NotFoundController::class;
$method = 'index';
}
ob_start();
// Magic happens here:
// we create a controller instance and call the respective method
$response = (new $class())->$method();
/**
* Given that it’s not a tutorial on PSR-7 compatible requests
* and responses I stripped the response processing
**/
// This is the place where the headers and body are usually sent
$response = $response->send();
// And now we send it at once
ob_end_flush();
}
public function getPathByName(string $name): string
{
foreach ($this->routes as $routePart) {
foreach ($routePart as $routePath => $routeData) {
if ($name === $routeData['name']) {
return $routePath;
}
}
}
// Throw 'route not found' exception at this point
// if you can afford throwing exceptions
return '';
}
public function registerRoutes(): Router
{
foreach ($this->getControllers() as $controller) {
$reflectionController = new \ReflectionClass($controller);
foreach ($reflectionController->getMethods() as $method) {
$attributes = $method->getAttributes(Route::class, \ReflectionAttribute::IS_INSTANCEOF);
foreach ($attributes as $attribute) {
$route = $attribute->newInstance();
$this->register($route->method, $route->path, [$controller, $method->getName()], $route->name);
}
}
}
return $this;
}
private function getControllers(): array
{
// Something that returns an absolute path to the 'src' directory
// You might want to inject it into the Router constructor method
$baseDir = App::getBaseDir();
$controllerDirectory = "{$baseDir}/Controller";
$files = scandir($controllerDirectory);
$namespace = self::CONTROLLER_NS;
$classes = array_map(function ($file) use ($namespace) {
$className = str_replace('.php', '', $file);
return $namespace . '\\' . $className;
}, $files);
$controllers = array_filter($classes, function ($possibleClass) {
return class_exists($possibleClass);
});
return $controllers;
}
private function routeExists(string $requestMethod, string $path): bool
{
return isset($this->routes[$requestMethod][$path]);
}
private function register(string $requestMethod, string $path, array $action, string $name): self
{
if ($this->routeExists($requestMethod, $path)) {
throw new \LogicException(
"The method {$requestMethod} and path {$path} are already registered"
);
}
if ($this->getPathByName($name) !== '') {
throw new \LogicException(
"Duplicated route name '{$name}' for {$requestMethod} and path {$path}"
);
}
$this->routes[$requestMethod][$path] = [
'action' => $action,
'name' => $name
];
return $this;
}
}