Overview
In this article I want to show an experimental example of routing implementation using PHP attributes.
Dependencies
In our example application we will manage dependencies via Composer.
We need dependencies for making HTTP calls, app configs and testing.
{
"name": "example_app",
"autoload": {
"psr-4": {
"App\\": "app/"
}
},
"authors": [
{
"name": "John Smith"
}
],
"require": {
"php": "^8.2",
"vlucas/phpdotenv": "^5.6",
"guzzlehttp/guzzle": "^7.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
}
}
In this application I have decided that each route should be defined in its own route file, kind of single responsibility.
Services, helpers, up and running
We use some services and helpers, that I don't want to copy-paste here, so you can find them in php-routing-attributes-example repository.
Also, in repository you will find the bootstrap.php
which is required
in index.php
. It supposed to load environment variables and handle routes.
Use docker compose
if you want to get the app up and running. Don't forget to create .env
file in the root of the project with following single variable:
JSON_PLACEHOLDER_BASE_URL=https://jsonplaceholder.typicode.com
JSONPlaceholder provides fake API for testing.
Router
Routes
Let's define and implement two routes which are classes CreateUser
and RetrieveUser
.
CreateUser route
<?php
namespace App\Routing\Routes\Users;
use App\Routing\Route;
use App\Routing\RouterBase;
use App\Services\JSONPlaceholder\UserService;
readonly class CreateUser extends RouterBase
{
#[Route(method: 'post', endpoint: '/users')]
public function index(): array
{
$userService = new UserService();
$name = Request::get('name');
$username = Request::get('username');
$user = $userService->createUser([
'name' => $name,
'username' => $username
]);
return $this->response(
message: 'Creating user',
data: $user
);
}
}
RetrieveUser route
<?php
namespace App\Routing\Routes\Users;
use App\Helpers\Request;
use App\Routing\Route;
use App\Routing\RouterBase;
use App\Services\JSONPlaceholder\UserService;
readonly class RetrieveUser extends RouterBase
{
#[Route(method: 'get', endpoint: '/users')]
public function index(): array
{
$userService = new UserService();
$userId = Request::get('id');
$users = $userService->retrieveUser($userId);
return $this->response(
message: 'List of users',
data: $users
);
}
}
In samples above we have defined two classes for user creation and retrieval.
There is method named index
in each route class, and route classes are extending RouterBase
.
RouterBase
<?php
namespace App\Routing;
readonly abstract class RouterBase
{
abstract public function index(): array;
protected function response(
string $message = '',
array $data = [],
int $httpStatusCode = 200
): array
{
http_response_code($httpStatusCode);
return [
'message' => $message,
'data' => $data
];
}
}
As you see RouterBase
contains methods response
and index
. These methods should be used/implemented in child route classes.
Route attribute
Now let's create Route
class and mark it as attribute.
The class will require parameters $method
and $endpoint
, also it will run validation checks to make sure the endpoint is not duplicated, and the method is in array of get
, post
, put
, patch
, delete
items.
We want to be able to define Route attribute:
#[Route(method: 'post', endpoint: '/users')]
.
<?php
namespace App\Routing;
#[Attribute]
final readonly class Route
{
public function __construct(
private string $method,
private string $endpoint,
)
{
self::validateMethod();
self::validateEndpoint();
}
public function validateMethod(): void
{
$method = strtolower($this->method);
$allowedMethods = ['get', 'post', 'put', 'patch', 'delete'];
if(!in_array($method, $allowedMethods)) {
throw new \Exception("Method {$method} not allowed");
}
}
public function validateEndpoint(): void
{
$endpoint = strtolower($this->endpoint);
$routes = RouterHandler::getRegisteredRoutes();
foreach ($routes as $route) {
if($route['endpoint'] === $endpoint) {
throw new \Exception("Endopint {$endpoint} is already registered");
}
}
}
}
Basically to create a route in this application you need to implement a route class with index()
method.
RouterHandler
<?php
namespace App\Routing;
use App\Helpers\FileSystemUtil;
class RouterHandler
{
private static array $registeredRoutes = [];
private static string $routesDir = __DIR__ . '/Routes';
public static function handle(): void
{
self::register();
$uri = isset($_SERVER['REDIRECT_URL']) ? strtolower($_SERVER['REDIRECT_URL']) : '/';
$method = strtolower($_SERVER['REQUEST_METHOD']);
$executableRoute = null;
foreach (self::getRegisteredRoutes() as $route)
{
if($route['endpoint'] === $uri && $route['method'] === $method) {
$executableRoute = $route;
break;
}
}
if(!$executableRoute) {
http_response_code(404);
return;
}
$executable = new $executableRoute['executable'];
echo json_encode($executable->index());
}
public static function register(): void
{
$routeFiles = FileSystemUtil::getFilesFromFolder(self::$routesDir);
foreach ($routeFiles as $file)
{
$explode = explode('app/', $file);
$class = 'App\\' . str_replace('/', '\\', ltrim($explode[1], '/'));
$class = explode('.', $class)[0];
$reflection = new \ReflectionClass($class);
if ($reflection->isAbstract()) {
continue;
}
$attributes = $reflection->getMethod('index')->getAttributes(Route::class);
foreach ($attributes as $attribute)
{
$arguments = $attribute->getArguments();
self::$registeredRoutes[] = [
'method' => $arguments['method'],
'endpoint' => $arguments['endpoint'],
'executable' => $class,
];
}
}
}
public static function getRegisteredRoutes(): array
{
return self::$registeredRoutes;
}
}
RouterHandler
is the core component where all the magic happens.
To handle routes we iterate through App/Routing/Routes
directory and retrieving all route files.
We use Reflection API to get the index
method with Route
attribute to know the registering route method and endpoint (URI).
Finally, when request happens, we get uri
and method
from $_SERVER
superglobal variable, and if appropriate route found, we execute the index
method of that route.
That's all. Further you can explore the php-routing-attributes-example repository. Thanks! :)
Top comments (2)
This a great tutorial to show how routing works in a framework.
But for production code I recommend to use a routing library like symfony.com/doc/current/create_fra...
Of course! This app is just an example for the article. :)