Using the PHP 8 attributes feature to implement Symfony-alike routing.
Disclaimer: I tried to make this tutorial 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);
// We will write this class in a moment
use Framework\Model\Attribute\Route;
// The class below is not covered in this tutorial.
// To avoid this import you might want to change the action to
// return ‘My response string’;
use Framework\Model\Http\Message\Response\TemplateResponse;
namespace Framework\Controller;
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.
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:
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:
Here is how we 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:
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 ;-)
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)
$requestPath = preg_replace('#/index.php#', '', $request->getUri()->getPath());
// Consider an empty path to be the same as root
$requestPath = $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 are usually sent
$rawResponse = doSomethingWithResponse();
// This is the place where the response content is sent
echo $rawResponse;
// And now we send it at once
ob_end_flush();
}
NotFoundController could be the same as IndexController:
src/Controller/NotFoundController.php
<?php
declare(strict_types=1);
namespace Framework\Controller;
use Framework\Model\Attribute\Route;
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();
} 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);
// We will write this class in a moment
use Framework\Model\Attribute\Route;
// The class below is not covered in this tutorial.
// To avoid this import you might want to change the action to
// return ‘My response string’;
use Framework\Model\Http\Message\Response\TemplateResponse;
namespace Framework\Controller;
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\Attribute\Route;
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
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)
$requestPath = preg_replace('#/index.php#', '', $request->getUri()->getPath());
// Consider an empty path to be the same as root
$requestPath = $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 are usually sent
$rawResponse = doSomethingWithResponse();
// This is the place where the response content is sent
echo $rawResponse;
// 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;
}
}