Expérience avec le PSR-0 : Drupal, Zend Framework 2, Symfony 2

Portrait de lewinp

Introduction

Le PSR-0 est une norme du PHP Framework Interop Group basée sur les espaces de noms (namespaces, PHP >= 5.3) pour permettre la création de composants interopérables et chargeables (par autoloading) sans efforts.

Ceci est une petite expérience permettant de montrer l’intérêt du PSR-0.

Il faut un niveau un peu avancé en PHP objet pour bien comprendre cet article.

Le projet

Dans cette expérience, nous allons créer une petite application la plus robuste possible en utilisant des composants de différents frameworks.

Le projet choisi est une application pour uploader un fichier sur un serveur en SSH.

Composants (Packages) et dépendances

Choix des composants

Base Silex\Silex Micro-framework basé sur Symfony 2 avec une bonne intégration de Twig
ServiceManager Zend\ServiceManager ServiceManager simple à utiliser à partir d’un array de configuration
Formulaires Zend\Form Utile pour le rendu de formulaire et facilite la validation des données
Transfert de fichiers Drupal\Core\FileTransfer\SSH Implémentation de FileTransfer de Drupal 8 utilisé pour les mises à jour. Facilement remplaçable par FTP ou Local
Templates Twig\Twig Système de templates pour les vues MVC de plus en plus répandu
Thème de base Bootstrap Style prêt à être utilisé pour qu’on puisse se concentrer sur le code.

On va voir plus loin comment le PSR-0 nous simplifie la vie pour utiliser tous ces différents composants.

Gestion des dépendances

Pour la gestion des dépendances et la génération de l’autoloader, on utilisera Composer.

Arborescence du projet

J’ai choisis d’utiliser 2 fichiers php à la Zend pour la configuration de l’application.
Le fichier config/global.config.php retourne un array avec la configuration globale à écrire dans le référentiel de code.
Le fichier config/local.config.php contient la configuration renseignée par l’utilisateur de l’application. Il n’est pas ajouté dans le référentiel de code.
Les fichiers uploadés seront stockés dans le dossier /upload.
Le dossier /views contiendra les vues Twig.

/config/global.config.php
/config/local.config.php
/css
/img
/js
/upload
/vendor
/views
/composer.json
/index.php

Mise en place du projet

Création du composer.json

{
   "name" : "Simple Uploader",
   "description" : "PSR0",
   "type" : "application",
   "authors" : [{
           "name" : "Philippe Lewin",
           "email" : "philippe.lewin @ gmail.com"
       }
   ],
   "keywords" : [
       "demo",
       "psr0"
   ],
   "homepage" : "http://www.clubnix.fr",
   "license" : [
       "GPL-3.0+"
   ],
   "require" : {
       "easyrdf/easyrdf" : "0.8.0-beta.1",
       "twig/twig" : ">=1.8,<2.0-dev",
       "symfony-cmf/routing" : ">=1.0.1",
       "php" : ">=5.3.3",
       "drupal/drupal" : "8.x-dev",
       "kriswallsmith/assetic" : "v1.1.0-alpha1",
       "silex/silex" : "1.0.*@dev",
       "zendframework/zendframework" : ">=2.1.0"
   },

   "minimum-stability" : "dev",
   "repositories" : [
           "type" : "vcs",
           "url" : "git://github.com/drupal/drupal.git"
       }, {
           "type" : "vcs",
           "url" : "git://github.com/kriswallsmith/assetic.git"
       }, {
           "type" : "vcs",
           "url" : "git://github.com/symfony-cmf/Routing.git"
       }
   ],
   "config" : {
       "process-timeout" : 3000
   },
   "minimun-stability" : "dev"
}

Installation des dépendances

$ composer install

Loading composer repositories with package information
 Installing dependencies 
 - Installing symfony/yaml (v2.2.0)
    Loading from cache
 - Installing psr/log (1.0.0)
    Loading from cache
 - Installing twig/twig (v1.12.1)
    Loading from cache
 - Installing doctrine/common (2.3.0)
    Loading from cache
 - Installing phpunit/php-text-template (dev-master 1eeef10)
    Cloning 1eeef106193d2f8c539728e566bb4793071a9e18
 - Installing phpunit/phpunit-mock-objects (1.2.x-dev 8e9d897)
    Cloning 8e9d897b39ca6310ccc2d81b8f29cabd5ce12786
 - Installing phpunit/php-timer (1.0.x-dev ecf7920)
    Cloning ecf7920b27003a9412b07dad79dbb5ad1249e6c3
 - Installing phpunit/php-token-stream (dev-master c25dd88)
    Cloning c25dd88e1592e66dee2553c99ef244203d5a1b98
 - Installing phpunit/php-file-iterator (dev-master 2deb24c)
    Cloning 2deb24c65ea78e126daa8d45b2089ddc29ec1d26
 - Installing phpunit/php-code-coverage (1.2.x-dev b0ec2d0)
    Cloning b0ec2d014fe497315aad876c6289a38fd4c075ff
 - Installing phpunit/phpunit (3.7.15)
    Loading from cache
 - Installing easyrdf/easyrdf (0.8.0-beta.1)
    Loading from cache
 - Installing symfony/http-foundation (v2.2.0)
    Loading from cache
 - Installing symfony/event-dispatcher (v2.2.0)
    Loading from cache
 - Installing symfony/http-kernel (v2.2.0)
    Loading from cache
 - Installing symfony/routing (v2.2.0)
    Loading from cache
 - Installing symfony-cmf/routing (dev-master 1d9cff6)
    Cloning 1d9cff60cf3690831b0555dbc1f4b1b81cd50de3
 - Installing symfony/process (2.1.x-dev 3b1b54a)
    Cloning 3b1b54a6148e505132e397ae05cbf58c9f6b9f29
 - Installing kriswallsmith/assetic (v1.1.0-alpha1)
    Loading from cache
 - Installing guzzle/common (v3.1.0)
    Loading from cache
 - Installing guzzle/stream (v3.1.0)
    Loading from cache
 - Installing guzzle/parser (v3.1.0)
    Loading from cache
 - Installing guzzle/http (v3.1.0)
    Loading from cache
 - Installing symfony/translation (dev-master 4c354b0)
    Cloning 4c354b0f1bb4278c0179a38f8ef850a06f943cc3
 - Installing symfony/validator (v2.2.0)
    Loading from cache
 - Installing symfony/serializer (v2.2.0)
    Loading from cache
 - Installing symfony/dependency-injection (v2.2.0)
    Loading from cache
 - Installing symfony/class-loader (v2.2.0)
    Loading from cache
 - Installing drupal/drupal (8.x-dev ca665f7)
    Cloning ca665f7be315aedc499228b156bd194982d1cd74
 - Installing pimple/pimple (dev-master v1.0.2)
    Cloning v1.0.2
 - Installing silex/silex (1.0.x-dev 7ae0fd8)
    Cloning 7ae0fd8b871eaebf95b856940c47679da40666c6
 - Installing zendframework/zendframework (dev-master 2e22109)
    Cloning 2e22109954ccbc43b9c97e4d47b7b97129e3b5de
phpunit/php-code-coverage suggests installing ext-xdebug (>=2.0.5)
phpunit/phpunit suggests installing phpunit/php-invoker (>=1.1.0,<1.2.0)
symfony/http-kernel suggests installing symfony/browser-kit (2.2.*)
symfony/http-kernel suggests installing symfony/config (2.2.*)
symfony/http-kernel suggests installing symfony/console (2.2.*)
symfony/http-kernel suggests installing symfony/finder (2.2.*)
symfony/routing suggests installing symfony/config (2.2.*)
kriswallsmith/assetic suggests installing leafo/lessphp (Assetic provides the integration with the lessphp LESS compiler)
kriswallsmith/assetic suggests installing leafo/scssphp (Assetic provides the integration with the scssphp SCSS compiler)
kriswallsmith/assetic suggests installing ptachoire/cssembed (Assetic provides the integration with phpcssembed to embed data uris)
symfony/translation suggests installing symfony/config (2.2.*)
symfony/validator suggests installing symfony/locale (2.2.*)
symfony/validator suggests installing symfony/config (2.2.*)
symfony/dependency-injection suggests installing symfony/config (2.2.*)
silex/silex suggests installing symfony/browser-kit (>=2.1,<2.4-dev)
silex/silex suggests installing symfony/css-selector (>=2.1,<2.4-dev)
silex/silex suggests installing symfony/dom-crawler (>=2.1,<2.4-dev)
silex/silex suggests installing symfony/form (>= 2.1.4,<2.4-dev)
zendframework/zendframework suggests installing zendframework/zendservice-recaptcha (ZendService\ReCaptcha for rendering ReCaptchas in Zend\Captcha and/or Zend\Form)
zendframework/zendframework suggests installing zendframework/zendpdf (ZendPdf for creating PDF representations of barcodes)
zendframework/zendframework suggests installing ircmaxell/random-lib (Fallback random byte generator for Zend\Math\Rand if OpenSSL/Mcrypt extensions are unavailable)
zendframework/zendframework suggests installing ext-intl (ext/intl for i18n features (included in default builds of PHP))
zendframework/zendframework suggests installing doctrine/annotations (Doctrine Annotations >=1.0 for annotation features)
zendframework/zendframework suggests installing ocramius/proxy-manager (ProxyManager to handle lazy initialization of services)
Writing lock file
Generating autoload files

Téléchargement de Twitter Bootstrap

http://twitter.github.io/bootstrap/assets/bootstrap.zip
Copier les dossiers css, img et js dans le dossier du projet
Création des fichiers de configuration et d’index.php

config/global.config.php

<?php
return array (
   'upload-form' => array(
   // description du formulaire ici
   ),
   'factories' => array(
   // factories pour le ServiceManager de Zend
   ),
   'invokables' => array (
   // invokables pour le Service Manager de Zend
   ),
);

config/local.config.php

<?php
return array(
);

Fichier index.php

Dans la première version d’index.php on fait l’inclusion de vendor/autoload.php qui est l’autoloader généré par Composer. Composer a généré ce fichier en se basant sur les déclarations “PSR-0” des composer.json des différents packages du dossier vendor. Grâce à l’autoloader dans vendor/autoload.php, lorsqu’une classe est utilisée, le fichier .php contenant la classe sera automatiquement chargée depuis les sources du bon package.
Toujours dans ce fichier index.php, la configuration est aussi chargée et le ServiceManager est configuré.
On en profite pour réaliser un “Hello World” en affichant un message lorsqu’on arrive sur la route “/”.

<?php
require_once 'vendor/autoload.php';
 
use Zend\ServiceManager\Config as ServiceManagerConfig;
use Zend\ServiceManager\ServiceManager;
 
$globalConfig = require_once 'config/global.config.php';
$localConfig  = require_once 'config/local.config.php';
 
$config = array_merge_recursive($globalConfig, $localConfig);
 
$sm = new ServiceManager(new ServiceManagerConfig($config));
 
$app = new Silex\Application();
 
$app->get('/', function () {
    return "Hello World";
});
 
$app->run();

Premier test de l’application

On utilise le serveur intégré à php 5.3
Dans le dossier du projet :
php -S 127.0.0.1:8080

Ouvrir le navigateur à l’adresse http://127.0.0.1:8080/ “Hello World” devrait s’afficher.

Création des vues Twig

Intégration de Twig dans notre application Silex

On commence par ajouter le TwigServiceProvider à l’application Silex pour supporter les vues Twig.
Pour bien faire, on va utiliser une factory du service manager pour contenir l’étape de configuration de notre objet Silex\Application.

Au passage, on va activer le mode debug de Silex au cas où.

Dans global.config.php :

'factories' => array(
    'App' => function($sm) {
        $app = new \Silex\Application();
        $app['debug'] = true;
 
        $app->register($sm->create('TwigServiceProvider'), array('twig.path' => __DIR__.'/../views'));
 
        return $app;
    },
    ),
    'invokables' => array (
        'TwigServiceProvider'        => 'Silex\Provider\TwigServiceProvider',
    ),

Dans index.php, on remplace
$app  = new Silex\Application();
par
$app  = $sm->create('App');

Création de notre template

views/template.twig

<!DOCTYPE html>
<html lang="fr">
 <head>
    <meta charset="utf-8">
    <title>Simple Upload Form</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="description" content="Simple Upload Form">
    <meta name="author" content="Philippe Lewin">
    <link href="css/bootstrap.min.css" rel="stylesheet">
    <link href="css/custom.css" rel="stylesheet">
    <link href="css/bootstrap-responsive.min.css" rel="stylesheet">
</head>
<body>
   <div class="container">
   {% block content %}{% endblock %}
   </div>
   <script src="js/bootstrap.min.js"></script>
</body>
</html>

css/custom.css

body {
   padding-top: 40px;
   padding-bottom: 40px;
   background-color: #f5f5f5;
}
.form-upload {
   max-width: 300px;
   padding: 19px 29px 29px;
   margin: 0 auto 20px;
   background-color: #fff;
   border: 1px solid #e5e5e5;
   -webkit-border-radius: 5px;
   -moz-border-radius: 5px;
   border-radius: 5px;
   -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
   -moz-box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
   box-shadow: 0 1px 2px rgba(0, 0, 0, .05);
}
.form-upload .form-upload-heading,
.form-upload input[type="text"],
.form-upload input[type="password"] {
   font-size: 16px;
   height: auto;
   margin-bottom: 15px;
   padding: 7px 9px;
}

Le template est basé sur l’exemple “sign-in” http://twitter.github.io/bootstrap/examples/signin.html

Création des blocs index.twig et thanks.twig

index.twig contient les éléments affichés dans la page d’accueil (le formulaire) et thanks.twig contient un message de remerciement pour informer à l’utilisateur que l’upload c’est bien passé.

Je ne me casse pas la tête sur l’internationalisation pour ce petit projet.

index.twig

Pour l’instant, on n’a pas encore défini le formulaire, ce fichier est pour l’instant assez vide.

{% extends "template.twig" %}
 
{% block content %}
   <!-- ... code du formulaire qui va venir … -->
{% endblock %}

thanks.twig

{% extends "template.twig" %}
 
{% block content %}
   <h2>Merci</h2>
   <p>Le fichier a bien été téléchargé sur le serveur.<p>
{% endblock %}

Test avec une vue Twig

On remplace la route / dans le fichier index.php :

$app->get('/', function () use ($app) {
   return $app['twig']->render('index.twig', array (
       // modèle à fournir à la vue
   ));
});

On remarque que la closure doit déclarer utiliser $app pour pouvoir utiliser twig.

Lorsqu’on aura notre formulaire de construit, il faudra le passer à la vue via l’array en paramètre de la méthode render.

Si tout va bien jusque là, une page planche devrait s’afficher et le code HTML doit être généré.

Mise en place de notre formulaire Zend Form

Éléments du formulaire

Notre formulaire va comporter un champ texte pour le login ssh, un champ password pour le mot passe, un champ fichier (pour sélectionner le fichier à uploader) et un bouton valider.

Nous allons utiliser Zend\Form\Factory pour créer l’objet formulaire à partir d’un array de configuration. Il faudra donc ajouter Zend\Form\Factory au service manager.

Modification dans global.config.php

'upload-form' => array(
       'hydrator' => 'Zend\Stdlib\Hydrator\ArraySerializable',
       'attributes' => array('class' => 'form-upload'),
       'elements' => array (
           array (
               'spec' => array (
                   'name'       => 'username',
                   'attributes' => array(
                       'type'  => 'text',
                       'placeholder' => 'Username',
                       'class'       => 'input-block-level',
                       'required'    => 'required',
                   ),
               ),
           ),
           array (
               'spec' => array (
                   'name'       => 'password',
                   'attributes' => array (
                       'type'  => 'password',
                       'placeholder' => 'Password',
                       'class'       => 'input-block-level',
                       'required'    => 'required',
                   ),
               ),
           ),
           array (
               'spec' => array (
                   'type' => 'Zend\Form\Element\File',
                   'name' => 'file',
                   'attributes' => array(
                       'class' => 'input-block-level',
                       'required' => 'required',
                   ),
               ),
           ),
           array (
               'spec' => array (
                   'name' => 'send',
                   'attributes' => array (
                       'type'  => 'submit',
                       'value' => 'Envoyer',
                       'class' => 'btn btn-large btn-primary',
                   ),
               ),
           ),
       ),
   ),
 
// …
 
'invokables' => array (
       'FormFactory'                => 'Zend\Form\Factory',
       'TwigServiceProvider'        => 'Silex\Provider\TwigServiceProvider',
),

On remarque que j’utilise l’attribut HTML5 placeholder à la place de labels.

Création du formulaire dans l’index

On peut maintenant créer notre formulaire à partir de la configuration.

Dans index.php, juste avant la création de l’application :
$form = $sm->get('FormFactory')->createForm($config['upload-form']);

On modifie aussi le contrôleur pour la route “/”, il doit fournir le formulaire à la vue :

$app->get('/', function () use ($app, $form) {
   $form->prepare();
   return $app['twig']->render('index.twig', array (
       'uploadForm' => $form,
   ));
});

Utiliser les aides de vue dans Twig

Et là c’est la catastrophe. Pour afficher le formulaire Zend, on dispose d’aides de vue (les classes Zend\Form\View\Helper\Form*) mais pour une vue Zend View. Inversement, on dispose d’helpers Twig pour des formulaires Symfony 2. On n’utilise pas Zend View ni de formulaire Symfony 2 actuellement donc comment allons nous faire ?

La doc de Twig est une grande aide :
http://twig.sensiolabs.org/doc/advanced.html

A première vue, la solution la plus simple est de faire de nos View Helper des variables Twig globales. C’est ce que nous allons faire.

Nous allons utiliser ces 3 ViewHelpers : Zend\Form\View\Helper\{Form, FormElementErrors, FormInput}

Ajout des invokables :

    'invokables' => array (
       'FormFactory'                => 'Zend\Form\Factory',
       'ViewForm'                   => 'Zend\Form\View\Helper\Form',
       'ViewFormElementErrors'      => 'Zend\Form\View\Helper\FormElementErrors',
       'ViewFormInput'              => 'Zend\Form\View\Helper\FormInput',
       'TwigServiceProvider'        => 'Silex\Provider\TwigServiceProvider',
   ),

Injection des variables globales depuis la factory App

    'factories' => array(
       'App' => function($sm) {
           $app = new \Silex\Application();
           $app['debug'] = true;
 
           $app->register($sm->create('TwigServiceProvider'), array('twig.path' => __DIR__.'/../views'));
 
           $twigGlobalHelpers = array(
               'form'               => 'ViewForm',
               'formInput'          => 'ViewFormInput',
               'formElementErrors'  => 'ViewFormElementErrors',
           );
 
           foreach($twigGlobalHelpers as $helperName => $helperInvokable) {
               $app['twig']->addGlobal($helperName, $sm->get($helperInvokable));
           }
 
           return $app;
       },
   ),

Le block index qui affiche le formulaire

Maintenant que les aides de vues sont ajoutés et que le contrôleur fourni à la vue l’objet formulaire, nous pouvons finir le code Twig :

index.twig

{% extends "template.twig" %}
 
{% block content %}
   {{ form.openTag(uploadForm)|raw }}
   <h2 class="form-upload-heading">Envoyer un fichier</h2>
   {% for i in ['username', 'password', 'file'] %}
       {% set element = uploadForm.get(i) %}
       {{ formInput.render(element)|raw }}
       {{ formElementErrors.render(element)|raw }}
       <br />
   {% endfor %}
 
   {{ formInput.render(uploadForm.get("send"))|raw }}
 
   {{ form.closeTag(uploadForm)|raw }}
{% endblock %}

Twig protège toutes les sorties affichées, comme les View Helpers Zend le font déjà, on doit utiliser le filtre “raw” pour empêcher un deuxième niveau de htmlentities().

Tester l’affichage du formulaire

Le formulaire devrait s’afficher lorsqu’on charge la page “/”.

Traitement du formulaire d’upload

Ajout d’un nouveau contrôleur pour cette route

Étant donné que rien n’a été précisé, le formulaire est en méthode POST avec comme target la même page.

Pour pouvoir répondre à la soumission du formulaire on ajoute un contrôleur pour la route en post dans index.php :

$app->post('/', function () use ($app, $form, $config) {
   // Code traitement du formulaire ici
   return $app['twig']->render('thanks.twig');
});

Validation des du formulaire et réaffichage du formulaire si erreur

Dans le contrôleur qu’on vient de créer, on injecte les paramètres POST et FILES dans l’objet formulaire et on vérifie si ces données valident le formulaire.

Si les paramètres de la requête ne valident pas le formulaire, on réaffiche le formulaire en appelant le premier contrôleur.

  $parameters = array_merge_recursive($_POST, $_FILES);
   $form->setData($parameters);
   if (!$form->isValid()) {
       $subRequest = Request::create('/', 'GET');
       return $app->handle($subRequest, HttpKernelInterface::SUB_REQUEST);
   }

Attention à bien ajouter ces lignes use en haut de index.php.

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpKernelInterface;

Envoi du fichier par SSH

Pour utiliser les classes FileTransfer de Drupal, je fais appel à la méthode statique factory de la classe que je veux instancier. Cette méthode prend 2 paramètres : un chemin “jail” et un array avec les identifiants de connexion. J’ai pris soin de nommer les noms des champs du formulaire comme l’attend cette méthode pour ne pas passer par une étape de conversion.

Dans la configuration local.config.php

return array(
   'file-transfer' => array(
       'class' => 'Drupal\Core\FileTransfer\SSH',
       'jail'  => realpath(__DIR__ . '/../upload'),
   ),
);

Code complet du nouveau contrôleur

$app->post('/', function () use ($app, $form, $config) {
   $parameters = array_merge_recursive($_POST, $_FILES);
   $form->setData($parameters);
   if (!$form->isValid()) {
       $subRequest = Request::create('/', 'GET');
       return $app->handle($subRequest, HttpKernelInterface::SUB_REQUEST);
   }
   $validatedData = $form->getData();
   $fileTransfer = $config['file-transfer']['class']::factory($config['file-transfer']['jail'], $validatedData);
   $fileTransfer->copyFile($validatedData['file']['tmp_name'], $config['file-transfer']['jail'] . '/' . uniqid());
   return $app['twig']->render('thanks.twig');
});
Fichier attachéTaille
Image icon simple-upload-html.png12.41 Ko
Fichier simple-upload-mockup.svg13.43 Ko