Routing implementation using PHP attributes
Tags: php, reflectionapi, routing, api
Published: 2024-03-10 10:10:04

## Overview

In this article I want to show an experimental example of routing implementation using [PHP attributes](https://www.php.net/manual/en/language.attributes.overview.php).

### Dependencies

In our example application we will manage dependencies via [Composer](https://getcomposer.org/).

We need dependencies for making HTTP calls, app configs and testing.

```json
{
    "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](https://github.com/hakobyansen/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
<?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
<?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
<?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
<?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
<?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](https://github.com/hakobyansen/php-routing-attributes-example) repository. Thanks! :)