Php ( 20 / 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.