Surcharger une route d'un contrôleur séparé dans Silex

Si vous développez des applications web de moyenne envergure à l'aide du micro-framework PHP5.3+ Silex, vous n'ignorez probablement pas (n'est-ce pas ?) que la structure de celui-ci peut être découplée, passant d'un unique fichier index.php à une multitude de contrôleurs distincts.

Tout cela est bien beau, mais peut l'être encore plus si vous organisez vos contrôleurs de manière à faire certains d'entre eux hériter d'autres. Ceci peut être particulièrement utile si vous avez plusieurs contrôleurs dont les fonctionnalités sont très similaires. Vous pouvez par exemple procéder ainsi :

<?php

namespace MyCoolApp\Controller;

// je zappe volontairement les use pour gagner du temps

abstract class AbstractController implements ControllerProviderInterface
{
public function connect(Application $app)
{
// Cette méthode est appelée par défaut par Silex lorsque vous "montez" un contrôleur dans votre application.

return $this->route($app);
}

protected function route($app, $extra_params = null)
{
$ctrl = $app['controllers_factory'];

$ctrl->get(
'/my-first-uri',
function () use ($app, $extra_params) {
// ...
}
);

$ctrl->get(
'/my-second-uri',
function () use ($app, $extra_params) {
// ...
}
);

return $ctrl;
}
}

<?php

namespace MyCoolApp\Controller;

// idem

class TrueController extends AbstractController
{
protected function route($app, $extra_params = array('param1' => 'value1', 'param2' => 'value2'))
{
// En une ligne, nous assignons à ce contrôleur toutes les routes déclarées dans son parent
// Celles-ci récupèrent du même coup des paramètres supplémentaires propres à chaque classe fille

$ctrl = parent::route($app, $extra_params);

// Nous pouvons déclarer d'autres routes ici le cas échéant...

return $ctrl;
}
}

Plutôt sympa, non ? Pourtant, il reste un souci de taille : lorsqu'il sélectionne la route qui sera utilisée pour répondre à une requête, Silex prend la première qu'il trouve. Dans ce que nous venons de mettre en place, les routes du parent sont définies avant celles de l'enfant ; ainsi, si nous faisons la chose suivante :

// Dans le parent

$ctrl->post(
'/my-post-uri',
function () use ($app) {
return new Response('Luke, je suis ton père !');
}
);

// Dans l'enfant (roooh)

$ctrl->post(
'/my-post-uri',
function () use ($app) {
return new Response('Areuh areuh');
}
);

Lorsqu'on appellera /my-post-uri dans le contrôleur (enfant) concerné, c'est le code du parent qui s'exécutera et la célèbre réplique de film qui s'affichera sous nos yeux ébahis.

Notez que dans certains cas (et notamment sur des routes utilisant la méthode GET), la version de l'enfant supplantera celle du parent. Je vous avoue ne pas avoir suffisamment plongé dans le code pour m'expliquer pourquoi, mais je serai ravi de l'apprendre si vous avez la réponse.

Il faudrait donc que nous ayons la possibilité de "supprimer" une route déclarée préalablement dans un contrôleur, afin de pouvoir la supplanter dans un tel cas. Hélas, $app['controllers_factory'], en tant qu'instance de Silex\ControllerCollection, ne nous le permet pas. Qu'à cela ne tienne, nous allons étendre cette classe afin de le lui apprendre ! Nous nommerons la méthode idoine cancel, afin d'éviter toute confusion avec la méthode delete :

<?php

namespace MyCoolApp\Whatever;

use Silex\ControllerCollection as BaseControllerCollection;

class ControllerCollection extends BaseControllerCollection
{
public function cancel($path, $methods = array('GET', 'POST', 'PUT', 'DELETE'))
{
$methods = array_map('strtoupper', (array)$methods);

foreach ($this->controllers as $key => $controller) {
$route = $controller->getRoute();

// La route courante nous intéresse si :
// - son path est identique à celui recherché
// - elle emploie une ou plusieurs méthodes parmi celles que nous voulons annuler pour ledit path

if (($route->getPath() == $path) && (count(array_intersect($methods, $route->getMethods())))) {
$methods_diff = array_diff($route->getMethods(), $methods);

if (! count($methods_diff)) {
// Si nous éliminons toutes les méthodes pour cette route, nous pouvons la faire disparaître totalement
unset($this->controllers[$key]);
} else {
// Sinon, on redéfinit ses méthodes avec ce qui reste
$controller->getRoute()->setMethods($methods_diff);
}
}
}

return $this; // pour pouvoir chaîner cette méthode avec d'autres
}
}

Il nous faut ensuite, dans index.php, indiquer à Silex d'utiliser cette classe :

$app = new Silex\Application();

$app['controllers_factory'] = function () use ($app) {
return new MyCoolApp\Whatever\ControllerCollection($app['route_factory']);
};

// Faites ensuite vos $app->mount(...)

Enfin, cette méthode s'utilisera ainsi, dans la classe fille :

$ctrl->cancel('/my-post-uri', 'post');

$ctrl->post(
'/my-post-uri',
function() use ($app) {
return new Response('Areuh areuh');
}
);

Cette fois, la même requête recevra bien la réponse attendue.