PHP: Projet Silex - Jouons avec Silex

Si vous découvrez le projet, je vous invite à commencer par ici, je pense que ce sera mieux pour vous plutôt que de prendre en cours de route.
Pour les fidèles, on retrouve notre road map habituelle :

Road map du projet

  1. Pré-requis et architecture
  2. Configuration de Silex
  3. Jouons avec Silex
  4. Ecriture de tests fonctionnels
  5. TDD pour le code métier
  6. Templating

Contrairement à la dernière fois, nous n'allons pas avancer sur le projet aujourd'hui car nous allons essayer de comprendre un peu comment fonctionne Silex et nous allons essayer de faire ressortir quelques petites astuces afin de mieux utiliser le framework par la suite.
Comme prévu (et demandé la dernière fois), j'ai créé un dépôt Github sur lequel j'ai placé les sources donc vous pouvez les récupérer ainsi :

git clone --no-checkout git@github.com:eexit/Portfolio.git
git checkout 2cc4529a1345b6cfbd34
git submodule update --init --recursive

Du coup, n'oubliez pas de re-télécharger Silex :

curl https://silex-project.org/get/silex.phar -o vendor/Silex/silex.phar

Je vous fournis donc une base de travail mais tout ce qui va être testé aujourd'hui ne sera pas commité, c'est session bac-à-sable aujourd'hui.

3. Jouons avec Silex

Gros-gros article donc pour vous simplifier la tâche :

  • Parser n’importe quelle route
  • Conversion à la volée
  • Alias de route
  • Importer des librairies externes
  • Récupérer un service interne
    • Liste des namespaces/préfixes déclarés
    • Liste des routes déclarées
  • Déclarer un nouveau service
  • Service partagé
  • Stockage de closure
  • Instances de Silex imbriquées

Parser n'importe quelle route

Ce qui est vraiment énorme avec Silex, c'est qu'on peut faire pas mal de chose avec presque rien. On va voir comment rediriger toutes les requêtes GET sans lever d'erreur en définissant une assertion dans la route. Bon, certes cela ne sert à rien mais c'est tout de même bon à savoir.

Créez une route de cette manière-ci :

<?php
// portfolio.php
 
$app->get('/{q}', function($q) use ($app) {    
    return $app['request']->getRequestUri();
})->assert('q', '(.*)?');

Vous pouvez tester avec l'URL suivante : https://local.dev.photo.eexit/foo-bar^baz`yux'_"tux" :

Remarque : la valeur d'échappement des quotes par défaut (ENT_COMPAT) est appliquée.

Conversion à la volée

Une méthode a été introduite assez récemment permettant de traiter la route comme par exemple ici le remplacement d'un mot :

<?php
// portfolio.php
 
$app->get('/{q}', function($q) use ($app) {    
    return $q;
})
->assert('q', '(.*)?')
->convert('q', function($q) {
    return str_replace('foo', 'fou', $q);
});

On y retourne : https://local.dev.photo.eexit/foo-bar^baz`yux'_"tux" :

Remarque : on ne retourne plus $app['request']->getRequestUri(); mais bien $q qui correspond au pattern {q} après traitement ; et la valeur n'est plus échappée !

Ce que je trouve dommage avec cette méthode de conversion, c'est qu'elle n'accepte finalement que le type Closure et pas de nom de fonction callable comme un strtolower par exemple...

Alias de route

Si et seulement si vous avez des traitements spécifiques avec les routes dans votre contrôleur, il peut être intéressant de spécifier des alias à vos routes, aussi longues soient-elles. Je vous montrerais un usage utile un peu plus loin dans l'article.
En attendant, voici comment procéder :

<?php
// portfolio.php
 
$app->get('/{q}', function($q) use ($app) {    
    return $q;
})
->bind('stoneage') // alias de la route
->assert('q', '(.*)?')
->convert('q', function($q) {
    return str_replace('foo', 'fou', $q);
});

Visuellement, cela n'a aucun intérêt et ne sert à rien si vous n'en avez pas usage...

Sinon, si vos yeux fonctionnent bien, vous pourrez trouver encore quelques chouettes méthodes à explorer sur la page de documentation de Silex comme la redirection, le pre et post-filtrage des requêtes HTTP, la définition d'une valeur de variable de route par défaut et l'échappement.

Importer des librairies externes

L'autoloader de Symfony est partie intégrante de Silex donc il est très simple de déclarer une nouvelle librairie.
Prenons exemple pour une lib qui utilise des namespaces (Foo) et des préfixes (Bar) :

tree vendor/FooBar 
vendor/FooBar
└── lib
    ├── Bar
    │  └── Bar.php
    └── Foo
        └── Foo.php

Vous pourrez les utiliser ainsi dans votre application Silex :

<?php
// portfolio.php
 
$app['foobar.path'] = __DIR__ . '/../vendor/FooBar/lib';
$app['autoloader']->registerNamespace('Foo', $app['foobar.path']);
$app['autoloader']->registerPrefix('Bar_', $app['foobar.path']);

Note : déclarer vos librairies ne lèvera pas d'erreur si les chemins sont faux, seulement lors de l'appel des classes

Récupérer un service interne

Nous avons vu que Silex hérite de Pimple donc nous pouvons accéder à ses services internes comme on accède aux éléments d'un tableau. De cette manière, on va presque tout pouvoir récupérer : disséquons Silex !

Liste des namespaces/préfixes déclarés

Parfait ! Nous venons de déclarer 2 nouvelles libraires en plus de Monolog et de Twig (que nous avions déjà déclaré la fois dernière). Avec ce petit bout de code, nous allons avoir un aperçu des librairies déclarées :

<?php
// portfolio
 
$app->get('/autoloader', function() use ($app) {
    $output = '<p>Namespaces :</p><ul>' . PHP_EOL;
    foreach ($app['autoloader']->getNamespaces() as $ns => $path) {
        $output .= sprintf('<li>%s => %s</li>%s', $ns, implode(', ', $path), PHP_EOL);
    }
    $output .= '</ul>' . PHP_EOL . '<p>Prefixes</p><ul>';
    foreach ($app['autoloader']->getPrefixes() as $px => $path) {
        $output .= sprintf('<li>%s => %s</li>%s', $px, implode(', ', $path), PHP_EOL);
    }
    $output .= '</ul>';
    return $output;
});

Allez faire un tour sur https://local.dev.photo.eexit/autoloader :

Liste des routes déclarées

Pour cela, on va utiliser notre librairie externe Foo de tout à l'heure. Vous allez voir comment on va dumper les routes de Symfony avec un petit bout de code :

<?php
// Foo.php
 
namespace Foo;
 
class Foo
{
    public function routeDumper(\ArrayIterator $routes)
    {
        $output = '<p>Routes :</p><ul>' . PHP_EOL;
        foreach ($routes as $name => $route) {
            $pattern_entity = trim($route->getPattern(), '{/}');
            $req = $route->getRequirements();
            $default = $route->getDefaults();
            $output .= sprintf('<li><dl><dt>Route name</dt><dd>%s</dd><dt>Route pattern</dt><dd>%s</dd>', $name, $route->getPattern());
 
            if (!empty($default[$pattern_entity])) {
                $output .= sprintf('<dt>Default value</dt><dd>%s</dd>', $default[$pattern_entity]);
            }
 
            if (!empty($req[$pattern_entity])) {
                $output .= sprintf('<dt>Must apply to</dt><dd>%s</dd>', $req[$pattern_entity]);
            }
 
            $output .= sprintf('</dl></li>%s', PHP_EOL);
        }
        $output .= '</ul>';
        return $output;
    }
}

On retourne maintenant dans notre fichier portfolio.php :

<?php
// portfolio.php
 
// Bootstrap + déclaration des librairies externes + etc.
$foo = new \Foo\Foo();
 
$app->get('/autoloader', function() use ($app) {
    // code
});
 
$app->get('/{q}', function() use ($app, $foo) {
    return $foo->routeDumper($app['routes']->getIterator());
})
->bind('stoneage')
->value('q', 'Joris Berthelot')
->assert('q', '(.*)?');

A partir de ce moment-là, vous pouvez aller sur n'importe quelle route (sauf sur /autoloader) afin de voir afficher ceci :

De la même manière, vous pouvez dumper tous les core services de Silex que vous trouverez listés dans la documentation.

Déclarer un nouveau service

Sinon pour être vachement dans la mode de @fabpot, on peut déclarer notre objet Foo en temps que service et l'inclure directement dans notre application grâce au DIC. Cela nous évitera de passer un argument supplémentaire à notre closure-contrôleur mais surtout cela nous permettra d'agrémenter un peu la déclaration de notre service en amont.

Nous n'allons pas le faire avec Foo mais avec Bar (histoire de changer un peu de pied) :

<?php
// Bar.php
 
class Bar_Bar
{
    private static $_id = 0;
 
    public function __construct()
    {
        self::$_id++;
    }
 
    public function getId()
    {
        return self::$_id;
    }
}

On mets juste en place un compteur afin de savoir ce qu'il se passe quand on utilise notre service, on verra juste après.
Maintenant que notre (pauvre) service a un corps de métier, on va pouvoir le déclarer et l'utiliser dans une route :

<?php
// portfolio.php
 
// Bootstrap + déclaration des librairies externes + etc.
$app['service.bar'] = function() {
    return new Bar_Bar();
};
 
$app->get('/service/bar', function() use ($app) {
    $output = '<ol>' . PHP_EOL;
    for ($i = 0; $i < 10; $i++) {
        $output .= vsprintf('<li>Call ID: %d</li>%s', array(
            $app['service.bar']->getId(), PHP_EOL
        ));
    }
    $output .= '</ol>';
    return $output;
});

Si vous allez maintenant sur cette page (https://local.dev.photo.eexit/service/bar), vous vous trouverez en face de cela :

Comme on peut le voir de manière évidente, chaque appel au service nous renvoi une instance du service et ça n'est pas forcément désirable si vous souhaitez avoir un service persistant.

Remarque : si vous vous retrouvez sur le route dumper, sachez que Silex applique la première route qui correspond à sa pile de routes. Tout comme pour les règles de réécriture Apache : écrivez les routes les plus spécifiques en premier et hiérarchisez le tout.

Service partagé

Pour palier à ce problème, Silex propose une méthode qui va permettre de conserver notre instance : la possibilité de faire un Singleton en fait.

<?php
// portfolio.php
 
// Bootstrap + déclaration des librairies externes + etc.
$app['service.bar'] = $app->share(function() {
    return new Bar_Bar();
});
 
// Reste du code

Un bref rafraichissement nous permet de tout de suite voir le résultat :

Nous avons donc obtenu le résultat désiré, nous pouvons imaginer que ce service est un session handler ou pourquoi pas une connexion à une base de données ?

Stockage de closure

Dans Silex, les closures sont utilisées comme injecteur de dépendance mais cela peut rendre le code moche si vous souhaitez simplement stocker une closure pour l'exécuter une fois récupérée et non pas récupérer le résultat de son exécution.
C'est pas vraiment un problème puisqu'on peut tout aussi bien injecter une closure qui renvoie elle-même une closure mais c'est moche alors que Pimple a tout prévu :

<?php
// portfolio.php
 
// Ici notre lambda stockée en mode "moche"
$app['closure.inception.hello'] = function() {
    return function() {
        return '<p>Hello world from an inception closure! Go <a href="/protected">protected</a>.</p>';
    };
};
 
// La même chose mais plus propre grâce à Pimple::protect()
$app['closure.protected.hello'] = $app->protect(function() {
    return 'Hello world from a protected closure! Go <a href="/inception">inception</a>.</p>';
});
 
$app->get('/{mode}', function($mode) use ($app) {
    $hello = $app['closure.' . $mode . '.hello'];
    return $hello();
})
->assert('mode', '(protected|inception)')
->value('mode', 'protected');

Direction https://local.dev.photo.eexit/protected et là vous pouvez vous applaudir !

Voici donc comment gagner un peu de temps et de clarté grâce à Pimple mais seulement à condition que vous sachiez exactement ce que cela signifie parce que la notion derrière share et protect n'est pas évidente de tous.

Instances de Silex imbriquées

Alors si il y a bien un truc qui déchire dans Silex, c'est la possibilité de monter votre architecture sous forme de brique, je m'explique : si votre site contient un module blog, portfolio, forum, foo puis bar, le code source de votre fichier de bootstrap risque très vite de devenir un sacré bordel !

En mettant en place la réutilisabilité, Silex permet de monter des application Silex les unes dans les autres ; voici un exemple : je veux créer un module forum pour mon portfolio donc de la même manière que j'ai créé portfolio.php, je créer forum.php et j'y insère le code suivant :

<?php
// forum.php
require_once __DIR__ . '/../vendor/Silex/silex.phar';
 
$app = new Silex\Application();
 
$app->get('/', function() use ($app) {
    return $app->redirect('/forum/list');
});
 
$app->get('/list', function() {
    return "Here is a list of all topics!";
});
 
return $app;

Je viens donc de déclarer mon module forum et maintenant, je souhaite que toutes mes URL commençant par /forum* soient redirigées vers ce module :

<?php
// porfolio.php
require_once __DIR__ . '/../vendor/Silex/silex.phar';
$app = new Silex\Application();
 
$forum = require_once __DIR__ . '/forum.php';
$app->mount('/forum', $forum);
 
// Reste du code de mon portfolio

Magique n'est-ce pas ? Go https://local.dev.photo.eexit/forum et admirez la puissance de Silex :

Il est clair que Silex a tout à envier d'un micro-framework puisqu'il offre une possibilité quasi-inifinie de modularité dans son utilisation. Nous pouvons bien le qualifier de framework puisqu'il permet réellement de gagner du temps sur des choses qui pourraient être bien complexes lors de la mise en route d'un projet, sans compter les nombreuses extensions déjà à disposition.
Dans cet article, nous avons vu comment travailler avec un outil performant, fiable mais sans se perdre dans 50 fichiers de configuration comme dans les gros frameworks.

Personnellement, j'ai pris autant de plaisir à rédiger cet article pour vous que de découvrir les possibilités qu'offre Silex. La prochaine fois, nous verrons comment écrire des tests fonctionnels. N'hésitez pas à me suivre sur Twitter pour restez informé.

Aller à l'étape suivante ›

‹ Aller à l'étape précédente