Using the PHP 8 attributes feature to implement Symfony-alike routing.
The image above illustrates ChatGPT's vision of the phrase 'an elephant biting the bullet Salvador Dali style'.

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.

  • ‘method’ is for HTTP method
  • ‘path’ is for the path component of the requested URI
  • ‘name’ is 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/Controllers directory into the array
  • transform these filenames into the class names by removing the ‘.php’ extension.
  • 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.


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:

  1. validate that the route name is unique
  2. 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 ;-)

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;
	}
}        


要查看或添加评论,请登录

Viktar Dubiniuk的更多文章

社区洞察

其他会员也浏览了