Routing implementation using PHP attributes

hakobyansen

Senik Hakobyan

Posted on March 10, 2024

Routing implementation using PHP attributes

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"
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

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");
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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! :)

💖 💪 🙅 🚩
hakobyansen
Senik Hakobyan

Posted on March 10, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related