Php ( 163 articles - Voir la liste )

Astuce Gestionnaire de rollbacks

Problématique

La plupart des SGBD proposent un fonctionnement en transactions qui permet, en cas d’erreur, de retrouver les données telles qu’ells étaient au début de la transaction.

Schématiquement, cela se passe ainsi :

  • On déclare le début d’une transaction
  • On effectue toutes les opérations que l’on souhaite
  • Si l’on considère que tout s’est bien passé, on valide la transaction et toutes les modifications de données induites par les opérations sont entérinées
  • Si, à l’inverse, une opération échoue, on effectue un rollback qui remet les données comme elles étaient au début

Ce fonctionnement est facile à mettre en œuvre dans Symfony, avec Doctrine. C’est plutôt simple et efficace, mais cela ne concerne que les écritures en base de données.

On peut avoir exactement le même besoin pour des écritures via des appels API.

Exemple avec une création de compte

  • Je crée un compte utilisateur dans mon application (donnant lieu à une écriture en base de données)
  • J’appelle une API externe d’authentification, pour lui demander de créer ce compte utilisateur (dans sa base de données)
  • J’appelle une autre API externe de gestion de fiches client, pour lui demander d’en créer une nouvelle pour cet utilisateur (dans sa base de données)

L’appel à la seconde API échoue. Sans fiche client, l’application ne fonctionnera pas correctement et on préfère donc annuler complètement la création du compte. Il nous faut donc annuler :

  • l’écriture dans la BDD de l’application
  • l’écriture réalisée auprès de la 1re API

Cet exemple est encore relativement simple s’il n’y a que deux traitements à annuler, mais s’ils sont nombreux, un service dédié aux rollbacks va vite devenir utile.

Service de gestion de rollbacks

Voici un exemple simple d’un tel service, avec deux méthodes

  • registerRollbackOperation() : ajoute une opération de rollback dans un registre interne au service
  • rollbackAll() : exécute toutes les opérations de rollbacks du registre

Note :

Contrairement à celui de Doctrine, on ne déclare pas de début de transaction, et on ne valide pas de transaction.
On ne peut donc pas réinitialiser le registre pour commencer une seconde transaction.
On peut tout de même gérer plusieurs lots de rollbacks indépendants, en utilisant plusieurs instances du service en parallèle.

<?php

namespace App\Service;

use Psr\Log\LoggerInterface;
use Symfony\Component\Serializer\SerializerInterface;

class RollbackService
{
    private array $rollbackOperations = [];

    public function __construct(
        private readonly SerializerInterface $serializer,
        private readonly LoggerInterface $logger,
    ) {
    }

    /**
     * Ajoute une opération de rollback au registre.
     *
     * @param string $pool Lot d’opération
     * @param callable $rollbackFunction Fonction à exécuter lors du rollback
     * @param array $rollbackArguments Arguments à passer à la fonction de rollback
     * @param array $context Contexte de l’opération, utile pour les logs
     *
     * @return void
     */
    public function registerRollbackOperation(
        string $pool,
        callable $rollbackFunction,
        array $rollbackArguments,
        array $context,
    ): void {
        $this->rollbackOperations[] = [
            'pool' => $pool,
            'function' => $rollbackFunction,
            'arguments' => $rollbackArguments,
            'context' => $context,
        ];
    }

    /**
     * Dépile et exécute les opérations de rollback du registre, une à une, de la dernière à la première ajoutée.
     *
     * @return void
     */
    public function rollbackAll(): void
    {
        $totalNbOfOperations = \count($this->rollbackOperations);
        $index = 0;
        while ($rollbackOperation = array_pop($this->rollbackOperations)) {
            $index++;
            $this->logger->debug(
                sprintf('Rollback %s/%s - %s', $index, $totalNbOfOperations, $rollbackOperation['pool']),
            );

            try {
                $rollbackOperation['function'](...$rollbackOperation['arguments']);
                $this->logger->debug(
                    sprintf(
                        'Rollback %s/%s successful - %s %s',
                        $index,
                        $totalNbOfOperations,
                        $rollbackOperation['pool'],
                        $this->argsAndContextToString($rollbackOperation),
                    ),
                );
            } catch (\Exception $e) {
                $this->logger->error(
                    sprintf(
                        'Failure for rollback %s/%s - %s : %s %s',
                        $index,
                        $totalNbOfOperations,
                        $rollbackOperation['pool'],
                        $e->getMessage(),
                        $this->argsAndContextToString($rollbackOperation),
                    ),
                    ['exception' => $e],
                );
            }
        }
    }

    private function argsAndContextToString(array $rollbackOperation): string
    {
        return sprintf(
            '(args : %s; context : %s)',
            $this->encode($rollbackOperation['arguments']),
            $this->encode($rollbackOperation['context']),
        );
    }

    private function encode(mixed $context): string
    {
        return $this->serializer->serialize($context, 'json');
    }
}

Exemple d’utilisation, combiné avec les transactions Doctrine:

<?php

namespace App\Service;

use Doctrine\ORM\EntityManagerInterface;
use App\Entity\User;

class AccountCreationService
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly Api1Service $api1Service,
        private readonly Api2Service $api2Service,
        private readonly RollbackService $rollbackService,
    ) {
    }

    public function createAccountFromAdminInterface(array $data): void
    {
        $this->entityManager->beginTransaction();
        try {
            $this->persistUser($data);
            $this->createAccount($data);
            $this->createCustomerSheet($data);

            $this->entityManager->commit();
        } catch (\Exception $e) {
            $this->rollbackService->rollbackAll();
            $this->entityManager->rollBack();

            throw $e;
        }
    }

    public function persistUser(array $data): void
    {
        $this->entityManager->persist(User::fromData($data));
        $this->entityManager->flush();
    }

    public function createAccount(array $data): void
    {
        $accountId = $this->api1Service->createAccount($data);

        $this->rollbackService->registerRollbackOperation(
            'API 1',
            $this->api1Service->deleteAccount(...),
            [$accountId],
            [
                'description' => 'Account creation from admin interface',
            ],
        );
    }

    public function createCustomerSheet(array $data): void
    {
        $customerSheetId = $this->api2Service->createCustomerSheet($data);

        $this->rollbackService->registerRollbackOperation(
            'API 2',
            $this->api2Service->deleteCustomerSheet(...),
            [$customerSheetId],
            [
                'description' => 'Account creation from admin interface',
            ],
        );
    }
}

Explications :

  • À la création d’un compte depuis l’interface d’admin, on veut exécuter 3 opérations, que l’on place dans un try-catch
  • Comme on veut être capable de réinitialiser des données dans la BDD de notre application via Doctrine, on encadre ces opérations avec les méthodes beginTransaction() et commit() de l’entity Manager.
  • Les deux méthodes de création auprès des API sont similaires : on demande la création d’un utilisateur, puis on déclare sa suppression comme opération de rollback.
  • En cas d’erreur, on déclenche les opérations de rollback de notre RollbackService et le rollback de Doctrine.

Notes :

  • La méthode User::fromData() simule l’instanciation d’une entité User à partir d’un tableau de données
  • La notation myfunction(...) permet de générer une callable depuis PHP 8.2.
  • On utilise le Sérialiseur de Symfony pour transformer les tableaux de contexte et d’arguments en chaîne JSON pour les logs

Aller plus loin

Organisation des services

Si on appelle à plusieurs endroits les fonctions de création pour les API, on peut imaginer la structure suivante :

  • Service\Api1Service
  • Service\Api1ServiceWithRollbacks

Le premier service effectue les appels basiques à l’API (typiquement des requêtes HTTP).
Le second service utilise le premier. Il contient des méthodes comme le createCustomerSheet() ci-dessus, qui gèrent plusieurs appels successifs à l’API.

Rollback des suppressions/modifications

Pour rétablir les données telles qu’avant suppression ou modification, il faut les passer en argument de l’opération de rollback. Pour cela, il faut déjà les avoir sous la main, et donc les avoir récupérées auprès de l’API.

On peut donc imaginer une méthode updateCustomerSheet() qui effectue ces étapes :

  • Appel de l’API pour mémoriser la fiche client actuelle
  • Appel de l’API pour mettre à jour la fiche avec les nouvelles données
  • Déclaration de l’opération de callback : un appel à l’API pour mettre à jour la fiche avec les données mémorisées

RollbackService

On pourrait implémenter un RollbackService plus complet/complexe, permettant :

  • de gérer des lots d’opérations de rollback, que l’on pourrait appeler transactions
  • d’intégrer directement le rollback doctrine

Marque-page Refactoring avec Rector

Pour faire du refactoring automatique en PHP, on peut utiliser son IDE ou la lib Rector (installable via Composer).

Elle permet notamment d'automatiser la migration de code PHP vers une version plus récente de PHP.

Quelques exemples :

  • Opérateurs ?? et ?:
  • Promotion de propriété de constructeur

Il est également possible de créer ses propres règles de refactoring.

Grafikart a sorti une vidéo de présentation de l'outil.

Astuce Les formulaires dans Symfony

Cet article est une synthèse de la documentation officielle : Forms.

Généralités

Dans Symfony, un formulaire et un champ sont représentés tous deux par la FormInterface.

Un formulaire contient des champs ou des sous-formulaires enfants, pouvant eux-mêmes en avoir.
(Il s'agit là du Design Pattern Composite.)

Type de champ/formulaire

La FormTypeInterface permet de représenter un type de champ/formulaire (ex: ContactFormType, BirthdayType, ...). En fonction de ce type, le champ/formulaire aura telles ou telles options, et tel ou tel rendu.

La classe BaseType l'implémente. En héritent deux sous-classes : FormType pour les champs et ButtonType pour les boutons.

Définition de formulaire

Pour définir un nouveau type de champ/formulaire, il faut donc implémenter FormTypeInterface.
Pour simplifier cette tâche, on étend AbstractType, qui implémente déjà les méthodes avec des comportements neutres.

De plus, on surcharge éventuellement sa méthode getParent(), pour indiquer le type précis dont on veut hériter les propriétés (ex: EmailType, EntityType, ...).

Cette mécanique consistant à ne pas utiliser l'héritage direct PHP des FormTypes qui nous intéresse, évite de devoir appeler nous-même les méthodes parentes avant d'y ajouter nos traitements spécifiques.
L'appel au parent est fait automatiquement et nous n'avons qu'à nous occuper des traitements spécifiques.

Exemples natifs :

use \Symfony\Component\Form\AbstractType;

// Héritage PHP du type neutre
class DateType extends AbstractType {
    public function getParent() {
        // Héritage des propriétés/options/... du type de base
        return FormType::class;
    }
}

// Héritage PHP du type neutre
class BirthDayType extends AbstractType {
    public function getParent() {
        // Héritage des propriétés/options/... de DateType
        return DateType::class;
    }
}

// Héritage PHP du type neutre
class FormType extends AbstractType {
    public function getParent() {
        // Pas d'héritage de propriétés/options/...
        return null;
    }
}

Extension de type

Pour des modifications légères de types déjà existants (par exemple ajouter des options), on peut implementer l'interface FormTypeExtensionInterface.

Documentation

Création d'un formulaire

Pour créer un formulaire, on utilise un FormBuilder ou on laisse Symfony deviner les champs en fonction d'une classe.

Pour cela :

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

// Depuis un contrôleur étendant AbstractController
$form = $this->createFormBuilder($task)
        ->add('task', TextType::class)
        ->add('dueDate', DateType::class)
        ->add('save', SubmitType::class, ['label' => 'Create Task'])
        ->getForm();

// ou en laissant Symfony deviner
$form = $this->createForm(Task::class, $task, $options);

// Depuis n'importe où
/** @var \Symfony\Component\Form\FormFactoryInterface $form */
$form = $this->formFactory
        -> createBuilder(Task::class, $task, $options)
        ->add('task', TextType::class)
        ->add('dueDate', DateType::class)
        ->add('save', SubmitType::class, ['label' => 'Create Task'])
        ->getForm();

// ou en laissant Symfony deviner
$form = $this->formFactory->create(Task::class, $task, $options);

Dans le second cas, Symfony se base sur des Guesser qui devinent les champs à partir :

  • du mapping Doctrine (pour les entités)
  • du typage PHP des propriétés (>= 7.4)
  • des contraintes de validation

Vérification de la requête

FormInterface définit la méthode handleRequest(), chargée de vérifier que tout est valide puis éventuellement d'appeler la méthode submit() du formulaire :

  • Vérification du verbe HTTP (POST, PUT, ...)
  • Récupération des données depuis la query ou dans le corps, selon la configuration
  • Vérification des paramètres serveur (ex: post_max_size)
  • Soumission éventuelle du formulaire

Soumission du formulaire

À la soumission du formulaire (ak. méthode submit()) :

  • les données sont enregistrées dans le formulaire
  • chaque champ est validé, selon ses contraintes
    • en cas d'erreur, la méthode addError() est appelée sur le champ
  • les évènements sont dispatchés

Listes des options d'un FormType

La commande suivante permet de lister toutes les options d'un type de champ/formulaire et d'indiquer si elles sont héritées ou surchargées :

# Remplacer EntityType par n'importe quel FormType
bin/console debug:form EntityType

Rendu avec Twig

Il est considéré comme une bonne pratique, de rendre directement un formulaire ou un de ses champs via les fonctions de rendu dédiées. Toute modification du balisage qu'elles génèrent devrait être réalisée via la surcharge de thème.

Par défaut, plusieurs thèmes sont disponibles, notamment Bootstrap 3 et 4. Pour en ajouter :

# config/packages/twig.yaml

twig:
  form_themes:
    - 'form/my_custom_form_theme.html.twig'

Un formulaire ou un champ dans un template est un objet de type FormView.

Choix du thème

Il est possible de choisir un thème spécifique pour rendre un formulaire ou un champ, via :

{% form_theme my_form 'form/app_custom_form_theme.html.twig' %}

{% form_theme my_form.my_field 'form/app_custom_form_theme.html.twig' %}

Surcharge du thème

Chaque thème définit un certain nombre de blocs, nommés d'une manière à définir quel type de champ il est susceptible de rendre (ex: integer_widget).

Pour le surcharger, il suffit de redéfinir le bloc dans le template courant, comme n'importe quel bloc.

Les suffixes disponibles pour les noms de bloc sont les suivants : _widget, _row, _label, _help.
Les préfixes disponibles sont listés dans la variable block_prefixes présente dans le contexte du bloc.

Exemple : ['form','text','textarea', '_post_content'], pour un champ nommé content, de type TextAreaType (héritant de TextType héritant lui-même de FormType), au sein du type de formulaire spécifique PostType.

Data transformers

Un data transformer est chargé de transformer une donnée (sous forme de tableau, objet, entier, ...) vers une chaîne (ou un tableau de chaînes) exploitable dans un formulaire, et vice versa.

Il est représenté par l'interface DataTransformerInterface et requiert deux méthodes :

  • transform() : Donnée => (tableau de) chaîne(s) de caractères
  • reverseTransform() : (Tableau de) chaîne(s) de caractères => donnée

Pour en ajouter un à formulaire, on utilise le FormBuilder :

$formBuilder->addViewTransformer(new MyCustomTransformer());

// Note: il existe également cette méthode si la transformation doit être effectuée en amont de la transformation
// de la précédente
$formBuilder->addModelTransformer(new MyCustomModelTransformer());

Évènements

Tous les évènements liés aux formulaires sont des FormEvent, permettant d'accéder aux données du formulaire et éventuellement de les modifier.

Ils ont lieu dans cet ordre chronologique :

  • FormEvents::PRE_SET_DATA : on peut y modifier la donnée en fonction du formulaire

  • Hydratation du formulaire et ses enfants, avec la donnée

  • FormEvents::POST_SET_DATA : on peut y modifier le formulaire en fonction de la donnée (ex: champ affiché en fonction d'un autre champ)

  • FormEvents::PRE_SUBMIT : on peut y changer la donnée soumise dans la requête (ex: la normaliser, la trimmer, ...)

  • Transformation vers un "objet" de données, via les méthodes reverseTransform() des DataTransformer.

  • FormEvents::SUBMIT : les données sont prêtes, mais peut-être pas celles des parents

  • FormEvents::POST_SUBMIT : les données sont prêtes, le composant Validation écoute cet évènement pour les valider

90% du temps, on n'a besoin d'agir que sur les évènements FormEvents::POST_SET_DATA ou FormEvents::PRE_SUBMIT uniquement.

Astuce Les contrôleurs dans Symfony

Cet article est une synthèse de la documentation officielle : Controller.

Généralités

Qu'est-ce qu'un contrôleur ? Son rôle est de transformer la Request de l'utilisateur en une Response à lui renvoyer. Concrètement, c'est juste un callable.
On a l'habitude d'appeler contrôleur la classe contenant une action associée à une route, mais en réalité c'est l'action en elle-même qui est un contrôleur.
Pour éviter cette incohérence, on peut n'avoir qu'une action par fichier contrôleur, nommée __invoke(). Comment Symfony sait-il ce qui est un contrôleur ? C'est le fichier services.yaml qui l'indique :

services:
    # controllers are imported separately to make sure services can be injected
    # as action arguments even if you don't extend any base controller class
    App\Controller\:
        resource: '../src/Controller/'
        tags: ['controller.service_arguments']

Comme on le voit dans ce fichier, pas de contrainte sur le nommage de la classe ou de ses actions.
Tous les services dans src/Controller/ auront le tag controller.service_arguments. Qu'elles sont les particularités des contrôleurs, apportées via le tag controller.service_arguments ? Ce tag permet à Symfony de :

  • passer le service contrôleur en public (car ce sera un point d'entrée de l'application, donc non appelé explicitement par nos services)
  • injecter les éléments demandés dans les signatures des actions (des services, des arguments, la Request), via un ArgumentResolver. (Cf. classe RegisterControllerArgumentLocatorsPass) Un contrôleur doit-il retourner absolument une Response ? Pas obligatoirement.
    Si ce n'est pas le cas, le HttpKernel dispatche un ViewEvent. Un listener pourra traiter cet évènement pour forwarder vers un contrôleur en fallback.

    AbstractController

    Cette classe peut être héritée par une classe de contrôleur. (Cf. classe AbstractController) Elle fournit un certain nombre de raccourcis, pour accéder à des méthodes de certains services (cf. Service locator ci-après).

    Service Locator

    Il s'agit d'un proxy du Conteneur de services, contenant un sous-ensemble des services.
    Par exemple, la propriété $container d'AbstractController en est un (et pas le vrai Conteneur).
    Il donne accès à seulement certains services et arguments (cf. AbstractController::getSubscribedServices()).

    Redirections et Forwards

    Quelle est différence entre forward et redirect ? La redirection redirige l'utilisateur vers une nouvelle page et son navigateur fait une seconde requête.
    Cela lui est donc visible. C'est une redirection HTTP. Le forward exécute une seconde (sous-)requête directement, pour en retourner sa réponse.
    L'utilisateur reçoit directement le résultat de cette seconde requête sans en avoir conscience. C'est une redirection interne.
    Note : Tous les listeners seront à nouveaux appelés, même sur la requête secondaire.

    Redirection

    Par défaut, une redirection n'est pas permanente. Elle est conditionnée ponctuellement. Par exemple, si on n'est pas connecté et qu'on essaie d'accéder à une page privée, on pourra être redirigé vers la page de login. Ce n'est pas une redirection permanente, car une fois connecté, l'utilisateur devra pouvoir y accéder. Par défaut, le code HTTP est donc 302. On peut le rendre permanent via le code301.

Il existe également leurs pendants 308 (par défaut) et 307.
Cela oblige de conserver la même méthode HTTP (GET, POST,...). Pour effectuer une redirection dans un contrôleur, on peut utiliser plusieurs méthodes :

public function __invoke(): RedirectResponse
{
    // Si on étend l'AbstractController
    return $this->redirectToRoute('app_homepage');
    // ou
    return $this->redirect($this->generateUrl('app_homepage'));

    // Ou comme on le ferait dans un contrôleur agnostique
    return new RedirectResponse(
        return $this->urlGenerator->generate('app_homepage')
    );
}

HttpException

Qu'est-ce qu'une HttpException ? C'est une exception classique, dont le code correspond à un code HTTP (ex: 404, 403, ...).
Quand Symfony rencontre une erreur de ce type, il la transforme en une Response évitant ainsi de retourner une erreur 500 au client. Symfony implémente beaucoup d'exceptions HTTP, visibles dans Symfony\Component\HttpKernel\Exception.

Request

Documentation La Request contient des conteneurs de données appelés bags :

  • query : paramètres d'URL (ex: ?foo=bar&bar=foo)
  • request : données envoyées en POST
  • cookies
  • files : fichiers téléversés
  • server : variables $_ENV et $_SERVER
  • headers : variables $_SERVER['headers']
  • attributes : variables spécifiques au fonctionnement (ex: _route) Elle permet principalement d'accéder aux données dans ces conteneurs, mais propose également un certain nombre de raccourcis, souvent pour accéder aux headers les plus communs.

    Récupération des valeurs

    Les valeurs de chaque bag sont accessibles de la même manière, via la même méthode get(). Ex :

    use Symfony\Component\HttpFoundation\Request;
    /** @var Request $request */
    $request->request->get('email');

    Il existe des variantes permettant de filter/convertir la donnée directement : Filtres (utilise la fonction filter() de php derrière) :

  • getAlpha()
  • getAlnum()
  • getDigits() Conversions :
  • getInt()
  • getBoolean()

    Cookies

    Comment envoyer un cookie au navigateur de l'utilisateur ?

    use Symfony\Component\HttpFoundation\Response;
    use Symfony\Component\HttpFoundation\Cookie;
    $response = new Response('A foo enters in a bar');
    $response->headers->setCookie(
    Cookie::create('last_visit', \time())
    );

    Comment supprimer un cookie du navigateur de l'utilisateur ?

    use Symfony\Component\HttpFoundation\Response;
    $response = new Response('A foo enters in a bar');
    $response->headers->clearCookie('last_visit');

    Note : il existe également removeCookie(), qui permet (via un listener par exemple) de retirer un cookie qui devait être ajouté via setCookie() durant le traitement de la requête.

    Session

    Documentation Comment récupérer la session ? Plusieurs manières possibles. Elle est disponible dans le bag session de la Request :

    use Symfony\Component\HttpFoundation\Request;
    /** @var Request $request */
    $session = $request->getSession();
    $emailInSession = $session->get('email');

    Elle est disponible via le Service Locator d'AbstractController :

    $session = $this->get('session');
    $emailInSession = $session->get('email');

    On peut également l'injecter en tant que service (classe SessionInterface).

    Flash message

    Documentation Un message flash est stocké dans la session.
    Le principe est d'y stocker un message qui disparaitra dès qu'il est consommé/lu.

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
// Ajout d'un message flash
/** @var SessionInterface $session */
$session->getFlashBag()->add('success', 'You reach level 2!');
// ou via le raccourci d'AbstractController
/** @var AbstractController $this */
$this->addFlash('success', 'You reach level 2!');
// Consommation d'un message flash
/** @var SessionInterface $session */
$session->getFlashBag()->get('You reach level 2!');

Note : Il existe également les fonctions peek() et peekAll() qui permettent de lire des messages sans les consommer.

Dans twig

Ce bag est accessible dans Twig via app.flashes :

{% for message in app.flashes('notice') %}
    <div class="flash-notice">
        {{ message }}
    </div>
{% endfor %}

Contrôleurs Symfony spécifiques

Symfony fournit deux contrôleurs spécifiques :

Astuce Créer une page

Cet article est une synthèse de la documentation officielle : Create your First Page in Symfony.

Généralités

Créer une page, c'est indiquer quelle réponse générer lorsqu'on reçoit une requête auprès d'une certaine URL.

Plus, précisément, la partie de l'URL qui nous intéresse est appellée la route, ou en français, le chemin. Par exemple pour les URL http://mon-site.com/recherche et http://mon-site.com/article/1234-mon-article, les routes seront respectivement /recherche et /article/1234-mon-article.

Le générateur de réponse est une fonction PHP appelée contrôleur. Dans Symfony, elle doit retourner un objet Response, contenant du texte (HTML, JSON, ...), une fichier binaire (ex: fichier, image, ...), ...

On appelle route le chemin présent dans l'URL permettant de déterminer qu'elle génération on souhaite.

Routes et Controllers

Par défaut, les routes se déclarent dans le fichier config/routes.yaml. Par exemple :

# the "app_lucky_number" route name is not important yet
my_route_name:
    path: /some-path-to/my_page
    controller: App\Controller\MyPageController::myFunctionToGenerateThePage

On a ici définit que les requêtes sur la route /some-path-to/my_page devait recevoir une réponse générée par la méthode myFunctionToGenerateThePage() de la classe App\Controller\MyPageController.

Généralement, les contrôleurs seront des méthodes de classes placées dans le répertoire src/Controller.

Annotations

Pour faciliter le mapping route-controller, vous pouvez déclarer vos routes en annotations à la place d'utiliser le fichier routes.yaml. Cela regroupe ainsi la route et le contrôleur au même endroit.

Pour cela, la dépendance annotations doit être installée :

coomposer require annotations

Vous pouvez dès lors indiquer la route en annotation de votre contrôleur :

<?php
// src/Controller/MyPageController.php
namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class MyPageController
{
    /**
     * @Route("/some-path-to/my_page", name="my_route_name")
     */
    public function myFunctionToGenerateThePage(): Response
    {
        return new Response(
            '<html><body>Hey guys!</body></html>'
        );
    }
}

Remarque : Si vous utilisez PHP 8, vous pouvez utiliser directement les annotations du langage :

#[Route('/some-path-to/my_page', name: 'my_route_name')]

Liste des routes

Pour lister les routes disponible et les contrôleurs associés, utilisez la commande suivante :

bin/console debug:router

Moteur de template

Pour faciliter l'écriture de pages HTML, Symfony propose le moteur de templates Twig.

Il faut installer la dépendance :

coomposer require twig

Vous pouvez désormais utiliser ce moteur dans vos contrôleurs. Pour y avoir accès, la solution la plus simple est de faire étendre vos contrôleurs de la classe AbstractController. Ex :

  // src/Controller/MyPageController.php

  // ...
+ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

- class MyPageController
+ class MyPageController extends AbstractController
  {
      // ...
  }

La méthode render() vous permet de transformer un template twig en du code HTML et de le placer dans un objet Response :

<?php
// src/Controller/MyPageController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class MyPageController extends AbstractController
{
    /**
     * @Route("/some-path-to/my_page", name="my_route_name")
     */
    public function myFunctionToGenerateThePage(): Response
    {
        return $this->render('some-path-to/my-page.html.twig', [
            'who' => 'guys',
        ]);
    }
}
{# templates/some-path-to/my-page.html.twig #}
<html>
<body>
    <h1>Hey {{ who }}!</h1>
</body>
</html>

Remarques :

Ici, on a définit la variable who avec pour valeur guys et on l'a transmise au template en argument de la fonction render(). La syntaxe {{ my_var }} permet de l'afficher.

En général, on n'ajoute pas les balises <html> et <body> directement dans le template. À la place, on utilise l'héritage de Twig et notre template étend alors base.html.twig, par exemple.

Structure du projet

Voici les principaux répertoires de votre projet Symfony :

├── bin/
│   └── console     # exécutable pour lancer les commandes Symfony
├── config/         # répertoire contenant les fichiers de config des routes, services et dépendances
├── public/         # répertoire contenant tous les fichiers accessibles publiquement
│   ├── a-public-file.txt
│   └── index.php
├── src/            # répertoire contenant tout votre code PHP
├── templates/
├── var/            # répertoire contenant les fichiers auto-générés
│   ├── cache/
│   └── log/
├── vendor/         # répertoire contenant les dépendances installées avec Composer (dont Symfony !)

Astuce Twig dans Symfony

Cet article est une synthèse de la documentation officielle : Twig.

Twig est un gestionnaire de templates, des fichiers contenant du texte et des variables qui seront remplacées à terme, lorsqu'on "rendra" le template, par des valeurs.

C'est un langage écrit en PHP, générant du PHP. Les templates seront transpilés en PHP.
La fonction la plus courante est le echo() de PHP, représentée par {{ }} dans Twig, pour afficher une valeur.

Comme pour les autres composants, on peut lister ses options de paramétrage disponibles via la commande :

bin/console config:dump twig

Blocs

Un bloc est une partie de template nommée et réutilisable. On peut le considérer comme une fonction qui affiche du contenu.

Par défaut, les blocs sont affichés/exécutés/rendus dès qu'ils sont présents dans un template. On peut toutefois en appeler un explicitement comme une fonction :

  • s'il est dans le même contexte que le template courant :
    • dans le même fichier
    • dans un template parent (cf. héritage ci-après)
    • dans un template "utilisé" (via le tag use), à la manière d'un Trait PHP
  • ou si on indique le fichier dans lequel il se trouve explicitement
{# Bloc dans le même template, ou son parent #}
{{ block('my_block') }}

{# Blocs déclarés dans un autre template, mais considéré comme faisant partie du contexte #}
{% use 'a_template_with_several_blocks.twig' %}

{{ block('custom_block_1') }}
{{ block('custom_block_2') }}
{{ block('custom_block_3') }}

{# Bloc déclaré dans un autre template, lequel est indiqué explicitement #}
{{ block('my_external_block', 'another_template.twig') }}

Comme beaucoup de langage permettant de créer des fonctions dans d'autres fonctions, Twig permet de créer des blocs dans des blocs.

Macros

Les blocs sont comme des fonctions, mais n'ont pas d'argument. Les variables disponibles sont celles du contexte.
Une macro est comme un bloc sauf :

  • qu'elle peut recevoir des arguments
  • elle n'est pas affichée/exécutée/rendue automatiquement (il faut l'appeler explicitement)

Exemple :

{% macro my_macro(text) %}
Some text: {{ text }}
{% endmacro %}

{# Appel de la macro présente dans le contexte #}
{{ _self.my_macro('some text') }}

{# Appel dune macro présente dans un autre template #}
{% import 'my_macros.twig' as my_alias %}

{{ my_alias.some_external_macro() }}

Héritage

L'héritage de templates fonctionne comme l'héritage de classes. Les blocs sont alors comme des méthodes publiques, dont on hérite et que l'on peut surcharger.

Pour déclarer un template comme enfant d'un autre, on utilise le tag extends (comme pour une classe PHP) :

{% extends 'base.html.twig' %}

{# Pour vider le contenu d'un bloc parent, on le redéclare en le laissant vide #}
{% block title %}
{% endblock %}

{# Pour remplacer le contenu d'un bloc parent, on le redéclare avec le nouveau contenu souhaité #}
{% block title %}
My child title
{% endblock %}

{# Pour conserver le contenu d'un bloc parent mais y ajouter du contenu #}
{% block title %}
My child title | {{ parent() }}
{% endblock %}

Remarque : Spécificité Twig, un template enfant ne doit jamais afficher du contenu hors des blocs définis par son parent.

Pour expliquer cela, on peut considérer le parent comme une interface. Seuls ses blocs (= ses méthodes) sont connues. Quand on manipule les templates (= les objets) qui l'implémentent, on n'a connaissance que de celles-ci.

Surcharge de blocs externes

À l'instar d'un trait PHP, un bloc de template externe inclus au contexte (via le tag use) peut être surchargé. On peut alors vider, remplacer, ou modifier le contenu du bloc (toujours via la fonction parent()) .

Variables globales

Une variable globale est disponible partout dans Twig, à tout moment. La liste de ces variables est visible avec la commande suivante :

bin/console debug:twig

Remarque : ces variables peuvent être surchargées par une variable locale au template (si elles ont le même nom).

Variables internes

Twig en fournit 3 par défaut :

  • _self : le nom du template courant
  • _context : la liste des variables (locales) disponibles (celles passées dans le render() côté PHP)
  • _charset : le charset courant

Variables globales Symfony

Symfony (ou plutôt son Twig Bridge) en ajoute une pour nous : app (documentation).

Ajouter des variables globales

On peut en ajouter

  • directement via un fichier de config :
# config/packages/twig.yaml

twig:
  globals:
    my_var: 'value'
    my_param1: '%some_parameter%'
    my_service: '@my_service'
    my_array_var:
      - 'Monday'
      - 'Tuesday'
  • dans une extension Twig, qui doit alors implémenter GlobalInterface et sa méthode getGlobals().

Inclusions

Pour inclure un template dans un autre, on utilise le tag include, ou la fonction include() :

{# Avec tout le contexte courant #}
{% include 'template.twig' %}
{{ include('template.twig') }}

{# Avec le contexte courant et des variables spécifiques #}
{% include 'template.twig' with {'my_var': 'some_value'} %}
{{ include('template.twig', {'my_var': 'some_value'}) }}

{# Sans le contexte courant #}
{% include 'template.twig' only %}
{{ include('template.twig', with_context = false) }}

{# Sans le contexte courant mais des variables spécifiques #}
{% include 'template.twig' with {'my_var': 'some_value'} only %}
{{ include('template.twig', {'my_var': 'some_value'}, with_context = false) }}

Il est considéré comme une bonne pratique de retirer le contexte courant lors de l'inclusion L'avantage d'utiliser le tag, est de pouvoir facilement basculer vers embed si on a besoin de plus de flexibilité (cf. ci-après).

Inclusion avec surcharge

Pour modifier l'un des blocs du contenu inclus, il faut utiliser le tag embed.

{# Avec tout le contexte courant #}
{% embed 'template.twig' %}
{% block my_block %}
  This block has been overrided!
{% endblock %}
{% endembed %}

On peut retirer le contexte pour embed de la même manière que include.

Filtres et fonctions

La distinction entre les deux est la même que dans le monde bash Linux.
Un filtre est une fonction, recevant au moins un argument.

{{ some_function() }}
{{ some_function_with_args('arg1', 'arg2') }}

{{ 'a string as argument'|some_filter }}
{{ 'a string as argument'|some_filter_with_more_args('arg2') }}

Les filtres permettent de chainer facilement plusieurs traitements sans que la variable initiale soit perdue dans la notation :

{# avec filtres, on lit de gauche à droite #}
{{ 'mon texte'|gras|italique|color('red')|majuscule }}

{# avec fonctions, on lit de l'intérieur vers l'extérieur #}
{{ majuscule(color(italique(gras('mon texte)), 'red')) }}

Opérateurs logiques

Ces opérateurs ajoutent du sucre syntaxique pour les if des templates.

Exemples (in, is, matches, starts, end) :

{% if 'monday' in my_day_list %}{% endif %}
{% if 'a' in my_string %}{% endif %}

{% if my_var is defined %}{% endif %}

{% if my_var matches '/some_regex/' %}{% endif %}

{% if my_var starts with 'my_prefix' %}{% endif %}
{% if my_var not ends with 'my_suffix' %}{% endif %}

Il est possible d'en ajouter dans une extension twig, en implémentant la méthode getOperators().

Tests

Les tests ajoute du sucre syntaxique pour les if des templates contenant l'opérateur logique is.

Exemples (defined, empty, iterable, odd) :

{% if my_var is defined %}{% endif %}
{% if my_var is empty %}{% endif %}
{% if my_var is iterable %}{% endif %}
{% if my_var is odd %}{% endif %}
{% if my_var is sameas(some_var) %}{% endif %}

Il est possible d'en ajouter dans une extension twig, en implémentant la méthode getTests().

Extensions Twig

Documentation

Une extension Twig permet d'ajouter des filtres et fonctions personnalisés, mais également des tags, des opérateurs logiques, des tests, ...

Ajout de filtres et fonctions

Les filtres (TwigFilter) et fonctions (TwigFunction) que l'on déclare sont des objets qui associent un nom à un Callable. Lorsqu'on utilisera ce nom dans un template, le Callable sera appelé.

Remarque : par défaut, toutes fonctions ou filtres ajoutés verra sa valeur de retour échappée. Pour éviter cela, on peut indiquer ['is_safe' => ['html']] par exemple lors de leur déclaration.

Dépendances dans une extension

Si une extension a besoin d'un service pour le traitement de l'un de ses filtres ou fonctions, il faut éviter d'ajouter cette dépendance à l'extension, par soucis de performance.

En effet, si on l'injecte dans le constructeur de l'extension, il sera instancié très tôt durant l'exécution : dès l'enregistrement de l'extension.

Pour éviter ça, on peut déplacer le code nécessitant la dépendance dans un service dédié, implémentant TwigE\Extension\RuntimeExtensionInterface.

On utilisera ensuite le nom de ce service comme "instance" de Callable, et twig l'instanciera automatiquement au bon moment :

use \Twig\TwigFilter;
use \App\Twig\MyServiceRuntime;

public function getFilters(): array {
    return [
        new TwigFilter('my_filter', [MyServiceRuntime::class, 'methodOfServiceToCall']),
    ];
}

Le service ne sera instancié qu'à l'appel du filtre ou de la fonction, et pas avant. Il est donc logique de créer un service par dépendance.

La convention utilisée par les extensions du Twig Bridge est de les ranger sous le namespace \App\Twig avec les extensions, et de les suffixer avec Runtime.

Échappement

L'échappement permet à du texte de ne pas être interprêté par un langage.

Dans Twig, par défaut toute variable affichée (ex: {{ my_variable }}) est échappée.

Échappement par défaut

Toutes ces syntaxes sont équivalentes :

{{ my_var }}
{{ my_var|e }}
{{ my_var|escape }}
{{ my_var|escape('html') }}

Explication : e est un alias au filtre escape, qui reçoit par défaut la valeur html.

Type d'échappement

Le filtre escape accepte les valeurs suivantes en argument : html, js, css, url, html_attr.
Le dernier permet d'échapper les attributs des balises HTML.

Annuler l'échappement

Le filtre raw permet d'annuler l'échappement de la valeur.

{{ my_var|raw }}

Une autre technique consiste à changer l'extension du template twig. Si votre template s'appelle my_template.js.twig, alors escape recevra js pour valeur.
Si toutefois le filtre ne reconnais pas l'extension, html sera choisi.

Rendu de contrôleur

Imaginons qu'on souhaite afficher le nom de l'utilisateur courant en haut de chaque page de l'application.
Tous nos templates héritent d'un même parent qui contient un bloc affichant cette variable.

Pour que l'utilisateur courant soit passé au template, la solution de base consiste à l'ajouter dans le tableau des variables lors du render(). Cela implique par contre de devoir la passer pour les rendus de tous les templates enfants et vient complexifier le contrôleur qui demande le rendu.

La solution consiste à créer un contrôleur dédié au rendu de cette variable uniquement, qui sera lui-même directement rendu dans le template :

{{ render(controller('App\\Controller\\MyController::myAction', {})) }}

Explications :

  • La fonction render() appelle une URL et en affiche la réponse
  • La fonction controller() retourne la réponse du contrôleur en argument

Utiliser le rendu de contrôleur est considéré comme une bonne pratique.
On peut la privilégier pour chaque bloc du design mutualisé entre plusieurs pages.

Http cache

L'inclusion de contrôleur permet de bénéficier du Cache HTTP via les fragments.

Pour cela, il faut remplacer render() par render_esi(), et activer son utilisation dans le framework.yaml.

Traduction

Pour traduire une chaîne, on peut utiliser le filtre trans ou le bloc {% trans %}{% endtrans %}.
Le premier échappe la chaîne.

Traductions conditionnelles

Pour faire varier la traduction selon une variable (ex: le nombre ou le genre), on peut utiliser ICU.
Pour que le service de traduction utilise bien cette bibliothèque système, il faut ajouter le suffixe +intl-icu au nom du fichier de traduction (ex: messages+intl-icu.fr_FR.yaml).

Documentation

Autres

Filtre de tableau

Depuis PH7.4 et l'arrivée des arrow functions, le filtre filter accepte des fonctions anonymes :

{% for item in items|filter(i => i.relevent) %}
  <p>{{ item.name }}</p>
{% else %}
  <p>No item</p>
{% endfor %}

sprintf()

Le filtre format() disponible dans les templates équivaut au sprintf() de PHP.

{{ 'My string with %d word(s).'|format(5) }}

dump()

La fonction dump() comme dans PHP affiche le contenu d'une variable (ou de toutes celles disponibles si on l'appelle sans argument).

Pour les collecter mais ne pas les afficher dans le contexte (et seulement les voir via la debugtoolbar), on peut utiliser le tag dump à la place (ie. {% dump my_var %})

Déconnexion

La fonction logout_path() a été ajoutée pour générer une URL de logout directement.
Il faut ajouter la route dans le security.yaml.

Formatage

Beaucoup de fonctions ont été ajoutées pour formater une heure, une date, un nombre, ... Elles sont toutes préfixées par format_.

Elles sont fournies par IntlExtension.

Astuce Le routage dans Symfony

Cet article est une synthèse de la documentation officielle : Routing.

Généralités

Qu'est-ce qu'une route ?

C'est une configuration qui décrit comment trouver le contrôleur à exécuter pour un chemin (ou un ensemble de chemins) donné.

Pour cela, elle va décrire précisément :

  • le ou les chemins concerné(s)
  • le contrôleur cible
  • le contexte pour lequel cela doit matcher (telle IP, telle méthode HTTP, ...)

Elle peut être de différents types, Symfony gérant nativement annotation, yaml, xml et php.

Est-il possible d'avoir une route par langue pour un même contrôleur ? (ex: /about-us et /a-propos-de-nous)

Oui, en indiquant un tableau associatif pour path :

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * @Route({
 *     "en": "/about-us",
 *     "nl": "/over-ons"
 * }, name="about_us")
 */
public function about(): Response
{
    // ...
}

Regex

Pour indiquer une regex décrivant le format attendu pour l'un des paramètres de la route, deux syntaxes sont possibles :

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * @Route("/blog/{page}", name="blog_list", requirements={"page"="\d+"})
 */
public function list(int $page): Response
{
    // ...
}

/**
 * @Route("/blog/{slug<[a-z-]+>}", name="blog_show")
 */
public function show(string $slug): Response
{
    // ...
}

Explications :

  • La première route utilise la liste des requirements pour définir la regex du paramètre page.
  • La seconde utilise la notation abrégée indiquant la regex directement au niveau du paramètre.

Valeurs par défaut

Même chose, deux possibilités :

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * @Route("/blog/{page}", name="blog_list", requirements={"page"="\d+"}, defaults={"page"="homepage"})
 */
public function list(int $page): Response
{
    // ...
}

/**
 * @Route("/blog/{slug<[a-z-]+>?homepage}", name="blog_show")
 */
public function show(string $slug): Response
{
    // ...
}

Notes :

  • Pour laseconde notation, s'il n'y a pas de regex, on indique la valeur par défaut juste après le paramètre (ie. "/blog/{slug?homepage}").
  • Si on ne mets rien après le ?, alors le paramètre devient optionnel

Générer des URL

La génération est fournie via la méthode generate() du service UrlGeneratorInterface.

Elle peut générer plusieurs choses selon le paramètre $referenceType qu'on lui fournit :

  • UrlGeneratorInterface::ABSOLUTE_URL : une URL absolue (ex: http://example.com/dir/file)
  • UrlGeneratorInterface::ABSOLUTE_PATH : un chemin absolu (ex: /dir/file)
  • UrlGeneratorInterface::RELATIVE_PATH : un chemin relatif au chemin de la requête courante (ex: ../parent-file)
  • UrlGeneratorInterface::NETWORK_PATH : un chemin réseau (ex: //example.com/dir/file)

Redirection systématique

Le RedirectController fourni par Symfony, permet des redirections temporaires ou permanente

  • d'un chemin donné vers un autre
  • d'un chemin vers une route donnée

Il s'utilise directement par configuration dans le routing.yaml.


Paramètres internes de routage

L'ArgumentResolver rend disponible automatiquement les attributs de la requête par injection dans l'action de contrôleur.
La variable injectée doit avoir le nom de l'attribut (commençant par un _). Ex: $_route_params, $_controller, ...

Note : On peut également les récupérer classiquement via la Request dans le bag attribute.


Conditions spécifiques

Il est possible de définir des conditions spécifiques de match pour une route.
Celles-ci sont définies par de l'expression language.

Ex :

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class DefaultController extends AbstractController
{
    /**
     * @Route(
     *     "/contact",
     *     name="contact",
     *     condition="context.getMethod() in ['GET', 'HEAD'] and request.headers.get('User-Agent') matches '/firefox/i'"
     * )
     *
     * expressions can also include config parameters:
     * condition: "request.headers.get('User-Agent') matches '%app.allowed_browsers%'"
     */
    public function contact(): Response
    {
        // ...
    }
}

Commande Symfony pour le routing

Symfony fournit 2 commandes utiles pour travailler avec le routage :

  • bin/console debug:router --show-controllers : pour lister toutes les routes gérées et par quels contrôleurs
  • bin/console debug:router name_of_the_route : pour afficher la configuration complète d'une route
  • bin/console router:match /some/url [--method=GET] : pour afficher si l'URL match avec une route et si oui laquelle

Route Loader

Documentation

Pour pouvoir charger des routes provenant d'une source spécifique (ex: base de donnée, fichier .ini, ...), on peut créer un Route Loader spécifique.

Celui-ci doit étendre Symfony\Component\Config\Loader\Loader\Loader.
En surchargeant la méthode supports(), il peut gérer des types de route spécifiques.

Astuce Architecture Symfony

Licences

  • Symfony est sous Licence MIT Licence
  • La documentation officielle est sous Licence Creative Commons (Attribution-Share Alike 3.0 Unported Licence)
  • Twig est sous licence BSD-3

Composants, Bundles et Bridges

  • Un composant est une bibliothèque écrite en PHP, pouvant fonctionner sans Symfony.
    Exemples : Mailer, HttpClient, Form, ...

  • Un bundle est un plugin pour Symfony, servant d'interface entre une bibliothèque et Symfony. Il permet par exemple de configurer facilement la bibliothèque via Symfony.
    Exemples : twig-bundle qui permet de configurer Twig

  • Un bridge est un élément Symfony permettant d'étendre une bibliothèque en y ajoutant des choses utiles à Symfony Exemples : twig-bridge qui ajoute des fonctions à twig


Best practices

La liste des best practices est disponible ici.


Arborescence

Recommandée

├── assets/
├── bin/
│   └── console
├── config/
├── public/
│   └── index.php
├── src/
│   └── Kernel.php
├── templates/
├── tests/
├── translations/
├── var/
│   ├── cache/
│   └── log/
├── vendor/

Personnalisation

Non recommandé :

  • config/ : peut poser problème pour le déploiement des recipes Flex

Configurable :

  • /var/cache/ et /var/log/ : via surcharge des méthodes getCacheDir()et getCacheLog() du Kernel.php
  • templates/ : via twig.default_path (par défaut dans le twig.yaml)
  • translations/ : via framework.translator.default_path (par défaut dans le translation.yaml)

Risqué à modifier :

  • public/ : vérifier que tous les chemins restent corrects dans l'index.php.
    Il faut également ajouter la propriété extra.public-dir dans le composer.json (si on utilise Flex)

  • vendor/ : vérifier que tous les chemins restent corrects dans l'index.php. Il faut également ajouter la propriété config.vendor-dir dans le composer.json.


Flex

Documentation

C'est un plugin Composer qui va permettre d'ajouter/modifier les commandes Composer.

Par exemple, la commande require va permettre d'installer des paquets particuliers appelés Recipes.

Recipes

Une recipe est un paquet ne contenant qu'une liste de dépendances (via un composer.json), ainsi qu'une liste de modifications à effectuer (via un manifest.json) :

  • créer un fichier à tel endroit
  • ajouter telles lignes de configuration dans tel fichier
  • ...

Certains recipes sont officielles (= maintenues par la code team Symfony), d'autres contrib.

Elles sont toutes visibles sur https://flex.symfony.com.

Configurators

Les fichiers manifest.json des recipes sont lues via les Configurators.

Ex de fichier :

{
    "bundles": {
        "Alexandre\\EvcBundle\\AlexandreEvcBundle": ["all"]
    },
    "copy-from-recipe": {
        "config/": "%CONFIG_DIR%/"
    },
    "env": {
        "EVC_API": "Enter the api version provided by evc.de support",
        "EVC_USERNAME": "Enter your reseller account number",
        "EVC_PASSWORD": "Enter your api password, not the password to connect on evc.de website"
    }
}

Cette configuration nécessite par exemple 3 Configurators pour :

  • activer un bundle
  • copier un répertoire de configuration
  • ajouter des variables d'environnement

Les configurators natifs sont visibles ici.

Commandes composer pour Flex

De nouvelles commandes Composer ont été créées pour interagir avec Flex :

  • composer recipes : liste les recipes disponibles (et affiche les mises à jour éventuelles)
  • composer sync-recipes [--force] : met à jour une recipe

Gestion des erreurs et exceptions

Lorsqu'une erreur (PHP) est levée :

  • L'ErrorHandler la transforme en exception, qui est levée

Lorsqu'une exception est levée :

  • L'HttpKernel l'attrape et dispatche un évènement kernel.exception
  • L'ErrorListener effectue un forward vers l'ErrorController
  • L'ErrorController retourne une Response contenant l'erreur formatée via le ErrorRenderer

Gestion des évènements

Documentation

La classe Event contient deux méthodes :

  • stopPropagation() : elle indique qu'on ne souhaite pas que les prochains listeners ne traitent l'évènement
  • isPropagationStopped() : elle indique si on a demandé à stopper la propagation

À l'instar des services, on utilisait historiquement une chaîne de caractères comme identifiant de l'évènement (ex: kernel.terminate). Depuis Symfony 5, on utilise directement le nom de sa classe (ex: Symfony\Component\Mailer\Event\MessageEvent).

Commande Symfony

Pour lister les évènements disponibles et leurs listeners, utiliser la commande :

bin/console debug:event-dispatcher

Listener vs Subscriber

  • Un Listener est un callable qui reçoit un Event.
  • Un Subscriber est une classe qui mappe des évènements à des Listeners

Best practice :

Le subscriber est plus pratique d'utilisation, car il ne nécessite aucune configuration (en fichier yaml).
Il suffira qu'il implémente EventSubscriberInterface.

Principaux évènements

Les évènements du Kernel sont visibles ici.


Versions de Symfony

Documentation

Il y a deux versions principales à connaitre :

  • La dernière version stable (ex: 5.1.8) : elle inclue toutes les nouvelles fonctionnalités, corrections de bogue et patchs de sécurité.

  • La version LTS (Long Term Support) (ex: 4.4.16) : elle inclue toutes les dernières fonctionnalités de la version majeure, ainsi que les nouvelles corrections (pendant 3 ans au total) de bogue et patchs de sécurité (pendant 4 ans au total).

La version LTS est changée tous les 2 ans. Comme les versions mineures sortent tous les 6 mois, on a seulement 4 versions mineures par majeures.

Les versions non-LTS sont supportées 8 mois.

Note : en version 2.x, il y a eu plusieurs versions LTS

Backward compatibility promise

D'une version mineure à l'autre, Symfony garantie un fonctionnement à l'identique pour toute l'API publique (= ni @internal, ni @experimental).

Dépréciations

Documentation

Les dépréciations indiquent qu'une fonction/classe/... ne devrait plus être utilisée grâce à l'annotation @deprecated.

  • Elle doit préciser depuis quand la fonction/classe/... est dépréciée.
  • Elle peut indiquer la version à laquelle elle a été ajoutée, et la méthode à utiliser à la place.

Surcharger des éléments de Symfony ou de bundle

Comment surcharger un champ de formulaire (FormType) ?

On crée une extension de type.

Comment surcharger une route définie par un bundle ?

Il faut :

  • supprimer les imports des routes de ce bundle
  • déclarer sa propre route

Comment surcharger un contrôleur ?

On peut :

  • en surcharger la route (cf. ci-avant), pour pointer vers un nouveau contrôleur.
  • le décorer comme n'importe quel service

Comment modifier un service ?

On peut :

  • le décorer (best practice)
  • redéfinir sa définition (via le service.yaml) et le faire pointer vers un nouveau service
  • le modifier via le Compiler Pass

Interopérabilité

Le framework est compatible avec de nombreuse PSR :

  • PSR-1/PSR-2 : coding standards

  • PSR-4 : namespaces et autoloading

  • PSR-3 : logger (via MonologBundle)

  • PSR-6/PSR-16 : cache

  • PSR-11 : conteneur de services

  • PSR-14 : dispatcheur d'évènements

  • HttpClient est compatible : PSR-7/PSR-17/PSR-18

  • HttpPlug est compatible avec l'implémentation : PSR-7

Astuce Tips - Symfony

Console

Liste des commandes disponibles

bin/console

Conteneur de services

bin/console debug:autowiring

Cela liste les interfaces disponibles.

Pour en rechercher une concernant un sujet (ex: cache, mail, markdown)

bin/console debug:autowiring markdown

Pour voir toutes les implémentations de ces interfaces

bin/console debug:container

Ou pour avoir le détail sur l'une d'entre elles (ex: markdown)

bin/console debug:container markdown

Pourvoir les paramètres présents dans le conteneur

bin/console debug:container markdown

Configuration

bin/console debug:container --parameters

Cela liste toutes la configuration par défaut pour le bundle.

Pour voir la config actuelle

bin/console debug:config MyBundle

Liste des routes et de leur contrôleur

bin/console debug:router --show-controllers

Affiche la liste de toutes les routes et les contrôleurs/actions associés :

Création auto

La commande make permet de générer un squelette de classe (Command, TwigExtension, ...) :

bin/console make

Dump

dump($someVariable);

Pour afficher une variable et stopper l'exécution :

// "Dump and Die"
dd($someVariable);

Twig

Pour afficher toute la syntaxe ainsi que les variables globales disponibles dans Twig :

bin/console debug:twig

Injection de dépendances

Argument binding via services.yaml

La section _default est héritée par toutes les déclarations suivantes.
C'est pourquoi si on y utilise bind pour binder des arguments, ils seront disponibles pour tous les services.

On peut typer ces arguments. Ex:

services:
  _default:
    bind:
      bool $isDebug: '%kernel.debug%'
      Psr\Log\LoggerInterface $someLogger: '@monolog.logger.some_logger'

Quand il y a plusieurs implémentations d'une interface (par exemple LoggerInterface, cf.bin/console debug:autowiring log), on peut accéder à celui que l'on veut en nommant l'argument comme indiqué.

Ex:

public function __constructor(LoggerInterface $consoleLogger) {}
// à la la place de
public function __constructor(LoggerInterface $logger) {}

Alias de service

Un alias de service peut être créé en une ligne :

services:
      Some\Path\To\SomeService $newName: '@id_of_the_target_service'

Logging

Monolog peut loguer des messages sur des channel spécifiques. Pour cela il faut :

  • Déclarer ce nouveau channel
# ex: dans monolog.yaml
monolog:
  channels: ['my_new_channel']
  • Utiliser le nouveau service référencé par Monolog (cf.bin/console debug:autowiring log)
public function __constructor(LoggerInterface $myNewChannelLogger) {}

Bundles sympas

StofDoctrineExtensionsBundle

Ajoute des extensions doctrine (Slug, Blameable, Softdeleteable, ...)

Dépôt Git

KnpTimeBundle

Ajoute le filter twig ago, pour afficher depuis quand la date est passée.

Dépôt Git

Foundry

Ajoute une commande Symfony (make:factory) pour générer des Factory pour les entités.

Dépôt Git

Compatible avec le bundle ci-dessous

FakerBundle

Permet de générer du faux contenu de manière intelligente.

Dépôt Git

Astuce Installation de Symfony

Cet article est une synthèse de la documentation officielle : Installing & Setting up the Symfony Framework.

Prérequis

  • Symfony 4.4 : PHP 7.1 / Symfony 5.3 : PHP 7.2
  • Extensions PHP : Ctype, iconv, JSON, PCRE, Session, SimpleXML et Tokenizer
  • Composer

Pour faciliter l'installation et le développement avec Symfony, téléchargez l'exécutable symfony.

La première fois que vous lancez exécutez symfony, il vérifie automatiquement les prérequis et vous propose de l'ajouter aux exécutables système.

Installation

Nouveau projet

L'installation consiste à initialiser un nouveau projet Symfony.

Deux choix principaux s'offrent à vous :

  • installer la version minimale
  • installer une version plus complète, avec la majorité des composants nécessaires à une application web
symfony new my_project_name --full
# Ou, sans l'exécutable, juste avec Composer
composer create-project symfony/website-skeleton my_project

symfony new my_project_name
# Ou, sans l'exécutable, juste avec Composer
composer create-project symfony/skeleton my_project

Si vous ne souhaitez pas la version courante de Symfony, vous pouvez en spécifier une autre :

symfony new my_project --version=4.4 
# Ou, sans l'exécutable, juste avec Composer
composer create-project symfony/skeleton:"^4.4" my_project

Note : vous pouvez également remplacer le numéro de version par lts ou next.

Récupération d'un projet existant

Si vous récupérez un projet Symfony depuis un gestionnaire de version - au hasard, Git - il vous faut juste téléchargez les dépendances via Composer :

cd my_projects
git clone [...]

cd my_project/
composer install

Généralement, les configurations par défaut sont définies dans le fichier .env, et celles propres à l'environnement doivent être surchargées dans un fichier .env.local (tous deux à la racine).
Dans ce dernier, on retrouvera uniquement les propriétés du premier que l'on souhaite modifier. Typiquement, on y indiquera les informations de connexion à la base de données.

Permissions sur les fichiers

Les répertoires <my_project>/var/cache/ et <my_project>/var/log/ doivent être accessibles en écriture.

(Plus d'informations ici : Setting up or Fixing File Permissions.)

De plus, si vous souhaitez utiliser la console symfony (bin/console) plus facilement, vous devez la rendre exécutable :

cd my-project/
chmod +x bin/console

Remarque : Cela n'est pas nécessaire si vous l'utilisez à travers PHP (ex : php bin/console about).

Démarrage de l'application

En phase de développement, on peut se passer d'un serveur web classique comme apache ou nginx.
À la place, l'exécutable symfony peut en lancer un pour nous :

cd my-project/
symfony server:start

Plus d'informations ici : Symfony Local Web Server

Si vous préférez un serveur web classique, voici des exemples pour les configurer : Configuring a Web Server

Ajout de dépendances

Si vous souhaitez ajouter des composants Symfony prêts à l'emploi, vous pouvez en récupérer les bundles avec Composer.
Symfony lui a ajouté le plugin Flex, pour vous éviter d'avoir à les configurer.

De plus, cela vous permet d'installer un composant sans savoir où le trouver. Par exemple, vous avez besoin d'un logger ? Lancez la commande :

cd my-project/
composer require logger

Vous souhaitez un débogueur en mode dev ? Lancez la commande :

cd my-project/
composer require --dev debug

Sécurité des dépendances

Pour vérifier la présence de failles connues parmi les dépendances de votre projet, symfony propose une commande :

symfony check:security

Astuce Liste des caractères unicode d'espaces spéciaux

Selon la langue, différents caractères d'espace peuvent être utilisés.

En français par exemple (même si les claviers ne nous en facilitent pas l'écriture), l'espace entre des guillemets et le mot qu'ils encadrent n'est normalement pas d'une taille normale. Même chose avant :.
Les éditeurs de traitement de texte (comme LibreOffice ou Word) connaissent mieux que nous cette norme d'écriture et font le remplacement automatiquement.

Quand on récupère une chaîne qui contient de tels espaces, cela peut poser des problèmes d'affichage (ex: génération d'un PDF).

Voici la liste de ces caractères spéciaux, pour PHP :

$spaceUnicodeChars = [
            '\x{0020}',
            '\x{00A0}',
            '\x{180E}',
            '\x{2000}',
            '\x{2001}',
            '\x{2002}',
            '\x{2003}',
            '\x{2004}',
            '\x{2005}',
            '\x{2006}',
            '\x{2007}',
            '\x{2008}',
            '\x{2009}',
            '\x{200A}',
            '\x{200B}',
            '\x{202F}',
            '\x{205F}',
            '\x{3000}',
            '\x{FEFF}',
        ];

Pour javascript :

const SPACE_UNICODE_CHARS = [
    '\u{0020}',
    '\u{00A0}',
    '\u{180E}',
    '\u{2000}',
    '\u{2001}',
    '\u{2002}',
    '\u{2003}',
    '\u{2004}',
    '\u{2005}',
    '\u{2006}',
    '\u{2007}',
    '\u{2008}',
    '\u{2009}',
    '\u{200A}',
    '\u{200B}',
    '\u{202F}',
    '\u{205F}',
    '\u{3000}',
    '\u{FEFF}',
  ];

Astuce Autoriser Composer à utiliser toute la mémoire dont il a besoin

Pour lancer Composer sans la limite de mémoire imposée par la configuration de PHP, on peut utiliser cette commande :

php -d memory_limit=-1 $(which composer) update

Explications :

  • On lance Composer via PHP, ce qui permet de surcharger la valeur de memory_limit (-1 pour infinie)
  • which est une commande système qui affiche le chemin complet vers un exécutable (ici composer)
  • On ajoute ensuite les arguments classiques à passer à Composer

Astuce Éviter les injections de wildcards dans les requêtes avec Doctrine

Le query builder de doctrine protège automatiquement des injections SQL, car elle utilise des requêtes préparées où sont injectés les paramètres :

// Utilisateurs dont le nom est 'Toto'
$userName = 'Toto';
->where('u.name = :userName')
->setParameter('userName', $userName);

Par contre si on utilise un LIKE pour retourner tous les utilisateurs dont le nom commence par :

// Utilisateurs dont le nom commence par 'To'
$prefix = 'To';
->where('u.name LIKE :userNameStart')
->setParameter('userNameStart', '%'.$prefix);

Si $prefix est une donnée provenant d'un formulaire, on est pas à l'abri qu'elle ne contienne pas d'autres wildcards (_ ou %).
Il est préférable de l'échapper :

// Utilisateurs dont le nom commence par 'Toto'
$prefix = 'To';
->where('u.name LIKE :userNameStart')
->setParameter('userNameStart', '%'.addcslashes($prefix, '%_'));

Astuce Les étapes d'authentification via Ldap dans Symfony

Si vous utilisez l'authentification via Ldap de Symfony 4, avec les composants symfony/ldap et symfony/security-bundle voici les 3 étapes qui se jouent en arrière plan :

  • Authentification au Ldap
  • Recherche de l'utilisateur correspondant au login
  • Vérification du couple login/mot de passe

Selon l'étape, différentes classes et paramètres du framework seront utilisés.

Authentification au Ldap et recherche de l'utilisateur

Les deux premières étapes ont lieu au même endroit : dans la méthode loadUserByUsername() du LdapUserProvider (Symfony\Component\Security\Core\User\LdapUserProvider).

    public function loadUserByUsername($username)
    {
        try {
            // Etape 1
            $this->ldap->bind($this->searchDn, $this->searchPassword);
            // Etape 2
            $username = $this->ldap->escape($username, '', LdapInterface::ESCAPE_FILTER);
            $query = str_replace('{username}', $username, $this->defaultSearch);
            $search = $this->ldap->query($this->baseDn, $query);
        } catch (ConnectionException $e) {
            throw new UsernameNotFoundException(sprintf('User "%s" not found.', $username), 0, $e);
        }

        $entries = $search->execute();
        $count = \count($entries);

        if (!$count) {
            throw new UsernameNotFoundException(sprintf('User "%s" not found.', $username));
        }

        if ($count > 1) {
            throw new UsernameNotFoundException('More than one user found');
        }

        $entry = $entries[0];

        try {
            if (null !== $this->uidKey) {
                $username = $this->getAttributeValue($entry, $this->uidKey);
            }
        } catch (InvalidArgumentException $e) {
        }

        return $this->loadUser($username, $entry);
    }

Remarque : Vous pouvez surcharger ce comportement en créant un UserProvider héritant de LdapUserProvider.
Il faut alors le déclarer dans le config/security.yaml, et l'affecter à un firewall :

security:
    providers:
        # Je déclare mon provider spécifique
        my_ldap_provider:
            id: App\Security\MyLdapUserProvider

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false

        api:
            pattern: ^/
            stateless: true
            anonymous: true
            # J'indique que je souhaite utiliser le provider déclaré en haut
            provider: my_ldap_provider
            json_login_ldap:
                service: Symfony\Component\Ldap\Ldap
                dn_string: '%env(resolve:LDAP_SEARCH_DN_FOR_BIND)%'
                check_path: api_login
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure
                require_previous_session: false
            guard:
                authenticators:
                    - lexik_jwt_authentication.jwt_token_authenticator

Authentification

C'est l'instruction $this->ldap->bind($this->searchDn, $this->searchPassword); qui se charge de l'authentification.
Les arguments de la méthode sont récupérés dans les paramètres yaml du config/services.yaml :

parameters:
    # Alimentera $this->searchDn (par exemple "cn=Admin User,dc=mycompany,dc=local")
    ldap.search_dn: '%env(resolve:LDAP_SEARCH_DN)%'
    # Alimentera $this->searchPassword (par exemple "m0tDeP4sseAdm1n")
    ldap.search_password: '%env(resolve:LDAP_SEARCH_PASSWORD)%'

Explications :

  • Admin User correspond à l'identifiant d'un utilisateur ayant le droit d'accéder au Ldap, dont le mot de passe est m0tDeP4sseAdm1n.
  • Le searchDn est un "chemin" pour Ldap, permettant de trouver un utilisateur. Il est composé de son CN (Common Name) et de la baseDn.

Remarque : Les valeurs de ces paramètres sont stockées dans le .env (ou .env.local). Elles sont récupérées via le %env(resolve:XXX)%.

Recherche de l'utilisateur

C'est l'instruction $search = $this->ldap->query($this->baseDn, $query); qui s'en charge.
La valeur de $this->baseDn provient à nouveau du fichier config/services.yaml :

parameters:
    # Alimentera $this->baseDn (à priori la même chose que pour le searchDn, mais sans le nom d'utilisateur 
    # par exemple "dc=mycompany,dc=local")
    ldap.base_dn: '%env(resolve:LDAP_BASE_DN)%'

La valeur de la $query est un "filtre" Ldap pour trouver l'utilisateur. Il contiendra à priori son identifiant unique. Par exemple (uid=jl.david@mycompany.com).

Vérification du couple login/mot de passe

Cette fois ça se passe dans la méthode checkAuthentication() de la classe LdapBindAuthenticationProvider (Symfony\Component\Security\Core\Authentication\Provider) :

    protected function checkAuthentication(UserInterface $user, UsernamePasswordToken $token)
    {
        $username = $token->getUsername();
        $password = $token->getCredentials();

        if ('' === (string) $password) {
            throw new BadCredentialsException('The presented password must not be empty.');
        }

        try {
            $username = $this->ldap->escape($username, '', LdapInterface::ESCAPE_DN);

            if ($this->queryString) {
                $query = str_replace('{username}', $username, $this->queryString);
                $result = $this->ldap->query($this->dnString, $query)->execute();
                if (1 !== $result->count()) {
                    throw new BadCredentialsException('The presented username is invalid.');
                }

                $dn = $result[0]->getDn();
            } else {
                $dn = str_replace('{username}', $username, $this->dnString);
            }

            // Etape 3
            $this->ldap->bind($dn, $password);
        } catch (ConnectionException $e) {
            throw new BadCredentialsException('The presented password is invalid.');
        }
    }

Comme pour l'authentification, c'est l'instruction $this->ldap->bind($dn, $password); qui est utilisée.
Cette fois le DN contiendra le nom de l'utilisateur à authentifier et $password son mot de passe.

Il est configuré dans le fichier config/security.yaml :

security:
        api:
            json_login_ldap:
                #  Alimentera $this->dnString (par exemple "uid={username},ou=SomeRandomOU,dc=mycompany,dc=local")
                dn_string: '%env(resolve:LDAP_SEARCH_DN_FOR_BIND)%'
                # Il est aussi possible d'utiliser un query_string
                #query_string: '%env(resolve:LDAP_SEARCH_QUERY_STRING)%'

Le {username} sera remplacé par le nom de l'utilisateur recherché.

Astuce Utiliser un schéma spécifique pour la base de données PostgreSQL

Il n'est pas possible de préciser le schéma à utiliser dans l'url de connexion à la base de données PostgreSQL :

DATABASE_URL=pgsql://my_user:my_pwd@localhost:5432/my_db

Par défaut, c'est le schéma public de postgres qui est utilisé.

Pour en changer, il faut exécuter cette commande SQL sur la base de données :

ALTER USER my_user SET search_path = my_custom_schema;

Astuce S'authentifier dans le Swagger d'API Platform

Lorsqu'on utilise JWT pour l'authentification dans API Platform, on a besoin d'ajouter ce jeton à nos requêtes pour communiquer avec l'API. API Platform propose un bouton pour faire ça facilement dans Swagger :

Bouton d'authentification dans Swagger

Ce bouton n'apparaît pas par défaut. Il faut l'activer dans la configuration d'API Platform :

# config/package/api_platform.yaml
api_platform:
    swagger:
        api_keys:
            apiKey:
                name: Authorization
                type: header

Erreur [Symfony 4] L'utilisateur connecté est anonyme

Si vous utilisez JWT pour gérer l'authentification entre le backend Symfony et un front autre (ex: Angular), il peut arriver que le jeton "se perde" entre les deux.

Par exemple :

  • Vous vous connectez via la mire de login en front, recevez bien un jeton JWT en réponse
  • Vous lancer une autre requête auprès du backend, et vous voyez bien (dans la console de votre navigateur) que le jeton JWT est passé dans les headers
  • Dans votre contrôleur Symfony, vous récupérez l'utilisateur connecté (par exemple via AbstractController::getUser()) mais il est null

Si vous utilisez PHP-FPM avec Apache le problème peut venir du passage des headers entre les deux. Symfony préconise d'ajouter la ligne suivante dans le Virtual host de votre application :

SetEnvIfNoCase ^Authorization$ "(.+)" HTTP_AUTHORIZATION=$1

Pour vérifier si vous avez ce symptôme, il suffit de consulter le profiler Symfony. Parmi les headers de la partie Request / Response, vous devriez trouver le jeton JWT (comme c'est le cas dans la console de votre navigateur).

Astuce Auditer du code PHP

Voici trois outils utiles pour auditer du code PHP de manière statique :

PHPStan

Installation

cd my_project
wget https://github.com/phpstan/phpstan-shim/raw/master/phpstan.phar

Utilisation

php phpstan analyse src -l 0

Explications :

  • src est le répertoire à analyser. Plusieurs peuvent être passés, séparés par des espaces.
  • L'option -l permet de choisir le niveau d'acceptation des erreurs, avec 0 le moins strict possible, et 7 pour le plus strict.

SonarQube

Installation

Créez un fichier sonar-project.properties à la racine de votre projet, contenant les lignes suivantes :

sonar.projectKey=my:project
sonar.projectName=My project
sonar.projectVersion=1.0
sonar.sources=src
sonar.sourceEncoding=UTF-8

Explication : src est le répertoire à analyser

Remarques :

  • Pour retirer certains fichiers/répertoires de l'anlayse, utilisez l'option sonar.exclusions
  • Les autres options sont listées dans la documentation officielle

Lancez SonarQube via Docker :

docker pull sonarqube
docker run -d --name sonarqubedocker_sonarqube_1 -p 9000:9000 sonarqube
  • Accédez à SonarQube avec votre navigateur via cette adresse : http://localhost:9000.
  • L'interface indique que le site est en cours de maintenance. Attendez qu’il soit opérationnel.
  • Connectez-vous en tant qu'administrateur (admin/admin).

Lancez Sonar runner avec Docker, pour exécuter l'analyse :

docker run --link sonarqubedocker_sonarqube_1:sonarqube \
  --entrypoint /opt/sonar-runner-2.4/bin/sonar-runner \
  -e SONAR_USER_HOME=/data/.sonar-cache \
  -v $(pwd):/data -u $(id -u) sebp/sonar-runner \
    -Dsonar.host.url=http://sonarqube:9000 \
    -Dsonar.jdbc.url=jdbc:h2:tcp://sonarqube/sonar \
    -Dsonar.jdbc.username=sonar \
    -Dsonar.jdbc.password=sonar \
    -Dsonar.jdbc.driverClassName=org.h2.Driver \
    -Dsonar.embeddedDatabase.port=9092

Utilisation

L'application SonarQube affiche maintenant le rapport d'analyse de votre pojet.

Voici la page d'accueil du rapport, à partir de laquelle on accède aux alertes remontées et au code associé.

Accueil rapport SonarQube

PHPStorm

PHPStorm affiche des alertes directement dans le code, en fonction des réglages utilisés. Ceux-ci peuvent être personnalisés par projet ou au niveau global, via Settings > Editor > Inspections.

L'IDE propose aussi une vue d'ensemble de tout le projet. Pour l'utiliser, faites un clic-droit sur le répertoire à scanner (ex src), puis cliquez sur Inspect code...

Rapport PHPStorm

Le rapport peut ensuite être exporté au format HTML ou XML.

Astuce Mocker des fonctions natives PHP

PHPUnit fournit des outils pour mocker des classes et leurs méthodes. Ex :

$valueINeed = 'my_value';

$myMockedObject = $this->createMock(MyClassToMock::class);
$myMockedObject->method('myMethodToMock')
    ->willReturn($valueINeed);

Ce n'est pas le cas pour les fonctions natives, comme time() ou random_bytes().
C'est problématique si vous voulez tester une méthode comme celle-ci :

// src/Generator/TokenGenerator.php
namespace App\Generator;

use App\Exception\TokenGenerationException;

class TokenGenerator
{
    public function generateBase64Token(int $byteLength): string
    {
        if ($byteLength <= 0) {
            throw new \InvalidArgumentException('The length must be greater than zero');
        }

        try {
            $token = random_bytes($byteLength);
        } catch(\Exception $e) {
            throw new TokenGenerationException('An unexpected error has occured');
        }

        return base64_encode($token);
    }
}

Gérer le retour d'une fonction native

À la place de mocker la fonction, vous pouvez la redéfinir à votre convenance, dans le namespace où vous en avez besoin.

Par exemple :

namespace App\Generator;

function random_bytes(int $length) {
    return 'fake_random_string';
}

Lors de l'exécution, PHP cherchera la fonction dans le namespace courant, puis dans le namespace racine / s'il ne la trouve pas.
(À noter que si vous utilisez \random_bytes(), PHP cherchera alors uniquement dans le namespace racine.)

On peut ainsi tester la méthode correctement :

// tests/What/Ever/Probably/Just/App/Generator/TokenGeneratorTest.php
namespace App\Generator {
    function random_bytes(int $length) {
        return 'fake_random_string';
    }
}

namespace What\Ever\Probably\Just\App\Generator {

    use App\Generator\TokenGenerator;
    use PHPUnit\Framework\TestCase;

    class TokenGeneratorTest extends TestCase
    {
        /** @var TokenGenerator */
        private $tokenGenerator;

        public function testGenerateBase64Token(): void
        {
            $validLength = 128;
            $generated = $this->tokenGenerator->generateBase64Token($validLength);

            $expected = 'ZmFrZV9yYW5kb21fc3RyaW5n';

            $this->assertEquals($expected, $generated);
        }

        public function testGenerateBase64TokenWithInvalidLength(): void
        {
            $invalidLength = -12;

            $this->expectException(\InvalidArgumentException::class);

            $this->tokenGenerator->generateBase64Token($invalidLength);
        }

        protected function setUp(): void
        {
            parent::setUp();
            $this->tokenGenerator = new TokenGenerator();
        }
    }
}

Remarque : Quand il y a plusieurs namespace utilisés dans un même fichier PHP, on précise le code concerné en l'encadrant par des {}.

Gérer plusieurs retours d'une fonction native

Imaginons maintenant qu'on veuille tester le cas où la fonction random_bytes() lève une exception, puisque la documentation nous informe que cela peut arriver (en cas de problème mémoire).

Il faut donc que notre "fonction de surcharge" puisse retourner notre valeur "bouchon" ou lever une exception.
La difficulté c'est qu'on souhaite sans modifier sa signature, qu'elle ait un comportement différent selon le cas de test où on l'appelle.

Pour ça il y a une astuce (assez sale) : utiliser une variable globale pour indiquer à la fonction le comportement à suivre.

On peut ainsi modifier notre fonction de surcharge et implémenter un 3e cas de test :

// tests/What/Ever/Probably/Just/App/TokenGeneratorTest.php
namespace {
    $randomBytesFuncThrowsException = false;
}

namespace App\Generator {

    function random_bytes(int $length) {
        global $randomBytesFuncThrowsException;

        if (isset($randomBytesFuncThrowsException) && $randomBytesFuncThrowsException === true) {
            throw new \Exception();
        }

        return 'fake_random_string';
    }
}

namespace What\Ever\Probably\Just\App\Generator {

    use App\Exception\TokenGenerationException;
    use App\Generator\TokenGenerator;
    use PHPUnit\Framework\TestCase;

    class TokenGeneratorTest extends TestCase
    {
        // [...]

        public function testGenerateBase64TokenWithUnexpectedError(): void
        {
            global $randomBytesFuncThrowsException;

            $randomBytesFuncThrowsException = true;
            $noMatterWhatLength = 666;

            $this->expectException(TokenGenerationException::class);

            $this->tokenGenerator->generateBase64Token($noMatterWhatLength);
        }

        // [...]
    }
}

Remarque : Pour déclarer une variable globale, on se place dans le namespace racine.

DateTime

Un autre cas courant et problématique est le constructeur DateTime().

Quand on veut la date courante, on l'utilise souvent sans argument et là encore, pas moyen de mocker l'objet généré. À chaque test l'heure aura un peu avancé..

La solution toute simple consiste à utiliser le premier argument du constructeur :

// Remplacer
$dateTime = new \DateTime();

// par
$dateTime = new \DateTime(sprintf('@%s', time()));

On peut alors utiliser la technique présentée plus haut, et surcharger la fonction time() :

namespace Here\I\Instanciate\Datetime {
    function time()
    {
        return 1557481811;
    }
}

namespace Some\Where {

    class SomethingWhichDealsWithDateTest extends KernelTestCase
    {

        private $someServiceWhichInstanciatesDateTime;

        public function testMethodWhichInstanciateDatetime(): void
        {
            $this->someServiceWhichInstanciatesDateTime->getDatetimeInstance();

            // TODO: test something
        }
    }

    // [...]
}

Astuce Les nouveaux opérateurs introduits par PHP 7

PHP 7 introduit 2 nouveaux opérateurs : ?? et <=>, nommés respectivement Null coalescent et Spaceship.

Null coalescent

Cet opérateur permet de simplifier l'affectation des valeurs par défaut via les opérateurs ternaires :

$value = (isset($x) && $x !== null) ? $x : $defaultValue;

peut maintenant s'écrire :

$value = $x ?? $defaultValue;

Comme l'indique la documentation, il permet de vérifier si une variable existe et est non nulle.

Remarque : Il ne faut pas le confondre avec l'opérateur ternaire "abrégé" (depuis PHP 5.3), à savoir ?:. Celui-ci permet juste d'omettre la partie centrale, sans vérifier l'existence de la variable.

$value = $x ?: $valueIfXIsFalse;

$value devient $x si $x est considéré comme "vrai" . $value devient $valueIfXIsFalse si $x est considéré comme "faux".
Contrairement au null coalescent, il reverra un warning si $x n'est pas défini.

Spaceship

Cet opérateur permet de simplifier la comparaison entre deux variables :

$comparison = ($a < $b) ? -1 : (($a > $b) ? 1 : 0);

peut maintenant s'écrire :

$comparison = $a <=> $b;

Comme l'indique la documentation, cet opérateur retourne donc -1, 1 ou 0 selon la différence entre $a et $b.

Astuce La fonction __invoke()

Utilisation

Parmi les méthodes magiques disponibles en PHP, la méthode __invoke() permet de "transformer un objet en fonction".

Ainsi, voici l'exemple donné dans la documentation :

<?php
class CallableClass
{
    public function __invoke($x)
    {
        var_dump($x);
    }
}
$obj = new CallableClass;
$obj(5);
var_dump(is_callable($obj));
?>

Ce qui affiche :

int(5)
bool(true)

Explication :

On peut appeler l'instance de notre classe comme une fonction. C'est alors la méthode __invoke() qui est exécutée.

Utilité

Dans certains languages, comme le javascript, les fonctions sont des Objets. On appelle ce principe "first-class". C'est parfois utile d'avoir une fonction sous forme d'objet à manipuler. Selon telle ou telle propriété, elle pourra s'exécuter de manières différentes.

Voici un cas d'usage proposé sur stackoverflow.com :

Imaginons qu'on veuille trier ce tableau :

$arr = [
    ['key' => 3, 'value' => 10, 'weight' => 100],
    ['key' => 5, 'value' => 10, 'weight' => 50],
    ['key' => 2, 'value' => 3, 'weight' => 0],
    ['key' => 4, 'value' => 2, 'weight' => 400],
    ['key' => 1, 'value' => 9, 'weight' => 150]
];

Via la fonction usort(), on peut trier facilement en fonction de la clé value :

$comparisonFn = function($a, $b) {
    return $a['value'] < $b['value'] ? -1 : ($a['value'] > $b['value'] ? 1 : 0);
};
usort($arr, $comparisonFn);

Maintenant si on souhaite trier en fonction de la clé weight :

usort($arr, function($a, $b) {
    return $a['weight'] < $b['weight'] ? -1 : ($a['weight'] > $b['weight'] ? 1 : 0);
});

La logique de la fonction est exactement la même que précédemment, mais on ne peut pas la réutiliser. À la place, on peut créer une classe avec la méthode __invoke() :

class Comparator {
    protected $key;

    public function __construct($key) {
        $this->key = $key;
    }

    public function __invoke($a, $b) {
        return $a[$this->key] < $b[$this->key]  ? -1 : ($a[$this->key] > $b[$this->key] ? 1 : 0);
    }
}

et ainsi choisir au moment de l'appel :

usort($arr, new Comparator('key')); // tri par 'key'
usort($arr, new Comparator('value')); // tri par 'value'
usort($arr, new Comparator('weight')); // tri par 'weight'

Source : stackoverflow.com

Astuce [eZ5] Authentifier un utilisateur programmatiquement

Pour authentifier programmatiquement un utilisateur en front-office, vous pouvez utiliser cette méthode :

<?php

use eZ\Publish\Core\MVC\Symfony\Security\User as SecurityUser;
use eZ\Publish\API\Repository\Values\User\User;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;

    /**
     * Connecte l'utilisateur en argument.
     *
     * @param User $user
     *   Utilisateur eZ Publish récupéré depuis le repository
     *
     * @throws \Exception Si une erreur survient lors de la récupération du service de gestion de jeton.
     */
    public function login(User $user)
    {
        // Authentification pour Symfony
        $roles =['ROLE_USER'];
        $security_user = new SecurityUser($user, $roles);
        $security_user->setAPIUser($user);

        $token = new UsernamePasswordToken($security_user, null, 'ezpublish_front', $roles);
        $this->container->get('security.token_storage')->setToken($token);

        // Authentification pour le repo eZ Publish
        $this->repository->setCurrentUser($user);
    }

Erreur [D8] Erreur lors de l'upgrade de Drupal à propos du module Views

Lors de la mise à jour de Drupal (par exemple de 8.3.7 vers 8.4.3), vous pouvez rencontrer ce genre d'erreur :

$ drush updb -y
The following updates are pending:

views module : 
  Fix table names for revision metadata fields.

Do you wish to run all pending updates? (y/n): y
Après la mise à jour de views                                                    [ok]
Failed: InvalidArgumentException : The configuration property                      [error]
display.default.display_options.filters.my_custom_filter_id.value.2 doesn&#039;t
exist. dans Drupal\Core\Config\Schema\ArrayElement-&gt;get() (ligne 76 de
/var/www/ftvpro/web/core/lib/Drupal/Core/Config/Schema/ArrayElement.php).
Cache rebuild complete.                                                            [ok]
Finished performing updates.

Le module Views est incapable de mettre à jour la base de données correctement, à cause d'un filtre de vue.

Pour éviter ça, cherchez dans vos modules (ou ceux communautaires que vous utilisez) le filtre de vue en question. (Ici my_custom_filter.) Le module qui déclare ce filtre doit également proposer une mise à jour de la base via un fichier yaml. Pour cet exemple, le module my_module doit contenir le fichier config/schema/my_module.views.schema.yml :

# Schema for the views plugins of the my_module module.

views.filter.my_custom_filter:
  type: views.filter.in_operator
  label: 'My custom view filter'

Si ce n'est pas le cas, crééz-le et relancer la commande drush updb.

Remarque : Si on regarde le fichier comment.views.schema.yml que propose le module Comment, on y trouve aussi ce genre de déclarations :

views.argument.argument_comment_user_uid:
  type: views_argument
  label: 'Commented user ID'

views.field.comment_depth:
  type: views_field
  label: 'Comment depth'

views.row.comment_rss:
  type: views_row
  label: 'Comment'
  mapping:
    view_mode:
      type: string
      label: 'Display type'

views.sort.comment_ces_last_comment_name:
  type: views_sort
  label: 'Last comment name'

Le problème peut donc sûrement se produire pour les arguments, les champs, les lignes et les tris des vues déclarés dans les modules.

Astuce Installer un patch via composer

Lorsqu'il y a un bug dans l'une de vos dépendances gérées par composer, il faut éviter de le corriger en modifiant directement la lib. Sinon, la correction sera automatiquement écrasée lors de la prochaine installation/mise à jour de la lib.

Si un patch est proposé sur le site du fournisseur de la lib, mais pas encore intégré dans leur dernière release, vous pouvez demander à Composer de l'appliquer lors de l'installation des dépendances (i.e. composer install).

La lib composer-patches pour Composer, permet cette fonctionnalité. Pour l'utiliser, ajoutez-la dans votre composer.json. Par exemple :

{
  // ...
  "require": {
    // ...
    "cweagans/composer-patches": "~1.0",
    "drupal/core": "~8.4.0"
  },
  // ...
  "extra": {
    // ...
    "patches": {
      "drupal/core": {
        "Quickedit missing css classes": "https://www.drupal.org/files/issues/2551373-35.patch"
      }
    }
  }
}

Explications :

  • Le projet requiert la lib composer-patches comme dépendance.
  • On a ausssi intégré drupal/core, que l'ont veut patcher.
  • Après avoir téléchargé et installé cette dépendance, Composer va appliquer le patch présent à l'URL https://www.drupal.org/files/issues/2551373-35.patch.

Remarque :

On peut appliquer plusieurs patchs successifs pour une ou plusieurs lib. Ex :

{
  // ...
  "require": {
    // ...
    "cweagans/composer-patches": "~1.0",
    "vendor1/lib1": "~1.0",
    "vendor1/lib2": "~1.0",
    "vendor2/lib3": "~1.0"
  },
  // ...
  "extra": {
    // ...
    "patches": {
      "vendor1/lib1": {
        "Patch 1": "https://www.vendor1.org/lib1/patch1.patch",
        "Patch 2": "https://www.vendor1.org/lib1/patch2.patch"
      },
      "vendor2/lib3": {
        "Patch 3": "https://www.vendor2.org/lib3/patch.patch"
      }
    }
  }
}

Astuce [d8] Ajouter des variables dans un render array existant

Les render array sont utilisés partout dans Drupal, pour générer des affichages. Lorsqu'on crée un bloc côté PHP, il retourne un render array. Même chose pour un formulaire ou pour un contrôleur.

Les modules peuvent définir leurs propres apparences pour les éléments : ils peuvent décrire de nouveaux render array (hook_theme()) qui seront ensuite utilisables partout.

Pour ajouter des variables à un render array proposé par un autre module, il faut tout d'abord modifier sa définition, pour que la variable puissent être transmise au template. On utilise pour cela le hook_theme_registry_alter().

/**
 * Implements hook_theme_registry_alter().
 */
function mon_module_theme_registry_alter(&$theme_registry) {
  $theme_registry['nom_du_render_array']['variables']['nouvelle_variable'] = 'valeur_par_defaut';
}

Maintenant que la variable "est autorisée", il faut l'ajouter au render array existant. Il s'agit d'un simple hook_preprocess_HOOK().

/**
 * Implements hook_preprocess().
 */
function mon_module_preprocess_nom_du_render_array(&$variables) {
  $variables['nouvelle_variable'] = 'valeur de la variable';
}

Explication :

  • nom_du_render_array est la clé définie dans le hook_theme() du module qui le propose. C'est celle qu'on renseigne lorsqu'on utilise le render array ('#theme" => 'nom_du_render_array').
  • C'est cette clé qu'on utilise dans le nom de notre fonction hook_preprocess_HOOK().

Remarque :

Comme après chaque implémentation de hook(), pensez à vider les caches.

Astuce Installer PHP 7 sous Debian 8

Par défaut, les dépôts de Debian 8 propose d'installer PHP 5.6. Si vous voulez la version 7, procédez ainsi.

  • Ajoutez un nouveau dépôt pour apt, en tant que super administrateur :
echo 'deb http://packages.dotdeb.org jessie all' > /etc/apt/sources.list.d/dotdeb.list
wget -O- https://www.dotdeb.org/dotdeb.gpg | apt-key add -
apt update
  • Installez PHP 7, avec les extensions que vous souhaitez. Probablement au moins gd, mcrypt, php-pear, intl :
apt-get -y install php7.0 libapache2-mod-php7.0 php-pear php7.0-gd php7.0-mcrypt php7.0-intl

Globalement, les paquets portent le même nom que ceux pour PHP 5.6.

Astuce Installation de PHP

Installation

PHP et ses extensions

Pour installer la version de PHP disponible par défaut sur le dépôt (PHP 5.6 pour Debian 8), procédez comme suit. Pour installer PHP 7, suivez cet article à la place.

  • Installez PHP via le gestionnaire de paquets :
sudo apt-get install libapache2-mod-php5
  • Installez les extensions dont vous avez besoin. Probablement au moins gd, mcrypt, php-pear, intl :
sudo apt-get install php5-gd php5-mcrypt php-pear php5-intl

Pour curl, il s'agit du paquet php5-curl.

Configuration de PHP

  • Modifiez le fichier /etc/php5/apache2/php.ini. À la fin de la section [Miscellaneaous], ajoutez la ligne suivante pour spécifier la locale à utiliser :
date.timezone = "Europe/Paris"
  • Redémarrez Apache :
sudo service apache2 restart

Vérification

  • Supprimez le fichier index.html de votre site (ex : /var/www/mon-site/index.html) et créez le fichier index.php à la place :
<?php
phpinfo();
?>
  • Appelez l'URL de votre site et vérifier qu'Apache vous retourne bien toute la configuration de PHP.

Composer

Si vous avez besoin de Composer, installez-le via ces commandes :

sudo apt-get install curl 
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer 

# Vérification
composer --version

Astuce Installer PHP 7 sous Debian 8

Par défaut, les dépôts de Debian 8 proposent d'installer PHP 5.6. Si vous voulez la version 7, procédez ainsi.

  • Ajoutez un nouveau dépôt pour apt, en tant que super administrateur :
echo 'deb http://packages.dotdeb.org jessie all' > /etc/apt/sources.list.d/dotdeb.list
wget -O- https://www.dotdeb.org/dotdeb.gpg | apt-key add -
apt update
  • Installez PHP 7, avec les extensions que vous souhaitez. Probablement au moins gd, mcrypt, php-pear, intl :
apt-get -y install php7.0 libapache2-mod-php7.0 php-pear php7.0-gd php7.0-mcrypt php7.0-intl

Globalement, les paquets portent le même nom que ceux pour PHP 5.6.

Astuce Le module Help

Le module Help fait partie du cœur de Drupal et est activé par défaut.

Il permet d'ajouter un message au haut de la page d'édition d'un contenu : Message formulaire d'édition

Pour cela il suffit d'éditer votre type de contenu, et de remplir la zone de texte sous le champ title (le HTML est accepté) :

Configuration du module Help

Pour que le message apparaisse dans le formulaire d'édition, il faudra que le bloc Aide soit activé dans une des régions de votre thème d'administration. C'est le cas pour celui par défaut (Seven).

Astuce [D8] Ajouter un bouton d'action en back-office

Lorsque vous êtes sur la page qui liste les types de contenu par exemple, il y a le bouton Ajouter un type de contenu en haut à gauche.

Plus généralement, lorsqu'on liste des éléments (nœuds, liens de menu, ...), on propose souvent un lien pour en ajouter de nouveaux.

Si vous utilisez les vues Drupal pour ajouter des pages de liste en back-office, vous vous voudrez probablement ce genre de bouton. Cela ne se fait pas dans la configuration de la vue, mais via un fichier my_module.links.action.yml. Par exemple :

# Article
node.add.article
  route_name: node.add
  route_parameters:
    node_type: article
  title: 'Ajouter un Temps Fort'
  appears_on:
    - view.my_view_article.display_1
    - view.other_view.display_name

Explication :

On indique

  • la route (et ses paramètres) vers laquelle devra pointer le bouton d'action
  • le libellé du bouton (title)
  • sur quelle(s) page(s) il devra apparaître (= ids de routes)

Remarque :

Pour une vue, l'id de la route est composée du préfixe 'view', du nom technique de la vue, puis du nom technique de l'affichage de la vue (une vue pouvant avoir plusieurs affichages).

Ces deux ID techniques sont visibles dans l'URL lorsque vous éditez un affichage d'une vue. Ex : http://www.mysite.fr/admin/structure/views/view/`my_view_article`/edit/`display_1`.

Astuce Modifier un élément de liste dans un formulaire

Pour ajouter une classe CSS sur un élément de formulaire basique (input, select, ...), on ajoute la clé #attributes à son render array.

Par contre pas possible de le faire pour une liste de boutons radio par exemple. La classe s'ajoutera sur le conteneur à la place.

Il existe donc la clé #after_build pour remédier à ce problème (cf. documentation). Elle attend une liste de noms de fonction en valeur.

Chacune de ces fonctions sera exécutée après coup pour modifier le render array de l'élément. À ce moment de l'exécution, les sous-éléments (ici les boutons radio) ont déjà été ajoutés et peuvent donc être modifiés.

Par exemple dans ma méthode buildForm() :

$form['my_field'] = [
  '#type' => 'radios',
  '#title' => t('My field'),
  '#options' => [
    0 => t('No'),
    1 => t('Yes'),
  ],
  '#after_build' => ['_my_module_radio_add_class']
];

Et dans mon fichier my_module.module :

function _my_module_radio_add_class(array $element, FormState $form_state) {
  $options = array_keys($element['#options']);

  // Parcours des sous-éléments options
  foreach ($options as $values) {
    $element[$values]['#attributes']['class'][] = 'myclass';
  }
  return $element;
}

Astuce Rediriger en 404 ou 403

Si vous voulez rediriger l'utilisateur vers la page 404 ("Page introuvable") ou 403 ("Vous n'avez pas le droit d’accéder à cette page") de Drupal, vous utilisiez sans doute ça en Drupal 7 :

return drupal_access_denied();
return drupal_not_found();

Voici l'équivalent pour Drupal 8 :

use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

throw new AccessDeniedHttpException();
throw new NotFoundHttpException();

Astuce Drupal dans un sous-répertoire

Si vous utilisez Drupal dans un sous-répertoire servi par Apache (ex : /var/www/html/mon_site), ou derrière un reverse proxy qui ajoute un contexte à l'URL (ex : http://mon-domaine.fr/mon-contexte), il est possible que seule la page d'accueil fonctionne.

Vous aurez alors une erreur 404 sur toutes les autres pages (ex : http://mon-domaine.fr/mon-contexte/user/login).

Il vous faut alors modifier le .htaccess à la racine du site, en modifiant la ligne :

# RewriteBase /

Décommentez-la et remplacer le / par le contexte ou le sous-répertoire. Ex :

# Modify the RewriteBase if you are using Drupal in a subdirectory or in a
# VirtualDocumentRoot and the rewrite rules are not working properly.
# For example if your site is at http://example.com/drupal uncomment and
# modify the following line:
# RewriteBase /drupal
#
# If your site is running in a VirtualDocumentRoot at http://example.com/,
# uncomment the following line:
RewriteBase /mon-contexte

Erreur The following module is missing from the file system

Si vous installez un module dans Drupal et que vous en supprimez les fichiers avant de le désinstaller, vous pouvez rencontrer une erreur du genre :

The following module is missing from the file system: 
paragraphs in drupal_get_filename() (line 240 of core/includes/bootstrap.inc).

Pour Drupal, le module est désinstallé. Pourtant, il en garde des traces et cela cause cette erreur.

Deux solutions sont alors possibles :

  • Réinstaller le module, puis le désinstaller correctement
  • Supprimer la référence en base, via la requête suivante :
drush sql-query "DELETE FROM key_value WHERE collection='system.schema' AND name='module_name';"

Astuce Limiter la version à mettre à jour avec Composer

Lorsque vous mettez à jour vos dépendances avec Composer, vous ne souhaitez pas avoir la dernière version disponible.

Par exemple, si j'ai un site Drupal en v8.2.7 et qu'une mise à jour de sécurité sort, je peux préférer passer en v8.2.8 plutôt qu'en 8.3.1.

Dans ce cas on peut préciser ça dans le fichier composer.json :

{
...
  "require": {
    "drupal/core": "~8.2.0",
    ...
  },
...
}

Explications :

  • ~8.2.0 signifie >= 8.2.0 & < 8.3.0
  • ~8.2 signifie >= 8.2 & < 9
  • Il existe aussi le signe ^, moins restrictif : ^8.2.1 signifie >=8.2.1 & < 9

Erreur Problème de mémoire avec composer

Si vous utilisez Composer dans une VM, vous pouvez avoir une erreur de mémoire dûe à un problème de swap :

Installation failed, reverting ./composer.json to its original content.
The following exception is caused by a lack of memory or swap, or not having swap configured
Check https://getcomposer.org/doc/articles/troubleshooting.md#proc-open-fork-failed-errors for details

[ErrorException]
proc_open(): fork failed - Cannot allocate memory

Plusieurs solutions sont décrites dans ce ticket sur Stackoverflow.

Celle (temporaire) qui marche particulièrement bien est la suivante. Il suffit de lancer les commandes :

/bin/dd if=/dev/zero of=/var/swap.1 bs=1M count=1024
/sbin/mkswap /var/swap.1
/sbin/swapon /var/swap.1

Astuce [D8] Installer la dernière version de drush

Sur les dépôts des distributions linux, c'est souvent une vieille version de drush qui est disponible (ex: Debian 8.4 -> drush 5.x). Voici comment installer la dernière.

Prérequis

Composer et Git doivent être installés.

Composer

sudo apt-get install curl
sudo curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer

# Vérification
composer --version

GIT

sudo apt-get install git

# Vérification
git --version

Installation

  • Téléchargez drush :
sudo git clone --depth 1 https://github.com/drush-ops/drush.git /usr/local/src/drush
  • Mettez-le à jour :
cd /usr/local/src/drush
sudo composer install
  • Créez les liens symboliques suivant :
sudo ln -s /usr/local/src/drush/drush /usr/local/bin/drush
sudo ln -s /usr/local/src/drush/drush.complete.sh /etc/bash_completion.d/drush
  • Vérifiez l'installation :
drush --version

Astuce [d8] Surcharger l'affichage d'une page existante

Drupal 8 propose nativement des pages pour gérer l'inscription, la connexion, l'oubli de mot passe.

Malheureusement actuellement il n'y a pas de suggestion de template proposée. (Comme on peut le voir habituellement en commentaire dans le code source lorsque le mode debug est activé.)

Il faut donc procéder autrement et utiliser les hook_form_alter() et hook_theme() classiques.

Par exemple, pour surcharger le formulaire de la page oubli de mot de passe :

// mymodule.module

/**
 * Implements hook_form_alter()
 */
function mymodule_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) {

  // Si le formulaire est celui d'oubli de mot de passe
  if ($form_id == 'user_pass') {
    $form['#theme'] = ['my_register_form'];
  }
}

/**
 * Implements hook_themer()
 */
function mymodule_theme(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) {

  return [
    'my_password_form' => [
      'template' => 'user_forget_password_form',
      'render element' => 'form',
    ],
  ];
}

Explications :

  • Le hook_form_alter() permet de modifier le thème à utiliser pour le formulaire. Le thème choisi doit exister ou être déclaré dans votre module.
  • Le hook_theme() permet de déclarer le nouveau thème my_password_form et d'y affecter un template spécifique.

Remarque :

Par défaut, sans cette configuration, le template natif form.html.twig serait utilisé. Pour créer votre propre template il peut donc être pratique d'en faire une copie, la renommer (ici user_forget_password_form.html.twig) et de s'en servir comme base pour effectuer vos modifications.

Astuce [D8] Ajouter des pages de configuration

Pour rendre votre site plus facilement paramétrable, il est utile de fournir une interface d'administration pour modifier telle ou telle option.

Ces options seront ensuite accessibles partout dans votre code :

$config = \Drupal::config('mon_module.settings');
$my_option_value = $config->get('my_option');

Comme son prédécesseur, Drupal 8 permet de générer rapidement ces interfaces, ainsi que les éléments du menu d'administration correspondant :

Page d'administration en back-office

Pour générer deux pages de formulaires avec des onglets pour passer de l'un à l'autre, vous aurez besoin des fichiers suivants :

Arborescence nécesaire

Remarque :

L'exemple qui suit requiert d'activer ces deux modules : admin_toolbar et admin_toolbar_tools.

Création d'un formulaire d'administration

Voici un exemple de formulaire d'administration affichant trois champs, de types numérique, texte riche et email.

Structure générale

<?php

namespace Drupal\my_module\Form;

use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;

/**
 * Gère le formulaire de configuration générale pour le module.
 */
class GlobalSettingsForm extends ConfigFormBase {

  /**
   * {@inheritdoc}
   */
  public function getFormId() {
    return 'my_module_settings_form';
  }

  /**
  * {@inheritdoc}
  */
  protected function getEditableConfigNames() {
    return [
      'my_module.settings',
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state) {

  }

  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state) {

  }

}

Explications :

  • Le formulaire hérite de la classe abstraite ConfigFormBase, fournie par Drupal.
  • En plus des méthodes getFormId(), buildForm() et submitForm() habituelles, elle impose que la méthode getEditableConfigNames() soit implémentée.
  • Cette méthode permet de définir un "namespace" pour votre configuration. Chaque propriété devra avoir un id unique au sein du même "namespace".

Méthodes buildForm() et submitForm()

/**
  * {@inheritdoc}
  */
public function buildForm(array $form, FormStateInterface $form_state) {

  // Récupération de la configuration avec le "namespace" my_module.settings
  $config = $this->config('my_module.settings');

  $form['nb_news_homepage'] = array(
    '#type' => 'number',
    '#title' => $this->t('Nombre d\'actualités afichées en page d\'accueil'),
    '#default_value' => $config->get('nb_news_homepage'),
  );

  $form['welcome_text'] = array(
    '#type' => 'text_format',
    '#title' => $this->t('Texte de bienvenue'),
    '#description' => $this->t('Texte affiché en page d\'accueil.'),
    '#default_value' => $config->get('welcome_text'),
  );

  $form['contact_receiver_email'] = array(
    '#type' => 'email',
    '#title' => $this->t('Adresse email du destinataire pour le formulaire de contact'),
    '#default_value' => $config->get('contact_receiver_email'),
  );

  return parent::buildForm($form, $form_state);
}

/**
 * {@inheritdoc}
 */
public function submitForm(array &$form, FormStateInterface $form_state) {
  $this->config('my_module.settings')
    ->set('nb_news_homepage', $form_state->getValue('nb_news_homepage', 5))
    ->set('welcome_text', $form_state->getValue('welcome_text', '<p>Texte de bienvenue à changer.</p>')['value'])
    ->set('contact_receiver_email', $form_state->getValue('contact_receiver_email', 'admin@monsite.com'))
    ->save();

  parent::submitForm($form, $form_state);
}

Explications :

La méthode buildForm() est semblable à celle d'un formulaire classique. À noter cependant :

  • Pour récupérer les valeurs présentes en base et préremplir les champs, on utilise la méthode config(), avec le "namespace" définit précédemment
  • Chaque valeur est accessible individuellement, via un simple getter
  • La méthode parente est appelée

La méthode submitForm() va enregistrer les données soumises en base :

  • Les configurations actuelles sont récupérées
  • Les nouvelles valeurs sont mises à jour, puis sauvegardées
  • La méthode parente est appelée

Remarque :

Pour un champ texte riche, la méthode getValue() proposée par FormStateInterface retourne un tableau et non pas une valeur enregistrable en base.

Il faut penser à ajouter ['value'] derrière, pour avoir une chaîne de caractère utilisable.

Configuration du menu

my_module.routing.yml

C'est le fichier classique de Drupal, permettant de lier des URL à vos contrôleurs et formulaires.

# Page générale listant les pages de configuration du module
my_module.overview:
  path: '/admin/config/my_module'
  defaults:
    _controller: '\Drupal\system\Controller\SystemController::systemAdminMenuBlockPage'
    _title: 'Mon module - Configuration'
    link_id: 'my_module.overview'
  requirements:
    _permission: 'administer site configuration'

# Page de configuration générale du module
my_module.settings:
  path: '/admin/config/my_module/general'
  defaults:
    _form: '\Drupal\my_module\Form\GlobalSettingsForm'
    _title: 'Mon module - Configuration générale'
  requirements:
    _permission: 'administer site configuration'

# Page de configuration des webservices du module
my_module.webservices.settings:
  path: '/admin/config/my_module/webservices'
  defaults:
    _form: '\Drupal\my_module\Form\WebservicesSettingsForm'
    _title: 'Mon module - Configuration des webservices'
  requirements:
    _permission: 'administer site configuration'

Explications :

  • Les deux dernières routes sont classiques. Elles pointent vers vos nouvelles pages de formulaire de configuration.
  • La première pointe vers un contrôleur fourni par Drupal, qui permet de lister des sous-pages (ex: http://www.monsite.com/admin/config/people)

my_module.links.menu.yml

Ce fichier définit de nouveaux éléments dans le menu d’administration.

# Page générale listant les pages de configuration du module
my_module.overview:
  title: 'Mon module'
  parent: system.admin_config
  description: 'Voir les pages de configuration du module "Mon module".'
  route_name: my_module.overview
  weight: -100

# Page de configuration générale du module
my_module.settings:
  title: 'Général'
  parent: my_module.overview
  description: 'Gérer la configuration générale du module.'
  route_name: my_module.settings
  weight: -10

# Page de configuration des webservices du module
my_module.webservice.settings:
  title: 'Webservices'
  parent: my_module.overview
  description: 'Gérer la configuration des webservices.'
  route_name: my_module.webservices.settings
  weight: -5

Explication :

Pour chaque lien souhaité, on définit :

  • le libellé (title)
  • la description au survol (attribut title pour le lien généré)
  • l'élément de menu parent
  • la route vers laquelle pointer
  • le poids de l'élément (le plus petit apparaîtra en premier)

Dans cet exemple, la page "overview" est parente des deux autres.

my_module.links.task.yml

Ce fichier définit des onglets accessibles dans les pages d'administration. Depuis la première page, on a donc un lien rapide vers la seconde, et vice-versa.

my_module.settings:
  route_name: my_module.settings
  title: 'Général'
  base_route: my_module.settings

my_module.webservices.settings:
  route_name: my_module.webservices.settings
  title: 'Webservices'
  base_route: my_module.settings

Explication :

Pour chaque onglet souhaité, on définit :

  • le libellé (title)
  • la route vers laquelle pointer
  • l'onglet principal

Astuce [D8] Commandes drush utiles

Les commandes drush pour Drupal 8 sont en partie identiques à celles pour Drupal 7. En fait, il s'agit surtout de la version de drush et non pas de celle de Drupal. Pour Drupal 8, il est conseillé d'utiliser la version 8.x de drush.

Voici une liste de commandes drush bien pratiques :

Features

Fonction Commande
Exporter un nouveau composant dans une feature drush cex
Importer les configurations du site drush cim

Modules

Fonction Commande
Information sur un module drush pmi nom_module
Télécharger un module drush dl nom_module
Activer un module drush en nom_module
Désinstaller un module drush pmu nom_module
Mettre à jour les tables en base concernant un module drush updb nom_module
Liste des modules communautaires activés drush pm-list --pipe --type=module --status=enabled --no-core
Liste des modules du cœur activés drush pm-list --pipe --type=module --status=enabled --core

Base de données

Fonction Commande
Exécuter une commande SQL drush sqlc "SELECT * FROM node;"
Créer un dump drush sql-dump > /chemin/fichier.sql
Vider une base de données drush sql-drop
Importer un dump drush sql-cli < /chemin/fichier.sql
Mettre à jour les tables en base pour tous les modules drush updb (utile après une mise à jour de sécurité)
Mettre à jour entités en base drush entup (utile après une mise à jour de module)

Autres

Fonction Commande
Vider tous les caches drush cr
Modifier le mot de passe d'un utilisateur drush upwd --password="nouveau_mot_de_passe" login_utilisateur
Exécuter une tâche planifiée drush php-eval 'monmodule_cron();'
Exécuter du code PHP drush php-eval 'echo "je suis du code php exécuté";'
Connaître la version de Drupal drush status

Astuce [D8] Ajouter un mode d'affichage à un nœud

Dans Drupal 7, pour ajouter un nouveau mode d'affichage il fallait utiliser un hook (cf: cet autre article).

Dans Drupal 8.x, tout est faisable en back-office, via Structure > Modes d'affichage. Ensuite, comme avant, il faut activer le nouveau mode pour le type de nœud correspondant :

Activation du mode d'affichage

Remarque :

Dans Drupal 8, les modes d'affichage sont cloisonnés par entité. Si vous voulez un mode d'affichage Liste pour les utilisateurs et pour les nœuds, il faudra en créer deux.

Astuce [D8] Créer un bloc

Pour créer un bloc programmatiquement, vous devez déjà avoir créé un module.

Dans cet exemple, on créera un bloc qui affiche "Hello" et le nom de l'utilisateur connecté. On pourra personnaliser qui saluer si aucun utilisateur n'est connecté.

Déclaration du bloc

Toute la déclaration/configuration du bloc se fait dans une classe PHP, placée traditionnellement dans le répertoire src/Plugin/Block/ de votre module :

<?php

namespace Drupal\my_module\Plugin\Block;

use Drupal\Core\Block\BlockBase;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Form\FormStateInterface;

/**
 * @Block(
 * id = "hello_block",
 * admin_label = @Translation("My Hello block"),
 * category = @Translation("My project")
 * )
 */
class HelloBlock extends BlockBase {

  /**
   * {@inheritdoc}
   */
  public function build() {

    $current_user = \Drupal::currentUser();
    if (!$current_user->isAnonymous()) {
      $who = $current_user->getDisplayName();
    }
    else {
      $config = $this->getConfiguration();
      $who = isset($config['who']) ? $config['who'] : 'World';
    }

    $build = array(
      '#cache' => array(
         'contexts' => array('user'),
         'max-age' => Cache::PERMANENT,
      ),
      '#markup' => '<p>Hello ' . $who . '</p>',
    );

    return $build;
  }

  /**
   * {@inheritdoc}
   */
  public function blockForm($form, FormStateInterface $form_state) {

    $form = parent::blockForm($form, $form_state);

    $config = $this->getConfiguration();

    $form['my_block_who'] = array(
      '#type' => 'textfield',
      '#title' => $this->t('Who ?'),
      '#default_value' => isset($config['my_block_who']) ? $config['my_block_who'] : 'world',
    );

    return $form;
  }

  /**
   * {@inheritdoc}
   */
  public function blockSubmit($form, FormStateInterface $form_state) {
    $this->configuration['my_module_who'] = $form_state->getValue('my_module_who');
  }

}

Explications :

  • La classe hérite de BlockBase, fournie par Drupal.
  • Des annotations permettent de préciser le nom, le nom technique et le groupe (= catégorie) du bloc.
  • La méthode principale est build(), qui retourne un tableau de theming Drupal pouvant être rendu à l'écran.
  • Les deux autres permettent d'ajouter un champ au formulaire de configuration de bloc natif fourni par Drupal.

Remarque :

Le tableau de theming définit un cache par utilisateur (#cache), et le code HTML (#markup) constituant le contenu de votre bloc.

Activation du bloc

Pour que Drupal trouve votre nouveau bloc, vous devez vider les caches.

  • Allez ensuite en back-office, dans Structure > Administration des blocs
  • Dans la région qui contiendra le bloc, cliquez sur Placer le bloc
  • Trouvez votre module dans la liste qui apparaît et cliquez sur Positionner le bloc

Positionner le bloc

Apparaît alors le formulaire d'administration du bloc.

Vous pouvez par défaut choisir pour quelles pages le module doit apparaître (en fonction de l'utilisateur, du contenu affiché en pleine page, de l'URL de la page, ...).

À ce paramétrage s'ajoute votre champ personnalisé Who ?.

Affichage

Par défaut, le bloc est affiché en suivant le template block.html.twig fourni par Drupal et le thème que vous utilisez.

Pour surcharger ce template, vous pouvez définir un nouvel habillage pour votre bloc et créer votre propre template twig.

Astuce [D8] Créer un module

La création d'un module est très rapide dans Drupal. Dans cet exemple, on créera le module mymodule.

Commencez par créer le répertoire mymodule. Vous pouvez le placer directement dans modules, ou créer un répertoire intermédiaire qui regroupera tous vos modules (ex: modules/myproject/mymodule ou modules/custom/mymodule).

Basiquement, un module n'a besoin que de deux fichiers, tous deux à la racine du répertoire : mymodule.info.yml et mymodule.module.

.info

Le fichier .info.yml permet de décrire votre module.

name: Mon module
description: Module d'exemple.
package: Mon projet
type: module
version: '8.x-1.x'
core: '8.x'
project: 'mon_module'

Explications :

  • Le package permet de regrouper les modules sur la page de liste des modules du back-office. Vous pouvez réutiliser le même package que celui d'un module existant.
  • La version est celle du module, généralement en deux partie, celle de la version du cœur et celle du module.

.module

Le fichier .module contiendra du code PHP. Pour l'instant, créez un fichier mymodule.module vide (avec uniquement <?php et au moins un retour à la ligne).

Architecture

Votre module contiendra probablement par la suite deux répertoires principaux :

  • src : répertoire contenant la grande majorité de votre code PHP, sous forme de classes d'objet
  • templates : répertoire contenant les templates fournis par votre module

Résultat

Une fois fait, vous devriez voir votre module en back-office :

Créer un module

Vous pouvez l'activer via cette interface ou utiliser drush :

drush en mymodule -y

Astuce [D8] Créer un contenu programmatiquement

Pour créer un nouveau contenu (ou n'importe quelle entité), quelques lignes suffisent :

<?php

use Drupal\node\Entity\Node;
use Drupal\Core\Entity\EntityStorageException;

$node = Node::create([
  'type' => 'article',
  'langcode' => 'fr',
  'uid' => '1',
  'status' => 1,
]);
$node->setTitle('Mon premier article');
$node->set('field_my_text', 'du texte');
$node->set('field_my_float', 150.42);
$node->set('field_my_date', date('Y-m-d'));

try {
  $node->save();
}
catch (EntityStorageException $e) {
  \Drupal::logger('mymodule')->error("La création de l'article a échouée : @message", [
    '@message' => $e->getMessage()
  ]);
  $node = NULL;
}

Explications :

  • Les arguments de la méthode create() permettent de définir le type de nœud, son créateur, son statut, sa langue, ...
  • Tous les champs sont ensuite valués via la méthode set().

Astuce [D8] Theming : définir une nouvelle apparence pour un élément

La plupart du temps dans Drupal, on définit la manière d'afficher des éléments via un tableau de theming côté PHP.

Par exemple, pour un bloc, on peut avoir un tableau du genre :

$build = array(
  '#cache' => array(
    'contexts' => array('user'),
    'max-age' => Cache::PERMANENT,
  ),
  '#markup' => '<p>Hello ' . $who . '</p>',
);

Dans cet exemple, on ne précise pas l'habillage à utiliser. Drupal sélectionnera donc un template par défaut en fonction du type de l'élément (block.html.twig dans le cas d'un bloc).

Habillage à utiliser

Pour utiliser votre propre template, il faut modifier le tableau et remplacer #markup par #theme :

$build = array(
  '#cache' => array(
    'contexts' => array('user'),
    'max-age' => Cache::PERMANENT,
  ),
  '#theme' => 'my_hello',
  '#who' => $who,
);

Déclaration de l'habillage

Pour que Drupal trouve votre habillage, vous devez implémenter le hook_theme() dans votre module.

// my_module.module

/**
 * Implements hook_theme().
 */
function my_module_theme() {
  return [
    'my_hello' => [
      'template' => 'my_hello_box',
      'variables' => [
        'who' => 'World',
      ],
    ]
  ];
}

Explications :

  • La fonction définit un nouvel habillage my_hello.
  • Le template à utiliser est my_hello_box.html.twig.
  • La variable who sera transmise au template, avec la valeur 'World' par défaut.

Template

Le template my_hello_box.html.twig placé dans le répertoire templates/ de votre module peut ressembler à ça :

{# Affichage d'un message Hello sous forme de boîte #}
<div class="box">
    <p>{{ 'Hello %who !'|t({ 'who': who }) }}</p>
</div>

Remarque :

Vous pouvez placer votre template dans n'importe quel sous répertoire de templates/. Drupal saura le trouver.

Erreur Impossible d'importer ou de télécharger les traductions de l'interface

Après l'installation d'un site chez un hébergeur, un problème peut survenir lors de l'import/du téléchargement des fichiers de traduction de l'interface :

Warning: move_uploaded_file(translations://fr.po): failed to open stream: "Drupal\locale\StreamWrapper\TranslationsStream::stream_open" call failed in Drupal\Core\File\FileSystem->moveUploadedFile() (line 79 of core/lib/Drupal/Core/File/FileSystem.php).

Drupal\Core\File\FileSystem->moveUploadedFile('/tmp/phpxTna7m', 'translations://fr.po') (Line: 856)[...]

La solution consiste à vérifier les droits sur le répertoire sites/default/files/translations. S'il n'existe pas, créez-le et donner le droit d'écriture pour l'utilisateur apache.

Erreur [D7] Les thumbnails générés ne s'affichent pas

Il arrive que Drupal génère des thumbnails avec des droits incorrects. Apache ne peut alors pas les servir et ils ne s'affichent pas dans le site.

C'est probablement parce que les droits sur le répertoire sites/default/files/ sont mauvais.

Exécutez ces commandes pour corriger ce problème :

find sites/default/files -type d -exec chmod 755 {} +
find sites/default/files -type f -exec chmod 644 {} +
chown -R www-data:www-data sites/default/files

Plus d'informations ici :

https://www.drupal.org/node/244924

Astuce Connaître la liste des services accessibles via le conteneur de services

Avec Symfony, on utilise très souvent le conteneur de services. Par exemple dans un contrôleur, si on veut récupérer le logger de Monolog :

$logger = $this->get('logger');

Pour connaitre la liste de tous les services disponibles, utilisez la commande suivante :

php app/console container:debug

Astuce [D7] Créer un webservice REST

Le fonctionnement d'un webservice REST est très proche de celui d'une page classique. On souhaite accéder à une ressource, la modifier, la supprimer, ...

On y accède via une requête HTTP, à laquelle on fournit des paramètres et/ou des données.

Voici les étapes à suivre pour créer un webservice REST dans Drupal, communicant en JSON.

URI

Pour que Drupal gère l'URL du webservice, il faut implémenter le hook_menu() habituel :

// my_module.module

/**
 * Implements hook_menu().
 */
function my_module_menu()
{
  $items = array();

  // Webservice de lecture d'un article
  $items['api/article/%'] = array(
    'title' => t('Read article'),
    'page callback' => 'my_module_ws_article_read',
    'file' => 'my_module.ws.inc,'
    'page arguments' => array(2),
    'access arguments' => array('access content'),
    'type' => MENU_CALLBACK,
  );
  // Webservice de mise à jour d'un article
  $items['api/article/%/update'] = array(
    'title' => t('Update article'),
    'page callback' => 'my_module_ws_article_update',
    'file' => 'my_module.ws.inc,'
    'page arguments' => array(2),
    'access arguments' => array('access content'),
    'type' => MENU_CALLBACK,
  );

  return $items;
}

Explications :

  • On souhaite que les URL de tous les webservices commencent par api/.
  • On définit 2 URL, une pour lire un article et l'autre pour le mettre à jour.
  • Les webservices seront implémentés dans les fonctions my_module_ws_article_read() et my_module_ws_article_update() du fichier my_module.ws.inc.
  • Les deux webservices devront recevoir un nid valide en paramètre dans l'URL.

Page callback

Webservice en lecture

Si le premier webservice doit retourner le nid, le titre, l'URL et le contenu de l'article, il pourrait s"implémenter ainsi :

// my_module.ws.inc

/**
 * Retourne un article au format JSON.
 *
 * @param int $nid Nid de l'article
 * @return string Le nid, le titre, l'URL et le contenu de l'article, au format JSON
 */
function my_module_ws_article_read($nid) {

  global $language;

  $data = array();

  // Requête autorisées
  drupal_add_http_header('Access-Control-Allow-Origin', "*");
  drupal_add_http_header('Access-Control-Allow-Methods', "GET, OPTIONS");
  drupal_add_http_header('Access-Control-Allow-Headers', "Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Auth-Token");

  // Format du flux de sortie
  drupal_add_http_header('Content-Type', 'application/json');

  if ($_SERVER['REQUEST_METHOD'] == 'GET') {

    $article = node_load($nid);

    if (!empty($article) && $article->type == 'article') {

      $view = node_view($article);

      $title = (!empty($article->title_field[$language->language])) ?  $article->title_field[$language->language][0]['value'] : '';
      $content = (!empty($view['field_content'])) ? render($view['field_content'])) : '';

      $data = array(
        'nid' => intval($nid),
        'url_alias' => drupal_get_path_alias('node/'. $nid),
        'title' => $title,
        'content' => $content
      );
    } else {

      http_response_code('404');
      $data['error'] = 'Article introuvable (nid: ' . $nid . ').';
    }

    echo drupal_json_encode($data);
  }
}

Explications :

  • On définit le type de requête attendue par le webservice (requêtes GET ou OPTIONS depuis n'importe quel origine), ainsi que le format de sortie (ici JSON).
  • On récupère le nœud dont le nid est en paramètre. S'il est valide, on prépare le rendu de l'affichage par défaut du nœud.
  • On récupère le titre de l'article depuis le nœud, et le rendu de son champ content depuis la vue.
  • On stocke le tout dans un tableau.
  • On transforme le tableau en JSON que l'on affiche.

Remarque :

Dans cet exemple, le webservices accepte les requêtes de type OPTIONS. Cela peut être nécessaire lors de l'utilisation de certains framework (ex: Sencha). Pour ces requêtes, le contenu n'a pas besoin d'être renvoyé, d'où la condition ($_SERVER['REQUEST_METHOD'] == 'GET').

Si vous n'en avez pas besoin il est tout à fait possible de l'enlever.

Webservice en écriture

Voici le code du second webservice, pour mettre à jour le titre et le contenu de l'article. Pour simplifier, on considère que toute la mise à jour de l'article est déportée dans une fonction _my_module_article_update($article, $title, $content) :

// my_module.ws.inc

/**
 * Met à jour un article.
 *
 * @param int $nid Nid de l'article
 * @return string 'OK' si la mise à jour a réussi, un message d'erreur sinon.
 */
function my_module_ws_article_update($nid)
{
  $data = array();

  // Requête autorisées
  drupal_add_http_header('Access-Control-Allow-Origin', "*");
  drupal_add_http_header('Access-Control-Allow-Methods', "PUT, OPTIONS");
  drupal_add_http_header('Access-Control-Allow-Headers', "Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Auth-Token");

  // Format du flux de sortie
  drupal_add_http_header('Content-Type', 'application/json');

  $article = node_load($nid);

  if (!empty($article) && $article->type == 'article') {

    // Récupération du corps de la requête
    $request_body_json = file_get_contents('php://input');
    $request_params = json_decode($request_body_json);
    $title = isset($request_params->title) ? $request_params->title : null;
    $content = isset($request_params->content) ? $request_params->content : null;

    if ($title != null || $content != null) {

      // Mise à jour de l'article
      $status_msg = _my_module_article_update($article, $title, $content);

      // Message de retour (ex: 'OK' si réussite, message d'erreur sinon)
      $data['content'] = $status_msg;

    } else {

      http_response_code('400');
      $data['error'] = 'Les données de mise à jour sont invalides, ou le json est mal formé (' . $request_params . ').';
    }
  } else {

    http_response_code('404');
    $data['error'] = 'Article introuvable (nid: ' . $nid . ').';
  }

  echo drupal_json_encode($data);
}

Explications :

  • On récupère le corps de la requête qui doit contenir les informations de mise à jour au format JSON. Exemple :
{
    "title": "Mon article",
    "content": "<p>Le contenu HTML de mon article</p>"
}
  • On vérifie que le JSON est valide et contient toutes les données.
  • On met à jour l'article

Remarques :

  • Il serait judicieux d'ajouter des logs dans ce webservice, en cas d'erreur d'une part, mais également en cas de réussite, puisqu'un contenu a été modifié et qu'il y a eu écriture dans la base.
  • L'accès à tout webservice en écriture devrait également être protégé. Dans l'exemple, on autorise toutes les requêtes quelle que soit leur origine. Il est important d'ajouter un filtrage sur les IP autorisées à appeler ce webservice, par exemple dans la configuration d'Apache, ou mieux, une authentification préliminaire (ex: oAuth).

Astuce [D7] Créer un script drush

Drush propose déjà pas mal de fonctionnalités. Il est en plus extensible. Voici comme ajouter votre propre script.

Déclaration du script

Créez un fichier my_module.drush.inc, et implémentez-y le hook_drush_command() :

// my_module.drush.inc

/**
 * Implements hook_drush_command().
 */
function my_module_drush_command()
{
  $items = array();
  $items['say_hello'] = array(
    'description' => t('Say "Hello"'),
    'arguments' => array(
      'who' => t('Who are you talking to ?'),
    ),
    'options' => array(
      'punctuation' => 'Which punctuation do you use ? (optional, if not provided, use ".")',
    ),
    'examples' => array(
      'drush say_hello Boby' => 'Says : "Hello Boby."',
      'drush mmsh Boby punctuation="!"' => 'Says : "Hello Boby !"',
      'drush mmsh Boby' => 'Uses the alias and says : "Hello Boby."',
    ),
    'aliases' => array('mmsh'),
    'bootstrap' => DRUSH_BOOTSTRAP_DRUSH,
  );
  return $items;
}

Explications :

  • Le nom du script est say_hello. La fonction appelée derrière sera donc drush_my_module_say_hello().
  • Le script attend un prénom en argument, et éventuellement un signe de ponctuation en option.
  • Dans cet exemple, l'alias correspond aux initiales de My_Module_Say_Hello. On peut appeler le script avec ou sans cet alias :
drush say_hello
drush mmsh

Implémentation du script

Le script pourrait être implémenté ainsi :

// my_module.drush.inc

/**
 * Dit bonjour.
 * @param string $who Nom de la personne à saluer
 */
function drush_logres_business_say_hello($who) {

  $start = new DateTime();

  if (empty($who)) {

    echo 'Say "Hello" to who ?';

  } else {

    // Récupération des options
    $punctuation = drush_get_option('punctuation');
    $punctuation = (!empty($punctuation)) ? ' ' . $punctuation : '.';

    // Traitement
    echo 'Hello ' . $who . $punctuation;
  }

  // Affichage du temps d'exécution
  $end = new DateTime();
  echo "\n" . t('Duration : !duration', array('!duration' => $end->diff($start)->format('%hH %imin %ss')) ) . "\n";
}

Explications :

  • On vérifie la valeur en argument
  • On récupère une éventuelle option
  • On effectue le traitement souhaité (ici, on dit bonjour)
  • On affiche le temps qu'a duré le script

Erreur [D7] Le nouvel alias d'URL n'est pas pris en compte

Lorsque vous utilisez les alias d'URL de Drupal et pathauto pour les générer automatiquement, il vous arrive peut-être ce problème.

  • Vous créez un contenu et vous laissez pathauto générer son alias URL par défaut.
  • Ensuite, vous modifiez le contenu et saisissez un alias personnalisé.
  • Et pourtant, le contenu a toujours l'URL automatique.

Le problème se produit probablement lorsque vous avez un site multilingue, que vous modifiez le pattern de génération de l'URL alors que vous avez déjà des contenus existants, et que vous utiliser l'option Créer un nouvel alias et conserver l'alias existant.

Une solution semble de modifier la fonction path_load() du fichier path.inc du cœur de Drupal.

Ajoutez cette ligne à la requête récupérant l'alias d'URL :

->orderBy('pid', 'DESC')

De cette manière, vous êtes sûr que Drupal choisira le dernier alias généré (= alias personnalisé) et non pas l'ancien (automatique).

Remarque :

Je n'ai pas réussi à isoler le problème sur une installation vierge, avec un minimum de modules. Je l'ai reproduit sur deux sites très similaires assez importants. Dans les deux cas, l'ajout de cette ligne à solutionner le problème.

Astuce [D7] Ajouter des pages de configuration

Pour gérer certains paramètres propres à votre site, vous utilisez souvent les fonctions variable_get() et variable_set() de Drupal.

Drupal fournit une API pour pouvoir très rapidement créer un formulaire d'édition pour ces variables :

Menu - page de configuration en BO Page de configuration en BO

Voici les différentes étapes pour ajouter une page de configuration.

hook_menu()

Pour que Drupal référence vos nouvelles pages, déclarez-les dans un hook_menu() :

// mon_module.module

/**
 * Implements hook_menu().
 */
function my_module_menu()
{
  // Page racine "Mon site"
  $items['admin/config/mon_site'] = array(
    'title' => t('My site'),
    'description' => t('Administration of the site.'),
    'page callback' => 'system_admin_menu_block_page',
    'access arguments' => array('administer mysite'),
    'position' => 'right',
    'file' => 'system.admin.inc',
    'file path' => drupal_get_path('module', 'system'),
  );

  // Page "Général"
  $items['admin/config/mon_site/general'] = array(
    'title' => t('General'),
    'description' => t('Adjust global settings.'),
    'page callback' => 'drupal_get_form',
    'page arguments' => array('my_module_admin_form_site_general'),
    'access arguments' => array('administer mysite'),
    'file' => 'my_module.pages.inc',
    'type' => MENU_LOCAL_TASK,
  );

  // Page "Webservices"
  $items['admin/config/mon_site/webservices'] = array(
    'title' => t('General'),
    'description' => t('Adjust global settings.'),
    'page callback' => 'drupal_get_form',
    'page arguments' => array('my_module_admin_form_site_ws'),
    'access arguments' => array('administer mysite'),
    'file' => 'my_module.pages.inc',
    'type' => MENU_LOCAL_TASK,
  );

  return $items;
}

Explications :

  • La première déclaration permet définir le premier élément de menu (Mon site).
  • Les deux suivantes définissent les sous-éléments de ce menu (Général et Webservices).
  • Les deux formulaires de configuration seront définis respectivement dans les fonctions de callback my_module_admin_form_site_general() et my_module_admin_form_site_ws().
  • Ces fonctions seront recherchées dans le fichier my_module.pages.inc.
  • Pour accéder à ces page, il faudra avoir la permission administer mysite.

Form callback

// my_module.page.inc

/**
 * Form callback: administration variables
 */
function my_module_admin_form_site_general() {

  // Création d'un fieldset
  $form['search'] = array(
    '#type' => 'fieldset',
    '#title' => t('Search'),
  );

  // Ajout d'un champ texte dans ce fieldset
  $form['search']['my_module_nb_results_per_page'] = array(
    '#type' => 'textfield',
    '#title' => t('Number of results per page'),
    '#default_value' => variable_get('my_module_nb_results_per_page'),
    '#description' => t('Some description'),
  );

  // Ajout d'une liste déroulante dans ce fieldset
  $options = array(
    'value1' => t('Label 1'),
    'value2' => t('Label 2'),
  );
  $form['search']['my_module_first_result'] = array(
    '#type' => 'select',
    '#title' => t('First result'),
    '#options' => $options,
    '#default_value' => variable_get('my_module_first_result', 0),
    '#description' => t('Some description'),
  );

  return system_settings_form($form);
}

Explications :

  • Le fieldset correspond à celui HTML, il permet de regrouper des champs.
  • L'imbrication d'un champ dans un fieldset est reproduite dans le tableau php $form.
  • L'enregistrement du formulaire est géré automatiquement par Drupal.
  • Vous gérez seulement la valeur par défaut des champs (= valeur présente dans la variable correspondante, récupérée via variable_get()).

Remarque :

Le formulaire décrit dans cette fonction fonctionne exactement comme tout autre formulaire Drupal. Vous pouvez y ajouter des règles de validation de champs, ...

Astuce [D7] Ajouter du JS à la fin du body

Par défaut, Drupal rend la variable $scripts disponible dans votre html.tpl.php. Elle contient toutes les lib javascript ainsi que tous le code javascript inline que vous avez ajouté à Drupal via :

  • le fichier .info de votre thème (mon_theme.info)
  • la fonction drupal_add_js()

Vous avez donc quelque chose comme ça au début de votre fichier html.tpl.php :

<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title><?php print $head_title; ?></title>
  <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
  <?php print $styles; ?>
  <?php print $scripts; ?>
  <?php print $head; ?>
</head>

Si vous déplacez <?php print $scripts; ?> tout à la fin du body, vous aurez sûrement des problèmes, car certains modules vont vouloir utiliser des lib javascript en amont (par exemple jQuery).

Un solution consiste à laissez $scripts où il se trouve et à créer une nouvelle variable $footer_scripts. Elle sera ajoutée à la fin du body et contiendra tout le javascript "non natif" dont aurez besoin pour votre site :

  <?php if (isset($footer_scripts)) { print $footer_scripts; } ?>
</body>
</html>

Voici les différentes étapes à suivre pour pouvoir utiliser cette variable.

Ajout de la variable au template html.tpl.php

Pour cela, utilisez le hook_process_html() :

// mon_module.module

/**
 * Implements hook_process_html().
 */
function mon_module_process_html(&$variables) {

  // Ajout des scripts JS à mettre en pied de page dans la variable $footer_scripts
  $variables['footer_scripts'] = drupal_get_js('footer');
}

Explication :

La variable $footer_scripts aura pour valeur le code HTML permettant d'inclure tout le code JS dont le scope est footer.

Ajout de javascript avec le scope footer

Pour cela, utilisez le hook_preprocess_page() :

// mon_theme/template.php

/**
 * Implements hook_preprocess_page().
 */
function mon_theme_preprocess_page(&$vars) {

  // Ajout de code javascript inline
  $js = 'alert("Ceci est du code Javascript.");';
  drupal_add_js($js, array('type' => 'inline', 'scope' => 'footer'));

  // Exemple d'ajout de code javascript externe
  drupal_add_js(
    'http://maps.googleapis.com/maps/api/js?v=3', 
    array('type' => 'external', 'scope' => 'footer')
  );
}

Astuce Générateurs DPE/GES

Si vous créez un site proposant des logements, vous aurez sans doute besoin d'afficher leurs Diagnostics de Performance Énergétique (DPE) et leurs Émissions de Gaz à effet de Serre (GES) :

Générateurs DPE/GES

Si vous avez déjà une image ou un document PDF représentant ces graphiques pas de problème. Sinon, voici une petite API PHP pour les générer en HTML/CSS.

L'archive contient une classe PHP, un fichier CSS, un répertoire d'images et un fichier index.php.

Cette API est inspirée de celle proposée sur ce site : http://dpe-ges.c-prod.com/telechargement/.

Astuce [D7] Afficher le numéro de version du site en back-office

Il est utile de numéroter les différentes versions d'un site web, particulièrement quand il doit être déployé sur plusieurs environnements (ex: dev, recette, production, ...). Ce numéro peut correspondre à un tag svn par exemple.

Pour savoir dans quelle version se trouve chaque instance, voici comment l'afficher dans la page d'information système de Drupal en back-office :

Version du site en BO

hook_field_widget_form_alter()

Pour modifier le formulaire présent sur la page d'information système, il faut utiliser le hook_field_widget_form_alter().

Dans ce hook, vous pouvez modifier un formulaire pour y ajouter/enlever des champs. Dans notre cas, l'objectif est d'ajouter un champ version, non modifiable :

/**
 * Implements hook_field_widget_form_alter().
 */
function mon_module_form_alter(&$form, &$form_state, $form_id) {

  switch($form_id) {

    case 'system_site_information_settings':
      $form['site_information']['mon_site_version'] = array(
        '#type' => 'textfield',
        '#title' => t('Version'),
        '#value' => variable_get('mon_site_version'),
        '#attributes' => array('readonly' => 'readonly')
      );
      break;

    default:
      break;
  }
}

Explications :

  • On ajoute un champ de type textfield, avec pour libellé Version, en lecture seule (= avec l'attribut HTML readonly).
  • La valeur du champ sera une variable drupal ayant pour nom mon_site_version.

Remarque :

Pour mettre à jour le numéro de version automatiquement à la mise à jour du module, voir l'exemple dans cet article : hook_update() dans D7.

Astuce [D7] hook_update()

Description

Dans Drupal, le hook_update() permet d'exécuter du code PHP à la mise à jour d'un module.

Imaginons par exemple que vous ayez un numéro de version de votre site web, enregistré dans une variable drupal. Lors de la mise à jour de votre site, vous souhaiter incrémenter ce numéro de version.

Il suffit d'utiliser le hook_update(), et lorsque le module sera mis à jour, l'incrémentation sera appliquée automatiquement.

De plus, si vous avez plusieurs hook_update(), il seront tous exécutés un à un, dans l'ordre, lors de la mise à jour. Lors des mises à jour suivantes seuls les nouveaux hooks n'ayant pas encore été exécutés le seront.

Application

Hook

Par convention, ce hook doit être utilisé dans le fichier mon_module.install :

/**
 * Update v1.0.1
 */
function mon_module_update_7101() {
  variable_set('mon_site_version', '1.0.1');
}

Explication :

Le numéro à la fin du hook correspond à la version du module. Le premier chiffre, par convention, désigne la version majeure de drupal utilisée.

Mise à jour du module

Une fois la nouvelle version du module déployée sur votre site, vous devez lancer la mise à jour. Cela peut-être fait en back-office dans la page de gestion des modules, ou mieux, via une commande drush :

drush updb -y

Mises à jours successives

Imaginons que vous ayez passé cette première mise à jour (7101) sur l'un de vos sites (exemple : l'instance de production), et qu'entre temps vous ayez créé deux nouvelles versions du module.

Le fichier mon_module.install devient :

/**
 * Update v1.0.1
 */
function mon_module_update_7101() {
  variable_set('mon_site_version', '1.0.1');
}

/**
 * Update v1.0.2
 */
function mon_module_update_7102() {
  variable_set('mon_site_version', '1.0.2');

  // Autres traitements
}

/**
 * Update v1.0.3
 */
function mon_module_update_7103() {
  variable_set('mon_site_version', '1.0.3');

  // Autres traitements
}

Si vous lancez la mise à jour sur votre instance de production, les fonctions mon_module_update_7102() et mon_module_update_7103() seront exécutées dans cet ordre.

La fonction mon_module_update_7101() elle, ne sera pas exécutée.

Erreur Apache sert de vieux fichiers statiques

Si vous développez avec VirtualBox et Apache, il vous êtes peut-être arrivé le même problème.

Vous avez une image ou un fichier CSS servi par Apache. Vous le modifiez et lorsque vous tentez d'y accéder via le navigateur c'est l'ancienne version que vous voyez.

C'est un bug de VirtualBox qui cause le problème (voir le ticket).

Pour l'éviter, ajouter la ligne suivante dans votre configuration d'Apache (.htaccess, httpd.conf, configuration de virtualhost, ...) :

EnableSendfile Off

Remarque : N'oubliez pas de redémarrer Apache après ça.

Erreur DomPDF : Affichage des images et prise en compte des feuilles de styles

DomPDF peut poser problème pour afficher les images et prendre en compte les feuilles de style.

A priori c'est parce que les URL vers ces fichiers sont relatives et commencent par un /. Pour éviter ce problème il suffit de modifier deux fichiers de l'API en supprimant ce caractère au début des URL, avec ce code :

// Suppression du / devant l'url 
$url = ltrim($url, '/');

Modifiez dompdf/include/dompdf.cls.php :

  protected function _process_html() {
    $this->save_locale();

    $this->_tree->build_tree();

    $this->_css->load_css_file(Stylesheet::DEFAULT_STYLESHEET, Stylesheet::ORIG_UA);

    $acceptedmedia = Stylesheet::$ACCEPTED_GENERIC_MEDIA_TYPES;
    $acceptedmedia[] = $this->get_option("default_media_type");

    // <base href="" />
    $base_nodes = $this->_xml->getElementsByTagName("base");
    if ( $base_nodes->length && ($href = $base_nodes->item(0)->getAttribute("href")) ) {
      list($this->_protocol, $this->_base_host, $this->_base_path) = explode_url($href);
    }

    // Set the base path of the Stylesheet to that of the file being processed
    $this->_css->set_protocol($this->_protocol);
    $this->_css->set_host($this->_base_host);
    $this->_css->set_base_path($this->_base_path);

    // Get all the stylesheets so that they are processed in document order
    $xpath = new DOMXPath($this->_xml);
    $stylesheets = $xpath->query("//*[name() = 'link' or name() = 'style']");

    foreach($stylesheets as $tag) {
      switch (strtolower($tag->nodeName)) {
        // load <link rel="STYLESHEET" ... /> tags
        case "link":
          if ( mb_strtolower(stripos($tag->getAttribute("rel"), "stylesheet") !== false) || // may be "appendix stylesheet"
            mb_strtolower($tag->getAttribute("type")) === "text/css" ) {
            //Check if the css file is for an accepted media type
            //media not given then always valid
            $formedialist = preg_split("/[\s\n,]/", $tag->getAttribute("media"),-1, PREG_SPLIT_NO_EMPTY);
            if ( count($formedialist) > 0 ) {
              $accept = false;
              foreach ( $formedialist as $type ) {
                if ( in_array(mb_strtolower(trim($type)), $acceptedmedia) ) {
                  $accept = true;
                  break;
                }
              }

              if (!$accept) {
                //found at least one mediatype, but none of the accepted ones
                //Skip this css file.
                continue;
              }
            }

            $url = $tag->getAttribute("href");

            // Suppression du / devant l'url
            $url = ltrim($url, '/');

            $url = build_url($this->_protocol, $this->_base_host, $this->_base_path, $url);

            $this->_css->load_css_file($url, Stylesheet::ORIG_AUTHOR);
          }
          break;

Et dompdf/include/image_frame_decorator.cls.php :

  function __construct(Frame $frame, DOMPDF $dompdf) {
    parent::__construct($frame, $dompdf);
    $url = $frame->get_node()->getAttribute("src");

     // Suppression du / devant l'url
     $url = ltrim($url, '/');

    $debug_png = $dompdf->get_option("debug_png");
    if ($debug_png) print '[__construct '.$url.']';

    list($this->_image_url, /*$type*/, $this->_image_msg) = Image_Cache::resolve_url(
      $url,
      $dompdf->get_protocol(),
      $dompdf->get_host(),
      $dompdf->get_base_path(),
      $dompdf
    );

    if ( Image_Cache::is_broken($this->_image_url) &&
         $alt = $frame->get_node()->getAttribute("alt") ) {
      $style = $frame->get_style();
      $style->width  = (4/3)*Font_Metrics::get_text_width($alt, $style->font_family, $style->font_size, $style->word_spacing);
      $style->height = Font_Metrics::get_font_height($style->font_family, $style->font_size);
    }
  }

Marque-page Créer des graphiques en PHP

L'API JpGraph permet de créer tous les types de graphiques en PHP.

En quelques lignes de code, vous pouvez créer un camembert, un histogramme ou d'autres graphiques plus complexes.

Voici un aperçu de la galerie disponible sur le site :

Galerie JpGraph

Astuce [D7] Effectuer des requêtes sur une autre base de données

Il est possible d'effectuer des requêtes SQL sur une base de données autre que celle de Drupal, tout en utilisant les fonctions db_select(), db_query(), ....

Pour cela, il faut déclarer la ou les bases externes dans le fichier site/default/settings.php :

$databases = array (
  'default' => array (
    'default' => array (
      'database' => 'drupal',
      'username' => 'username',
      'password' => 'password',
      'host' => 'localhost',
      'port' => '',
      'driver' => 'mysql',
      'prefix' => '',
    ),
  ),
  'ma_nouvelle_base' => array (
    'default' => array (
      'database' => 'db1',
      'username' => 'username2',
      'password' => 'password2',
      'host' => 'db.example.com',
      'port' => '',
      'driver' => 'mysql',
      'prefix' => '',
    ),
  ),
);

Vous pouvez maintenant utiliser la nouvelle base dans vos modules, grâce à la fonction db_set_active() :

// Sélection de la nouvelle base
db_set_active('ma_nouvelle_base');

// Exécution d'un requête
$results = db_query($sql);

// Retour à la base par défaut
db_set_active('default');

Astuce [D7] Activer automatiquement tous les modules d'un profil

Si vous utiliser Drush dans Drupal, vous pouvez activer automatiquement tous les modules déclarés en dépendance de votre profil.

Pour cela utilisez cette commande :

drush en $(grep dependencies /path/to/my-site/profiles/my_profile/my_profile.info | sed -n 's/dependencies\[\]=\(.*\)/\1/p')

Astuce [D7] Ajouter un mode d'affichage à un noeud

Pour créer un mode d'affichage programmatiquement, vous devez déjà avoir créé un module.

Par défaut, Drupal propose les modes d'affichage suivant : Contenu complet (= Full), Accroche (= Teaser) et RSS. Le hook_entity_info_alter() dans le fichier mymodule.module permet d'en ajouter de nouveaux.

/**
 * Implements hook_entity_info_alter();
 */
function mymodule_entity_info_alter(&$entity_info) {

  $entity_info['node']['view modes']['my_view_mode'] = array(
    'label' => t('My view mode'),
    'custom settings' => FALSE,
  );
}

Après avoir vidé les caches, vous devriez voir votre nouveau mode d'affichage en back-office :

Activation du mode d'affichage

Astuce [D7] Ajouter des variables au template node.tpl.php

Lorsque vous affichez un nœud dans le template node.tpl.php ou une de ses surcharges (ex: node--article.tpl.php), vous avez souvent besoin d'effectuer des traitements particuliers.

Pour séparer la partie traitement de l'affichage, il est préférable de mettre le maximum de code PHP dans votre fichier mymodule.module (ou mieux, dans d'autres fichiers PHP à vous). Pour cela, utilisez le hook_node_view().

Par exemple si dans le template d'un article on veut afficher les 3 derniers articles publiés :

/**
 * Implements hook_node_view().
 */
function mymodule_node_view($node, $view_mode, $langcode) {

  global $language;
  if ($node->type === 'article) {

    // Last published articles
    $query = db_select('node', 'n')
      ->fields('n', array('nid'))
      ->condition('status', 1)
      ->condition('bundle', 'article')
      ->orderBy('changed', 'DESC')
      ->range(0, 3);
    $nids = $query->execute()->fetchCol();
    $nodes = !empty($nids) ? node_load_multiple($nids) : array();

    $node->content['last_published_articles'] = $nodes;
  }
}

Explications :

  • On vérfie le type de nœud, pour n'effectuer le traitement que pour les articles
  • La requête récupère les nids des nœuds recherchés. Ils sont ensuite chargés.
  • Les nœuds sont envoyés en paramètre au template. La variable $content['last_published_articles'] sera disponible dans le template.

Remarque :

  • En général on ajoute une condition sur le type d’affichage (Contenu complet, Accroche, ...) disponible via la variable $view_mode, pour éviter d'effectuer le traitement là où c'est inutile.

Astuce [D7] Créer un module

La création d'un module est très rapide dans Drupal. Dans cet exemple, on créera le module mymodule.

Commencez par créer le répertoire mymodule/. Vous pouvez le placer directement dans site/all/modules, ou créer un répertoire intermédiaire qui regroupera tous vos modules (ex: site/all/modules/myproject/mymodule/, ou site/all/modules/custom/mymodule/).

Basiquement, un module n'a besoin que de deux fichiers, tous deux à la racine du répertoire : mymodule.info et mymodule.module.

.info

Le fichier .info permet de décrire votre module.

name = mymodule
description = module de test
package = Mypackage
core = 7.x
version = "7.x-1.0"

Explications :

  • Le package permet de regrouper les modules sur la page de liste des modules du back-office. Vous pouvez réutiliser le même package que celui d'un module existant.
  • La version est celle du module, généralement en deux parties, celle de la version du cœur de Drupal et celle du module
  • Cf. documentation officielle

.module

Le fichier .module contiendra une bonne partie de votre code PHP, et surtout vos hooks. Pour l'instant, créez un fichier mymodule.module vide.

Résultat

Une fois fait, et après vidage des caches, vous devriez voir votre module en back-office :

Nouveau module en BO

Astuce [D7] Créer un bloc

Pour créer un bloc programmatiquement, vous devez déjà avoir créé un module.

Deux hooks vont être nécessaires dans le fichier mymodule.module. L'un pour déclarer votre bloc, l'autre pour définir son contenu.

hook_block_info()

/**
 * Implements hook_block_info();
 */
function mymodule_block_info() {

  $blocks['myblock'] = array(
    'info' => t('My block'),
    'cache' => DRUPAL_CACHE_PER_ROLE,
  );

  $blocks['myotherblock'] = array(
    'info' => t('My other block'),
    'cache' => DRUPAL_CACHE_PER_PAGE,
  );

  return $blocks;
}

Explications :

  • Ce hook retourne la liste des blocs à définir, avec pour chacun d'eux, son nom, le type de cache à utiliser, ... (Cf. Documentation)
  • Vous pouvez ajouter autant de blocs que vous le souhaitez dans le tableau de résultats.

hook_block_view()

/**
 * Implements hook_block_view();
 */
function mymodule_block_view($delta = '') {

  switch ($delta) {

    case 'myblock' :
      $block['subject'] = t('My block');
      $block['content'] = '<p>Contenu de mon bloc.</p>';
      break;

    case 'myotherblock' :
      $block['content'] = 'Contenu de mon second bloc.';
      break;
  }

  return $block;
}

Explications :

  • Ce hook reçoit le nom d'un bloc en argument et retourne son contenu sous forme de tableau de rendu. (Cf. Documentation)
  • Le tableau retourné doit au moins contenir la clé content, avec du texte simple ou du html en valeur.
  • Souvent, chaque case effectue une ou plusieurs requêtes en base pour récupérer des données, puis prépare le texte à afficher.

Remarque :

Si vous affichez des nœuds dans votre bloc, vous pourrez appeler directement la fonction node_view() pour générer leur contenu html. Ex :

$content = '<h2>' . t('Last published') . '</h2>';
$content .= '<ul>';
foreach ($node_list as $node) {

  $content .= '<li>';
  $content .= node_view($node, 'list');
  $content .= '</li>';
}
$content .= '</ul>'; 

$block['content'] = $content;

Astuce [D7] Fonctions utiles

String

t($text, $params = array(), $options = array())

Description :

Traduit une chaîne de caractères, avec d'éventuels paramètres. Le troisième argument permet de spécifier un contexte ou une langue.

Exemple :

$text = t('String with @myparam', array('@myparam' => $my_value));

Documentation

Images

image_style_url($style_name, $path)

Description :

Génère L’URL vers le thumbnail d'image correspondant au style d'image en premier argument.

Exemple :

image_style_url(
  'image_thumbnail',
  $node->field_photos[$language->language][$index]['uri']
);

Documentation

URL

l($text, $path, $options = array())

Description :

Crée une balise <a> avec le premier argument comme libellé et le deuxième en href.

Exemple :

image_style_url(
  'image_thumbnail',
  $node->field_photos[$language->language][$index]['uri']
);

Documentation

file_create_url($uri)

Description :

Génère l'URL vers un fichier ou une image, à partir de son uri (de la forme public://mon-image-03.jpg).

Exemple :

file_create_url($node->field_logo[$language->language][0]['uri']);

Documentation

url($path = NULL, $options = array())

Description :

Génère une URL interne ou externe, à partir d'une URL relative ou absolue.

Exemple :

url('node/' . $node->nid)

Documentation

drupal_get_path_alias($path = NULL, $path_language = NULL)

Description :

Retourne l'alias d'URL pour la page avec le chemin en argument

Exemple :

drupal_get_path_alias('node/' . $node->nid)

Documentation

Objets et tableaux

element_children(&$elements, $sort = FALSE)

Description :

Retourne le tableau en entrée, sans les valeurs dont la clé commence par #.

Documentation

Développement

dpm($variable)

Description :

Affiche le contenu de la variable, ses éléments dans le cas d'un tableau, ses attributs dans le cas d'un objet, de manière récursive. (Cette fonction est fournie par le module devel)

Documentation

dpq($select)

Description :

Affiche la requête finale. (Cette fonction est fournie par le module devel)

Documentation

Astuce [D7] Hook après édition/suppression de contenu, multilingue ou non

Drupal fournit 3 hooks pour effectuer des traitements après création/édition/suppression de contenu.

Pour un nœud

À la création

/**
 * Implements hook_node_insert().
 */
function my_module_node_insert($node) {

  // Do something
}

À l'édition

/**
 * Implements hook_node_update().
 */
function my_module_node_update($node) {

  // Do something
}

À la suppression

/**
 * Implements hook_node_delete().
 */
function my_module_node_delete($node) {

  // Do something
}

Pour une entité multilingue

Dans le cadre de contenus (et autres entités) multilingues, 3 autres hooks permettent de connaitre la langue pour laquelle la révision est créée/supprimmée.

La langue utilisée est disponible dans le hook vie la variable $translation['language']. Le type d'entité (ex: user, node, ...) est disponible dans la variable $entity_type.

À la création

/**
 * Implements hook_entity_translation_insert().
 */
function my_module_entity_translation_insert($entity_type, $entity, $translation, $values = array()) {

  // Do something
}

À l'édition

/**
 * Implements hook_entity_translation_update().
 */
function my_module_entity_translation_update($entity_type, $entity, $translation, $values = array()) {

  // Do something
}

À la suppression

/**
 * Implements hook_entity_translation_delete().
 */
function my_module_entity_translation_delete($entity_type, $entity, $langcode) {

  // Do something
}

Astuce [D7] Effectuer des traitements en masse

Pour effectuer des traitements en masse et limiter les problèmes de mémoire, on peut demander à Drupal de les gérer par lots.

Supposons par exemple que l'on souhaite supprimer tous les nœuds de type article, et qu'il y en ait une dizaine de milliers.

// Récupération des nid des nœuds de type article
$results = db_select('node', 'n')
  ->fields('n', array('nid'))
  ->condition('type', 'article', '=')
  ->execute()
  ->fetchCol();

Pour éviter de tout supprimer en une fois, on peut effectuer des suppressions par lots de 500 nœuds.

$nb_op = 500;
$nb_total = count($results);

// Découpage des traitements en lots
foreach (array_chunk($results, $nb_op) as $nids) {
  $operations[] = array('_my_module_batch_delete_nodes', array($nids, $nb_total));
}

// Construction du tableau de paramètre pour le batch
$batch = array(
  'operations' => $operations,
  'title' => t('Delete batch'),
  'init_message' => t('Initializing'),
  'error_message' => t('An error occurred'),
  'finished' => 'my_module_my_custom_end_function'
);

// Exécution du batch
batch_set($batch);
drush_backend_batch_process();

La fonction qui va effectuer la suppression est _my_module_batch_delete_nodes() :

/**
 * Custom batch function to delete multiple nodes.
 *
 * @param $nids Nids of nodes that must be deleted
 * @param $nb_total Number of nodes already deleted
 * @param $context Context to display the progression
 */
function _my_module_batch_delete_nodes($nids, $nb_total, &$context) {

  if (empty($context['results']['progress_d'])) {
    $context['results']['progress_d'] = 0;
  }

  node_delete_multiple($nids);

  // Affichage de la progression
  $context['results']['progress_d'] += count($nids);
  $context['message'] = 'Deleted ' . $context['results']['progress_d'] . '/' . $total;
}

Astuce Géocoder une adresse et récupérer les limites d'une ville

Voici une classe utilitaire pour géocoder une adresse et/ou récupérer les limites (= bounds) d'une ville. Elle utilise l'API Google Map.

/**
 * Classe utilitaire de géocodage.
 *
 * Permet de :
 *  - géocoder simplement une adresse auprès de l'API Google Map.
 *  - récupérer les limites d'une ville.
 *
 * L'appel à l'API Google Map utilise curl.
 *
 * @class Geocoder
 */
class Geocoder {

  /**
   * L'URL d'appel au service de geocodage de Google.
   *
   * @const string
   */
  const BASE_URL = 'https://maps.google.com/maps/api/geocode/json';

  /**
   * Clé d'API Google Map.
   *
   * @var string
   */
  private $apiKey;

  /**
   * ID client à utiliser avec la clé privée ci-dessous.
   *
   * @var string
   */
  private $clientID;

  /**
   * Clé privée permettant de dépasser la limitation de 2000 requêtes/jour.
   *
   * @var string
   */
  private $privateKey;

  /**
   * Copnstructeur.
   *
   * @param string $api_key
   *   Clé d'API Google Map.
   * @param string $client_id
   *   ID client pour l'API. Laisser vide pour une utilisation classique.
   * @param string $private_key
   *   Clé secrète associéeà 'ID client. Laisser vide pour
   *   une utilisation classique.
   */
  public function __construct($api_key, $client_id = '', $private_key = '') {
    $this->apiKey = $api_key;
    $this->clientID = $client_id;
    $this->privateKey = $private_key;
  }

  /**
   * Retourne le nom d'une ville à partir du tableau de résultat de géocodage.
   *
   * @param array $geocode_result
   *   Tableau de résultat d'un géocodage.
   *
   * @return string
   *   Un tableau dont les clés sont 'top', 'right, 'bottom' et 'left'
   */
  public function getName(array $geocode_result) {

    $city_name = '';
    $address_components = $geocode_result['address_components'];

    $i = 0;
    $length = count($address_components);

    while (empty($city_name) && $i < $length) {

      if (!empty($address_components[$i]['types'][0]) && $address_components[$i]['types'][0] == 'locality') {
        $city_name = $geocode_result['address_components'][$i]['long_name'];
      }

      $i++;
    }

    return $city_name;
  }

  /**
   * Retourne les limites d'une ville.
   *
   * @param array $geocode_result
   *   Tableau de résultat d'un géocodage.
   *
   * @return array
   *   Un tableau dont les clés sont 'top', 'right, 'bottom' et 'left'
   */
  public function getBounds(array $geocode_result) {

    $bounds = [];

    if (!empty($geocode_result['geometry']['bounds'])) {

      $geo_bounds = $geocode_result['geometry']['bounds'];

      $bounds = [
        'top' => $geo_bounds['northeast']['lat'],
        'right' => $geo_bounds['northeast']['lng'],
        'bottom' => $geo_bounds['southwest']['lat'],
        'left' => $geo_bounds['southwest']['lng'],
      ];
    }
    return $bounds;
  }

  /**
   * Retourne le nom de la ville et ses limites.
   *
   * @param string $city_name
   *   Nom de la ville, avec son code postal.
   *
   * @return array
   *   Un tableau dont les clés sont 'name' et 'bounds'
   */
  public function getCity($city_name) {

    $city = NULL;

    $geocode = $this->geocode($city_name);

    if (!empty($geocode)) {

      $city['name'] = $this->getName($geocode);
      $city['bounds'] = $this->getBounds($geocode);
    }

    return $city;
  }

  /**
   * Retourne le résultat d'un géocodage sur l'adresse en argument.
   *
   * @param string $address
   *   Adresse à géocoder.
   *
   * @return array
   *   Le flux json de Google décodé
   */
  public function geocode($address) {

    $result = NULL;

    $query_parameters = [
      'address' => $address,
      'key' => $this->apiKey,
      'result_type' => 'locality',
      'components' => 'country:FR',
      'sensor' => 'false',
    ];

    $url = self::BASE_URL;
    $query_string = '';

    foreach ($query_parameters as $key => $value) {
      $query_string .= '&' . $key . '=' . urlencode($value);
    }
    $url .= '?' . substr($query_string, 1);

    if (!empty($this->clientID) && !empty($this->private_key)) {
      $url = $this->signUrl($url);
    }

    $json_response = $this->curlFileGetContent($url);
    $response = json_decode($json_response, TRUE);

    if ($response['status'] == 'OK') {
      $result = $response['results'][0];
    }

    return $result;
  }

  /**
   * Retourne l'URL en argument en y ajoutant le paramètre de signature.
   *
   * Ce paramètre de signature est construit à partir de la clé privée et de
   * l'ID client. Elle permet notamment de dépasser les 2000 requêtes/jour.
   *
   * @param string $unsigned_url
   *   URL à signer.
   *
   * @return string
   *   L'URL signée
   */
  private function signUrl($unsigned_url) {

    $url = parse_url($unsigned_url);

    $url_part_to_sign = $url['path'] . "?" . $url['query'];

    if (strpos($url_part_to_sign, 'client') === FALSE) {
      $url_part_to_sign .= '&client=' . $this->clientID;
      $unsigned_url .= '&client=' . $this->clientID;
    }

    // Décode la clé privée dans son format binaire
    $decoded_key = $this->decodeBase64UrlSafe($this->privateKey);

    // Crée une signature binaire via HMAC SHA1
    $signature = hash_hmac('sha1', $url_part_to_sign, $decoded_key, TRUE);
    $encoded_signature = $this->encodeBase64UrlSafe($signature);

    return $unsigned_url . '&signature=' . $encoded_signature;
  }

  /**
   * Appelle l'URL en argument via curl, et retourne le résultat de l'exécution.
   *
   * @param string $url
   *   URL à appeler.
   *
   * @return mixed
   *   Le retour de l'API
   */
  private function curlFileGetContent($url) {

    $c = curl_init();

    curl_setopt($c, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($c, CURLOPT_URL, $url);

    $content = curl_exec($c);
    curl_close($c);

    return $content;
  }

  /**
   * Encode une chaîne en base 64.
   *
   * Échappe les caractères gênants dans une URL (+ => - et / => _).
   *
   * @param string $value
   *   Chaîne à encoder.
   *
   * @return string
   *   La chaîne encodée
   */
  private function encodeBase64UrlSafe($value) {

    $base64_value = base64_encode($value);
    return str_replace(['+', '/'], ['-', '_'], $base64_value);
  }

  /**
   * Décode une chaîne en base 64 dont les caractères gênants sont échappés.
   *
   * @param string $value
   *   Chaîne à décoder.
   *
   * @return string
   *   La chaîne décodée
   */
  private function decodeBase64UrlSafe($value) {

    $value = str_replace(['-', '_'], ['+', '/'], $value);
    return base64_decode($value);
  }

}

Pour récupérer les limites de la ville de Paris, on aura par exemple :

$api_key = '<MON API KEY>';
$city = 'Paris';
$postal_code = 75000;
$geocoder = new Geocoder($api_key);
$geocoder->getCity($city . ' ' . $postal_code);

Remarques :

  • Cette classe a été créée initialement pour récupérer les limites de toutes les villes de France. Elle permet toutefois de construire l'appel Google pour géocoder n'importe quelle adresse.
  • La classe permet de gérer un compte client Google Map, permettant de dépasser les 2000 requête/jours. La classe a été réécrite pour l'API Google Map v3. Cette fonctionnalité n'a pas été retestée dans cette version.

Astuce [D7] Remplir des champs de contenu programmatiquement

Lorsqu'on crée programmatiquement un contenu, voici comment remplir différents types de champ (les lignes ci-dessous sont à ajouter à la place de [...(1)] et/ou [...(2)] dans l'article lié).

Champs multivalués

Dans Drupal, tous les champs sont potentiellement multivalués. Pour ajouter plusieurs valeurs à un même champ, il suffit d'ajouter une nouvelle ligne en incrémentant le [0] dans les lignes ci-dessous.

Par exemple pour un champ texte basique ayant 3 valeurs :

$node->field_text[LANGUAGE_NONE][0]['value'] = 'ma première valeur';
$node->field_text[LANGUAGE_NONE][1]['value'] = 'ma deuxième valeur';
$node->field_text[LANGUAGE_NONE][2]['value'] = 'ma troisième valeur';

Champs multilingues

Dans Drupal, tous les champs sont potentiellement multilingues. Pour ajouter une valeur dans une autre langue, il suffit d'ajouter une ligne en remplaçant LANGUAGE_NONE par une langue.

Exemple avec un champ texte basique et deux langues :

$node->field_text['fr'][0]['value'] = 'ma valeur';
$node->field_text['en'][1]['value'] = 'my value';

Champs texte

Pour remplir un champ texte basique, cette ligne suffit :

$node->field_text[LANGUAGE_NONE][0]['value'] = 'ma valeur';

Si ce champ utilise un format de texte particulier, il faut le préciser. Exemple avec simple_text :

$node->field_text[LANGUAGE_NONE][0]['value'] = 'ma valeur';
$node->field_text[LANGUAGE_NONE][0]['format'] = 'simple_text';

Champs entier/décimal

Même principe pour les champs de type nombre :

$node->field_number[LANGUAGE_NONE][0]['value'] = 42;

Champs booléen

Encore la même chose pour les booléens, $my_boolean étant en fait un entier égal à 0 ou 1 :

$my_boolean = 0;
$node->field_number[LANGUAGE_NONE][0]['value'] = $my_boolean;

Champs image

Pour importer une image programmatiquement vous pouvez utiliser cette fonction :

  /**
   * Copy the image in argument in the drupal upload dir, and return it.
   *
   * @param string $image_path Image path from the root directory
   * @return array an array representing the copied image
   */
  private function copy_image($image_path) {

    $root_dir_path = getcwd();
    $upload_sample_files_uri = file_default_scheme() . '://sample_data'; 

    $file_path = $root_dir_path . $image_path;
    $file = (object) array(
                'uid' => 1,
                'uri' => $file_path,
                'filemime' => file_get_mimetype($file_path),
                'status' => 1,
    );
    $file = file_copy($file, $upload_sample_files_uri);
    return (array) $file;
  }

Pour une image dans un répertoire temp/ à la racine de Drupal ça donne ça :

$node->field_image[LANGUAGE_NONE][0] = copy_image('/temp/mon_image.jpg');

Champs lien

Un champ lien avec une URL, un libellé et d'éventuels attributs HTML :

$node->field_link[LANGUAGE_NONE][0] = array(
  'url' => 'http://www.google.fr',
  'title' => 'Libellé du lien',
  'attributes' => array('title' => 'Contenu de l'attribut HTML title'),  
);

Champs référence entre entités (entityref)

Le champ entityref stocke des id (et donc des nid pour des nœuds) :

$node->field_related_content[LANGUAGE_NONE][0]['target_id'] = $other_node->nid;

Champs adresse

Le champ adresse découpe les adresses en 5 parties :

$node->field_adresse[LANGUAGE_NONE][0] = array(
  'country' => 'FR',
  'locality' => 'Paris',
  'postal_code' => '75000',
  'thoroughfare' => '1, Avenue des Champs Élysées',
  'premise' => '2ème étage',
);

Champs coordonnées - Bounds

Le champ coordonnées permet entre autres de stocker des limites géographiques : des bounds.

$node->field_coordonnees[LANGUAGE_NONE][0]['input_format'] = GEOFIELD_INPUT_BOUNDS;
$node->field_coordonnees[LANGUAGE_NONE][0]['geom']['left'] = '2.320915';
$node->field_coordonnees[LANGUAGE_NONE][0]['geom']['top'] = '48.869911';
$node->field_coordonnees[LANGUAGE_NONE][0]['geom']['right'] = '2.350928';
$node->field_coordonnees[LANGUAGE_NONE][0]['geom']['bottom'] = '48.854086';

Champs métadonnées

Le champ métadonnées ajoutera des balises méta dans le <head> de la page :

$node->metatags[LANGUAGE_NONE] = array(
    'title' => array('value' => 'Contenu de la balise title de la page'),
    'description' => array('value' => 'Contenu de la balise méta description'),
    'abstract' => array('value' => 'Contenu de la balise méta abstract'),
    'keywords' => array('value' => 'Contenu de la balise méta keywords'),
);

D'autres clés peuvent être ajoutées au tableau.

Astuce [D7] Trier les résultats d'une requête de manière aléatoire

La fonction orderRandom() permet de trier les résultats d'une requête de manière aléatoire :

db_select('node', 'n')
    ->fields('n', array('nid'))
    ->condition('status', 1)
    ->range(0, 10)
    ->orderRandom();

La requête ci-dessus retourne 10 nœuds aléatoires, à l'état publié.

Astuce [D7] Rediriger après la connexion

Pour rediriger l'utilisateur après sa connexion, on peut utiliser le hook_user_login() :

/**
 * Implements hook_user_login().
 */
function my_module_user_login(&$edit, $account) {

    if (!isset($_POST['form_id']) || $_POST['form_id'] != 'user_pass_reset') {

        if (in_array('authenticated user', $account->roles)) {

            // Modification de l'url de destination
            $_GET['destination'] = 'admin/workbench/content/all';
        }
    }
}

L'exemple ci-dessus redirige l'utilisateur vers la page de gestion des contenus du module workbench (admin/workbench/content/all).

Erreur $facebook->getUser() always returns 0

Si vous utilisez le SDK Facebook pour PHP en suivant l'exemple fourni avec les sources vous aurez peut-être ce problème : la méthode getUser() qui retourne toujours 0.

De nombreuses personnes ont eu ce problème (cf. recherche Google, pour des raisons diverses et variées.

Voici une explication possible : le SDK ne trouve pas le certificat fourni avec les sources (fb_ca_chain_bundle.crt).

Pour savoir si vous avez cette erreur, consultez les logs d'Apache et cherchez le message :

Invalid or no certificate authority found, using bundled information.

Si vous le trouvez, c'est que le certificat ne se trouve pas au niveau de votre script.

Pour spécifier le bon chemin vers le fichier, utilisez cette ligne de code :

\Facebook::$CURL_OPTS[CURLOPT_CAINFO] = getcwd() . '/path_depuis_le_repertoire_courant/fb_ca_chain_bundle.crt';

Marque-page [eZ5] Les répertoires dans le cœur d'eZ Publish 5

Le cœur d'eZ Publish 5 se trouve dans le répertoire vendor/ezsystems/ezpublish-kernel/ de l'application.

Voici quelques répertoires utiles qu'il contient :

  • eZ/Publish/API/Repository/ : contient les interfaces des services avec les signatures de toutes leurs méthodes.
  • eZ/Publish/Core/ : contient l'implémentation des interfaces du répertoire précédent
    • Repository/ : contient l'implémentation de ces mêmes services.
    • Base/Exceptions/ : contient toutes les exceptions fournies par eZ, et surtout leurs constructeurs.
    • Persistence/Legacy/Content/Search/Gateway/CriterionHandler/ : contient les critères de recherche fournis par eZ.
    • Persistence/Legacy/Content/Search/Gateway/SortClauseHandler/ : contient les méthodes de tri fournies par eZ.

Remarque :

Les 4 derniers répertoires, sont sous eZ/Publish/Core/.

Astuce [eZ5] Rechercher des contenus par mot clé

L'extension eZTags pour eZ Publish fournit un système de mots clés pour regrouper des contenus par thématique. Avec elle arrive un nouveau type de champ, pour taguer vos contenus.

Dans eZ Publish 4.x (ou en mode legacy), le template de ce champ affiche un lien vers une page qui liste les contenus avec ce mot clé. La version pour eZ Publish 5 est disponible ici. Malheureusement, elle ne fournit aucune méthode pour trouver des contenus à partir d'un mot clé.

Voici trois méthodes pour récupérer ces contenus.

<?php

// [...]

use \Netgen\TagsBundle\API\Repository\Values\Tags\Tag;

/**
 * Retrouve les contenus avec le mot clé en argument.
 *
 * @param string $keyword Mot clé recherché
 * @param int $offset Offset pour les résultats de la recherche
 * @param int $limit Nombre maximal de résultats de recherche
 *
 * @return \eZ\Publish\API\Repository\Values\Content\Content[]
 *
 * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException Si l'utilisateur courant n'a pas le droit de voir les tags
 * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException Si aucun tag avec cet ID n'existe
 */
public function getRelatedContentByKeyword($keyword, $offset = 0, $limit = 50) {

    $rootTagID = 2;

    // Recherche du tag correspond au mot clé
    $tag = $this->getTagByKeyword($rootTagID, $keyword);

    $relatedContentList = array();

    if (!empty($tag)) {

        // Recherche des contenus avec le mot clé souhaité
        $tagService         = $this->container->get('ezpublish.api.service.tags');
        $relatedContentList = $tagService->getRelatedContent($tag, $offset, $limit);
    }

    return $relatedContentList;
}

/**
 * Retrouve un Tag à partir de son mot clé.
 * Le premier trouvé parmi les descendants de celui dont l'ID est en argument est retourné.
 *
 * @param string $rootTagID ID du tag parmi les descendants duquel rechercher
 * @param string $keyword Mot clé recherché
 *
 * @return \Netgen\TagsBundle\API\Repository\Values\Tags\Tag
 *
 * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException Si l'utilisateur courant n'a pas le droit de voir le tag courant
 * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException Si aucun tag avec cet ID n'existe
 */
public function getTagByKeyword($rootTagID, $keyword) {

    $tag = null;

    // Récupération du tag racine
    $tagService = $this->container->get('ezpublish.api.service.tags');
    $rootTag    = $tagService->loadTag($rootTagID);

    if (!empty($rootTag)) {

        // Récupération des tags descendants
        $descendantTagList = $this->getTagDescendantList($rootTag);

        if (!empty($descendantTagList)) {

            // Parcours des tags descendants
            for ($i = 0, $length = count($descendantTagList); $i < $length && $tag == null; $i++) {

                if ($descendantTagList[$i]->keyword == $keyword) { 
                    $tag = $descendantTagList[$i];
                }
            }
        }
    }

    return $tag;
}

/**
 * Retourne tous les tags descendant de celui en argument.
 *
 * @param \Netgen\TagsBundle\API\Repository\Values\Tags\Tag $rootTag Tag racine
 *
 * @return \Netgen\TagsBundle\API\Repository\Values\Tags\Tag[]
 *
 * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException Si l'utilisateur courant n'a pas le droit de voir le tag courant
 */
public function getTagDescendantList( Tag $rootTag ) {

    // Récupération des tag descendants
    $descendantTagList = array();

    $tagService       = $this->container->get( 'ezpublish.api.service.tags' );
    $childrenTagList  = $tagService->loadTagChildren( $rootTag );

    // Parcours des tags enfants
    foreach ( $childrenTagList as $childTag ) {

        $descendantTagList[] = $childTag;

        // Récupération des descendants
        $descendantTagList = array_merge( $descendantTagList, $this->getTagDescendantList( $childTag ) );
    }

    return $descendantTagList;
}

Remarques :

  • Ces méthodes peuvent être utilisées dans un contrôleur, où l'attribut $container (ContainerInterface) est disponible.
  • Tous vos tags doivent avoir une racine commune (= une seule arborescence). Cette racine servira de base pour les recherches.
  • Dans la première méthode, la variables $rootTagID (identifiant du tag racine) est en dur et devrait être récupérée depuis un fichier de configuration.
  • Dans cet exemple, la recherche ne fonctionne pas pour les synonymes. La deuxième méthode peut être améliorée pour les gérer.

Astuce [eZ5] Ajouter des filtres et des fonctions à Twig

Twig fournit de nombreuses fonctions et une liste de filtres pour simplifier le développement des templates.

Quelques exemples :

{# Des fonctions natives : #}
Contenu d'une variable : {{ dump(my_var) }}
Nombre aléatoire : {{ random(5) }}

{# Des filtres natifs : #}
Taille d'un tableau : {{ my_array|length }}
Mise en minuscule : {{ my_string|upper }}
Échappement de caractère : {{my_string|escape}}

L'intérêt de Twig c'est qu'il est très facilement extensible, et vous vous pouvez créer vos propres fonctions et vos propres filtres. Par exemple :

{# Une nouvelle fonction : #}
Affiche l'Url actuelle : {{ current_uri() }}

{# Un nouveau filtre : #}
{{ "Ma phrase est trop longue parce que la fin n'est pas intéressante."|truncate(28) }}

Prérequis

  • Vous avez déjà créé le Bundle Acme/MyBundle, et l'avez activé dans le fichier ezpublish/EzPublishKernel.php.

Remarque :

Si ce n'est le nom du fichier de kernel, tout cet exemple est valable pour une application Symfony 2 non eZ.

Création de l'extension Twig

L'ajout de filtres et fonctions se fait via un fichier PHP, qu'on appelle une extension Twig.

Créez le répertoire Twig/ dans votre bundle, et le fichier MyExtension.php à l'intérieur :

<?php
namespace AT\APIToolsBundle\Twig;

use \Symfony\Component\DependencyInjection\ContainerInterface;

class MyExtension extends \Twig_Extension {

    /**
     * @var \Symfony\Component\DependencyInjection\ContainerInterface;
     */
    protected $container;

    /**
     * Contructeur de l'extension Twig MyExtension.
     *
     * @param \Symfony\Component\DependencyInjection\ContainerInterface $container
     */
    public function __construct(ContainerInterface $container) {

        $this->container = $container;
    }

    /**
     * Retourne le nom de l'extension.
     *
     * @return string
     */
    public function getName() {

        return 'MyExtension';
    }

    /**
     * Retourne la liste des Filtres de template à ajouter à Twig.
     *
     * @return array
     */
    public function getFilters() {

        return [
            'truncate' => new \Twig_Filter_Method($this, 'truncate'),
        ];
    }

    /**
     * Retourne la liste des Fonctions de template à ajouter à Twig.
     *
     * @return array
     */
    public function getFunctions() {

        return [
            'current_uri' => new \Twig_Function_Method($this, 'getCurrentURI'),
        ];
    }

    /**
     * Retourne l'URI courante.
     *
     * @return string $_SERVER['REQUEST_URI']
     */
    public function getCurrentURI() {

        return $_SERVER['REQUEST_URI'];
    }

    /**
     * Tronque le texte en argument.
     * Si la longueur du texte est supérieure à $maxLength, $suffix est ajouté à la chaîne.
     *
     * @param string $text Chaîne à tronquer
     * @param int $maxLength Longueur maximale autorisée pour la chaîne
     * @param string $suffix Le sufixe à ajouter si besoin
     * @return string
     */
    public function truncate($text, $maxLength, $suffix = '...') {

        $truncatedText = $text;

        mb_internal_encoding('UTF-8');

        $length      = mb_strlen($text );
        $sufixlength = mb_strlen($suffix);

        // Si le texte est trop long
        if ($length > $maxLength && $length >= $sufixlength) {

            $truncatedText = mb_substr($text, 0, $maxLength - $sufixlength) . $suffix;
        }

        return $truncatedText;
    }
}

Explications :

  • La classe MyExtension étend la classe Twig_Extension fournie par Symfony.
  • La méthode getName() retourne le nom de votre choix pour votre extension.
  • Les méthodes getFilters() et getFunctions() retournent la liste des filtres et des fonctions à ajouter à Twig.
  • Le nom du filtre ou de la méthode est défini par la clé dans le tableau (ici truncate et current_uri).
  • Pour instancier un nouveau filtre, on utilise new \Twig_Filter_Method($this, 'méthode_à_appeler').
  • Et de la même manière, pour une nouvelle fonction new \Twig_Function_Method($this, 'méthode_à_appeler').
  • Les deux dernières méthodes sont celles appelées dans les constructeurs. Elles contiennent le code métier qui effectue le traitement.

Informer Symfony

L'extension Twig est terminée mais Symfony ne sait pas encore qu'elle existe. Il vous faut la déclarer en tant que service, dans le fichier Resources/config/services.yml de votre bundle :

parameters:
    acme_my.twig_extension.class: Acme\MyBundle\Twig\MyExtension

services:
    acme_my.twig_extension:
        class: %acme_my.twig_extension.class%
        arguments: [@service_container]
        tags:
            - { name: twig.extension }

Explications :

  • Chaque paramètre et chaque service du fichier a un identifiant unique (ex : acme_my.twig_extension).
  • On définit la classe MyExtension comme paramètre. Si on déplace ou renomme la classe par la suite, seul le paramètre sera à changer.
  • On déclare l'extension Twig en tant que service, en spécifiant la classe à utiliser et l'argument à passer au constructeur.
  • On tague le service avec twig.extension pour que Symfony sache de quel type de service il s'agit.

Astuce [eZ5] Créer une page de login

Objectif

Le but de cet article est de proposer un exemple de page de connexion. Il s'articule autour de deux fichiers principaux : un template Twig et un contrôleur PHP.

Il est réalisé entièrement en mode Symfony, sans utiliser le stack Legacy.

Si l'utilisateur saisit de mauvais identifiants, un message d'erreur est affiché. Une fois connecté, il est redirigé vers la page qu'il consultait avant de se connecter.

Prérequis

  • Vous avez déjà créé le Bundle Acme/MyBundle, et l'avez activé dans le fichier ezpublish/EzPublishKernel.php.
  • Le pagelayout utilisé par défaut est AcmeMyBundle::pagelayout.html.twig. Il possède un bloc nommé col_main.
  • Dans le pagelayout, la variable redirect_uri doit être définie et contenir l'url courante (ex: /Ma-rubrique/Mon-article).

Création du template

Dans le répertoire de templates de votre bundle (Resources/views/), créez les répertoires user/connection/, qui contiendront tous les templates pour la connexion des utilisateurs (connexion, inscription, ...)

Créez ensuite le fichier login.html.twig dans le répertoire user/connection/.

Voici à quoi il peut ressembler :

{# Surcharge du bloc 'col_main' du template pagelayout.html.twig, pour la page de connexion #}
{% extends noLayout ? viewbaseLayout : "AcmeMyBundle::pagelayout.html.twig" %}

{#
    Affiche un article en mode Full

    Paramètres :
    - noLayout      : False
    - fail_login    : Si la connexion a échouée
    - redirect_uri  : URI vers laquelle rediriger après la connexion
 #}

{% block col_main %}

<div class="main-content user user-login panel">

    {# Titre #}
    <header class="heading">
        <h1>Connexion</h1>
    </header>

    {# Contenu #}
    <div class="content-body">

        {# Message d'erreur #}
        {% if fail_login %}
            <div class="alert alert-danger">
                <button data-dismiss="alert" class="close" type="button">×</button>
                <p><strong>Erreur !</strong></p>
                <p>Identifiant ou mot de passe invalide.</p>
            </div>
        {% endif %}

        {# Formulaire #}
        <form class="form-horizontal" method="post"
              action="{{ path( 'acme_my_user_login', { 'redirectURI': redirect_uri } ) }}">

            <div class="form-group {% if fail_login %}has-error{% endif %}">
                <label class="col-lg-3 control-label" for="login">Identifiant</label>
                <div class="col-lg-4">
                    <input type="text" placeholder="Identifiant" 
                           id="login" name="Login" class="form-control input-small">
                </div>
                <div class="validation col-lg-5">
                    <img src="{{ asset( "bundles/acmemy/images/error.png" ) }}" alt="Erreur" />
                </div>
            </div>

            <div class="form-group {% if fail_login %}has-error{% endif %}">
                <label class="col-lg-3 control-label" for="password">Mot de passe</label>
                <div class="col-lg-4">
                    <input type="password" placeholder="Mot de passe" 
                           id="password" name="Password" class="form-control input-small">
                </div>
                <div class="validation col-lg-5">
                    <img src="{{ asset( "bundles/acmemy/images/error.png" ) }}" alt="Erreur" />
                </div>
            </div>

            <div class="form-group">
                <div class="col-lg-10 text-right">
                    <button class="btn btn-primary" type="submit">Connexion</button>
                </div>
            </div>
        </form>
    </div>
</div>
{% endblock %}

Explications :

  • Ligne 2, on commence par étendre le pagelayout par défaut pour modifier le bloc principal.
  • Juste en dessous, en commentaire, on liste les paramètres disponibles/nécessaires dans le template.
  • Ensuite, on commence la surcharge du bloc principal de la page : col_main.
  • On affiche un message d'erreur si les identifiants sont mauvais ({% if fail_login %}).
  • On affiche un formulaire avec un champ identifiant et un champ mot de passe.
  • Le formulaire pointe vers la route acme_my_user_login, sur laquelle Symfony va brancher le futur contrôleur. L'URL courante lui sera transmise en argument.

Remarques :

  • L'image d'erreur doit être présente dans le répertoire Resources/public/images/ du bundle.
  • Ce template utilise le framework CSS Bootstrap 3.
  • Les textes devraient être entre les tags {% trans %} et {% endtrans %}, pour pouvoir être traduits facilement par la suite si besoin.

Création du contrôleur

C'est le contrôleur qui va gérer les actions de connexion et déconnexion des utilisateurs. Il aura donc deux actions : login et logout.

Créez le fichier UserConnectionController.php dans le répertoire Controller/ de votre Bundle :

<?php
namespace Acme\MyBundle\Controller;

use \eZ\Bundle\EzPublishCoreBundle\Controller;
use \eZ\Publish\API\Repository\Exceptions\NotFoundException;
use \eZ\Publish\API\Repository\Values\User\User;
use \Symfony\Component\HttpFoundation\Cookie;
use \Symfony\Component\HttpFoundation\RedirectResponse;

/**
 * Controleur pour gérer les vues de connexion des utilisateurs.
 */
class UserConnectionController extends Controller {

    /**
     * Gestion de l'affichage de la page de login.
     *  - Affiche la page de login par défaut
     *  - Affiche d'éventuelles erreur de connexion
     *  - Connecte et redirige l'utilisateur
     *
     * @param string URI vers laquelle rediriger après la déconnexion
     *
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function loginAction( $redirectURI ) {

        $failLogin = false;

        // Suppression d'un éventuel doublon dans l'URI
        if ( ( $offset = strpos( $redirectURI, '/user/login' ) ) === 0 ) {
            $redirectURI = substr( $redirectURI, $offset );
        }

        $request = $this->getRequest()->request;

        // Si le formulaire de connexion a été soumis
        if ( $request->has( 'Login' ) && $request->has( 'Password' ) ) {

            $login       = $request->get( 'Login' );
            $password    = $request->get( 'Password' );

            if ( trim( $login ) != '' && trim( $password ) != '' ) {
                $userService = $this->getRepository()->getUserService();

                try {
                    $user = $userService->loadUserByCredentials( $login, $password );
                    return $this->connectUser( $user, $redirectURI );

                } catch (NotFoundException $e) {
                    $failLogin = true;
                }
            } else {
                $failLogin = true;
            }
        }

        return $this->render(
            'AcmeMyBundle:user\connection:login.html.twig',
            array(
                'noLayout'      => false,
                'fail_login'    => $failLogin,
                'redirect_uri'  => $redirectURI
            )
        );
    }

    /**
     * Déconnecte l'utilisateur courant.
     *
     * @param string URI vers laquelle rediriger après la déconnexion
     *
     * @return \Symfony\Component\HttpFoundation\Response
     *
     * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException Si l'utilisateur anonyme n'existe pas
     */
    public function logoutAction( $redirectURI ) {

        // Suppression d'un éventuel doublon dans l'URI
        if ( ( $offset = strpos( $redirectURI, '/user/logout' ) ) === 0 ) {
            $redirectURI = substr( $redirectURI, $offset );
        }

        // Récupération de l'utilisateur anonyme
        $userService  = $this->getRepository()->getUserService();
        $anonymousUser = $userService->loadAnonymousUser();

        return $this->connectUser( $anonymousUser, $redirectURI );
    }

    /**
     * Connecte l'utilisateur en argument et retourne une redirection 302.
     * Si l'utilisateur en argument est anonyme, alors c'est une déconnexion.
     *
     * @param \eZ\Publish\API\Repository\Values\User\User $user L'utilisateur à connecter
     * @param string $redirectURI URI vers laquelle rediriger l'utilisateur après connexion
     *
     * @return RedirectResponse
     */
    protected function connectUser( User $user, $redirectURI = '/' ) {

        $repository = $this->getRepository();
        $repository->setCurrentUser( $user );
        $session    = $this->getRequest()->getSession();

        try {
             $response = new RedirectResponse( $redirectURI );

        } catch (NotFoundException $e) {
             $response = new RedirectResponse( '/' );
        }

        $userService    = $repository->getUserService();
        $anonymousUser  = $userService->loadAnonymousUser();

        // Si l'utilisateur en argument est anonyme
        if ( $user->id === $anonymousUser->id ) {
            // Déconnexion de l'utilisateur courant
            $response->headers->setCookie( new Cookie( 'is_logged_in', 'false' ) );
            $session->remove( 'eZUserLoggedInID' );

        } else {
            // Connexion de l'utilisateur
            $response->headers->setCookie( new Cookie( 'is_logged_in', 'true' ) );
            $session->set( 'eZUserLoggedInID', $user->id );
        }

        return $response;
    }
}

Explications :

  • Le contrôleur est une classe PHP qui étend la classe Controller du Bundle eZ\Bundle\EzPublishCoreBundle fourni par eZ Publish.
  • Ses deux premières méthodes sont des actions et leurs noms finissent d'ailleurs par Action. Elles retournent un objet Response.
  • La troisième est une méthode utilitaire pour connecter un utilisateur.
  • Par défaut, loginAction() inclue le template de login dans sa réponse, grâce aux lignes :
return $this->render(
    'AcmeMyBundle:modules/user:login.html.twig',
    array(
        'noLayout'      => false,
        'fail_login'    => $failLogin,
        'redirect_uri'  => $redirectURI
    )
);
  • Si le formulaire est soumis et valide, on appelle la méthode connectUser() qui connecte l'utilisateur et on fournit une réponse de type redirection.

Configuration de la route

Pour que Symfony sache quoi faire lorsqu'on appelle la page http://mon-site/user/login, il faut modifier le fichier Resources/config/routing.yml dans votre bundle.

Ajoutez-y les deux routes suivantes, pour se connecter et se déconnecter :

acme_my_user_login:
    pattern:  /user/login{redirectURI}
    defaults: { _controller: AcmeMyBundle:UserConnection:login }
    requirements:
        redirectURI: ".*"

acme_my_user_logout:
    pattern:  /user/logout{redirectURI}
    defaults: { _controller: AcmeMyBundle:UserConnection:logout }
    requirements:
        redirectURI: ".*"

Explications :

  • Une route doit être nommée par un identifiant unique de votre choix (ex : acme_my_user_login). C'est lui qui est appelé dans la fonction path() côté template.
  • Elle associe un pattern d'URL, à l'action d'un contrôleur.
  • Pour la première route, si on appelle la page /user/login<quelque chose>, la méthode loginAction() du contrôleur UserConnectionController du bundle AcmeMyBundle sera exécutée. La méthode recevra quelque chose en argument.
  • Les éléments dans requirements permettent entre autres de spécifier la forme que doit avoir le paramètre. Ici, n'importe qu'elle chaîne est autorisée.

Astuce Mettre à jour les dépendances avec Composer

Pour mettre à jour les dépendances d'un projet Symfony (ou pas), vous pouvez utiliser Composer.

À l'instar d'apt ou yum pour des distributions Linux, cet outil va mettre à jour votre application par une simple ligne de commande.

Pour mettre à jour Composer, utilisez :

composer self-update

Pour mettre à jour les dépendances du projet, utilisez :

composer update

Pour ne mettre à jour qu'une seule dépendance, ajoutez-la à la commande :

composer update ma/dépendance

Remarques :

  • Pour modifier les dépendances, éditez le fichier composer.json.
  • Il ne faut pas confondre :
    • composer update : va chercher la dernière version disponible sur le dépôt et l'installer,
    • composer install : va chercher la version installée lors du dernier composer update sur le dépôt et l'installe. Ce numéro de version est stocké dans le fichier composer.lock. Cela permet d'avoir une version identique entre chaque environnement.

Astuce Créer un arbre à partir d'un tableau d'objets

Il arrive qu'on récupère un tableau d'objets tous liés entre eux, alors qu'on voudrait un arbre.

Imaginons par exemple que le tableau contienne des objets Node, ayant entre autres les attributs id et parentNodeId. La structure qu'on souhaite ressemblerait à :

Array (
    [0] => stdClass Object (

        [node] => Node object (
            [...]
        )
        [children] => Array (
            [0] => stdClass Object (

                [node] => Node object  (
                    [...]
                )
                [children] => Array (
                    [0] => stdClass Object (
                        [node] => Node object (
                            [...]
                        )
                        [children] => Array()
                    )
                )
            )
        )
    )
    [1] => stdClass Object (

        [node] => Node object (
            [...]
        )
        [children] => Array()
    )
)

Voici la fonction qui permet de passer du tableau à l'arbre :

/**
 * Construit un arbre à partir du tableau de nœuds en argument.
 *
 * @param array $node_list Tableau de nœuds
 * @return array Tableau d'objets avec deux attributs :
 *      - node : Nœud
 *      - children : Tableau d'objets de ce type
 */
public static function buildTreeNode(array $node_list) {

    $tree = null;

    $children = [];

    // Création d'une structure exploitable
    $node_std_object_list = [];
    foreach ($node_list as $node) {
        $node_std_object_list[] = (object) [
            'id' => $node->id,
            'parentNodeId' => $node->parentNodeId,
            'node' => $node,
            'children' => []
        ];
    }

    // Mémorisation des liens entre les nœuds
    foreach ($node_std_object_list as $node_std) {
        $children[$node_std->parentNodeId][] = $node_std;
    }

    // Création de l'arbre
    foreach ($node_std_object_list as $node_std) {

        if (isset($children[$node_std->id])) {
            $node_std->children = $children[$node_std->id];
        }

        unset($node_std->id);
        unset($node_std->parentNodeId);
    }

    if (!empty($children)) {
        $tree = array_shift($children);
    }

    return $tree;
}

Explications :

  • La première boucle crée un tableau d'objets standards ayant chacun 4 attributs : l'id d'un nœud, celui de son parent, le nœud en question et la liste des nœuds enfants (vide pour l'instant).
  • La deuxième boucle remplit l'attribut children de tous ces objets standards. On a alors la liste de tous les nœud avec pour chacun d'eux ses enfants.
  • La dernière boucle "accroche" les nœuds les uns aux autres pour former l'arbre.

Remarques :

  • A l'instar de la classe Object en java, il existe la classe stdClass (Standard Class) pour représenter des objets.
  • Il est possible de caster un tableau en objet stdClass et vice-versa.

Astuce [eZ5] Créer un bundle

Qu'est-ce qu'un Bundle ?

Bundle est le nom Symfony pour un module, une brique, une extension (terme eZ Publish 4.X). Il peut contenir tout ou une partie du design, une API technique, une fonctionnalité particulière, ...

Il peut être dépendant d’autres Bundles, mais est réutilisable. Il est identifié par un nom de domaine (namespace) et un nom de Bundle (finissant par Bundle), le tout concaténé.

Création du Bundle

La création du Bundle peut se faire via ligne de commande, à partir du répertoire racine de l’application eZ Publish :

php ezpublish/console generate:bundle --namespace="MonNamespace/MonBundle"

Explications :

  • Le paramètre namespace est le nom du namespace et le nom du Bundle concaténés.
  • Le nom de domaine peut être le nom du projet, celui de l’entreprise, ... Il peut contenir des /.
  • Le nom du Bundle doit finir par Bundle.

Nom du Bundle

L’assistant de création du Bundle propose alors le nom final du Bundle : MonNamespaceMonBundle.

Appuyez sur Entrée pour conserver ce nom standard.

Remarque :

Si vous créez le Bundle principal de votre application, et que vous souhaitez avoir un namespace et un nom de Bundle identique, c'est à cette étape que vous pouvez simplifier le nom final pour éviter d'avoir MonNamespaceMonNamespaceBundle.

Emplacement du Bundle

Vous pouvez ensuite choisir le chemin où se trouvera le Bundle.

Par défaut il sera créé dans le répertoire src/ à la racine de l’application eZ Publish, ce qu'il est préférable de conserver : appuyez sur Entrée.

Format de la configuration

Choisissez yml comme format de configuration, et validez avec Entrée.

Génération et autres configurations

Pour une première fois, choisissez de générer la structure complète du Bundle.

Appuyez sur Entrée pour confirmer toute la génération.

Même chose pour les questions suivantes.

Fichiers générés

L’assistant de création de Bundle a généré l’arborescence suivante :

Arborescence bundle généré

Comme tout bundle Symfony, il se compose de 3 répertoires principaux :

  • Controller/ : vous y créerez vos contrôleurs (équivalents de vos modules dans eZ4).
  • Resources/ : s'y trouvent tous les fichiers non PHP (templates Twig, fichiers de config, js, CSS, ...).
  • Tests/ : répertoire contenant vos tests unitaires.

Erreur [eZ5] FatalErrorException: Error: Class 'XSLTProcessor' not found

Si vous rencontrez l'erreur suivante après l'installation d'eZ Publish 5 :

FatalErrorException: Error: Class 'XSLTProcessor' not found in 
[...]\vendor\ezsystems\ezpublish-kernel\eZ\Publish\Core\FieldType\XmlText\Converter\Html5.php line 77

C'est que l'extension xsl n'est pas activée pour PHP.

Erreur [eZ5] The extension "ext/fileinfo" must be loaded in order for this class to work

Lorsque vous migrez vers eZ Publish Community Project 2013.06, vous pouvez rencontrer cette erreur :

The extension "ext/fileinfo" must be loaded in order for this class to work.

Fileinfo est une extension pour PHP. Elle est généralement déjà packagée sous Linux, mais pas sous Windows avec WampServer.

Activez l'extension php_fileinfo et redémarrez apache.

Marque-page [eZ] Créer un datatype

Dans eZ Publish 4, un datatype est un type de champ pour une classe de contenu.

EZ Publish en fournit un certain nombre : image, ligne de texte, texte riche, case à cocher, nombre, ... Vous pouvez également créer votre propre datatype, pour facilité la contribution et l'affichage d'un champ.

On peut par exemple imaginer un champ couleur, avec côté back-office une pipette ou une palette de couleur pour choisir facilement sa couleur.

Voici un tutoriel complet pour créer son propre datatype, rédigé par Jérôme Vieilledent et Nicolas Pastorino pour PHP Solutions.

Astuce Installer PEAR pour PHP et WampServer

PEAR est un gestionnaire de librairies pour PHP, permettant d'étendre les fonctionnalités de PHP par simple ligne de commande. Il tend toutefois à disparaître au profit de Composer.

Installation

Récupération de l'exécutable

Si le fichier go-pear.bat n'est pas présent dans le répertoire de PHP de WampServer :

  • Téléchargez le fichier go-pear.phar (http://pear.php.net/go-pear.phar).
  • Copiez le fichier dans le répertoire de PHP de WampServer (ex : D:\Dev\wamp\bin\php\php5.3.13\).

Exécution de l'installeur

Une fois le fichier batch en place, lancez l'invite de commande et exécutez-le :

cd D:\Dev\wamp\bin\php\php5.1.13\
go-pear

Durant l'installation, appuyez toujours sur Entrée ou Y, pour utiliser les paramètres par défaut.

Configuration de l'environnement

Lancez le fichier PEAR_ENV.reg présent dans le répertoire PHP de WampServer, pour mettre à jour les clés de registre nécessaires. (Double-cliquez sur le fichier pour le lancer.)

Mise à jour de PEAR

Pour mettre à jour votre installation, et vérifier que PEAR est bien installé, exécutez la commande suivante :

pear channel-update pear.php.net

Astuce Installer la librairie SVN pour PHP

Pour pouvoir utiliser simplement subversion en PHP, vous pouvez utiliser la librairie VersionControl_SVN.

Remarque :

Pour git, la librairie VersionControl_Git existe également et s'installe de la même façon.

Installation

Exécutez juste la commande suivante :

pear install VersionControl_SVN

En cas d'erreur :

Failed to download pear/VersionControl_SVN within preferred state "stable", latest release is version 0.5.0, 
stability "alpha", use "channel://pear.php.net/VersionControl_SVN-0.5.0" to install
install failed

exécutez plutôt la commande suivante pour indiquer le canal d'installation à utiliser :

pear install channel://pear.php.net/VersionControl_SVN-0.5.0

Classe à corriger

Le fichier Diff.php de la librairie comporte une erreur. Il se trouve dans le répertoire VersionControl\SVN\Command\ du répertoire PEAR (par exemple : D:\Dev\wamp\bin\php\php5.3.13\pear\VersionControl\SVN\Command\).

Tout à la fin du fichier, remplacez summerize par summarize (il était temps que l'été arrive apparemment :).

Astuce La taille des fichiers uploadés via PHP

La taille des fichiers uploadés via PHP est limitée. Par défaut PHP fixe cette limite à 2Mo, mais cela est bien sûr configurable.

Pour cela, éditez le fichier php.ini et modifiez la propriété upload_max_filesize avec la taille que vous souhaitez (ex: 16M).

Deux autres propriétés peuvent brider la taille maximale :

  • post_max_size : Nombre d'octets transmissibles via une requête POST (si vous voulez uploader un fichier via un formulaire).
  • memory_limit : Nombre d'octets que PHP peut stocker en mémoire.

Ces deux propriétés doivent être supérieures ou égales à upload_max_filesize.

On est souvent confronté à cette limite lorsqu'on utilise PHPMyAdmin pour uploader un dump SQL. Par exemple, si vous avez post_max_size: 3M et upload_max_filesize: 16M, vous ne pourrez pas uploadé un fichier de plus de 3Mo.

Astuce [eZ5] Développez en mode dev

Avec eZ Publish 5, pour activer la console de développement de Symfony vous devez modifier la configuration Apache, à priori dans votre virtual host.

Remplacez index.php par index_dev.php :

DirectoryIndex index.php
...
RewriteRule .* /index.php

Remarque :

EZ Publish et Symfony doivent également être configurés en mode développement.

Erreur [eZ5] L'arbre des contenus n'est plus disponible dans le back-office

Avec eZ Publish 5, le mot de passe de la base de données est stocké à deux endroits : dans eZ et dans Symfony.

Si tout fonctionne correctement dans le back-office, excepté l'arbre des contenus qui n'apparait pas, c'est probablement que les deux mots de passes sont différents.

Pour vérifier qu'ils sont bien configurés, vérifier dans ces deux fichiers :

  • ezpublish_legacy/override/site.ini.append.php
  • ezpublish/config/ezpublish.yml

S'il y a d'autres problèmes dans le back-office, comme l'impossibilité de naviguer dans les contenus via les éléments enfants, c'est sans doute un problème de cache.

Astuce [eZ4] Les préférences utilisateur

Le module user d'eZ Publish fournit une vue pour stocker simplement des préférences utilisateurs : user/preferences.

Concrètement, ces préférences sont stockées en base de données, avec pour clé le couple (user_id, preference_name) et pour valeur celle de notre choix.

Ce fonctionnement est souvent utilisé en back-office, pour afficher tel ou tel bloc de la page, comme la barre de droite par exemple.
Pour la partie publique côté front-office il est à éviter, car tous les utilisateurs anonymes auront la même préférence. (Ou alors il ne faut pas leur permettre de modifier la valeur.)

Créer/modifier une préférence

Côté template ou HTML

Il suffit d'appeler l'URL user/preferences, en proposant par exemple un lien à l'utilisateur.

Pour afficher/masquer la barre de droite du back-office, par exemple on a juste ce genre de liens :

{* Afficher la barre *}
<a href="{'user/preferences/set/admin_right_menu_show/1'|ezurl('no')}">
    Afficher la barre de droite
</a>

{* Masquer la barre *}
<a href="{'user/preferences/set/admin_right_menu_show/0'|ezurl('no')}">
    Masquer la barre de droite
</a>

Explication :

Pour créer/modifier une préférence, il faut appeler la vue user/preferences, avec 3 paramètres : set, le nom de la préférence puis sa valeur.

Côté PHP

Il faut utiliser la méthode setValue() de la classe eZPreferences :

eZPreferences::setValue( 'my_preference_name', 'my_value' );

Remarque :

Par défaut, la préférence sera associée à l'utilisateur connecté. Un troisième argument est disponible ($user_id), pour l'affecter à un autre utilisateur.

Récupérer la valeur de la préférence

Côté template

{ezpreference( 'my_preference_name' )}

Explication :

L'utilisateur courant est automatiquement utilisé.

Côté PHP

Il faut utiliser la méthode value() de la classe eZPreferences :

eZPreferences::value( 'my_preference_name' );

Remarques :

  • Pour récupérer la valeur pour un autre utilisateur que celui connecté, utilisez le second argument facultatif.
  • La méthode values() de la classe eZPreferences permet de récupérer toutes les préférences d'un utilisateur.

Astuce [eZ4] Différences entre les méthodes variable() et variableArray() de la classe eZINI

La classe eZINI propose deux méthodes pour récupérer des variables sous forme de tableau : variable() et variableArray().

variable()

C'est la méthode habituelle que vous utilisez pour récupérer des chaînes de caractères. Par exemple pour récupérer la valeur dans cette configuration :

[MySection]
MyProperty=value

La méthode retournera :

value1

Elle fonctionne aussi pour un tableau de valeurs :

[MySection]
MyProperty[]
MyProperty[]=value1
MyProperty[]=value2
...

La méthode retournera :

Array (
  '0' => value1
  '1' => value2
  ...
)

variableArray()

Cette méthode permet de récupérer un tableau de valeurs pour ce genre de configuration :

[MySection]
MyProperty=value1;value2;value3;...

La méthode retournera :

Array (
  '0' => value1
  '1' => value2
  '2' => value3
  ...
)

Marque-page Utiliser l'API Google Analytics en PHP

Pour pouvoir utiliser les données de Google Analytics directement en PHP, il existe une petite api pour communiquer simplement avec Google : GAPI.

En deux lignes de code, vous pouvez ainsi récupérer le nombre de visualisations par page pour les 30 derniers jours :

$ga = new gapi('address@server.com', 'my_password');

// Récupération du nombre de visualisations, pour chaque couple (url de la page, titre de la page)
$ga->requestReportData('71538601', ['pagePath', 'pageTitle'], ['pageviews']);

Astuce Utiliser l'API Google Analytics en PHP

Pour pouvoir utiliser les données de Google Analytics directement en PHP, il existe une petite api pour communiquer simplement avec Google : GAPI.

En deux lignes de code vous pouvez par exemple récupérer le nombre de consultations par page ces 30 derniers jours :

$ga = new gapi('address@server.com', 'my_password');

// Récupération du nombre de visualisations, 
// pour chaque couple (url de la page, titre de la page)
$ga->requestReportData('71538601', ['pagePath', 'pageTitle'], ['pageviews']);

Astuce [eZ5] Générer la configuration Symfony du projet existant

Lorsque vous migrez un site eZ Publish 4.x vers 5.x sans utiliser l'assistant d'installation automatisée, vous devez générer la configuration Symfony de votre site.

EZ Publish 5 est maintenant un projet Symfony et utilise la gère sa configuration en fichiers .yml. Pour passer des anciens .ini vers les nouveaux .yml, utilisez la commande suivante à la racine de votre projet :

php ezpublish/console ezpublish:configure --env=prod <group> <admin-siteaccess>

Remplacez <group> par votre groupe de siteaccess (ex: mon_site), et <admin-siteaccess> par le nom du siteaccess de votre back-office.

Remarques :

  • Symfony permet de switcher simplement entre les environnements de production et de développement. Pour chacun d'eux elle propose un fichier de configuration par défaut : ezpublish/config/ezpublish_dev.yml et ezpublish/config/ezpublish_prod.yml.
    Remplacez --env=prod par --env=dev pour utiliser la configuration de développement.
  • Le groupe de siteaccess est une nouvelle notion introduite par eZ Publish 5.

Astuce [eZ5] Les liens symboliques dans eZ Publish 5

EZ Publish 5 utilise le principe des assets de Symfony. Les fichiers statiques (css, js, images, ...) que vous utilisez dans vos bundles doivent donc être aussi présents dans le répertoire web/.

De plus, tous les fichiers uploadés via le back-office (qui est encore en eZ Publish 4), sont stockés par défaut dans le répertoire ezpublish_legacy/var/storage/. De la même manière, ils doivent aussi se retouver dans le répertoire web/ pour être servis par apache.

Pour mettre à jour votre répertoire web/, vous avez le choix entre copier tous les fichiers statiques, ou créer des liens symboliques.

Pour cela eZ Publish a surchargé la console de Symfony, et vous propose ces deux commandes (à lancer à la racine de votre projet) :

php ezpublish/console assets:install --symlink web
php ezpublish/console ezpublish:legacy:assets_install --symlink web

Explications :

  • La première commande crée des liens symboliques dans le répertoire web/, pointant vers les ressources des bundles.
  • La seconde crée des liens pointant vers les ressources du répertoire ezpublish_legacy/.

Remarque :

L'option --symlink web est facultative. Si vous la retirer (ou si elle ne fonctionne pas), eZ Publish créera des copies des fichiers au lieu des liens symboliques.

Astuce [eZ4] Utiliser l'API Ajax d'eZ Publish

L'extension ezjscore d'eZ Publish permet d'appeler des fonctions PHP via des requêtes Ajax. Vous pouvez l'utiliser pour mettre à jour une partie de la page sans la recharger complètement.

Le principe

Le javascript va lancer une requête Ajax à la vue call du module ezjscore (et donc appeler l'URL /ezjscore/call). Cette vue va retourner le résultat d'une méthode PHP, en fonction de la configuration du fichier ezjscore.ini.

Le résultat est alors disponible côté js et peut être utilisé pour modifier une partie de la page.

PHP

Les méthodes disponibles pour un appel Ajax doivent être implémentées dans des classes héritant de ezjscServerFunctions.

Par exemple dans le fichier monextension/classes/MyServerCallFunctions.php :

<?php

/**
 * Classe de fonctions à appeler en ajax.
 */
class MyServerCallFunctions extends ezjscServerFunctions {

    /**
     * Retourne le message "Hello x !", avec x le premier élément du tableau de paramètres, 
     *  ou "world" si aucun paramètre n'est passé.
     *
     * @param array $args Arguments
     * @return string
     */
    public static function helloMessage( array $args ) {

        // Log de l'appel de la fonction
        eZLog::write( 'Appel Ajax : ' . __METHOD__, 'debug.log' );

        if ( !empty( $args ) ) {
            $message = 'Hello ' . $args[0] . ' !';
        } else {
            $message = 'Hello world !';
        }
        return $message;
    }
}

Configuration

Pour que votre classe soit utilisable vous devez la déclarer le fichier ezjscore.ini :

Par exemple, dans le fichier monextension/settings/ezjscore.ini.append.php :

<?php /* #?ini charset="utf-8"?

[ezjscServer]
# Liste des fonctions accessibles via des appels Ajax
FunctionList[]=my_function_name

[ezjscServer_my_function_name]
# Nom de la classe PHP
Class=MyServerCallFunctions
# Nom du fichier contenant la classe
File=extension/monextension/classes/MyServerCallFunctions.php
# Nom des fonctions proposées par la classe
Functions[]=my_function_name

*/ ?>

Remarque :

Une fois le fichier modifié, videz les caches et régénérez les autoloads, pour qu'eZ Publish trouve votre nouvelle classe.

Un premier test

Vous pouvez déjà appeler votre fonction en tapant l'url suivante dans votre navigateur :

http://monsite.com/index.php/ezjscore/call/my_function_name::helloMessage::dude

On appelle bien la vue call du module ezjscore, à laquelle on fournit le nom d'une fonction et la liste des arguments, séparés par ::.

Remarque :

Cela ne fonctionne que si vous êtes connecté en tant qu'administrateur. Pour éviter ça, vous devez autorisez d'autres rôles à accéder à la vue ezjscore/call. Vous pouvez même définir des limitations, pour n'autoriser que l'accès à la fonction my_function_name.

Javascript

Maintenant que votre fonction est accessible, voici comment l'utiliser dans vos template.

Tout d'abord, vous devez ajouter le code suivant à votre template, pour inclure l'API Javascript d'ezjscore :

{ezscript_require( array( 'ezjsc::jquery', 'ezjsc::jqueryio' ) )}

Remarques :

  • L'exemple présenté utilise jQuery. Vous pouvez également utiliser l'API YUI fournie avec eZ.
  • Le premier élément du tableau (ezjsc::jquery) est facultatif si vous avez déjà inclus jQuery dans votre page, et peut même poser problème si la version de jQuery incluse est différente.

Voici maintenant le code Javascript :

var  dataSent = {arg0: 'dude', arg1: 'not_used'};
$.ez( 
    'my_function_name::helloMessage::dude::not_used',
    dataSent,
    function(data) {        
        // Si l'appel Ajax a retourné une erreur
        if ( data.error_text ) {
            console.error('Erreur : ' + data.error_text )
        // Si l'appel Ajax a retourné des résultats
        } else if ( data.content.length > 0 ) {
            console.info('Résultat : ' + data.content);
        }
    }
 );

Explications :

  • Si une erreur se produit, le message est disponible dans la variable data.error_text.
  • Si l'appel réussit, le résultat est présent dans la variable data.content.

Astuce [eZ4] Utiliser les alias de fetch

Pour simplifier l'utilisation des fetch dans les templates, eZ Publish propose d'utiliser des alias.

Exemple d'utilisation

Par exemple, si vous voulez compter récursivement les articles fils du nœud courant, le fetch standard serait :

{def $nb = fetch( 'content', 'tree_count',
    hash( 
        'parent_node_id', $node.node_id,
        'class_filter_type', 'include',
        'class_filter_array', array( 'article' ) 
    ) 
)}

Si vous utilisez souvent ce même fetch, vous aimerez sans doute lui créer un alias. La syntaxe devient alors :

{def $nb = fetch_alias( 
    'children_article_count', 
    hash( 'parent_node_id', $node.node_id ) 
)}

Explications :

  • L'alias s'appelle children_article_count()
  • Il n'a qu'un seul paramètre : l'ID du nœud parent.

Configuration

Pour informer eZ Publish de votre alias, il faut le déclarer dans le fichier fetchalias.ini.

Par exemple, dans le fichier monextension/settings/fetchalias.ini.append.php :

<?php /* #?ini charset="utf-8"?

[children_article_count]
# Compte récursivement le nombre d'articles sous le nœud dont l'ID est en paramètre
Module=content
FunctionName=tree_count
Constant[class_filter_type]=include
Constant[class_filter_array]=article
Parameter[parent_node_id]=parent_node_id

*/ ?>

Explications :

  • La section (children_article_count) est le nom de votre alias, à utiliser dans votre template.
  • Le module et la fonction sont ceux que vous auriez appelés dans le fetch standard.
  • Les constantes sont les paramètres fixes que vous auriez passés au fetch standard.
  • Les paramètres permettent de mapper le nom des paramètres de l'alias avec ceux du fetch standard.

Remarques :

  • N'oubliez pas de vider les caches pour qu'eZ Publish prennent en compte cette configuration.
  • EZ Publish fournit déjà des alias, visibles dans le fichier settings/fetchalias.ini.

Astuce [eZ4] Créer ses fetch personnalisés

EZ Publish fournit un grand nombre de fonctionnalités, accessibles dans les templates via des fetch (voir la documentation).

Vous pouvez créer vos propres fetch via le système de function_definition des modules.

Le module

  • Commencez par créer un nouveau module (ou utilisez un module déjà existant dans votre extension).
  • Si c'est un nouveau, vous devez le déclarer dans le fichier module.ini.

Par exemple, dans le fichier monextension/settings/module.ini.append.php :

<?php /* #?ini charset="utf-8"?

[ModuleSettings]
ExtensionRepositories[]=monextension
ModuleList[]=monmodule

Le PHP

Vous n'avez qu'un seul fichier à créer dans votre extension : modules/monmodule/function_definition.php.

Ce fichier contient un tableau php qui liste les fonctions disponibles. Pour chacune d'elle, vous préciserez son nom, son type (lecture ou écriture), la méthode PHP à appeler et les paramètres à lui passer.

Par exemple :

<?php
$FunctionList = array();

$FunctionList['tree_unique'] = array( 
    'name' => 'tree_unique',
    'operation_types' => array( 'read' ),
    'call_method' => array( 
        'class' => 'ATContentFunctionCollection',
        'method' => 'fetchObjectTree' 
    ),
    'parameter_type' => 'standard',
    'parameters' => array( 
        array( 
            'name' => 'parent_node_id',
            'type' => 'integer',
            'required' => true 
        )  
    ) 
);

Explications :

  • Cette fonction s'appelle tree_unique
  • Elle est de type lecture (elle n'effectue pas de modifications sur les données, mais en retourne)
  • Elle retourne le résultat de la méthode fetchObjectTree() de la classe ATContentFunctionCollection
  • Elle a un paramètre obligatoire : l'ID du nœud parent

Remarque :

Vous devez bien sur avoir créer une classe ATContentFunctionCollection possédant la méthode fetchObjectTree (). Le nom de la classe n'a pas d'importance, mais dans le cœur d'eZ Publish on ajoute FunctionCollection pour mettre en évidence les fonctionnalités utilisables dans les fetch.

Dans les templates

Vous pouvez maintenant utiliser votre fetch après avoir vider les caches. Utilisez-le comme n'importe quel fetch natif :

{def $node_list = fetch( 
    'monextension', 'tree_unique', 
    hash( 'parent_node_id', $node.node_id ) 
)}

Marque-page Utiliser les assertions avec PHP

Il est utile pour simplifier le débogage, d'utiliser les assertions d'un langage, que ce soit en Java ou en PHP. Les assertions ne s'activent qu'en mode développement, et seront tout simplement ignorées en production (= pas de perte de performance).

Voici les deux cas dans lesquels utiliser les assertions :

  • Pour valider les arguments passés à une fonction non publique. On peut ainsi éviter de tester les paramètres d'une fonction private appelée dans une boucle, par exemple.
  • Pour valider des post-conditions. Par exemple, pour vérifier que l'objet nouvellement créé à bien un ID.

Tutoriel pour utiliser les assertions avec PHP : http://openclassrooms.com/courses/les-assertions-en-php.

Astuce L'EntityManager de Doctrine pour Symfony

Tout d'abord, voici comment récupérer cet EntityManager, depuis un Contrôleur :

// Récupération de l'entity manager
$entityManager = $this->getDoctrine()->getManager();

Voici les principales méthodes de l'EntityManager (voir toutes les méthodes).

persist($entity)

Cette méthode signale à Doctrine que l'objet doit être enregistré. Elle ne doit être utilisée que pour un nouvel objet et non pas pour une mise à jour.

Ex :

// Crée l'article et le signale à Doctrine.
$article1 = new Article;
$article1->setTitre('Mon dernier weekend');
$entityManager->persist($article);

flush()

Met à jour la base à partir des objets signalés à Doctrine. Tant qu'elle n'est pas appellée, rien n'est modifié en base.

Ex :

// Crée l'article en base et met à jour toutes les entités persistées modifiées.
$entityManager->persist($article);
$entityManager->flush();

clear($nomEntity = null)

Annule tous les persist() en cours. Si le nom d'une entité est précisé (son namespace complet ou son raccourci), seuls les persist() sur les entités de ce type seront annulés.

Ex :

$entityManager->persist($article);
$entityManager->persist($commentaire);
$entityManager->clear();
// N'exécutera rien, car les deux persists sont annulés par le clear
$entityManager->flush();

detach($entity)

Annule le persist() effectué sur l'entité en argument. Au prochain flush(), aucun changement ne sera donc appliqué à l'entité.

Ex :

$entityManager->persist($article);
$entityManager->persist($commentaire);
$entityManager->detach($article);
// Enregistre $commentaire mais pas $article
$entityManager->flush();

contains($entity)

Retourne true si l'entité donnée en argument est gérée par l'EntityManager (= s'il y a eu un persist() sur l'entité).

Ex :

$entityManager->persist($article);
var_dump($entityManager->contains($article)); // Affiche true
var_dump($entityManager->contains($commentaire)); // Affiche false

refresh($entity)

Rafraîchit l'entité donnée en argument pour la mettre dans l'état où elle se trouve en base de données. Cela écrase et annule donc tous les changements qu'il a pu y avoir sur l'entité depuis le dernier flush().

Ex :

$article->setTitre('Un nouveau titre');
$entityManager->refresh($article);
var_dump($article->getTitre()); // Affiche « Un ancien titre »

remove($entity)

Signale à Doctrine qu'on veut supprimer l'entité en argument de la base de données. Effectif au prochain flush().

Ex :

$entityManager->remove($article);
// Exécute un DELETE sur $article
$entityManager->flush();

Astuce Utiliser Doctrine avec la console de Symfony

Voici un récapitulatif des commandes de base de la console de Symfony pour Doctrine :

  • Créer une base de données :
php bin/console doctrine:database:create
  • Créer/mettre à jour le schéma des tables dans la base :
php bin/console doctrine:schema:update --dump-sql
php bin/console doctrine:schema:update --force

La première commande permet de visualiser les requêtes à exécuter. La seconde les exécute.

  • Générer le code d'une entité :
php bin/console generate:doctrine:entity

Renseignez ensuite le nom de l'entité avec le nom du bundle devant (ex: BlogBundle:Article), le format de configuration à utiliser, les éventuels champs à créer, et si vous souhaitez également générer le code du repository.

  • Compléter le code d'une entité (champs, getter() et setter()) :
php bin/console doctrine:generate:entities BlogBundle:Article

À adapter en fonction de l'entité à mettre à jour.

Marque-page Les deux php.ini de WampServer

WampServer utilise deux fichiers php.ini (adaptez les chemins en fonction du dossier racine de WampServer et des versions d'apache et PHP) :

  • D:\Chemin\vers\wamp\bin\php\php5.3.13\php.ini
  • D:\Chemin\vers\wamp\bin\apache\apache2.2.22\bin\php.ini

Si vous utilisez PHP en ligne de commande, celui-ci utilisera le premier php.ini : celui qui se trouve à la racine du répertoire de PHP.

Si vous utilisez PHP via votre navigateur, le second php.ini sera utilisé : celui qui se trouve dans l'arborescence d'Apache.

Erreur Impossible de générer l'autoloads pour les dépendances de Symfony avec Composer

Sous Windows, lorsque vous essayez d'installer les dépendances de Symfony avec Composer, vous pouvez obtenir l'erreur suivante :

Loading composer repositories with package information
Updating dependencies
Nothing to install or update
Generating autoload files

Script Sensio\Bundle\DistributionBundle\Composer\ScriptHandler::buildBootstrap h
andling the post-update-cmd event terminated with an exception

  [RuntimeException]
  An error occurred when generating the bootstrap file.

Exécutez alors les commandes suivantes :

composer update --no-scripts
php vendor/sensio/distribution-bundle/Sensio/Bundle/DistributionBundle/Resources/bin/build_bootstrap.php

Erreur Impossible de télécharger les dépendances avec Composer et Git

Sous Windows, lorsque vous essayer d'installer des dépendances avec Composer (le gestionnaire de dépendances de PHP), vous pouvez obtenir l'erreur suivante :

Loading composer repositories with package information
Installing dependencies
  - Installing twig/extensions (dev-master 5c2d515)
    Cloning 5c2d515d4624bdd588226d688173cf0399a4d8cf

  [RuntimeException]
  Failed to execute git checkout "5c2d515d4624bdd588226d688173cf0399a4d8cf" &
  & git reset --hard "5c2d515d4624bdd588226d688173cf0399a4d8cf"

  fatal: Not a git repository (or any of the parent directories): .git

Remplacez alors la commande :

composer install

par :

composer update --prefer-dist

Astuce Créer un Bundle

Pour créer un Bundle avec Symfony, utilisez la commande :

php app/console generate:bundle
  • Choisissez si votre bundle sera commun à plusieurs applications ou non
  • Saisissez le nom de votre bundle, finissant par Bundle (ex: MonBundle). Si vous avez mis oui à l'étape précédente, préfixez le d'un namespace (ex: MonSite\MonBundle).
  • Laissez le répertoire cible src/ inchangé.
  • Choisissez le format de configuration.

Si vous préférez le créer à la main, voici ce qu'il faut retenir pour le rendre opérationnel :

  • Le code source se trouve dans src/MonBundle/
  • Le seul fichier obligatoire doit être à sa racine : MonBundle.php.
  • Vous devez instancier votre bundle dans le noyau pour qu'il soit chargé. Pour cela, ajoutez la ligne new MonBundle\MonBundle(), dans AppKernel.php.
  • Pour que vos routes soient reconnues, il faut ajouter ces lignes au fichier app/config.routing.yml :
mon_bundle:
    resource: "@MonBundle/Controller/"
    type:     annotation
    prefix:   /
  • Pour que vos services soient reconnus, il faut ajouter cette ligne au fichier app/config.yml :
    imports:
    # [...]
    - { resource: "@MonBundle/Resources/config/services.yml" }

Marque-page [eZ4] Récupérer les urls et les chemins vers les répertoires

EZ Publish fournit la classe eZSys, propose pas mal de méthodes pour récupérer par exemple :

  • Le chemin vers le répertoire var
  • L'url du serveur
  • Le port utilisé
  • Le chemin vers le répertoire du projet
  • La version de php
  • ...

Cette classe se trouve dans lib/ezutils/classes/ezsys.php.

Marque-page [eZ4] Un système de chat

Si vous avez besoin d'un système de chat dans votre site eZ Publish, voici une extension qui peut faire l'affaire : eZ phpFreeChat.

Interface de l'extension eZ phpFreeChat

Elle fournit un module pour eZ Publish, dont l'unique vue affiche un chat. Le chat est composé d'une discussion principale visible par tous les utilisateurs connectés au chat et permet des discussions privées entre deux utilisateurs (en cliquant sur leur nom).

Remarque :

L'extension utilise la version 1.3 de phpfreechat, qui existe maintenant en 2.1.1.

Astuce [eZ4] Un système de forums élaboré

EZ Publish fournit par défaut 3 classes de contenus pour créer des forums : Forum, Forum topic et Forum reply. L'extension ezwebin fournit les templates associés et vous pouvez ainsi créer des forums, des conversations et des messages.

Malgré cela, il manque beaucoup de fonctionnalités courantes attendues sur un forum : une messagerie privée, un suivi des discussions, des statistiques, du BBCode, un affichage en tableau des forums et des sujets, une gestion de rangs, de la modération, la possibilité de signaler un abus, ...

Ces fonctionnalités sont implémentées par l'extension xrowForum. Elle fournit :

  • une interface dans le back-office pour administrer les modérateurs, les rangs et les paramètres des forums.
  • une nouvelle classe de contenu Forums, qui permet de regrouper dans un même affichage tous les forums enfants.
  • une interface front-office de messagerie privée et d'ajout de contacts

L'extension distribuée sur le repo svn est mal internationalisée et mal traduite. Voici la même version corrigée.

Astuce [eZ4] Configuration Apache et PHP pour eZ Publish avec WampServer

Voici la configuration à utiliser pour développer un site eZ Publish avec WampServer. Elle s'articule autour de 3 fichiers de configuration :

  • httpd.conf : Fichier de configuration du serveur Apache
  • httpd-vhosts.conf : Fichier de configuration apache pour les hôtes virtuels
  • php.ini : Configuration de php

Remarque :

Dans cet exemple, la version 2.2 64 bits de WampServer est utilisée, avec Apache 2.2.22 et PHP 5.3.13.

Configuration finale

  • Url du site : http://mon_site.loc/index.php
  • Chemin absolu vers le projet : D:\Dev\php_projects\MonSite\
  • Chemin absolu vers WampServer : D:\Dev\wamp\

Httpd.conf

Ce fichier se trouve dans bin\apache\apache2.2.22\conf\, à partir de la racine de WampServer.

Tout en bas du fichier, activez les hôtes virtuels en décommentant cette ligne :

Include conf/extra/httpd-vhosts.conf

Voici un exemple du fichier httpd.conf.

Modules Apache

Via l'interface de Wamp, activez les modules Apache suivants :

Modules Apache activés

Hôtes virtuels

Éditez le fichier host (C:\Windows\System32\drivers\etc\hosts) en tant qu'administrateur.

Pour pouvoir l'enregistrer avec Notepad++, lancez l'éditeur en tant qu'administrateur (Clic-droit sur l'exécutable) et ouvrez ensuite le fichier host.

Ajoutez la ligne

127.0.0.1          mon_site.loc

Lorsque vous utiliserez l'adresse mon_site.loc, votre navigateur saura ainsi qu'il s'agit de votre ordinateur et non pas d'une machine sur internet.

Modifiez maintenant le fichier httpd-vhosts.conf (ex : D:\Dev\wamp\bin\apache\apache2.2.22\conf\extra\httpd-vhosts.conf) en ajoutant les lignes :

<VirtualHost *:80>
    ServerName mon_site.loc
    DocumentRoot D:/Dev/php_projects/MonSite

    <Directory D:/Dev/php_projects/MonSite>
        Options Indexes FollowSymLinks MultiViews
        Order allow,deny
        allow from all
    </Directory>
</VirtualHost>

Voici un exemple du fichier httpd-vhosts.conf.

Php.ini

WampServer utilise deux fichiers php.ini différents. Le premier est utilisé par votre eZ Publish ou n'importe quel site servi par votre serveur Apache. Le second est utilisé lorsque vous appelez PHP via l'invite de commande. Voici où les trouver depuis la racine de votre dossier WampServer :

  • bin\apache\apache2.2.22\bin\php.ini
  • bin\php\php5.3.13\php.ini

Le plus simple est d'utiliser la même configuration entre les deux lorsque vous êtes en phase de développement.

Voici les propriétés à éditer :

# Temps maximum d'exécution de script (en s)
max_execution_time = 480 
# Temps maximum pour uploader un fichier
max_input_time = 180
# Taille maximale des fichiers uploadables
post_max_size = 8M

# Zone de date
date.timezone = "Europe/Paris"

Voici un exemple du fichier php.ini.

Remarque :

Vous pouvez également activer XDebug pour pouvoir déboguer de manière optimale.

Extensions PHP

Comme pour les modules Apache, activez ces extensions PHP via l'interface de WampServer :

Extensions de PHP activées

Erreur [eZ4] Les fichiers sont importés à la racine et pas dans la médiathèque

Vous pouvez modifier l'emplacement où les contenus sont créés par défaut, selon leur classe de contenu. Ainsi, eZ Publish définit par exemple qu'un contenu Image doit être placé dans medias/images et qu'un contenu Fichier dans medias/files.

Si vous renommez ces emplacements (ex: Fichiers à la place de Files) et lancez le cronjob de régénération des urls, medias/files devient medias/fichiers.

Si vous ne modifiez pas la configuration, eZ Publish ne trouve plus l'emplacement et importe les fichiers à la racine.

Vous devez donc surcharger le fichier content.ini en réécrivant les emplacements déjà existants et surtout en modifiant ceux que vous avez renommés.

Par exemple, dans le fichier monextension/settings/content.ini.append.php :


<?php /* #?ini charset="utf-8"?

[RelationAssignmentSettings]
ClassSpecificAssignment[]
ClassSpecificAssignment[]=user,user_group;utilisateurs/membres
ClassSpecificAssignment[]=image;medias/images
ClassSpecificAssignment[]=video;medias/multimedia
ClassSpecificAssignment[]=file;medias/fichiers
ClassSpecificAssignment[]=quicktime;medias/multimedia
ClassSpecificAssignment[]=windows_media;medias/multimedia
ClassSpecificAssignment[]=real_video;medias/multimedia
ClassSpecificAssignment[]=flash;medias/multimedia
*/ ?>

Astuce [eZ4] Priorité de surcharge des fichiers .ini

Dans eZ Publish on utilise tout le temps les fichiers de configuration .ini. Il y en a un peu partout dans l'arborescence d'un projet eZ Publish et il est donc facile de s'y perdre.

Où trouver les fichiers .ini ?

  • Toujours dans un répertoire settings.
  • Dans le répertoire settings/ à la racine du projet ou dans ceux des extensions.

Différence en les fichiers .ini et .ini.append.php

Les fichiers .ini sont les fichiers par défaut, fournis par eZ Publish et par les extensions. Lorsque vous surchargez ces fichiers, utilisez les fichiers .ini.append.php (ex: site.ini.append.php).

Si dans votre extension, vous avez besoin de créer un nouveau fichier de configuration, utilisez donc l'extension .ini. Si vous distribuer votre extension et que la personne qui l'utilise veur surcharger une propriété, elle créera elle, un fichier .ini.append.php.

La hiérarchie de surcharge

Voici l'ordre de prise en compte des fichiers .ini (et .ini.append.php), du moins important au plus important. Les chemins sont relatifs à la racine de l'application.

  • settings/ : Vous y trouverez tous les fichiers de configuration par défaut. Ils impactent indifféremment tous les siteaccess. Ces fichiers ne doivent pas être modifiés !
  • extension/mon_extension/settings/siteaccess/mon_siteaccess/ : Seule la configuration du siteaccess mon_siteaccess sera impactée.
  • settings/siteaccess/mon_siteaccess/ : Idem, seule la configuration du siteaccess mon_siteaccess sera impactée.
  • extension/mon_extension/settings/ : Le cas le plus couramment utilisé. Vous modifier ici toutes les configurations que vous voulez, pour tous les siteaccess.
  • settings/override/ : C'est le niveau le plus haut. Tout ce qui est ici sera pris en compte en priorité, quel que que soit le siteaccess.

Priorité entre les extensions

L'ordre d'activation des extensions dans le fichier site.ini a une importance. La première extension déclarée surcharge la deuxième, qui surcharge la troisième et ainsi de suite.

Astuce [eZ4] Afficher le contenu d'une variable

Pour connaitre le contenu d'une variable dans eZ Publish il existe plusieurs pour faciliter le débogage.

Côté PHP

La fonction print_r()

Cette fonction couplée avec les balises <pre>, affiche le contenu de la variable de manière récursive et structurée.

echo '<pre>';
print_r( $variable, false);
echo '</pre>';

affichera

print_r()

La fonction var_dump()

Cette fonction affiche la même chose que print_r(), mais de manière typée. Contrairement à l'autre, elle affiche aussi les valeurs null ou false.

echo '<pre>';
var_dump( $variable );
echo '</pre>';

affichera

print_r()

La fonction eZDebug::writeDebug()

Cette fonction affiche la même chose que la fonction var_dump(), mais dans la partie debug de bas de page (si le mode debug est activé).

eZDebug::writeDebug( $variable );

affichera

print_r()

Dans un template

L'opérateur attribute()

Cet opérateur affiche le contenu de tous les attributs de la variable, si celle-ci est un objet ou un tableau. Le second paramètre définit le niveau de profondeur à afficher.

{$variable|attribute( 'show', 1 )}

affichera

print_r()

{$variable|attribute( 'show', 2 )}

affichera

print_r()

La fonction debug-log

Cette fonction fait la même chose que eZDebug::writeDebug(), mais côté template.

{debug-log var=$variable}

affichera

print_r()

Astuce Débugger en PHP avec XDebug

Lorsque vous développez en PHP, déboguer avec une interface graphique peut vous faire gagner beaucoup de temps. Les IDE comme Eclipse ou NetBeans proposent ce genre d'interface pour Java, mais également pour PHP.

Grâce à eux, vous pouvez mettre des points d’arrêt, exécuter le programme pas à pas et voir l’évolution des valeurs des variables en temps réel, à chaque étape de l'exécution du script.

Pour vérifier l’installation de XDebug sur votre serveur, créez un page PHP contenant l'appel à la méthode phpinfo().

Une page de la forme suivante devrait apparaître :

Début phpinfo()

Plus bas dans la page, l'encadré suivant devrait apparaître si XDebug est activé :

XDebug phpinfo()

Installation de XDebug pour WampServer

XDebug est installé par défaut sur WampServer, mais n’est pas configuré en mode remote (débogage à distance). Pour modifier la configuration de Xdebug :

  • Éditez votre fichier php.ini (icône Wampserver > PHP > php.ini)
  • Modifiez ainsi les lignes de la section xdebug du fichier :
[xdebug]
xdebug.remote_enable=on
xdebug.remote_host="127.0.0.1"
xdebug.remote_port=9000
xdebug.remote_handler="dbgp"
xdebug.remote_mode=req
  • Enregistrez le fichier et redémarrez WampServer.

Installation de XDebug sous Debian

  • Commencez par installer le paquet XDebug
sudo apt-get install php5-xdebug
  • Éditez le fichier de configuration de XDebug :
sudo gedit /etc/php5/conf.d/xdebug.ini
  • Modifiez ainsi les lignes de la section XDEBUG Extension du fichier :
[xdebug]
xdebug.remote_enable=on
xdebug.remote_host="127.0.0.1"
xdebug.remote_port=9000
xdebug.remote_handler="dbgp"
xdebug.remote_mode=req
  • Sauvegardez le fichier et redémarrez Apache :
sudo /etc/init.d/apache2 restart

Module easy Xdebug pour Firefox

Pour simplifier le démarrage d’une session XDebug, il existe l'extension easy Xdebug pour Firefox. Une fois installée, deux icônes s’affichent dans la barre d'outils. Lorsque le débogage n'est pas actif, cliquez sur Activer Easy XDebug pour l'activer. Pour le désactiver, cliquez sur Désactiver Easy XDebug

Configuration d’Eclipse pour XDebug

Il s’agit maintenant d’indiquer à Eclipse que vous souhaitez utiliser XDebug pour déboguer votre projet :

  • Menu Window > Preferences
  • Sélectionnez l’item PHP > Debug
  • À la ligne PHP Debugger choisissez XDebug

Config XDebug Netbeans

  • Cliquez sur le lien configure... de XDebug
  • Sélectionnez XDebug dans la liste Installed Debuggers
  • Bouton Configure
  • Dans la liste Accept remote session (JIT), choisissez any
  • Cliquez deux fois sur OK

Configuration de NetBeans pour XDebug

  • Clic-droit sur votre projet > Properties
  • Cliquez sur Run configuration dans la barre de gauche
  • Remplissez les champs demandés :
    • Run as : Local Web Site
    • Project URL : l'url de la page d'accueil de votre site (ex : http://monsite.com)
    • Index File : le fichier index de votre site (a priori index.php)
  • Cliquez sur le bouton Advanced...
  • Sélectionnez Do Not Open Web Browser
  • Cliquez deux fois sur OK

Activer le débogueur pour NetBeans

Pour activer le débogueur, ouvrez NetBeans et cliquez sur Debug Project (Ctrl + F5). Dans votre navigateur, cliquez ensuite sur le bouton activer le débogage de l'extension easy Xdebug (sous Firefox).

Remarque : Si vous utilisez un autre navigateur que Firefox, vous pouvez activer le débogage en ajoutant ?XDEBUG_SESSION_START=netbeans-xdebug à la fin de votre URL. Un cookie sera ainsi créé et repéré par NetBeans, pour utiliser le débogage.

Astuce [eZ4] Créer un lien de téléchargement d'un fichier

Pour créer un lien de téléchargement vers le fichier d'un contenu de type File, utilisez :

{concat( 
    'content/download/', $object.data_map.file.contentobject_id,
    '/', $object.data_map.file.id, 
    '/version/', $object.data_map.file.version , 
    '/file/', $object.data_map.file.content.original_filename|urlencode() 
)|ezurl( 'no' )}

Explication :

La variable $object doit être un objet de contenu, soit une instance de eZContentObject.

Astuce [eZ4] Consommer des services web fournis par Play!

Voici une extension eZ Publish permettant de récupérer des webservices fournis par Play! : AT Play Connector.

Cette extension propose une application Play! d'exemple, correspondant aux exemples des articles précédents. Pour chaque service web de cette application, une méthode PHP et un fetch() permettent de récupérer son résultat côté eZ Publish.

Ces méthodes sont implémentées dans le fichier classes/ATWSFunctionCollection.php (à partir de la racine de l'extension), dont voici un extrait :

<?php

class ATWSFunctionCollection extends ATWebServices {

    /**
     * Appel du service web /test
     *
     * @param string $nom Nom des locataires recherchés
     * @param string $dateNaissance Date de naissance  des locataires recherchés
     * @param string $lang Langue des résultats
     */
    public static function test() {

        // Nom du service web
        $wsName = '/test';

        // Construction de la requête
        $wsRequest = $wsName;

        return array( 'result' => parent::getJSONResult( $wsRequest ) );
    }

    /**
     * Appel du service web /helloWorld
     *
     * @param string $name Nom de la personne à saluer
     */
    public static function helloWorld( $name ) {

        // Nom du service web
        $wsName = '/helloWorld';

        // Construction de la requête avec les paramètres
        $wsRequest = $wsName . '?';

        if ( $name != null ) {

            $wsRequest .= 'name=' . urlencode( $name );
        }

        return array( 'result' => parent::getJSONResult( $wsRequest ) );
    }
}

Explications :

  • Cette classe étend la classe ATWebservices également fournie par l'extension.
  • Chaque méthode ne fait que préparer le début d'une requête HTTP, avec le nom du service web et les paramètres à envoyer.
  • Ces requêtes HTTP sont complétées et soumises au services web de Play! via la méthode getJSONResult() de la classe ATWebServices.

Le fichier modules/atplayws/function_definition.php (à partir de la racine de l'extension) déclare des fetch() utilisables depuis les templates :

<?php

$FunctionList = array( );

$FunctionList['test'] = array(
        'name' => 'test',
        'operation_types' => array( 'read' ),
        'call_method' => array(
                'class' => 'ATWSFunctionCollection',
                'method' => 'test' ),
        'parameter_type' => 'standard',
        'parameters' => array( )
);

$FunctionList['hello_world'] = array(
        'name' => 'hello_world',
        'operation_types' => array( 'read' ),
        'call_method' => array(
                'class' => 'ATWSFunctionCollection',
                'method' => 'helloWorld' ),
        'parameter_type' => 'standard',
        'parameters' => array(
                array( 'name' => 'name',
                        'type' => 'string',
                        'required' => true ),
        )
);

Explications :

Pour chaque fetch, on déclare son nom, la méthode PHP correspondante à appeler, ainsi que les paramètres à lui fournir.

Remarque :

Pour plus de détails sur l'utilisation de l'extension, consultez le fichier README.txt présent à sa racine.

Astuce [eZ4] Utiliser eZ Publish avec Play! Framework

Play! Framework

Play! est un framework simple de développement MVC web open-source basé sur Java.

Ce n’est pas un framework Java EE puisqu’il n’implémente pas la norme servlet. Il n’a donc pas besoin de serveur d’application comme Tomcat ou WebSphere et utilise le « serveur http » JBoss Netty permettant de meilleures performances.

Il utilise une architecture stateless ce qui rend son utilisation idéale pour le développement de services web REST.

Pourquoi utiliser Play! ?

Vous pouvez faire à peu près autant de chose avec PHP qu'avec Java, mais si la solution dont vous avez besoin est déjà existante en Java, pourquoi la recréer en PHP ?

Play! étant basé sur Java, vous pouvez l'utiliser comme interface entre une solution Java et votre projet eZ Publish.

L'architecture eZ Publish + Play!

Architecture eZ Publish - Play

EZ Publish communique avec Play! en lui soumettant des requêtes REST via HTTP. Play! lui retourne en réponse les données au format JSON (par défaut). Play! fournit donc des services web utilisables par eZ Publish.

Astuce [eZ4] Les templates des pages d'erreur

Lorsqu'une erreur se produit, eZ Publish affiche une page avec un message d'erreur. Ces pages sont entièrement personnalisables, puisque elles utilisent des templates standards.

Voici la liste des templates de pages d'erreur fournis par défaut dans eZ Publish (dans design/standard/templates/) :

  • error/kernel/1.tpl : Accès à une page avec autorisations insuffisantes. Affiche un message d'erreur plus la mire de login.
  • error/kernel/2.tpl : Accès à un module inconnu.
  • error/kernel/21.tpl : Accès à une vue inconnue.
  • error/kernel/22.tpl : Accès à un module désactivé.
  • error/kernel/3.tpl : Accès à une ressource indisponible ou verrouillée.
  • error/kernel/4.tpl : Accès à un contenu déplacé. Redirige automatiquement vers le nouvel emplacement.
  • error/kernel/5.tpl : Création d'un brouillon dans une langue invalide.

Astuce [eZ4] Modifier le groupe de classes d'une classe de contenu

Si vous avez beaucoup de classes de contenu à créer, ou si vous n'utilisez que très peu
de celles fournies par eZ Publish, vous avez intérêt à créer votre propre groupe de classes.

Pour cela, allez dans Administration > Classes et cliquez sur le bouton Nouveau groupe de classes.

Pour regrouper vos nouvelles classes et les natives dont vous avez besoin (ex : Image, Folder, ...), vous pouvez déplacer ou copier/déplacer les classes de contenu d'un groupe vers un autre.

Pour cela, allez sur la page de détail de la classe que vous voulez copier (ou déplacer), et cliquez sur Le bouton Groupe de classes en haut. En bas de la page apparaît un bloc permettant de choisir le ou les groupes dans lesquels placer la classe de contenu :

Groupes de la classe

Remarque :

Pour être sûr de ne pas dégrader un fonctionnement natif sans le vouloir, il est plus sûr de "copier" les classes natives dans un autre groupe plutôt que de les retirer du groupe initial.

Astuce [eZ4] Afficher l'icône de la classe d'un contenu

Pour rendre le back-office plus simple et plus convivial, eZ Publish permet de définir un icône pour chaque classe de contenu.

Pour afficher ces icônes dans vos template, eZ Publish fournit un opérateur de template : class_icon(). Grâce à lui, vous pouvez simplement insérer l'icône d'une classe de contenu :

{'folder'|class_icon( 'small', "Texte alternatif à l'image" )}

Ce qui affichera Icône de la classe de contenu Dossier.

Remarques :

  • small affiche l'icône en 16x16 px, normal en 32x32 px.
  • Pour afficher l'icône d'un groupe de classes, utiliser l'opérateur de template classgroup_icon().

Erreur [eZ4] Impossible de se déconnecter

Si vous ne parvenez pas à vous déconnecter, ou si vous êtes automatiquement connecté en tant qu'un utilisateur lorsque vous arriver sur une certaine page, vérifiez les points suivants :

  • Vérifiez que l'utilisateur anonyme utilisé par défaut est le bon. Dans site.ini vous devez avoir AnonymousUserID=10 (10 par défaut, ou un autre si vous en avez choisi un autre).
  • Videz les caches.
  • Vérifiez que vous êtes toujours connecté malgré l'appel de la page http://mon_site.com/user/logout.
  • Vérifiez que vous n'avez pas modifié la configuration des droits et rôles récemment.
  • Vérifiez que vous n'avez pas installé une nouvelle extension récemment .
  • Vérifiez que sur la ou les pages qui posent problème, une fonction ne vous connecte pas en tant qu'un autre utilisateur sans vous reconnecter correctement par la suite.

Astuce [eZ4] Ajouter des boutons à la websitetoolbar

EZ Publish inclut par défaut l'extension ezwt, qui propose une barre d'outils pour l'édition en front-office :

Websitetoolbar

Cette barre d'outils utilise bien sûr des templates, que vous pouvez surcharger. Voici un exemple pour ajouter un bouton de déconnexion à cette barre d'outils.

Surcharger le template existant

Ajoutez la règle de surcharge suivante dans le fichier extension/mon_extension/settings/siteaccess/site/override.ini.append.php :

[website_toolbar]
Source=parts/website_toolbar.tpl
MatchFile=parts/website_toolbar.tpl
Subdir=templates

Copiez le fichier extension/ezwt/design/standard/templates/parts/website_toolbar.tpl dans votre extension (extension/mon_extension/design/mon_design/templates/parts/website_toolbar.tpl).

Ajouter le bouton

Ajouter le code de votre bouton avant le template d'aide :

{* Ajout d'un bouton pour se déconnecter *}
<div id="ezwt-monextensionaction-logout" class="ezwt-actiongroup">
    <a class="logout_button"
       href="{'user/logout'|ezurl( 'no' )}"
       title="{'Me déconnecter'|i18n( 'user/login' )}">
        <img src="{'global/picto_logout.png'|ezimage( 'no' )}" alt="" />
    </a>
</div>

{include uri='design:parts/websitetoolbar/help.tpl'}

Remarques :

  • Le div conteneur, avec la classe ezwt-actiongroup définit un nouveau groupe de boutons.
  • Vous pouvez choisir à quel endroit de la barre votre bouton doit apparaître (ici à la fin), et vous pouvez ajouter un bouton à un groupe déjà existant.

Résultat :

Websitetoolbar avec déconnexion

Astuce [eZ4] Utiliser le layout d'impression

Il est courant de proposer une fonction d'impression pour les articles de son site.

Cette fonction peut appeler simplement la fonction Imprimer du navigateur en javascript (window.print()), ou définir un affichage de l'article épuré. EZ Publish propose un système de layout pour définir plusieurs gabarits d'affichage. Celui d'impression notamment, qui permet d'afficher un article sans les menus, le header et le footer.

Alors que layout principal utilise le template pagelayout.tpl partout dans le site, celui d'impression utilise print_pagelayout.tpl.

Affichage en mode page, avec le layout principal :

Affichage en mode page, avec le layout principal

Affichage en mode impression, avec le layout d'impression :

Affichage en mode impression, avec le layout d'impression

Comment afficher un article avec ce layout épuré ?

Appelez simplement l'article avec une URL de type : http://mon_site.com/layout/set/print/content/view/full/<node_id>.

Remarques :

  • Vous pouvez définir d'autres layout, par exemple pour afficher un contenu sous forme xml ou json. Le template devra toujours avoir pour nom nomdulayout_pagelayout.tpl, et l'url la forme http://mon_site.com/layout/set/<nomdulayout>/content/view/full/<node_id>. Dans ce cas, déclarez votre nouveau layout dans le fichier layout.ini.
  • Vous pouvez appeler d'autres vues que content/view. Il suffit de les faire précéder par /layout/set/<nomdulayout>/.

Erreur [eZ4] L'arbre des contenus n'est plus acessible en front-office et en back-office

Si plus aucun contenu ne s'affiche en front-office, seulement le header et les menus, et qu'en back-office l'onglet Contenus n'est plus accessible, il s'agît d'un problème de cache. EZ Publish n'arrive plus à générer le cache.

Première approche

Vérifiez qu'eZ Publish a bien les droits d'écriture sur le répertoire qui contient le cache (var/cache/ par défaut).

Remarque :

Ce problème de droits est fréquent sous linux, mais peut également se produire sous Windows (ex : Windows 7 Professional).

Deuxième approche

Si le problème persiste, essayez la commande suivante à la racine du site, pour vraiment supprimer le cache :

php bin/php/ezcache.php --clear-all --purge

Troisième approche

Supprimez tout le contenu du répertoire var/cache/ à la main.

Quatrième approche

Faîtes le ménage sur le serveur. Il se peut que le disque soit plein.

Erreur [eZ4] data error dans le back-office, au chargement des sous-éléments

Lorsque vous chargez une page de back-office affichant l'arbre des contenus à gauche, ou le tableau des sous-éléments, il arrive parfois que le chargement échoue.

Data error en BO

Ce chargement est effectué par un appel AJAX, et il fonctionne mal si vous utilisez le mode_cgi d'apache.

Si cette erreur se produit trop souvent (notamment en développement), vous pouvez désactiver le mode cgi.

Sous Windows, si vous utilisez Wamp, il suffit de décocher cgi_module dans apache > Modules Apache, ou de modifier votre httpd.conf et commenter la ligne correspondante.

Sous Debian, vous pouvez le faire en ligne de commande :

sudo a2dismod cgi
sudo service apache2 reload

Astuce [eZ4] Renommer les custom tag de l'editeur WYSIWYG

Par défaut, eZ Publish propose des tags personnalisés dans son éditeur WYSIWYG (eZ Online Editor ou ezoe). Si les termes quote, underline ou autres ne vous conviennent pas, vous pouvez les renommer dans le fichier content.ini :

Par exemple, dans le fichier monextension/settings/content.ini.append.php :

<?php /* #?ini charset="utf-8"?

[CustomTagSettings]
AvailableCustomTags[]=quote
CustomTagsDescription[quote]=Citation

*/ ?>
  • AvailableCustomTags est la liste des tags personnalisés qui seront disponibles dans l'éditeur
  • CustomTagsDescription est la liste des libellés qu'auront ces tags

Dans l'exemple ci-dessus, le tag personnalisé quote, sera nommé Citation dans la liste en BO.

Astuce [eZ4] Modifier automatiquement les droits sur les fichiers eZ Publish sous linux

Plutôt que d'exécuter des commandes de type chmod pour modifier les droits de lecture/écriture et exécution sur les différents fichiers d'eZ Publish, vous pouvez simplement exécuter un fichier bash fourni par eZ : modfix.sh.

A la racine du projet, lancez la commande suivante :

./bin/modfix.sh 

Astuce Paramétrer les tâches planifiées

Pour fonctionner correctement, eZ Publish à besoin que des scripts soient exécutés à intervalles réguliers. Ces scripts permettent de supprimer les brouillons, effectuer les opérations de workflow, mettre à jours alias d'url des contenus, indexer les contenus pour la recherche, ...

Sous linux, la définition des tâches planifiées est stockée dans des crontab. Ces fichiers définissent quelles commandes doivent être exécutées, par quel utilisateur et à quel intervalle.

EZ Publish 4 fournit une crontab, qui peut être utilisée comme base pour votre installation. Il s'agit du fichier ezpublish.cron, à la racine.

Pour eZ Publish 4

# Chemin absolu vers la racine de votre projet (à modifier).
EZPUBLISHROOT=/path/to/the/ez/publish/directory

# Emplacement de l'exécutable PHP en mode ligne de commande (à modifier si besoin).
PHP=/usr/local/bin/php

# Liste des cronjobs principaux à exécuter
# à 5h00 tous les matins
0 5 * * * cd $EZPUBLISHROOT && $PHP runcronjobs.php -q 2>&1

# Liste des cronjobs "infrequent" d'eZ Publish
# à 4h20 tous les dimanches matin
20 4 * * 7 cd $EZPUBLISHROOT && $PHP runcronjobs.php -q infrequent 2>&1

# Liste des cronjobs "frequent" d'eZ Publish
# tous 15 minutes
0,15,30,45 * * * * cd $EZPUBLISHROOT && $PHP runcronjobs.php -q frequent 2>&1

Pour eZ Publish 5

A vous de créer le fichier ezpublish.cron à la racine, cat celui présent dans le sous-répertoire ezpublih_legacy/ n'est pas à jour.

# Chemin absolu vers la racine de votre projet (à modifier).
EZPUBLISHROOT=/path/to/the/ez/publish/directory

# Emplacement de l'exécutable PHP en mode ligne de commande (à modifier si besoin).
PHP=/usr/local/bin/php

# Environnement (prod ou dev)
ENV=prod

# Liste des cronjobs principaux à exécuter
# à 5h00 tous les matins
0 5 * * * cd $EZPUBLISHROOT && $PHP ezpublish/console --env=$ENV ezpublish:legacy:script runcronjobs.php -q 2>&1

# Liste des cronjobs "infrequent" d'eZ Publish
# à 4h20 tous les dimanches matin
20 4 * * 7 cd $EZPUBLISHROOT && $PHP ezpublish/console --env=$ENV ezpublish:legacy:script runcronjobs.php -q infrequent 2>&1

# Liste des cronjobs "frequent" d'eZ Publish
# tous 15 minutes
0,15,30,45 * * * * cd $EZPUBLISHROOT && $PHP ezpublish/console --env=$ENV ezpublish:legacy:script runcronjobs.php -q frequent 2>&1

Exécution

Pour que cette crontab soit utilisée par votre système, exécutez la commande suivante, en remplaçant le chemin par celui de votre projet :

crontab /var/www/mon_projet/ezpublish.cron
# Vérification
crontab -l

Astuce [eZ4] Activer la publication / dépublication automatique de vos contenus

Par défaut eZ Publish propose les champs Date de publication et Date de dépublication pour la classe de contenu Article, mais rien n'est automatisé.

La publication et la dépublication sont deux éléments différents et chacun d'eux doit être activé de manière indépendante dans eZ Publish.

Publication automatique

Pour cela on utilise le système de workflow d'eZ Publish :

  • Allez dans l'onglet Administration du back-office, puis sur Workflows.
  • Cliquez sur le groupe de workflow Standard, ou créez-en un nouveau pour y ranger votre workflow.
  • Cliquez sur le bouton Nouveau processus de workflow.
  • Donnez-lui un nom et créez un évènement de type Évènement / Attendre jusqu'à.

Vous devez maintenant informer eZ Publish des classes de contenu qui contiennent un champ date indiquant quand publier le contenu. Par exemple le champ Date de publication pour la classe de contenu Article.

Pour cela :

  • Sélectionnez la classe de contenu concernée (ex: Article).
  • Cliquez sur le bouton Mettre à jour les champs.
  • Sélectionnez le champ date qui servira de référence (ex: Date de publication).
  • Cliquez sur le bouton Sélectionner l'attribut.
  • Validez

Vous venez donc de créer un workflow qui vérifie la date de publication d'un contenu et décide s'il doit être publié. Ce n'est pas fini, il faut maintenant demander à eZ publish de déclencher ce workflow avant chaque publication d'un contenu. Pour cela :

  • Toujours dans l'administration, allez dans Déclencheurs.
  • Affectez votre nouveau workflow à l'évènement content publish before, c'est à dire "avant la publication".
  • Validez pour Appliquer les changements.

La publication automatique à la date souhaitée est maintenant effective.

Remarques :

  • Vous pouvez configurer le workflow pour plusieurs classes de contenu en même temps
  • Les traitement exécutés par le workflow sont exécutés via des cronjobs fournis par eZ Publish. Pour que la publication fonctionne, le cronjob workflow doit obligatoirement être exécuté. (Plus d'informations sur les cronjobs.)

Dépublication automatique

La dépublication automatique est réalisée via le script unpublish.php fourni par eZ Publish. Vous avez seulement deux choses à effectuer pour mettre en place cette fonctionalité :

  • Activez le cronjob principal, qui exécute entre autres le script unpublish.php. Pour cela, vous devez planifier l'exécution de la commande suivante au niveau du système, au moins un fois par jour :
php runcronjobs.php -q 2>&1
`

* Définissez les classes de contenu contenant un attribut de type Date, 
dont le nom technique est `unpublish_date`. Pour cela, vous devez surcharger le fichier `content.ini`.

Par exemple, dans le fichier `monextension/settings/content.ini.append.php` :

```ini
<?php /* #?ini charset="utf-8"?

[UnpublishSettings]
# Noeuds racines des arborescences pour lesquelles appliquer la dépublication
RootNodeList[]=2
 # Liste des ID des classes de contenu à prendre en compte
ClassList[]=46

*/ ?>

Explications :

  • La dépublication ne concernera que les contenus de l'abre des contenus (càd ni les utilisateurs, ni ce qu'il y a dans la médiathèque)
  • La dépublication ne concernera que les contenus de type Article (dont l'ID est par défaut 46)

Astuce [eZ4] ID des noeuds racines du site

Par défaut, eZ Publish initialise les arbres des contenus, des utilisateurs, des médias, ... avec des nœuds racines spécifiques. Cette configuration est spécifiée dans le fichier content.ini. Elle peut donc être surchargée.

Voici la configuration par défaut :

[NodeSettings]
# Le node ID du noeud racine de l'arbre des contenus
RootNode=2
# Le node ID du noeud racine de l'arbre des utilisateurs
UserRootNode=5
# Le node ID du noeud racine de la médiathèque
MediaRootNode=43
# Le node ID du noeud racine de l'arbre de configuration
SetupRootNode=48
# Le node ID du noeud racine de l'arbre de design
DesignRootNode=58

Remarques :

  • Les deux derniers arbres sont dépréciés et masqués par défaut dans eZ Publish.
  • Même sans avoir besoin de les modifier, il est intéressant connaître ces valeurs si on en a besoin lors d'un fetch depuis l'une de ces racines. On peut même envisager de récupérer la valeur via ezini() plutôt que de l'utiliser en dur.

Erreur [eZ4] Le front-office n'est plus accessible aux utilisateurs anonymes

Vous souhaitez autoriser l'accès au front-office aux utilisateurs non connectés, mais suite à une modification des droits et rôles, le front-office n'est plus accessible ?

Message d'erreur :

Accès refusé
Vous n'avez pas le droit d'accéder à cette zone.

Lorsque vous modifiez les politiques de sécurité du rôle Anonymous, veillez bien à laisser le droit d'accès à la vue user/login, pour le siteaccess du front-office. Sans ça, les utilisateurs du site n'auront plus accès au front-office.

Astuce [eZ4] Mettre en place un workflow de validation

EZ Publish propose une interface de création de workflow, et notamment de validation. Cela permet de définir des étapes de validation lors de la publication d'un contenu.

L'exemple le plus courant est le cas où un rédacteur soumet un article sur le site et attend sa validation par le responsable communication du site. C'est seulement une fois validé que l'article apparaitra publiquement sur le site.

Voici les étapes à suivre pour mettre en place un tel workflow.

Créer un processus de workflow générique

  • Rendez-vous dans le back-office du site, dans l'onglet Administration puis sur la page Workflows
  • Cliquez sur le groupe standard ou créez un nouveau groupe de workflow. (Le groupe standard est largement suffisant sauf si vous avez des dizaines de workflow différents)
  • Créez un Nouveau processus de workflow nommé par exemple Workflow de validation générique.
  • Ajoutez un évènement de type approbation (Évènement / Approuver)
  • Choisissez maintenant les éléments gérés par le workflow validation. Vous pouvez choisir :
    • les sections pour lesquelles le workflow s'appliquera (À priori Standard et/ou Média)
    • les langues concernées
    • si le workflow agit pour la création, la modification de l'objet, ou les deux
    • quels utilisateurs et groupes d'utilisateurs pourront valider les contenus avant leur publication
    • quels groupes d'utilisateurs pourront publier leur contenu sans validation

Remarque :

Il est conseillé de choisir les options les plus larges possible (ex: pour toutes les langues, pourr la création et la modification, ...). Vous pourrez affiner votre configuration par la suite (voir la suites).

Créer un processus de workflow spécifique

Vous venez de créer un processus de workflow générique, qui sera appliqué sur tous les contenus, sans distinction. Ce n'est pas vraiment utilisable tel quel.

On souhaite en général préciser que ça ne doit concerner que telle ou telle classe de contenu. Pour cela, on utilise des multiplexeurs. Les multiplexeurs sont aussi des processus de workflow, mais avec des options différentes des précédentes.

  • Créez un Nouveau processus de workflow nommé par exemple Workflow de validation des articles.
  • Ajoutez un évènement de type multiplexer (Évènement / Multiplexer)
  • Choisissez maintenant les éléments gérés par le workflow de validation. Vous pouvez choisir :
    • les sections pour lesquelles le workflow s'appliquera (À priori Standard et/ou Média, ou un section que vous avez créée)
    • les langues concernées
    • les classes de contenus concernées
    • si le workflow agit pour la création, la modification de l'objet, ou les deux
    • les groupes d'utilisateurs non concernés par la validation
    • le processus de workflow à lancer (ex: Workflow de validation générique)

Définir quand déclencher le workflow

Votre processus workflow est maintenant créé, mais il vous reste encore à indiquer à eZ Publish à quel moment il doit se déclencher. Pour cela :

  • Cliquez sur Déclencheurs dans le menu de gauche
  • Choisissez content publish before, pour que le processus de validation soit effectué avant la publication et affectez-y votre nouveau processus de workflow (choisissez celui avec le multiplexeur, pas le générique).

Mettre en place le cronjob workflow

Les étapes sont terminées côté administration, il reste cependant une dernière chose à mettre en place : le cronjob workflow.

Si vous êtes sous Linux/Unix, vous devez mettre à jour votre crontab pour que le cronjob soit exécuté à intervalle régulier :

EZPUBLISHROOT=/chemin/vers/ezpublish/mon_site
PHP=/chemin/vers/le/binaire/php
0,15,30,45 * * * *      cd $EZPUBLISHROOT; $PHP runcronjobs.php -q 2>&1 

Explications :

  • EZPUBLISHROOT indique le chemin absolu vers la racine de votre site,
  • PHP le chemin vers le binaire php
  • la dernière ligne que le fichier runcronjobs.php devra être exécuter tous les quarts d'heure.

Ce fichier va lancer les différents cronjobs de votre site, définis dans cronjob.ini. Vérifiez donc que dans ce fichier de configuration, vous ayez ce paramétrage :

<?php /* #?ini charset="utf-8"?

[CronjobSettings]
ScriptDirectories[]=cronjobs
Scripts[]=workflow.php 

*/ ?>

Astuce [eZ4] Créer une validation de champ spécifique pour l'édition de contenus

Lorsqu'un contenu est créé, eZ Publish vérifie la validité des valeurs envoyées. Pour un champ de type "Nombre entier" par exemple, il vérifie qu'on a bien un nombre et pas des lettres pour valeur.

Ces vérifications sont cependant limitées et il peut être intéressant d'en ajouter. On peut imaginer par exemple une classe de contenu Contrat, ayant un champ numéro de la forme XX-YYYY, avec XX deux lettres et YYYY quatre chiffres. Vous pouvez effectuer une vérification sur ce champ, en étendant la classe eZContentObjectEditHandler d'eZ Publish.

Pour cela, vous devez déclarer votre extension comme possédant un Content Edit Handler dans content.ini :

Par exemple, dans le fichier monextension/settings/content.ini.append.php :

<?php /* #?ini charset="utf-8"?

[EditSettings]
ExtensionDirectories[]=monpremier

*/ ?>

Avec cette configuration, eZ Publish va chercher le fichier extension/monextension/content/monpremierhandler.php.

Créez le répertoire content/ et le fichier monpremierhandler.php. Ce fichier doit contenir une classe qui étend eZContentObjectEditHandler.

Voici maintenant un exemple simple pour vérifier que le champ contract_number d'un contenu de type contract commence bien par un c :

<?php
class MonPremierHandler extends eZContentObjectEditHandler {

    /**
     * Effectue des opérations au moment de la soumission du formulaire de la vue /content/edit, après la vérification des champs.
     */
    function fetchInput( $http, &$module, &$class, $object, &$version, $contentObjectAttributes, $editVersion, $editLanguage, $fromLanguage ) {
    }

    /**
     * Retourne la liste des paramètres POST HTTP qui déclencheront une action.
     * (Retourne un tableau vide par défaut.)
     */
    static function storeActionList() {

        return array();
    }

    /**
     * Effectue des opérations au moment de la publication de l'objet.
     *
     * Rq : L'objet a déjà été affecté à un noeud à ce moment.
     *
     * @param int $contentObjectID
     * @param eZContentObjectVersion $contentObjectVersion
     */
    function publish( $contentObjectID, $contentObjectVersion ) {
    }

    /**
     * Effectue des vérifications supplémentaires sur les champs soumis à l'édition d'un contenu.
     *
     * @return array
     */
    function validateInput( $http, &$module, &$class, $object, &$version, $contentObjectAttributes, $editVersion, $editLanguage, $fromLanguage, $validationParameters ) {

        $result = array( 'is_valid' => true, 'warnings' => array() );

        if ( $class->Identifier == 'contract' ) {

            // Récupération de la liste des attributs modifiés
            $contentObjectAttributes = $object->contentObjectAttributes();

            // Recherche de l'attribut 'contract_number'
            foreach ( $contentObjectAttributes as $contentObjectAttribute ) {

                if ( $contentObjectAttribute->contentClassAttributeIdentifier() == 'contract_number' ) {

                    // Récupération du numéro de contrat envoyé
                    $postValues     = $http->attribute('post');
                    $contractNumber = $postValues['ContentObjectAttribute_' . $contentObjectAttribute->DataTypeString . '_data_text_' . $contentObjectAttribute->ID];

                    // Si le numéro de contrat ne commmence pas par 'c'
                    if ( strpos( $contractNumber, 'c' ) !== 0 ) {

                        $result['warnings'][] = array( 
                            'text' => ezpI18n::tr( 
                                'extension/monextension/validation', 
                                "The contract number should begin with a 'c'." 
                            ) 
                        );
                        $result['is_valid']   = false;
                    }
                }
            }
        }

        return $result;
    }
}
?>

La classe eZContentObjectEditHandler possédant 3 méthodes abstraites, vous devez les implémentez dans votre classe (même si vous les laissez vides) : fetchInput(), storeActionList() et publish().

Une quatrième méthode va nous intéresser : validateInput(). C'est elle qui permet d'ajouter une validation personnalisée.

Elle possède les paramètres suivants :

  • $http : les éléments soumis par le formulaire et les variables de session.
  • $module : le module courant (= content).
  • $class : une instance de la classe de contenu de l'objet édité.
  • $object : l'objet modifié.
  • $contentObjectAttributes : le tableau des attributs de l'objet, avant modification.
  • $version : le numéro de version de l'objet.

Par défaut, cette méthode retourne un tableau d'erreur vide :

$result = array( 'is_valid' => true, 'warnings' => array() );

Pour chaque champ vérifié, en cas d'erreur, on stocke le message d'erreur à afficher. Ex :

$result['warnings'][] = array( 
    'text' => ezpI18n::tr( 
        'extension/monextension/validation', 
        'The contract number should begin with \'c\'.'
    ) 
);
$result['is_valid']   = false;

Astuce [eZ4] Créer un Content Edit Handler personnalisé

Pour ajouter des traitements spécifiques lors de la création ou la modification d'objets, vous pouvez créer un Content Edit Handler personnalisé. On peut par exemple créer automatiquement un nouvel article souhaitant la bienvenue à un utilisateur lors de son inscription.

Configuration

Pour cela, vous devez déclarer votre extension comme possédant un Content Edit Handler dans content.ini :

Par exemple, dans le fichier monextension/settings/content.ini.append.php :

<?php /* #?ini charset="utf-8"?

[EditSettings]
ExtensionDirectories[]=monpremier

*/ ?>

Avec cette configuration, eZ Publish va chercher le fichier extension/monextension/content/monpremierhandler.php.

Content Edit Handler

Créez le répertoire content/ et le fichier monpremierhandler.php. Ce fichier doit contenir une classe, qui étend eZContentObjectEditHandler.

Pour qu'eZ Publish prenne en compte cette classe, videz les caches et régénérer les autoloads.

Voici maintenant un exemple simple pour loguer le nom d'un utilisateur lorsqu'il est publié :

<?php
class MonToutPremierHandler extends eZContentObjectEditHandler {

    /**
     * Effectue des opérations au moment de la soumission du formulaire de la vue /content/edit, après la vérification des champs.
     */
    function fetchInput( $http, &$module, &$class, $object, &$version, $contentObjectAttributes, $editVersion, $editLanguage, $fromLanguage ) {

        // Exemples :

        // Si l'action courante est l'enregistrement en base
        // (liste des actions disponibles dans kernel/content/module.php)
        if( $module->isCurrentAction( 'Store' ) ) {

            // Traitement ...
        }

        // Si un input "Name" a été soumis
        if( $http->hasPostVariable( 'Name' ) ) {

            // Traitement ...
        }
    }

    /**
     * Retourne la liste des paramètres POST HTTP qui déclencheront une action.
     * (Retourne un tableau vide par défaut.)
     */
    static function storeActionList() {

        return array();
    }

    /**
     * Effectue des opérations au moment de la publication de l'objet.
     *
     * Rq : L'objet a déjà été affecté à un noeud à ce moment.
     *
     * @param int $contentObjectID
     * @param eZContentObjectVersion $contentObjectVersion
     */
    function publish( $contentObjectID, $contentObjectVersion ) {

        // Exemple : On logue le nom d'un utilisateur lorsqu'il s'inscrit.

        // Récupération de l'objet publié
        $object = eZContentObject::fetch( $contentObjectID );

        // Récupération de la classe de l'objet
        $contentClass = $object->attribute('content_class');

        // Si l'objet est de type utilisateur et qu'il vient d'être créé
        if ( $contentClass->Identifier == 'user' && $contentObjectVersion == 1 ) {

            $message = 'Nouvel utilisateur inscrit : ' . $object->Name;
            eZLog::write( $message );
        }
    }
}
?>

La classe eZContentObjectEditHandler possédant 3 méthodes abstraites, vous devez les implémentez dans votre classe.

fetchInput() :

Cette méthode permet d'effectuer des traitements au moment où le formulaire de la vue content/edit/ est soumis. Les paramètres de la fonction permettent d'effectuer des traitements différents selon l'action en cours, les valeurs postées, l'objet édité, ...

storeActionList() :

Cette méthode retourne la liste des paramètres POST HTTP qui déclencheront une action, ou un tableau vide s'il n'y en a pas.

publish() :

Cette méthode permet d'effectuer des traitements au moment où l'objet est publié pour la première fois, ou lorsqu'une nouvelle version est publiée. L'objet a déjà été affecté à un nœud quand cette méthode est exécutée.

Remarque :

Vous pouvez également surcharger la méthode validateInput(), pour effectuer des vérification complémentaires sur les champs de l'objet. (Voir l'article [eZ4] Créer une validation de champ spécifique pour l'édition de contenus.)

Astuce [eZ4] Utiliser l'ActionHandler d'eZ Publish

Les actions dans eZ Publish

Tout d'abord, qu'est-ce qu'une action dans eZ Publish ?

Il s'agit d'un traitement effectué à la soumission d'un formulaire pointant vers content/action.

Exemple :

<form method="post" action="{'content/action'|ezurl( 'no' )}">
    <label for="message">{'Message'|i18n( 'sample' )} :</label>
    <textarea id="message" name="message"></textarea>

    <input type="hidden" name="ContentObjectID" value="{$node.object.id}" />
    <input type="hidden" name="NodeID" value="{$node.node_id}" />
    <input type="submit" name="LogMessageButton" value="Log" />
</form>

Le formulaire est constitué d'un textarea permettant à l'utilisateur de saisir son message, d'un bouton submit et de champs cachés. Notez que le champ ContentObjectID est obligatoire, même si l'action n'utilise pas sa valeur (vous pouvez retourner -1 dans ce cas).

Le nom du submit correspond au nom de l'action à effectuer. EZ Publish référence déjà beaucoup d'actions (notamment toutes celles relatives aux boutons submit du back-office), mais vous pouvez en rajouter.

Créer ses propres actions

Supposons qu'on veuille loguer le message du textarea et le node_id de la page à la soumission du formulaire.

Pour qu'eZ Publish trouve vos actions, vous devez déclarer votre extension comme ayant des actions, dans le fichier site.ini.

Par exemple dans extension/monextension/settings/site.ini.append.php :

<?php /* #?ini charset="utf-8"?

[ActionSettings]
ExtensionDirectories[]=monextension

*/ ?>

Dans votre extension, créez un répertoire actions/, contenant le fichier content_actionhandler.php :

<?php
include_once( 'lib/ezutils/classes/ezoperationhandler.php' );

function monextension_ContentActionHandler( &$module, $http, $objectID ) {

    // Action de loguer un message
    if ( $http->hasPostVariable( 'LogMessageButton' ) ) {
        // Si le formulaire soumis contient un message non vide
        if ( $http->hasPostvariable( 'message' ) && $http->postvariable( 'message' ) != '' ) {
            $message = $http->postvariable( 'message' );
            $nodeID  = $http->postvariable( 'NodeID' );

            $logMessage = 'La page ' . $nodeID . ' a été affichée et on a soumis le message : ' . $message;
            eZLog::write( $logMessage );
        }

        // Redirection vers la page du formulaire
        $module->redirectTo( '/content/view/full/' . $nodeID );
        return;
    }
}
?>

Explications :

  • Ce fichier contient une fonction dont le nom est obligatoirement préfixé par le celui de votre extension.
  • Toutes les actions sont listées, soit par une suite de if, soit dans un switch, en fonction du nom de l'action envoyé par le formulaire (LogMessageButton dans cet exemple).
  • Chaque action redirige vers une page, ou vers la vue d'un module.

Remarque :

N'oubliez pas de vider les caches et régénérer les autoloads pour qu'eZ Publish trouve votre nouvel ActionHandler.

Astuce [eZ4] Créer un opérateur de template

Les opérateurs de template sont des fonctions utilisables dans les template, qui pointent vers des fonctions PHP. EZ Publish en fournit plusieurs dizaines, comme explode(), fetch(), i18n(), ...

Voici comment créer vos propres opérateurs, effectuant tel ou tel traitement, et retournant une valeur de retour.

MonAPI

Dans cet exemple on supposera qu'on possède la classe MonAPI, contenant des méthodes PHP "utiles" à notre projet :

/**
 * Classe contenant les méthodes PHP utiles au projet.
 *
 * Rq : Tout nouvel opérateur doit également être référencé 
 * dans le fichier eztemplateautoload.php.
 */
class MonAPI {

    /**
     * Méthode retournant "Hello world XX YY" avec XX remplacé par $who et YY remplacé par $suffix.
     * 
     * @param string $who A qui dire bonjour
     * @param string $suffix Ponctuation de fin de bonjour
     */
    public static function sayHelloWorld( $who, $suffix ) {
        return 'Hello world ' . $who . ' ' . $suffix;
    }

    /**
     * Méthode retournant "Goodbye" autant de fois que $nb
     * 
     * @param int $nb Nombre de fois à dire au revoir
     */
    public static function sayGoodbye( $nb ) {

        $goodbye = '';

        for ( $i=0; $i < $nb; $i++ ) {
            $goodbye .= 'Goodbye';
        }

        return $goodbye;
    }
}

Les méthodes ci-dessus étant indispensables et pratiques, vous souhaitez pouvoir les utiliser directement dans vos templates.

Pour cela, vous devez avoir créé et activé votre extension. Dans le répertoire de l'extension, créez un dossier autoloads/, qui contiendra vos opérateurs de template.

Configuration

Pour qu'eZ Publish charge les classes PHP contenues dans ce dossier, vous devez déclarer votre extension comme ayant un dossier autoloads/, dans le fichier site.ini.append.php de votre extension.

Par exemple dans extension/monextension/settings/site.ini.append.php :

<?php /* #?ini charset="utf-8"?

[TemplateSettings]
ExtensionAutoloadPath[]=monextension

*/ ?>

EZ Publish va maintenant chercher le fichier extension/monextension/autoloads/eztemplateautoload.php. Créez donc ce fichier de la forme :

$eZTemplateOperatorArray = array();

$eZTemplateOperatorArray[] = array(
    'class' => 'MaClasseTemplateOperators',
    'operator_names' => array( 'say_hello_world', 'say_goodbye' )
);

Explications :

  • Vous déclarez dans un tableau toutes les classes PHP contenant vos opérateurs de template. Dans cet exemple on a seulement la classe MaClasseTemplateOperators (garder "TemplateOperators" à la fin du nom de la classe permet d'identifier rapidement son utilité).
  • Vous devez indiquer son nom et la liste des opérateurs qu'elle contient.

MaClasseTemplateOperators

Dans le fichier MaClasseTemplateOperators.php, on aura :

/**
 * Classe contenant différents opérateurs de templates de test.
 *
 * Rq : Tout nouvel opérateur doit également être référencé dans le fichier eztemplateautoload.php
 */
class MaClasseTemplateOperators {

    function MaClasseTemplateOperators() {
    }

    function operatorList() {
        return array( 'say_hello_world', 'say_goodbye' );
    }

    function namedParameterPerOperator() {
        return true;
    }

    function namedParameterList() {
        return array(
            'say_hello_world' => array(
                'who' => array( 
                    'type' => 'string',
                    'required' => true,
                    'default' => 'everybody' 
                ),
                'suffix' => array( 
                    'type' => 'string',
                    'required' => true,
                    'default' => '!'
                )
            ),
            'say_goodbye' => array(
                'nb' => array( 
                    'type' => 'int',
                    'required' => true,
                    'default' => 10
                )
            ),
        );
    }

    function modify( $tpl, $operatorName, $operatorParameters, &$rootNamespace, &$currentNamespace, &$operatorValue, &$namedParameters ) {
        switch ( $operatorName )
        {
            case 'say_hello_world':
            {
                $who           = $namedParameters['who'];
                $suffix        = $namedParameters['suffix'];
                $operatorValue = MonAPI::sayHelloWorld( $who, $suffix );
            }
            break;

            case 'say_goodbye':
            {
                $nb            = $namedParameters['nb'];
                $operatorValue = MonAPI::sayGoodbye( $nb );
            }
            break;
        }
    }
}

Explication :

Cette classe contient 3 méthodes importantes :

  • operatorList() : C'est la liste des noms de vos opérateurs.
  • namedParameterList() : C'est la liste des paramètres que peuvent recevoir vos opérateurs. Pour chacun d'eux, vous indiquer le nom et le type du paramètre, ainsi que sa valeur par défaut et s'il est obligatoire.
  • modify() : Cette méthode va être utilisée par eZ Publish lorsqu'il parsera votre template. C'est elle qui va appeler la "vraie" méthode PHP (de la classe MonAPI) avec les paramètres utilisés dans le template. et qui va retourner le résultat au template.

Remarque :

Vous ne devez ni modifier la signature de la méthode modify(), ni le nom de la variable $operatorValue, sans quoi le template operator ne fonctionnera plus.

Pour résumer

Dans votre extension vous devez avoir :

- monextension
    - autoloads
        - eztemplateautoload.php
        - MaClasseTemplateOperators.php
    - classes
        - MonAPI.php
    - settings
        - site.ini.append.php

Vous devez également régénérer les autoloads, pour qu'eZ Publish trouve vos nouvelles classes :

  • soit dans le back-office, dans Administration > Extensions > Regénérer le tableau de chargement des classes des extensions
  • soit en ligne de commande à la racine du projet :
php bin/php/ezpgenerateautoloads.php

Utilisation

C'est terminé, vous pouvez désormais profiter de ces précieux opérateurs. Par exemple, dans un template :

<p class"message1">
    {say_hello_world( 'John Doe', '?' )}
</p>
<p class"message2">
    {say_goodbye( 4 )}
</p>

Astuce [eZ4] Trier les résultats d'un fetch selon l'ordre défini en back-office

Dans le back-office d'eZ Publish, vous pouvez définir l'ordre dans lequel doivent être listés les enfants d'un nœud. Pour cela, cliquer sur l'onglet classement, et choisissez la méthode et le sens du tri à appliquer.

Pour que ce tri soit appliqué également en front-office, utilisez le paramétrage suivant dans votre template :

{def $node_list = fetch( 'content', 'list', 
    hash( 
        'parent_node_id', $node.node_id,
        'sort_by', $node.sort_array 
    ) 
)}

Explication :

Le type de tri configuré en BO est récupérable à partir du nœud via $node.sort_array.

Astuce [eZ4] Régénérer les alias d'URL

Lorsque vous déplacez ou renommez des nœuds, eZ Publish ne modifie pas automatiquement leurs alias d'url, pour éviter de briser les liens existants.

Pour forcer la mise à jour de l'url, vous pouvez appeler le script bin/php/updateniceurls.php depuis la racine de votre site :

php bin/php/updateniceurls.php

Astuce Différences entre isset(), empty() et is_null()

isset($var)

Teste si $var existe et n'a pas pour valeur null. Cette fonction ne différencie donc pas une variable nulle d'une variable qui n'existe pas, ce qui peut être gênant pour les tableaux :

$foo = null;
$bar = 'dummy';
$array = [
    'foo' => null,
    'bar' => 'dummy'
];
var_dump(
    isset($foo),
    isset($bar),
    isset($other),
    isset($array['foo']),
    isset($array['other'])
);

// Affiche :
//   bool(false)
//   bool(true)
//   bool(false)
//   bool(false)
//   bool(false)

Remarque :

Pour faire la différence entre une clé de tableau qui existe mais dont la valeur vaut null et une clé qui n'existe pas, il faut donc utiliser la fonction array_key_exists().

empty($var)

Teste si $var a pour valeur null, 0, false, est un tableau ou un attribut d'objet vide.

class Foo {
     public $bar; // est strictement équivalent à : public $bar = null;.
}

$foo = new Foo();
$a   = '';
$b   = 0;
$c   = '0';
$d   = null;
$e   = false;
$f   = [];
$g   = $foo->bar;

var_dump(
    empty($a),
    empty($b),
    empty($c),
    empty($d),
    empty($e),
    empty($f),
    empty($g)
);

// Affiche :
//     bool(true)
//     bool(true)
//     bool(true)
//     bool(true)
//     bool(true)
//     bool(true)
//     bool(true)

is_null($var)

Teste si $var a pour valeur null (et pas 0, false et autre). La variable doit également être définie (sinon warning PHP).

var_dump(
    is_null(null),
    is_null(0),
    is_null($other)
);

// Affiche :
//     bool(true)
//     bool(false)
//     bool(true) + Notice: Undefined variable: other in /some/where/- on line xx

Récapitulatif

Expression gettype() empty() is_null() isset() boolean : if ($x)
$x = ""; string TRUE FALSE TRUE FALSE
$x = null; NULL TRUE TRUE FALSE FALSE
var $x; NULL TRUE TRUE FALSE FALSE
$x indéfini NULL TRUE TRUE FALSE FALSE
$x = []; array TRUE FALSE TRUE FALSE
$x = false; boolean TRUE FALSE TRUE FALSE
$x = true; boolean FALSE FALSE TRUE TRUE
$x = 1; int FALSE FALSE TRUE TRUE
$x = 42; int FALSE FALSE TRUE TRUE
$x = 0; int TRUE FALSE TRUE FALSE
$x = -1; int FALSE FALSE TRUE TRUE
$x = "1"; string FALSE FALSE TRUE TRUE
$x = "0"; string TRUE FALSE TRUE FALSE
$x = "-1"; string FALSE FALSE TRUE TRUE
$x = "foo"; string FALSE FALSE TRUE TRUE
$x = "true"; string FALSE FALSE TRUE TRUE
$x = "false"; string FALSE FALSE TRUE TRUE

Remarque :

Pour savoir si une propriété d'un objet existe, utilisez la fonction property_exists() et non pas isset(). (Cf. documentation)

Astuce [eZ4] Passer des paramètres à une vue via l'url

En PHP standard, lorsqu'on veut passer des paramètres à une page via l'url, on utilise quelque chose de la forme http://monsite/?param1_name=param1_value&param2_name=param2_value.

EZ Publish masque ce fonctionnement lors de l'utilisation des modules et des vues. Il différencie deux types de paramètres :

  • ordonnées
  • non ordonnés

Exemple d'URL :

http://monsite/index.php/mon_module/ma_vue/value1/(param3url)/value3/(param2url)/value2

Dans cet exemple on a 3 paramètres :

  • param1, qui est un paramètre ordonné
  • param2url et param3url qui sont non ordonnés

Le param1 étant ordonné, vous n'avez pas besoin de le nommer. En revanche, param2url et param3url doivent être précisés entre parenthèses avant leurs valeurs respectives.

Ce fonctionnement n'est pas automatique, vous devez informer eZ Publish des paramètres susceptibles d'être passés à votre vue, grâce au fichier module.php.

Par exemple, dans le fichier monextension/modules/monmodule/module.php :

<?php
$ViewList = array();
$ViewList['sample'] = array(
    'functions' => array( 'sample' ),
    'script' => 'sample.php',
    'params' => array( 'param1' )
    'unordered_params' => array( 
        'param2url' => 'param2',
        'param3url' => 'param3' 
    )
);

$FunctionList['sample'] = array();

Pour l'URL d'exemple et cette configuration, on aura dans mon fichier de vue mavue.php :

<?php
echo $Params['param1']; // Affiche 'value1'
echo $Params['param2']; // Affiche 'value2'
echo $Params['param3']; // Affiche 'value3'

Pour les paramètres non ordonnés param2url et param3url, on a spécifié dans le fichier module.php que leur valeurs seraient disponibles avec les clés param2 et param3.

Pour param1 qui est ordonné, la clé param1 est disponible sans être renommée.

Erreur [eZ4] Error ocurred using URI: /stylesheets/t2/classes-colors.css

Il peut arriver de trouver ce message dans error.log :

[ May 22 2012 12:44:51 ] [127.0.0.1] index:
Undefined module: stylesheets
[ May 22 2012 12:44:51 ] [127.0.0.1] error/view.php:
Error ocurred using URI: /stylesheets/t02/site-colors.css
[ May 22 2012 12:44:51 ] [127.0.0.1] index:
Undefined module: stylesheets
[ May 22 2012 12:44:52 ] [127.0.0.1] error/view.php:
Error ocurred using URI: /stylesheets/t02/classes-colors.css

Ce message est dû à deux fichiers css qui ne sont pas trouvés par eZ Publish.

Dans les fichiers design.ini et page_head_style.tpl, est définit par défaut un fichier css à inclure :

<?php /* #?ini charset="utf-8"?

[StylesheetSettings]
SiteCSS=stylesheets/t02/site-colors.css
ClassesCSS=stylesheets/t02/classes-colors.css

*/ ?>

Ces fichiers n'existent pas, car le dossier n'est pas t02/ mais t2/ et parce qu'il manque le chemin depuis la racine du site (design/base/).

Si vous voulez éviter qu'eZ Publish essaie d'inclure ces fichiers css, il suffit de surcharger le fichier page_head_style.tpl ou le fichier design.ini. La deuxième solution est la plus simple.

Ajoutez donc ça dans monextension/settings/design.ini.append.php :

<?php /* #?ini charset="utf-8"?

[StylesheetSettings]
ClassesCSS=
SiteCSS=

*/ ?>

Astuce [eZ4] Créer un cronjob

Pour effectuer une tâche à intervalle régulier, eZ Publish propose un système de scripts : des cronjobs.

Pour créer un cronjob, vous devez avoir créé et activé votre extension. Dans le répertoire de l'extension, créez un répertoire cronjobs/, qui contiendra vos scripts.

Script

Voici un exemple simple d'un script qui affiche Hello world, et logue qu'il a bien été lancé.

<?php
/*
 * Script d'exemple, affichant Hello world.
 */

//-----------------------------------------------------------------------------------------------------\\
//-------------------------------------------  INITIALISATION  ----------------------------------------\\
//-----------------------------------------------------------------------------------------------------\\

// Inclusion de la classe eZUser
require_once( 'kernel/classes/datatypes/ezuser/ezuser.php' );

// Répertoire des logs
$logFolder = 'var/monsite/log/cronjobs/';
// Fichier de logs
$logFile = 'sample.log';

// Connexion d'un utilisateur spécifique ayant les droits
// nécessaires pour effectuer le traitement
$cronjobUser = ezUser::loginUser( 'user_login', 'user_password' );

//-----------------------------------------------------------------------------------------------------\\
//---------------------------------------------  TRAITEMENTS  -----------------------------------------\\
//-----------------------------------------------------------------------------------------------------\\

// Log du début de l'exécution du script
eZLog::write( 'Démarrage du script d\'exemple', $logFile, $logFolder );

if ( is_object( $cronjobUser ) ) {

    $message = 'Hello world !';

    // Autres traitements...

    // Affichage du message sur la sortie standard (invite de commande)
    $cli->output( $message );

} else {

    $message = 'ERROR : L\'utilisateur "cronjobUser" n\'a pas pu être identifié !';

    // Stockage de l'erreur dans les logs
    eZLog::write( $message, $logFile, $logFolder );

    // Affichage du message d'erreur sur la sortie standard
    $cli->output( $message );
}

eZLog::write( 'Fin du script d\'exemple', $logFile, $logFolder );

?>

Explications :

  • On connecte un utilisateur spécifique aux cronjobs, disposant des droits nécessaires pour effectuer les opérations souhaitées (dans cet exemple ça n'a pas d'intérêt puisqu'on ne fait pas d'autres opérations en base).
  • On logue le début et la fin de l'exécution du script, pour être sûr que le script a bien été exécuté, avec succès ou non, dans un fichier portant le même nom que le cronjob.
  • On affiche des informations sur le traitement en cours sur la sortie standard, via la variable $cli directement accessible dans le script et sa méthode output().
  • Le mot de passe et le login de l'utilisateur ainsi que le chemin vers le dossier des logs, peuvent être stockés dans un fichier de configuration, surtout s'ils communs à plusieurs scripts.

Configuration

Pour qu'eZ Publish trouve votre cronjob, vous devez le déclarer dans le fichier cronjob.ini.

Par exemple dans le fichier monextension/settings/cronjob.ini.append.php :

<?php /* #?ini charset="utf-8"?

[CronjobSettings]
# Déclare que l'extension possède des cronjobs
ExtensionDirectories[]=monextension

# Déclare un cronjob au nom 'sample', dont le script est dans le fichier monextension/cronjobs/sample.php
[CronjobPart-sample]
Scripts[]=sample.php

*/ 
?>

N'oubliez pas de vider les caches ensuite.

Utilisation

Pour exécuter ce cronjob, utilisez la commande suivante à la racine de votre site :

php runcronjobs.php sample

Remarques :

  • EZ Publish fournit déjà des cronjobs, présents dans le répertoire cronjobs/ à la racine. Ils sont documentés ici.
  • Pour lancer un cronjobs sur un seul siteacces, précédez son nom par -s mon_siteacces dans la commande.
  • A priori, on souhaite que nos cronjobs s'exécutent à intervalle régulier comme des tâches planifiées. Il suffit donc au niveau système de paramétrer la crontab pour d'exécuter la commande.

Astuce [eZ4] Créer des webservices avec eZ Publish

Avant de commencer, vous devez savoir appeler les webservices natif d'eZ Publish.

Dans cet exemple, on considèrera que vous venez de créer et d'activer une nouvelle extension : monextension.

Cette extension aura l'arborescence suivante :

monextension
    classes
        controller
            rest_controller.php
        provider
            rest_provider.php
        view
            view_controller.php
    settings
        rest.ini.append.php

Remarque :

Une fois tous ces fichiers créés et remplis, vous devrez vider les caches et régénérer les autoloads.

Configuration

Vous aller devoir déclarer un nouveau provider de webservices (ou plusieurs).

Cette déclaration se fait via le fichier rest.ini, qui permet également de choisir le type d'authentification à utiliser.

Exemple de fichier rest.ini.append.php :

<?php /* #?ini charset="utf-8"?

[Authentication]
RequireAuthentication=enabled
AuthenticationStyle=ezpRestBasicAuthStyle

[ApiProvider]
ProviderClass[monprovider]=ezxRestApiProvider

*/
?>

Explications :

  • RequireAuthentication=enabled permet d'activer l'authentification pour l'accès aux webservices
  • AuthenticationStyle=ezpRestBasicAuthStyle active l'authentification basique d'eZ Publish (via les rôles)
  • [ApiProvider] est la section pour gérer les providers
  • ProviderClass[monprovider] définit un nouveau provider. C'est la base qui sera utilisée lors de l'appel des webservices qui fournira (ex : http://mon_site/api/monprovider/v1/foo).
  • ezxRestApiProvider est le nom de la classe PHP qui contiendra le provider

Provider

La classe ezxRestApiProvider va lister toutes les routes valides gérées par le provider.

Exemple de fichier rest_provider.php :

<?php

class ezxRestApiProvider implements ezpRestProviderInterface {

    /**
     * Retourne les routes versionnées au provider
     *
     * @return array
     */
    public function getRoutes() {

        $routes = array( 
            new ezpRestVersionedRoute( 
                new ezcMvcRailsRoute( '/foo', 'ezxRestController', 'foo' ), 
                1 
            ),
            new ezpRestVersionedRoute( 
                new ezcMvcRailsRoute( '/foo', 'ezxRestController', 'fooBar' ), 
                2 
            )
        );

        return $routes;
    }

    /**
     * Returns associated with provider view controller
     *
     * @return ezpRestViewController
     */
    public function getViewController() {
        return new ezxRestApiViewController();
    }

}
?>

Explications :

La route new ezpRestVersionedRoute( new ezcMvcRailsRoute( '/foo', 'ezxRestController', 'fooBar' ), 2 ) se lit comme ceci :

  • new ezpRestVersionedRoute() : on déclare une nouvelle route, qui aura pour version 2. À chaque version, on attribue un service à utiliser.
  • /foo : nom du service à appeler dans l'url (/api/monprovider/foo/2), auquel on ajoutera la version.
  • ezxRestController, nom de la classe contenant la méthode à exécuter.
  • fooBar, nom de la méthode à utiliser. Cette méthode sera recherchée dans la classe ezxRestController et avec le nom doFooBar(). (On préfixe donc le nom de la méthode par do et on y on ajoute une majuscule.)

Pour appeler la première version du service et exécuter la fonction doFoo(), on appelle donc /api/monprovider/foo/1, pour la seconde /api/monprovider/foo/2.

La méthode getViewController() détermine le contrôleur de vue à utiliser. C'est lui qui transformera les données pour être utilisables dans une requête HTTP.

Contrôleur

Le contrôleur est la classe qui contient les différentes méthodes préparant les données à retourner via les webservices.

Chaque méthode doit retourner un objet ezcMvcResult. On y stocke tout ce que l'on veut dans le champ variables.

Exemple de fichier rest_controller.php :

<?php
class ezxRestController extends ezcMvcController {

    public function doFoo() {
        $res = new ezcMvcResult();
        $res->variables['message'] = 'This is FOO !';
        $res->variables['var'] = array( 'Hello world' );
        return $res;
    }

    public function doFooBar() {
        $res = new ezcMvcResult();
        $res->variables['message'] = 'This is FOOBAR !';
        return $res;
    }
}
?>

Contrôleur de vue

Pour préparer la réponse HTTP retournée par les webservices, et notamment le format de sortie, on a besoin d'un contrôleur de vue.

Pour cet exemple, les données seront transformées sous forme de flux JSON.

Exemple de fichier view_controller.php :

<?php
class ezxRestApiViewController implements ezpRestViewControllerInterface {

    /**
    * Crée le flux de sortie retourné au contrôleur
    *
    * @param ezcMvcRoutingInformation $routeInfo
    * @param ezcMvcRequest $request
    * @param ezcMvcResult $result
    * @return ezcMvcView
    */
    public function loadView( ezcMvcRoutingInformation $routeInfo, ezcMvcRequest $request, ezcMvcResult $result ) {

        return new ezpRestJsonView( $request, $result );
    }
}
?>

Récapitulatif

Lorsqu'on appelle un webservice, une méthode est exécutée dans un premier contrôleur. Elle retourne un objet contenant des données. Un contrôleur de vue est ensuite utilisée pour formater les données et préparer la réponse HTTP.

Astuce [eZ4] Utiliser les webservices eZ Publish

EZ Publish fournit une l'extension ezprestapiprovider, désactivée par défaut. Elle expose des webservices basiques permettant par exemple de récupérer les informations d'un nœud.

Activation

Activez l'extension ezprestapiprovider dans votre site.ini.

Dans settings/override/site.ini.append.php, ajoutez une ligne :

<?php /* #?ini charset="utf-8"?

[ExtensionSettings]
ActiveExtensions[]=ezprestapiprovider

*/
?>

Vous devez avoir activé le .htaccess pour votre site (renommez le fichier .htaccess_root en .htaccess), qui contient une règle de réécriture indispensable :

DirectoryIndex index.php

RewriteEngine On
RewriteRule ^/api/ /index_rest\.php [L]
RewriteRule ^api/ index_rest.php [L]
RewriteRule ^index_rest\.php - [L]

Cette règle permet d'appeler les webservices en utilisant /api/ au lieu de /index_rest.php/.

Authentification

Pour ajouter une authentification, modifier le fichier rest.ini.

Dans settings/override/rest.ini.append.php, ajoutez une ligne :

<?php /* #?ini charset="utf-8"?

[Authentication]
RequireAuthentication=enabled
AuthenticationStyle=ezpRestBasicAuthStyle

*/
?>

Ces lignes définissent que pour accéder aux webservices, une authentification est requise, de type Basic Auth (= via les rôles d'eZ Publish).

Vous pouvez également :

  • désactiver l'authentification (RequireAuthentication=disabled)
  • utiliser oAuth (AuthenticationStyle=ezpRestOauthAuthenticationStyle).

Utilisation

Une fois ces configurations terminées et les caches vidés, vous pouvez accéder aux webservices via une url de type : http://mon-site.dev/api/ezp/content/node/<node_id>.

Cette url est composée de plusieurs parties :

  • http://mon-site.dev : la base de l'url
  • /api/ezp/ : le nom du préfixe (api) et le nom du provider (ezp), tous deux définis dans rest.ini.
  • content/node/ : le nom du service appelé
  • <node_id> : le ou les paramètres à transmettre

Le webservice appelé dans cet exemple est documenté ici. On peut ainsi connaitre les paramètres attendus et le format de retour.

Pour aller plus loin : Créer des webservices avec eZ Publish

Astuce [eZ4] Réinitialiser le mot de passe admin

Si vous ne vous souvenez plus du mot de passe de l'administrateur, ou si on vous donne un site existant sans le mot de passe admin, vous pouvez vous en sortir en consultant directement la base de données.

Utilisez phpmyadmin ou un autre interface, pour consulter la base de données :

  • dans la table ezuser, trouver la ligne contenant l'utilisateur admin.
  • modifiez la valeur du champ password_hash, pour qu'elle devienne bab77ccf06f0b1f982e11c60f344c3c2.

L'utilisateur a maintenant pour mot de passe : admin.

Remarques :

  • Cette technique fonctionne uniquement si le type de hash est 2 (password_hash_type).
  • La requête sql à exécuter pour changer le mot de passe est la suivante (ar défaut l'admin a l'ID 14) :
UPDATE ezuser SET password_hash="bab77ccf06f0b1f982e11c60f344c3c2" WHERE contentobject_id="14"

Astuce [eZ4] Modifier les paramètres d'envoi de mail

EZ Publish propose un système d'envoi de mails, par collecte d'informations. Le template du mail envoyé est extension/mon_extension/design/mon_design/templates/content/collectedinfomail/my_form.tpl, avec my_form le nom de la classe de contenu gérant le formulaire d'envoi de mail.

Les paramètres du mail doivent être définis directement dans ce template, par définition de blocs.

Pour modifier le sujet du mail, utilisez par exemple:

{set-block scope=root variable=subject}Mon sujet de mail{/set-block}

La liste des paramètres acceptés (ici subject) sont listés dans le fichier kernel/content/collectinformation.php à partir de la ligne 272 :

  • subject : sujet de l'email
  • email_receiver : email du destinataire
  • email_cc_receivers : destinataires en copie
  • email_bcc_receivers : destinataires en copie cachée
  • email_sender : email de l'expéditeur
  • email_reply_to : email de réponse
  • redirect_to_node_id : node_id du nœud vers lequel effectuer une redirection après l'envoi de l'email

Astuce [eZ4] Afficher un pager

EZ Publish possède un pager prêt à l'emploi (ou presque) : extension/ezwebin/design/ezwebin/templates/content/search.tpl.

Pour l'utiliser, appelez-le dans votre template affichant une liste d'éléments :

{* Nombre maximum d'éléments par page *}
{def $page_limit = 10}

{* Nombre total d'éléments *}
{def $nb_total_articles = fetch( 
    'content', 'list_count',
    hash( 
        'parent_node_id', $node.node_id,
        'class_filter_type', 'include',
        'class_filter_array', array( 'article' ) 
    ) 
)}

{* Liste des éléments depuis $view_parameters.offset jusqu'à $page_limit *}
{def $articles = fetch( 
    'content', 'list',
    hash( 
        'parent_node_id', $node.node_id,
        'offset', $view_parameters.offset,
        'limit', $page_limit,
        'class_filter_type', 'include',
        'class_filter_array', array( 'article' ) 
    ) 
)}

{* Affichage des noms des éléments *}
{foreach $articles as $article }
    {$article.name|wash()}
{/foreach}

{* Affichage du pager *}
{include name=navigator
         uri='design:navigator/google.tpl'
         page_uri=$node.url_alias
         item_count=$nb_total_articles
         view_parameters=$view_parameters
         item_limit=$page_limit}

Remarques :

  • page_uri contient l'url de la page dans laquelle vous afficher le pager. Si vous êtes dans la vue d'un de vos modules, page_uri ressemblera plutôt à mon_module/ma_vue.
  • item_limit définit le nombre maximum d'éléments à afficher. Ce paramètre doit être passé en paramètre pour le fetch qui récupère la liste des éléments. On peut rendre ce nombre paramétrable via un fichier .ini (ex: mon_extension.ini).
  • Le template du pager peut être surchargé comme n'importe quel autre template.

Le template du pager a besoin des paramètres de la vue ($view_parameters). Si vous êtes dans la vue d'un de vos modules, vous devez gérer ce paramètre vous même et le transmettre à votre template.

Pour cela, ajoutez un paramètre non ordonné à votre vue, dans le fichier module.php :

$ViewList['ma_vue'] = array(
    'functions' => array( 'ma_vue' ),
    'script' => 'maVue.php',
    'unordered_params' => array( 'offset' => 'Offset' )
);

Dans votre vue récupérez l'offset et passez-le à votre template :

$viewParameters = array( 'offset' => 0 );
if ( isset( $Params['Offset'] ) ) {
    $viewParameters['offset'] = $Params['Offset'];
}
$tpl->setVariable( 'view_parameters', $viewParameters );

Astuce [eZ4] Créer un formulaire d'envoi de mail

Voici une méthode pour créer un formulaire d'envoi de mail en utilisant au maximum l'API d'eZ Publish.

  • Commencez par créer une nouvelle classe de contenu.
  • Ajoutez des champs sujet et message de type collecteur d'informations.
  • Déclarez maintenant le formulaire dans collect.ini.

Par exemple dans extension/mon_extension/settings/collect.ini.append.php :

<?php /* #?ini charset="utf-8"?

[InfoSettings]
# Associe l'identifier de la classe de contenu au type de collection d'information
TypeList[my_form]=my_form
TypeAttribute=collection_type

[EmailSettings]
# Définit que les informations collectées doivent être envoyées par email
SendEmailList[my_form]=enabled
SendEmailAttribute=collection_email

[DisplaySettings]
# Définit l'action a effectué une fois que les informations sont collectées (ici on affiche le résultat = message informant que l'email a bien été envoyé)
DisplayList[]
DisplayList[my_form]=result

[CollectionSettings]
# Autorise les utilisateurs anonymes à utiliser le formulaire
CollectAnonymousDataList[]
CollectAnonymousDataList[my_form]=enabled

# Autorise un utilisateur à utiliser plusieurs fois le formulaire
CollectionUserData=multiple
CollectionUserDataList[my_form]=multiple

*/ ?>

Créez les templates nécessaires :

  • extension/mon_extension/design/mon_design/override/templates/full/my_form.tpl
  • extension/mon_extension/design/mon_design/templates/content/collectedinfo/my_form.tpl
  • extension/mon_extension/design/mon_design/templates/content/collectedinfomail/my_form.tpl

Le premier contient le formulaire d'envoi de mail
Le deuxième contient la page de résultat à afficher une fois que le mail a été envoyé
Le troisième contient le contenu du mail et ses paramètres (destinataire, sujet, ...)

Remarques :

Astuce [eZ4] Echapper un bloc XML pour l'utiliser en javascript

Problème

Imaginons que vous vouliez stocker le contenu d'un champ de type bloc XML dans une variable javascript.

D'habitude, on affiche le champ de cette manière dans le template :

{attribute_view_gui attribute=$node.data_map.description}

D'autre part, si on veut échapper une chaîne pour pouvoir sans risque l'utliiser en javascript, on utiliser la fonction de template wash().

Pas facile à première vue de concilier les deux.

Solution

Stockez le rendu de votre bloc XML dans une variable :

{set-block variable=$description}
    {attribute_view_gui attribute=$node.data_map.description}
{/set-block}

Échappez la variable pour pouvoir utiliser son contenu dans du code javascript :

<script>
jsVar = "{$description|trim()|wash('javascript')}"; 
</script>

Remarque :

Si la variable contient des retours à la ligne, cela causera des erreurs. Pour cela on peut créer un opérateur de template qui recherchera et supprimera tous ces retours à la ligne.

/**
 * Supprime tous les retours à la ligne.
 * Cette fonction est utile pour injecter du code html généré par un template dans du javascript. (exemple : dans une infobulle Google Map)
 * @param string $string Chaîne à nettoyer
 */
public static function removeWraps( $string ) {
    $string = str_replace( '\r\n', '', $string );
    $string = str_replace( '\n', '', $string );
    $string = str_replace( '\r', '', $string );
    $string = str_replace( CHR( 10 ), '',$string );
    $string = str_replace( CHR( 13 ), '', $string );
    return $string;
}

Astuce [eZ4] Indexer le contenu des fichiers

Pour que le contenu des fichiers soit indexé et ainsi recherchable, il faut utiliser l'extension eztika.

Elle convertit les documents .pdf, .xls ou autres, en fichiers texte, qu'elle peut ensuite parser et indexer. Elle a besoin pour cela d'exécuter le fichier extension/eztika/bin/eztika.

  • Installez et activez l'extension eztika
  • Vérifier que le fichier extension/eztika/bin/eztika est bien exécutable
  • Régénérez les autoloads :
php bin/php/ezpgenerateautoloads.php -e

En cas de problème, consultez le fichier INSTALL.TXT présent dans le répertoire de l'extension.

Astuce [eZ4] Ajouter un favicon

Par défaut, le favicon utilisé est design/standard/images/favicon.ico.

Vous pouvez le surcharger comme un template, en créant le fichier mon_extension/design/mon_design/images/favicon.ico, et en ajoutant la règle de surcharge dans override.ini :

<?php /* #?ini charset="utf-8"?

[favicon]
Source=images/favicon.ico
MatchFile=images/favicon.ico
Subdir=templates

*/ ?>

Astuce [eZ4] Obtenir un noeud courant dans la vue d'un module

  • Déclarez le paramètre NodeID comme paramètre ordonné de la vue.
  • Récupérez le nœud, côté PHP, dans la vue de votre module :
$nodeID = intval( $Params['NodeID'] );
$node   = eZContentObjectTreeNode::fetch( $nodeID );
  • Déclarez la variable $node pour le template :
$tpl->setVariable( 'node', $node );
  • Renvoyer le node_id dans le résultat du module :
$Result['node_id'] = $myNodeId;
return $Result;

Astuce [eZ4] Gérer efficacement le cache

  • Pour qu'un template ne soit pas mis en cache, utilisez :
{set-block scope=global variable=cache_ttl}0{/set-block}
  • Pour qu'une partie de ce template soit mise en cache, utilisez :
{cache-block}
    <p>Ce texte sera mis en cache</p>
{/cache-block}
  • Pour qu'un cache soit mis à jour si un nœud fils (ou plus profond) est modifié, utilisez :
{cache-block subtree_expiry=$my_node.node_id}
    <p>Ce texte sera conservé en cache jusqu'à ce qu'un fils de $my_node soit modifié.</p>
{/cache-block}

Pour qu'un cache soit créé pour chaque valeur différente de certains paramètres, utilisez :

{cache-block keys=array( $param1, $param2 )}
    <p>
    Ce texte sera conservé en cache sous plusieurs versions, 
    une pour chaque couple {$param1} et {$param2} différent.
    </p>
{/cache-block}

Astuce [eZ4] Utiliser un template présent dans override/ comme template de vue

<?php /* #?ini charset="utf-8"?

[mavue_mon_type_de_noeud]
Source=monmodule/mavue.tpl
MatchFile=monmodule/mavue/mon_type_de_noeud.tpl
Subdir=templates
Match[class_identifier]=mon_type_de_noeud

*/ ?>
  • Déclarez cette surcharge pour le template dans le fichier .php de la vue du module :
$res = eZTemplateDesignResource::instance();
$designKeys = array( array( 'class_identifier', $node->attribute( 'class_identifier' ) ),
                     array( 'parent_node_id', $node->attribute( 'parent_node_id' ) ) );
$res->setKeys( $designKeys );

Astuce [eZ4] Passer des paramètres à la vue content/view

  • Appelez le module sans utiliser l'url_alias et ajouter les paramètres entre parenthèses. Par exemple /content/view/full/2/(mon_param)/sa_valeur
  • Les valeurs passées sont récupérables via :
{$view_parameters.ma_var} 

Astuce [eZ4] Récupérer le noeud racine

Pour récupérer le nœud racine dans un template, au moins deux méthodes sont possibles :

{def $root_node = fetch( 'content', 'node', hash( 'node_path', '/') )}

et

{def $root_node = fetch( 'content', 'node', hash( 'node_id', ezini( 'NodeSettings', 'RootNode', 'content.ini' ) )}

Remarque :

La deuxième solution retourne le nœud racine pour le siteaccess courant alors que la première retourne toujours le nœud root (dont le node_id est 2).

Astuce [eZ4] Récupérer les noeuds d'une relation d'objets

Lorsqu'un objet a un champ de type relation d'objets, il peut retourner un ou plusieurs objets liés.

Dans un template

Pour récupérer les nœuds principaux des objets liés, utilisez dans un template :

{def $related_nodes = array()}
{if $node.data_map.attribut_relation_objets.has_content}

    {def $related_objects = $node.data_map.relationlist_attribute.content.relation_list}

    {def $node_id_list = array()}
    {foreach $related_objects as $object_item}
        {set $node_id_list = $liste_node_id|append( $object_item.node_id )}
    {/foreach}
    {set $related_nodes = fetch( 'content', 'node', hash( 'node_id', $node_id_list ) )}

    {if is_object( $related_nodes )}
        {set $related_nodes = array( $related_nodes )}
    {/if}

    {undef $node_id_list}
    {undef $related_objects}
{/if}

Explications :

  • On vérifie que l'attribut relationlist_attribute de type relation d'objets a un contenu.
  • Si oui, on en parcourt les éléments et on stocke le node_id de chacun d'eux
  • On récupère tous les nœuds correspondant à la liste des node_id avec le fetch().
  • S'il n'y a qu'un résultat, le fetch retourne un objet. On le met donc dans un tableau.

Remarque :

Si on sait qu'on n'aura jamais plus d'un objet lié, on peut faire simplement :

{def $related_node = false()}
{if $node.data_map.attribut_relation_objets.has_content}
    {set $related_node = fetch( 'content', 'node', hash( 'node_id', $node.data_map.attribut_relation_objets.content.relation_list.0.node_id ) )}
{/if}

Côté PHP

$relatedNodeList = array();

$dataMap = $node->dataMap();
$relationListAttribute = $dataMap['relationlist_attribute'];

if ( $relationListAttribute->hasContent() ) {

    $relatedObjects = $relationListAttribute->content();
    $nodeIdList = array();

    foreach ( $relatedObjects['relation_list'] as $object_item ) {
        $nodeIdList[] = $object_item['node_id'];
    }

    $relatedNodeList = parent::fetch( $nodeIdList );
}

Explications :

Le fonctionnement est similaire à celui dans le template.
De la même manière, on peut utiliser une version simplifiée quand la relation est unique :

$relatedNode = false;

$dataMap = $node->dataMap();
$relationListAttribute = $dataMap['temp'];

if ( $relationListAttribute->hasContent() ) {
    $relatedObjects = $relationListAttribute->content();
    $relatedNode = parent::fetch( $relatedObjects['relation_list'][0]['node_id'] );
}

Astuce [eZ4] Diminuer le taux de compression des images

Si les images du site sont trop lourdes, ou de mauvaise qualité, vous pouvez modifier le taux de compression utilisé lors de la génération des thumbnails.

Pour cela, créez ou modifiez le fichier image.ini.append.php dans votre extension.

Exemple, pour le fichier extension/mon_extension/image.ini.append.php :

<?php /* #?ini charset="utf-8"?

[MIMETypeSettings] :
Quality[]=image/jpeg;100
Quality[]=image/png;100
Quality[]=image/gif;100

*/ ?>

Pour chaque format d'image, vous pouvez définir le taux de compression par rapport à l'image originale. 100% correspond à la qualité maximale utilisable.

Astuce [eZ4] Internationaliser un texte

Créez un fichier de traduction dans votre extension :

extension/mon_extension/translations/ma_lang/translation.ts avec ma_lang l'identifiant d'un langage (ex: fre-FR).

Malgré l'extension ts, il est formaté en XML :

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.0">

<!-- Contexte -->
<context>
    <name>mon/contexte</name>
    <message>
        <source>Ma phrase à traduire</source>
        <translation>Ma phrase traduite</translation>
    </message>
</context>

</TS>

Pour traduire un mot ou une phrase dans un template, utilisez la méthode i18n() :

{* La ligne suivante affichera 'Ma phrase traduite' *}
{'Ma phrase à traduire'|i18n( 'mon/contexte' )}

Remarque :

Les contextes permettent de regrouper les traductions, par thème, par page, etc.

Astuce [eZ4] Redimensionner automatiquement les images

Pour éviter que l'image uploadée par le contributeur ne dégrade l'affichage, si elle est trop grande par exemple, vous pouvez la redimensionner automatiquement. Le redimensionnement consiste à créer un thumbnail de l'image, qui sera utilisé à sa place à l'affichage.

Prérequis

Pour générer les thumbnails, un convertisseur d'images doit être installé sur la machine. (ex: ImageMagick) Pour l'activer, créez ou modifiez le fichier /settings/override/image.ini.append.php :

<?php /* #?ini charset="utf-8"?

[ImageMagick]
IsEnabled=true
# Chemin vers l'exécutable
ExecutablePath=C:/Program Files/ImageMagick-6.7.3-Q16
# Nom de l'exécutable
Executable=convert.exe

*/ ?>

Configuration

Créez ou modifiez le fichier extension/mon_extension/settings/siteaccess/mon_siteaccess/image.ini.append.php en ajoutant :

<?php /* #?ini charset="utf-8"?

[AliasSettings]
AliasList[]=mon_nom_de_definition

[mon_nom_de_definition]
Reference=
Filters[]
Filters[]=geometry/scaledownonly=86;47

*/ ?>

Explication :

86 et 47 sont la largeur et la hauteur maximalesde l'image en pixels. Lors de l'affichage, si l'une de ces deux mesures est trop importante, l'image est redimensionnée proportionnellement (= un thumbnail est généré et il remplacera l'image à l'affichage).

Remarque :

On peut également ne limiter que la largeur ou la hauteur, en utilisant scalewidth et scaleheight au lieu de scaledownonly.

Utilisation

Pour que l'image soit redimensionnée, dans le template appelez :

{attribute_view_gui attribute=$node.data_map.image image_class='mon_nom_de_definition'}

ou :

<img src="{$node.data_map.image.content[mon_nom_de_definition].full_path|ezroot( 'no' )}" alt="" />

Astuce [eZ4] Surcharger une classe du kernel

Pour corriger un bug ou étendre les fonctionnalités natives d'eZ Publish, on peut être tenter de modifier les fichiers du cœur du CMS, dans le dossier kernel/.

Il faut toujours éviter de modifier ces fichiers, surtout qu'il est possible de le faire proprement dans une extension, grâce au système d'autoload.

Pré-requi

Vous devez d'abord autoriser les surcharges des classes du kernel, dans le fichier config.php, à la racine du site. (Si ce fichier n'existe pas, copiez le fichier config.php-RECOMMENDED et renommez-le en config.php.)

Il faut modifier la constante EZP_AUTOLOAD_ALLOW_KERNEL_OVERRIDE pour la rendre égale à true :

define( 'EZP_AUTOLOAD_ALLOW_KERNEL_OVERRIDE', true );

Surcharge

Vous pouvez maintenant copiez dans votre extension le fichier du kernel que vous souhaitez modifier.

Par exemple extension/mon_extension/classes/override/ezcontentobjecttreenode.php.

Il faut ensuite régénérer les autoloads et vider le cache, via les commande suivantes exécutées à la racine du site :

php bin/php/ezpgenerateautoloads.php -o
php bin/php/ezpgenerateautoloads.php
php bin/php/ezcache.php --clear-all

Votre classe surcharge maintenant celle du kernel, et est référencée dans le fichier var/autoload/ezp_override.php :

<?php
/**
 * Autoloader definition for eZ Publish Kernel overrides files.
 *
 * @copyright Copyright (C) 1999-2012 eZ Systems AS. All rights reserved.
 * @license http://www.gnu.org/licenses/gpl-2.0.txt GNU General Public License v2
 * @version  2012.2
 * @package kernel
 *
 */

return array(
    'eZContentObjectTreeNode' => 'extension/apitools/classes/override/ezcontentobjecttreenode.php',
);

?>

Astuce [eZ4] Surcharger un template d'affichage

J'ai créé une nouvelle classe de contenu Article, et je souhaite modifier le template utilisé pour les articles.

Je dois d'abord commencer par déclarer mon extension comme une extension de design, dans le fichier design.ini.

Exemple de fichier extension/mon_extension/settings/siteaccess/site/design.ini.append.php :

<?php /* #?ini charset="utf-8"?

[ExtensionSettings]
DesignExtensions[]=mon_extension

*/ ?>

Ensuite, il faut déclarer les règles de surcharge que l'on souhaite appliquer, dans le fichier override.ini.

Exemple de fichier extension/mon_extension/settings/siteaccess/site/override.ini.append.php :

<?php /* #?ini charset="utf-8"?

[full_article]
Source=node/view/full.tpl
MatchFile=node/view/full/article.tpl
Subdir=templates
Match[class_identifier]=article

*/ ?>
  • On définit une section full_article, pour surcharger l'affichage pleine page (full) des articles.
  • La source définit le template à surcharger. Dans le cas de l'affichage full, c'est node/view/full.tpl qui est surchargé.
  • Matchfile définit le template qui remplacera celui par défaut.
  • Subdir définit dans quel sous-dossier de extension/mon_extension/design/mon_design/override/ le matchfile sera recherché.
  • Le Match[class_identifier] définit pour quelle(s) classe(s) de contenu la règle de surcharge doit s'appliquer.

Dans mon extension, j'ai donc :

mon_extension
    | settings
          | siteaccess
                | site
                      | design.ini.append.php
                      | override.ini.append.php
    | override
          | templates
                | node
                      | view
                           | full
                                 | article.tpl

Remarque :

La liste des Match disponibles en fonction du template à surcharger est visible dans la documentation officielle.

Astuce [eZ4] Surcharger les icônes du site

EZ Publish permet de surcharger les différents icônes du site et en particulier ceux du back-office. Le fichier de paramétrage de ces icônes est icon.ini.

Le système de gestion des icônes n'est malheureusement pas très bien géré, et il est complexe de l'utiliser de manière "pluginisée".

Voici deux méthodes pour surcharger les icônes :

  • La première, plus simple, oblige à ajouter des fichiers dans un répertoire extérieur à une extension.
  • La seconde, plus propre, oblige à copier tous les icônes par défaut dans son extension.

Méthode simple

Créez le fichier icon.ini.append.php dans votre extension.

Exemple de fichier extension/mon_extension/settings/siteaccess/bo/icon.ini.append.php :

<?php /* #?ini charset="utf-8"?

[ClassIcons]
# Mapping entre un class_identifier et l'icône à utiliser
# Le chemin complet depuis les dossiers 16x16 et 32x32 doit être précisé

# Nouveaux icônes pour les classes créées
ClassMap[category]=custom/category.png
ClassMap[article]=custom/article.png

*/ ?>

Remarques :

Pour fonctionner, vous devez également créer les fichiers category.png et article.png, dans :

  • share/icons/crystal-admin/16x16_ghost/custom
  • share/icons/crystal-admin/16x16_indexed/custom
  • share/icons/crystal-admin/16x16_original/custom
  • share/icons/crystal-admin/32x32/custom

Pour chaque nouvelle classe de contenu avec un icône spécifique, ajoutez une entrée dans le fichier icon.ini.append.php de votre extension, et les images dans les 4 dossiers ci-dessus.

Méthode propre

Commencez par copier le contenu du fichier share/icons/crystal-admin/icon.ini dans un fichier icon.ini.append.php, dans votre extension.

Exemple de fichier extension/mon_extension/settings/siteaccess/bo/icon.ini.append.php :

<?php /* #?ini charset="utf-8"?

[IconSettings]
# Dossier dans lequel les thèmes des icônes seront recherchés
Repository=extension/mon_extension/share/icons

# Thème d'icônes à utiliser (= nom du dossier à créer dans extension/mon_extension/share/icons)
Theme=mon_theme

# Noms des dossiers d'icônes à utiliser en fonction de la situation (= normal/small/...)
# Ces dossiers devront être présents dans le dossier du thème (ici dans mon_theme)
# Contrairement au fichier icon.ini par défaut, on utilise le même répertoire pour les affichages "small", "ghost" et "original"
Size=normal
Sizes[normal]=32x32
Sizes[small]=16x16
Sizes[ghost]=16x16
Sizes[original]=16x16

[MimeIcons]
# Icône par défaut quand aucun mimetype ne correspond au fichier
Default=mimetypes/binary.png

# Mapping entre les mimetypes et les icônes à utiliser. 
# Le chemin complet depuis les dossiers 16x16 et 32x32 doit être précisé
MimeMap[]
MimeMap[text]=mimetypes/ascii.png
MimeMap[image]=mimetypes/image.png
MimeMap[video]=mimetypes/video.png
MimeMap[audio]=mimetypes/sound.png
MimeMap[application/x-gzip]=mimetypes/tgz.png
MimeMap[application/x-bzip2]=mimetypes/tgz.png
MimeMap[application/x-tar]=mimetypes/tgz.png
MimeMap[application/zip]=mimetypes/tgz.png
MimeMap[application/x-rpm]=mimetypes/rpm.png
MimeMap[application/vnd.ms-powerpoint]=mimetypes/powerpoint.png
MimeMap[application/msword]=mimetypes/word.png
MimeMap[application/vnd.ms-excel]=mimetypes/excel.png
MimeMap[application/pdf]=mimetypes/pdf.png
MimeMap[application/postscript]=mimetypes/pdf.png
MimeMap[text/html]=mimetypes/html.png
MimeMap[video/quicktime]=mimetypes/quicktime.png
MimeMap[video/video/vnd.rn-realvideo]=mimetypes/real_doc.png

[ClassGroupIcons]
# Icône par défaut pour un nouveau groupe de classes de contenu
Default=filesystems/folder.png

ClassGroupMap[]
ClassGroupMap[content]=filesystems/folder_txt.png
ClassGroupMap[users]=apps/kuser.png
ClassGroupMap[media]=filesystems/folder_video.png
ClassGroupMap[setup]=apps/package_settings.png

[Icons]
# Icône par défaut pour un élément divers
Default=mimetypes/empty.png

IconMap[]
IconMap[role]=actions/identity.png
IconMap[section]=actions/view_tree.png
IconMap[translation]=apps/locale.png
IconMap[pdfexport]=apps/acroread.png
IconMap[url]=apps/package_network.png

[ClassIcons]
# Icône par défaut pour une nouvelle classe de contenu
Default=mimetypes/empty.png

# Mapping entre un class_identifier et l'icône à utiliser
# Le chemin complet depuis les dossiers 16x16 et 32x32 doit être précisé
ClassMap[]

ClassMap[comment]=mimetypes/txt2.png
ClassMap[common_ini_settings]=apps/package_settings.png
ClassMap[company]=apps/kuser.png
ClassMap[file]=mimetypes/binary.png
ClassMap[folder]=filesystems/folder.png
ClassMap[forum]=filesystems/folder_man.png
ClassMap[forum_message]=mimetypes/txt2.png
ClassMap[forum_reply]=mimetypes/txt2.png
ClassMap[forum_topic]=mimetypes/txt2.png
ClassMap[gallery]=filesystems/folder_image.png
ClassMap[image]=mimetypes/image.png
ClassMap[link]=mimetypes/html.png
ClassMap[person]=apps/personal.png
ClassMap[poll]=mimetypes/log.png
ClassMap[product]=apps/package.png
ClassMap[product_review]=mimetypes/txt2.png
ClassMap[multiprice_product]=apps/package.png
ClassMap[dynamic_vat_product]=apps/package.png
ClassMap[quicktime]=mimetypes/quicktime.png
ClassMap[real_video]=mimetypes/real_doc.png
ClassMap[review]=mimetypes/txt2.png
ClassMap[template_look]=apps/package_settings.png
ClassMap[user]=apps/personal.png
ClassMap[user_group]=apps/kuser.png
ClassMap[weblog]=mimetypes/document.png
ClassMap[windows_media]=mimetypes/video.png
ClassMap[user]=apps/personal.png
ClassMap[user_group]=apps/kuser.png

# Nouveaux icônes pour les classes créées
ClassMap[frontpage]=custom/home.png
ClassMap[category]=custom/category.png
ClassMap[article]=custom/article.png

*/ ?>

Il faut ensuite copier le contenu du dossier share/icons/crystal-admin/16x16_original dans extension/mon_extension/share/icons/mon_theme/16x16 et le contenu du dossier share/icons/crystal-admin/32x32 dans extension/mon_extension/share/icons/mon_theme/32x32, pour conserver les icônes déjà existants.

Les nouveaux icônes sont à ajouter dans les dossiers .../mon_theme/16x16/custom et .../mon_theme/32x32/custom.

Remarques :

  • Dans cet exemple, le nombre d'icônes est réduit, puisqu'on utilise le même fichier pour les affichages small, ghost et indexed.
  • Si vous avez activé le .htaccess pour votre site, vous devez y ajouter une règle de réécriture, sans quoi vos icônes ne seront pas acessibles :
RewriteRule ^extension/[^/]+/share/icons/[^/]+/.* - [L]

Astuce [eZ4] Définir les fichiers css et javascript à inclure pour la page courante

Il y a 3 moyens d'inclure des fichiers CSS et Javascript dans une page eZ Publish.

Directement dans le header

Comme toute page html, on peut définir des fichiers CSS et Javascript dans la balise <header>. À priori vous pourrez le modifier dans votre fichier pagelayout.tpl.

Par exemple, dans le fichier monextension/design/mon_design/override/templates/pagelayout.tpl :

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="{$site.http_equiv.Content-language|wash}" lang="{$site.http_equiv.Content-language|wash}">
<head>

{def $pagedata         = ezpagedata()
     $pagestyle        = $pagedata.css_classes
     $locales          = fetch( 'content', 'translation_list' )
     $pagedesign       = $pagedata.template_look
     $current_node_id  = $pagedata.node_id
     $current_node     = fetch( 'content', 'node', hash( 'node_id', $pagedata.node_id ) )}

<link rel="stylesheet" type="text/css" href="/extension/monextension/design/mon_design/stylesheets/mon_design.css" />
<script type="text/javascript" src="http://code.jquery.com/jquery-1.8.3.js" charset="utf-8"></script>

{include uri='design:page_head.tpl'}
{include uri='design:page_head_style.tpl'}
{include uri='design:page_head_script.tpl'}
</head>

Explication :

Une feuille CSS présente dans monextension/ et un script JS ont été inclus à la page.

Remarque :

De la même manière, le fichier JS peut être inclus à la fin du <body> à la place, ce qui est préférable d'un point de vue optimisation.

Dans les propriétés du fichier design.ini

Vous pouvez définir des fichiers CSS et Javascript à inclure sur toutes vos pages, en front-office ou en back-office, dans le fichier design.ini.

Par exemple, dans le fichier monextension/settings/design.ini.append.php :

<?php /* #?ini charset="utf-8"?

[StylesheetSettings]
# CSS Pour le front office
FrontendCSSFileList[]=structure.css
FrontendCSSFileList[]=global.css
FrontendCSSFileList[]=override.css
FrontendCSSFileList[]=wysiwyg.css
FrontendCSSFileList[]=noscript.css

[JavaScriptSettings]
JavaScriptList[]=https://ajax.googleapis.com/ajax/libs/jquery/1.9.0/jquery.min.js

Ces fichiers seront automatiquement ajoutés via les templates page_head_style.tpl et page_head_script.tpl à inclure dans votre balise <header>.

Par exemple, dans le fichier monextension/design/mon_design/override/templates/pagelayout.tpl :

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="{$site.http_equiv.Content-language|wash}" lang="{$site.http_equiv.Content-language|wash}">
<head>
{include uri='design:page_head_style.tpl'}
{include uri='design:page_head_script.tpl'}
</head>

Pour une page spécifique uniquement

Si vous avez besoin d'un fichier CSS ou Javascript particulier pour une page précise de votre site, l'extension ezjscore fournie avec eZ Publish propose des opérateurs de template bien pratiques :

{ezcss_require('override.css')}
{ezscript_require('lib/jquery-ui-1.8.16.custom.min.js')}

Remarques :

  • Si vous utilisez ezcss_require() ou ezscript_require(), les fichiers que vous passez en arguments sont mémorisés par eZ Publish.
    C'est à l'appel des opérateurs ezcss_load() et ezscript_load() qu'ils seront inclus effectivement au flux de la page. Il vous faut donc ajoutez ces derniers dans votre header/pied de page.

  • Si le mode développement est désactivé, ezcss_load() et ezscript_load() vont automatiquement minifier tous le CSS et le Javascript en deux fichiers, qui seront mis en cache.

  • Si l'une de vos feuilles CSS contient un @import pour en inclure une autre, cette dernière ne sera pas mis en cache et sera alors introuvable. Évitez-donc d'utiliser @import !

Astuce [D7] Créer et traduire un contenu programmatiquement

Basiquement, pour créer et publier un contenu, ces lignes suffisent :

$default_language = 'fr';
$node_type = 'article';

// Création d'un contenu vide
$node = new stdClass();
$node->type = $node_type;
$node->uid = 1;
$node->status = 1;
node_object_prepare($node);
$node->language = $default_language;

// Titre du contenu
$node->title = 'Titre de mon contenu'

// Autres champs du contenu
// [...(1)]

// Autres traductions
// [...(2)]

// Sauvegarde du contenu en base
node_save($node);

$nid = $node->nid;

Si le contenu est multilingue et que son titre est traduisible, il faut ajouter la ligne suivante (à la place de [...(1)]) :

$node->title_field[$node->language][0]['value'] = 'Titre de mon contenu';

Pour traduire un ou plusieurs autres champs dans une autre langue que la langue par défaut, il faut ajouter les lignes suivantes (à la place de [...(2)] :

$other_language = 'en';

// Traduction des champs
$node->title_field[$other_language][0]['value'] = 'The title of my content';

// Synchronisation et sauvegarde de la traduction en base
$handler = entity_translation_get_handler('node', $node);
$translation = array(
    'translate' => 0,
    'status' => 1,
    'language' => $other_language,
    'source' => $node->language,
);
$handler->setTranslation($translation, $node);