Créer framework PHP : Routage

Le rôle du routeur est de transmettre la requête HTTP au contrôleur adapté à son traitement.

Icône de calendrier
Avancé
10 chapitres

Rôle du routeur

Le rôle du routage est de savoir quelle page ou quelle action l’utilisateur souhaite en analysant l’URL et ses paramètres.

Lorsqu’une requête de l’utilisateur pointera 📄 index.php, il faudra faire en sorte de lui faire passer un paramètre de requête HTTP GET afin de spécifier ce que veut l’utilisateur. C’est le fondement de notre routage.

Exemple : plugo.local/index.php?path=/, plugo.local/index.php?path=/contact

En analysant le paramètre d’URL $_GET['path'], notre routeur saura à quel contrôleur passer le flambeau.

Définition des routes

Commençons par définir les routes de notre application dans un fichier de configuration dédié.

Ce fichier va contenir l’ensemble des routes d’une application.

routes.php
copié !
const ROUTES = [
	'/' => [
		'controller' => App\Controller\MainController::class,
		'method' => 'home'
	],
];

Ce fichier représente un tableau associatif dans lequel chaque clé correspond au chemin d’une route. Pour chaque route on spécifiera dans un sous-tableau les clés :

  1. controller : le nom de classe de contrôleur à instancier (ainsi que son namespace)
  2. method : la méthode à appeler depuis l’objet ainsi créé

Maintenant, il est temps d’écrire le code du routeur qui analysera le paramètre $_GET['path'] et déléguera le traitement à la bonne méthode de la bonne classe grâce à notre fichier de configuration.

Création du routeur

Il est temps de créer notre première classe PHP !

1. Définition du namespace

Dans un souci de modularité, nous définirons systématiquement les classes de notre framework au sein de namespaces. Le routeur étant un élément propre au framework et non à la logique métier de l’application (cf. chapitre précédent), il commencera par Plugo et sera suivi de Router, dossier dans lequel il se trouve.

Router.php
copié !
namespace Plugo\Router;

2. Importation des routes

Tout d’abord, on importe le fichier 📄 config/routes.php et par extension la constante ROUTES contenue à l’intérieur.

Router.php
copié !
namespace Plugo\Router;
require dirname(__DIR__, 2) . '/config/routes.php';

3. Création de la classe

Il est temps de créer notre classe Router.

Router.php
copié !
// ...

class Router {

}

4. Définition des attributs

Pour fonctionner, notre routeur nécessitera de connaître 3 informations, définies dans des attributs :

  1. $routes : l’ensemble des routes de l’application (définies dans 📄 config/routes.php)
  2. $availablePaths : l’ensemble des chemins contenus dans ces routes (Exemple : /, /mentions-legales…)
  3. $requestedPath : le chemin demandé par le client
Router.php
copié !
class Router {

	// ...

	private $routes;
	private $availablePaths;
	private $requestedPath;

}

5. Création du constructeur

Le constucteur aura ici un rôle double :

  • Celui d’initialiser les attributs
  • Celui de déclencher l’analyse des routes
Router.php
copié !
class Router {

	// ...

	public function __construct() {
		$this->routes = ROUTES;
		$this->availablePaths = array_keys($this->routes);
		$this->requestedPath = isset($_GET['path']) ? $_GET['path'] : '/';
		$this->parseRoutes();
	}

}

Dans $routes, on récupère les routes déclarées dans la constante ROUTES du fichier 📄 config/routes.php.

Dans $availablePaths, on récupère dans un tableau toutes les clés de la constante ROUTES avec la fonction array_keys(). Ces clés correspondent aux chemins des routes définis dans le fichier de routage.

La constante ROUTES suivante :

const ROUTES = [
	'/' => [
		'controller' => App\Controller\MainController::class,
		'method' => 'home'
	],
		'/contact' => [
		'controller' => App\Controller\MainController::class,
		'method' => 'contact'
	]
];

Donnerait l’attribut $availablePaths suivant :

array (size=2)
  0 => string '/' (length=1)
  1 => string '/contact' (length=8)

Dans $requestedPath, on récupère la valeur du paramètre $_GET['path'], ou / s’il n’est pas défini dans l’URL.

Pour l’URL plugo.local/index.php?path=/formations/php, $requestedPath récupererait /formations/php.

Enfin, notre constructeur appelle parseRoutes(), une méthode qui sera dédiée à l’analyse des routes. Il est temps de l’implémenter.

6. Analyse des routes

Créons une méthode private parseRoutes().

Router.php
copié !
class Router {

	// ...

	private function parseRoutes() { }

}

Pour déterminer quel contrôleur le routeur doit déclencher, il faut analyser la valeur du paramètre $_GET['path'] et le type de route rencontré :

  • Statique : il s’agit du scénario simple où une route correspond à une unique page. Exemples : /contact, /dashboard/statistiques, /mentions-legales, etc.
  • Dynamique : il s’agit du scénario complexe où une route correspond potentiellement à plusieurs pages. Exemples : /formations/{slug_formation} (/formations/php, /formations/python…), etc.

Partons du principe qu’une route statique sera écrite telle quelle et qu’une route dynamique verra son/ses paramètre(s) de route noté(s) entre accolades {}.

routes.php
const ROUTES = [
	'/formations' => [ //... ], // Route statique
	'/formations/{slug_formation}' => [ //... ], // Route dynamique
];

Si nous n’avions que des routes statiques, alors router notre application se résumerait à rechercher une route équivalente à $requestedPath dans $availablePaths, et déclencher la méthode de contrôleur associée, avec une condition de ce type :

if (in_array($_GET['path'], $this->availablePaths)) {
	// ...
}

MAIS… il est important de considérer qu’une potentielle route telle que /formations/php ou encore formations/python doit matcher avec la route dynamique au chemin noté /formations/{slug_formation}.

En ce sens, notre routeur devra effectuer un travail d’analyse quelque peu plus complexe.

Cela commence par le fait d’analyser plus en détails les parties constituants le chemin demandé. Pour cela on créé une méthode private explodePath() dont le rôle est de :

  • Supprimer l’éventuel premier et dernier / présent dans le chemin
  • D’éclater $_GET['path'] sur le caractère /
Router.php
copié !
class Router {

	// ...

	private function explodePath(string $path): array {
		return explode('/', rtrim(ltrim($path, '/'), '/'));
	}

}

Le chemin /formations/php, deviendrait alors : ["formations", "php"].

Utilisons désormais cette méthode pour éclater le chemin dans une variable $explodedRequestedPath :

Router.php
copié !
class Router {

	// ...

	private function parseRoutes(): void {
		$explodedRequestedPath = $this->explodePath($this->requestedPath);
	}

}

Aussi il va être essentiel de déclarer un tableau $params, destiné à accueillir les potentiels paramètres de route (notés entre accolades).

Router.php
copié !
class Router {

	// ...

	private function parseRoutes(): void {
		$explodedRequestedPath = $this->explodePath($this->requestedPath);
		$params = [];
	}

}

Il est désormais temps de parcourir une à une les routes définies dans 📄 config/routes.php et de confronter leur path avec celui demandé dans l’URL du navigateur.

Router.php
copié !
class Router {

	// ...

	private function parseRoutes(): void {
		$explodedRequestedPath = $this->explodePath($this->requestedPath);
		$params = [];

		foreach ($this->availablePaths as $candidatePath) {

		}
	}

}

À chaque tour de boucle, $candidatePath contient une route définie dans 📄 config/routes.php.

Partons du principe que la route sur laquelle on itère, matche avec celle demandée par l’utilisateur en définissant le booléen $foundMatch à true.

Router.php
copié !
class Router {

	// ...

	private function parseRoutes(): void {
		$explodedRequestedPath = $this->explodePath($this->requestedPath);
		$params = [];

		foreach ($this->availablePaths as $candidatePath) {
			$foundMatch = true;
		}
	}

}

Après avoir supposé cela, éclatons le chemin candidat $candidatePath sur le caractère /, afin de pouvoir comparer ses portions avec $explodedRequestedPath.

Router.php
copié !
class Router {

	// ...

	private function parseRoutes(): void {
		$explodedRequestedPath = $this->explodePath($this->requestedPath);
		$params = [];

		foreach ($this->availablePaths as $candidatePath) {
			$foundMatch = true;
			$explodedCandidatePath = $this->explodePath($candidatePath); 
		}
	}

}

Si le chemin demandé $explodedCandidatePath n’a pas la même profondeur que le chemin candidat $explodedRequestedPath, alors il est inutile d’aller plus loin… En revanche, si la longueur est identique, analysons chaque portion plus en détails, afin de voir si sa structure correspond.

Router.php
copié !
class Router {

	// ...

	private function parseRoutes(): void {
		$explodedRequestedPath = $this->explodePath($this->requestedPath);
		$params = [];

		foreach ($this->availablePaths as $candidatePath) {
			$foundMatch = true;
			$explodedCandidatePath = $this->explodePath($candidatePath);
			if (count($explodedCandidatePath) == count($explodedRequestedPath)) {
				foreach ($explodedRequestedPath as $key => $requestedPathPart) {

				}
			}
		}
	}

}

Implémentons une méthode private dédiée isParam() dont le rôle est d’analyser si une portion de la route candidate contient un paramètre (autrement dit, les caractères { et }) :

Router.php
copié !
class Router {

	// ...

	private function isParam(string $candidatePathPart): bool {
		return str_contains($candidatePathPart, '{') && str_contains($candidatePathPart, '}');
	}

}

Si le chemin candidat contient un paramètre de route (exemple : /formations/{slug_formation}), alors on ajoute ce paramètre dans le tableau $params.

Appelons la méthode isParam() en lui transmettant la portion actuellement analysée du chemin candidat.

Router.php
copié !
class Router {

	// ...

	private function parseRoutes(): void {
		$explodedRequestedPath = $this->explodePath($this->requestedPath);
		$params = [];

		foreach ($this->availablePaths as $candidatePath) {
			$foundMatch = true;
			$explodedCandidatePath = $this->explodePath($candidatePath);
			if (count($explodedCandidatePath) == count($explodedRequestedPath)) {
				foreach ($explodedRequestedPath as $key => $requestedPathPart) {
					$candidatePathPart = $explodedCandidatePath[$key];
					if ($this->isParam($candidatePathPart)) {
						$params[substr($candidatePathPart, 1, -1)] = $requestedPathPart;
					}
				}
			}
		}
	}

}

Imaginons que :

  • Le chemin demandé par le client est /formations/php
  • Le chemin candidat sur lequel on boucle est /formations/{slug_formation}

Cette condition aura pour effet de mettre à jour $params de la manière suivante :

array (size=1)
  'slug_formation' => string 'php' (length=3)

Si le chelmin candidat ne possède pas de paramètre mais bien une portion statique, il faut la comparer à la portion correspondante du chemin demandé par le client.

Router.php
copié !
class Router {

	// ...

	private function parseRoutes(): void {
		$explodedRequestedPath = $this->explodePath($this->requestedPath);
		$params = [];

		foreach ($this->availablePaths as $candidatePath) {
			$foundMatch = true;
			$explodedCandidatePath = $this->explodePath($candidatePath);
			if (count($explodedCandidatePath) == count($explodedRequestedPath)) {
				foreach ($explodedRequestedPath as $key => $requestedPathPart) {
					$candidatePathPart = $explodedCandidatePath[$key];
					if ($this->isParam($candidatePathPart)) {
						$params[substr($candidatePathPart, 1, -1)] = $requestedPathPart;
					}
					else if ($candidatePathPart !== $requestedPathPart) {
						$foundMatch = false;
						break;
					}
				}
			}
		}
	}

}

Si les portions ne correspondent pas, alors nous sommes certains que le chemin candidat ne correspond pas au chemin demandé, on ne s’intéresse pas aux portions restantes en sortant de la boucle foreach via l’instruction break (afin d’éviter tout tour de boucle inutile).

Aussi, comme la route candidate ne matche pas, on définit $foundMatch à false.

Dès lors que l’on a terminé d’analyser une route, on check si $foundMatch est true.

  • Si c’est le cas, on déclare une variable $route ayant pour valeur la route candidate ayant matchée puis on temrine l’analyse des routes candidates.
  • Si ce n’est pas le cas, on réitère le processus d’analyse sur la route candidate suivante, jusqu’à trouver celle qui matche avec la route demandée par le client…
Router.php
copié !
class Router {

	// ...

	private function parseRoutes(): void {
		$explodedRequestedPath = $this->explodePath($this->requestedPath);
		$params = [];

		foreach ($this->availablePaths as $candidatePath) {
			$foundMatch = true;
			$explodedCandidatePath = $this->explodePath($candidatePath);
			if (count($explodedCandidatePath) == count($explodedRequestedPath)) {
				foreach ($explodedRequestedPath as $key => $requestedPathPart) {
					$candidatePathPart = $explodedCandidatePath[$key];
					if ($this->isParam($candidatePathPart)) {
						$params[substr($candidatePathPart, 1, -1)] = $requestedPathPart;
					}
					else if ($candidatePathPart !== $requestedPathPart) {
						$foundMatch = false;
						break;
					}
				}
				if ($foundMatch) {
					$route = $this->routes[$candidatePath];
					break;
				}
			}
		}
	}

}

À ce stade :

  • Une route a été trouvée ($route est définie) : j’instancie le contrôleur correspondant (selon ce qui a été défini dans le fichier config/routes.php) et j’appelle sur l’objet créé, la méthode en question.
  • Aucune route n’a été trouvée…
Router.php
copié !
class Router {

	// ...

	private function parseRoutes(): void {
		$explodedRequestedPath = $this->explodePath($this->requestedPath);
		$params = [];

		foreach ($this->availablePaths as $candidatePath) {
			// ...
		}

		if (isset($route)) {
			$controller = new $route['controller'];
			$controller->{$route['method']}(...$params);
		}
	}

}

Routeur : intégralité du code

Voici l’intégralité du code de notre routeur, détaillé précédemment :

Router.php
copié !
namespace Plugo\Router;

require dirname(__DIR__, 2) . '/config/routes.php';

class Router {

	private $routes;
	private $availablePaths;
	private $requestedPath;

	public function __construct() {
		$this->routes = ROUTES;
		$this->availablePaths = array_keys($this->routes);
		$this->requestedPath = isset($_GET['path']) ? $_GET['path'] : '/';
		$this->parseRoutes();
	}

	private function parseRoutes(): void {
		$explodedRequestedPath = $this->explodePath($this->requestedPath);
		$params = [];

		foreach ($this->availablePaths as $candidatePath) {

			$foundMatch = true;
			$explodedCandidatePath = $this->explodePath($candidatePath);

			if (count($explodedCandidatePath) == count($explodedRequestedPath)) {
				foreach ($explodedRequestedPath as $key => $requestedPathPart) {
					$candidatePathPart = $explodedCandidatePath[$key];

					if ($this->isParam($candidatePathPart)) {
						$params[substr($candidatePathPart, 1, -1)] = $requestedPathPart;
					} else if ($candidatePathPart !== $requestedPathPart) {
						$foundMatch = false;
						break;
					}
				}

				if ($foundMatch) {
					$route = $this->routes[$candidatePath];
					break;
				}
			}
		}

		if (isset($route)) {
			$controller = new $route['controller'];
			$controller->{$route['method']}(...$params);
		}

	}

	private function explodePath(string $path): array {
		return explode("/", rtrim(ltrim($path, '/'), '/'));
	}

	private function isParam(string $candidatePathPart): bool {
		return str_contains($candidatePathPart, '{') && str_contains($candidatePathPart, '}');
	}

}

Mise à jour de index.php

Notre routeur est opérationnel !

Il ne nous reste plus qu’à instancier la classe Router dans notre contrôleur frontal 📄 index.php, afin qu’il déclenche le constructeur. Cela aura pour effet d’analyser la requête HTTP et de la transmettre au contrôleur en charge de retourner une réponse HTTP adaptée au client.

index.php
copié !
<?php

use Plugo\Router\Router;

require dirname(__DIR__) . '/lib/autoload.php';
new Router();

Il est temps de tester l’efficacité de notre système de routage en nous rendant à l’URL : plugo.local/index.php?path=/ (si virtual host défini) ou localhost/public/index.php?path=/.

Si le routeur trouve bien une correspondance pour la valeur du paramètre GET path (ici /) dans le fichier 📄 config/routes.php, alors il devrait essayer de déclencher la méthode de contrôleur associée.

Je dis bien essayer… car le contrôleur en question n’existe pas encore ! À ce stade, on obtient alors l’erreur PHP suivante :

Fatal error: Uncaught Exception: Fichier « {chemin_vers_repertoire_racine}/nom-projet/src/Controller/MainController.php » introuvable pour la classe « App\Controller\MainController ».
Vérifier le chemin, le nom de la classe ou le namespace in {chemin_vers_repertoire_racine}\nom-projet\lib\autoload.php on line 20

Si avoir des URLs comportant ...index.php?path=... vous irrite, je vous recommande de mettre en place de belles URLs grâce à l’URL rewriting.