Astuce Afficher le numéro de version

Pour simplifier les mises à jour de votre application, il est préférable de n'indiquer le numéro de version qu'à un seul endroit. Cet endroit peut être un fichier mis à jour manuellement ou via la CI. Par exemple le package.json.

Ex :

{
  "name": "my-app",
  "version": "0.0.1",
  [...]
}

Pour récupérer le numéro de ce fichier, vous pouvez utiliser simplement les imports javascript :

// src/app/App.tsx
import { version } from '../../package.json';

Si vous utilisez typescript, cela nécessite d'activer l'option resolveJsonModule dans tsconfig.json :

{
  "compilerOptions": {
    [...]
    "resolveJsonModule": true,
    [...]
  }
}

Astuce Modifier les métadonnées

Dans une application React, vous pouvez définir des métadonnées via des balises <meta> à l'intérieur de la balise <head>, directement dans le fichier index.html.

<!doctype html>
<html lang="fr">
<head>
    <meta charset="UTF-8"/>
    <base href="/"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=overlays-content">
    <meta name="description" content="A sample app that introduces useMetaTag and useTitleTag hooks"/>
    <title>My Awesome app v1.1</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

Malheureusement, ces données sont statiques et ne s'adaptent pas dynamiquement à la page en cours. Pour palier cela, la solution consiste à modifier ou ajouter ces balises dynamiquement dans le DOM en javascript.

Le projet React Helmet devrait convenir à cette tâche, mais ne semble plus maintenu.

Voici deux hooks simples permettant de gérer vous-même vos balises <meta> et <title>.

Balises <meta> :

/**
 * Crée ou met à jour dynamiquement une balise meta dans le head de la page.
 *
 * @param {string} name Valeur de l'attribut `name` de la balise
 * @param {string} content Valeur de l'attribut `content` de la balise
 */
export const useMetaTag = (name: string, content: string) => {
    let metaElement = document.querySelector(`meta[name='${name}']`);
    if (!metaElement) {
      metaElement = document.createElement('meta');
      metaElement.setAttribute('name', name);
      document.head.appendChild(metaElement);
    }

    metaElement.setAttribute('content', content);
  };

Balise <title> :

/**
 * Crée ou met à jour dynamiquement une balise title dans le head de la page.
 *
 * @param {string} title Titre de la page
 */
export const useTitleTag = (title: string) => {
    let titleElement = document.querySelector('title');
    if (!titleElement) {
      titleElement = document.createElement('title') as HTMLTitleElement;
      document.head.appendChild(titleElement);
    }

    titleElement.textContent = title;
  };

Exemple d'utilisation :

import { useNavigation } from './helpers/useNavigation.ts';
import { useTitleTag } from './helpers/useTitleTag.ts';
import { version } from '../../package.json';

export const App = () => {
  useMetaTag('description', 'A sample app that introduces useMetaTag and useTitleTag hooks');
  useTitleTag(`My Awesome app - v${version}`);
}

Note : Si vous souhaitez des métadonnées spécifiques à une certaine page, utiliser ces hooks dans votre composant associé à votre route plutôt que dans le App.tsx.

Astuce Structure d'un projet React

Comme pour pas mal d'aspects, React ne propose pas vraiment de solution standard pour organiser la structure de son projet.

Voici quelques pistes possibles, se rapprochant de ce qu'on peut trouver dans l'écosystème React.

Structure simple

src/
- components/
  - MyComponent/
    - MyComponent.tsx
- helpers/
- hooks/
- model/
  - MyEntity.ts
- services/

On regroupe ici chaque élément par type (les hooks avec les hooks, les composants avec les composants, les interfaces du modèle entre elles…). Au fur et à mesure que le projet grossit, on ajoute des sous répertoires. Par exemple :

src/
- components/
  - forms/
    - fields/
      - MyField/
        - MyField.tsx
    - MyForm/
        - MyForm.tsx
  - pages/
    - MyPage.tsx
- helpers/
- hooks/
- model/
  - interfaces/
    - MyEntity.ts
  - enums/
    - MyEnum.ts
- services/

Atomic design

Cette approche se focalise sur le découpage des composants, et nous pousse à réfléchir à leurs responsabilités. Pour le reste, on peut conserver la structure précédente.

src/
- components/
  - atoms/
    - MyField/
      - MyField.tsx
  - molecules/
    - MyForm/
      - MyForm.tsx
  - organisms/
    - MyForm/
      - MyForm.tsx

Le principe, c'est de hiérarchiser les composants en 3 groupes :

  • les atomes, sont les composants élémentaires de plus petit niveau, à la base de tout. On y trouve typiquement des boutons, des champs de formulaires… Bref, des composants réutilisables et sans aucun rapport avec le métier.
  • les molécules constituent le niveau intermédiaire, et représentent des ensembles fonctionnels réutilisables. On peut y ranger les formulaires, les listes…
  • les organismes correspondent au niveau le plus haut, typiquement à une page simple. Par exemple une liste de tâches, avec des filtres et une pagination.

Les dépendances sont interdites vers un niveau supérieur. Autrement dit, vos atomes ne doivent dépendre de rien.

Vous en saurez plus avec cet article de blog : https://blog.logrocket.com/atomic-design-react-native.

Bonus :

Si vous devez gérer des pages complexes, qui proposent plusieurs fonctionnalités indépendantes, mais sur un même écran, vous pouvez ajouter un quatrième niveau pages/, qui servira de passe-plat entre le routeur et les organismes.

Cela permet aux organismes de s'affranchir des paramètres d'URL et ajoute plus de souplesse si tout à coup, vous devez remplacer votre page de création d'utilisateur par une fenêtre modale sur votre page de liste.

De plus, comme pour la structure simple, vous pouvez créer des sous-répertoires pour regrouper vos composants (ex : buttons/, fields/, lists/, cards/…).

Features

Le problème des deux types de structures précédentes, c'est que les fichiers sont disséminés un peu partout, plutôt que d'être regroupés par zone fonctionnelle. Si vous voulez supprimer une page, vous devrez peut-être supprimer des fichiers dans model/, components/ et hooks/.

Pour éviter cela, vous pouvez utiliser la structure proposée sur le dépôt bulletproof-react.

Le principe est de créer un répertoire features/, contenant autant de sous-répertoires que de zones fonctionnelles de votre application. Dans chacun d'eux, on y retrouvera la structure simple (voire Atomic design) présentée précédemment.

Par exemple :

plusieurs features

Cette structure n'a d'intérêt que si l'on respecte une hiérarchie entre les features. Une première feature peut dépendre d'une ou plusieurs autres, mais ces deux autres ne doivent pas dépendre de la première. Pour résumer, attention aux dépendances cycliques.

L'exercice n'est pas forcément simple, et si l'on n'arrive pas à éviter les dépendances bidirectionnelles entre deux composantes métier, c'est qu'elles sont trop intriquées pour les séparer en deux features distinctes.
On peut alors éventuellement utiliser une troisième feature (ex : core), dont dépendront les deux autres, ou placer les fichiers transversaux dans des répertoires à la racine de src/ comme proposé par bulletproof-react.

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

Astuce Qui utilise le port ?

Sous Windows, pour savoir quel processus utilise un port, lancez la commande Get-Process dans Powershell :

Exemple pour le port 5433 :

Get-Process -Id (Get-NetTCPConnection -LocalPort 5433).OwningProcess

Marque-page Marque-pages React

Contrairement à Angular, React propose peu de solutions par défaut aux problèmes les plus courants. Il faut souvent ajouter des modules en dépendance, et il est parfois difficile de ne pas se perdre dans la jungle des modules pour React.

Voici une liste de modules et de pratiques utiles pour les besoins courants.

Socle

Mise en place

On peut initialiser facilement une application vierge React + Vite avec typescript, via la commande npm create vite@latest my-app -- --template react-ts.

Routage

Le module react-router fait ça très bien.

Appels API

Pour récupérer des données auprès d’une API ou via d’autres méthodes asynchrones, on peut utiliser SWR ou react-query.

État

Si on souhaite gérer un état globale de l’application au niveau local, on peut utiliser redux avec redux-toolkit ou alors recoil.

Formulaires

Le module react-hook-form fait ça très bien. Il supporte notamment Yup pour gérer la validation de champs.

Internationalisation

Le module react-i18next permet de gérer une appli multilingue.

Design

CSS

Pour restreindre des classes CSS à un composant en particulier, on peut utiliser le concept de modules CSS.

Material

Les principales bibliothèques graphiques sont disponibles pour React, et notamment Material UI. Elle fonctionne très bien, sauf avec le hook-form. C’est problématique et cela nécessite de recréer un composant « Contrôlable », pour chaque type de champ de formulaire.

Qualité

Tests

Plusieurs bibliothèques pour les tests fonctionnent bien avec React, notamment jest.

Lint

On peut combiner prettier et eslint, pour normaliser la base de code. Il existe des extensions à eslint spécialement pour React, afin de détecter et remonter si certaines mauvaises pratiques sont utilisées.

Documentation des composants

Pour documenter et présenter une bibliothèque de composants, on peut utiliser Storybook. Cela permet de manipuler et tester chaque composant, dans une interface dédiée.

Autres

Base de donnée locale

Pour utiliser plus facilement la base de données locale IndexedDB, on peut y accéder via dexie.

Authentification avec JWT

Pour extraire les données d’un jeton JWT, on peut utiliser jwt-decode.

Marque-page Modèle de composant

Voici un modèle de composant React minimal :

// MyComponent.tsx
import React, { ReactNode } from 'react';
import styles from "./MyComponent.module.scss";

interface Props {
  children: ReactNode;
  notRequiredProperty?: string;
}

export const MyComponent: React.FC = ({ children, notRequiredProperty }: Props) => {
  return <div className={styles.myComponentClass}>{children}< /div>;
};

Explications :

  • Le composant est utilisable dans un autre composant, via <MyComponent children={<p>EXAMPLE</p>} />
  • On importe le fichier MyComponent.module.scss, dans lequel se trouve une règle CSS pour la classe myComponentClass.
  • Le composant est un wrapper, auquel on passe un sous-composant via la propriété children.
  • On déclare l’interface Props pour définir les propriétés du composant. Cela facilite l’autocomplétion et permet la validation du typage à la compilation.
  • La propriété notRequiredProperty est facultative lorsqu’on appelle le composant.

Héritage

Si on souhaite créer un wrapper pour un composant existant, on veut souvent qu’il accepte les mêmes propriétés. Dans ce cas, on peut déclarer l’interface et le composant ainsi :

type Props = SomeComponentToWrapProps & {
  extraProperty1: string
};

export const MyComponent: React.FC = ({ extraProperty1, inheritedProp1, ...others }: Props) => {
  // [...]
};

Explications :

  • Les propriétés du composant sont l’union de celles de SomeComponentToWrapProps, avec celle déclarée ici.
  • On indique les quelques propriétés dont on a explicitement besoin, et on peut accéder à toutes les autres via l’objet others.

Astuce Debounce en React

Régulièrement en Javascript, on veut écouter les changements sur un élément, mais ne les prendre en compte qu’à intervalles réguliers.
Par exemple, si on écoute le déplacement de la souris pour lancer un traitement, on n’a pas besoin de l’exécuter à chaque changement de position, mais uniquement tous les 300 ou 500ms.

Pour éviter l’effet « rebond », on utilise alors une fonction de debounce, ou dans React, un hook.

Script

import { useEffect, useState } from 'react';

/**
 * Delays the value change, so it’s only committed once during the specified duration.
 *
 * Usage :
 * <pre>
 * const debouncedValue = useDebouncedValue(realTimeValue, 500);
 * <pre>
 */
export const useDebouncedValue = <T>(input: T, duration = 500): T => {
  const [debouncedValue, setDebouncedValue] = useState(input);

  useEffect(() => {
    const timeout = setTimeout(() => {
      setDebouncedValue(input);
    }, duration);

    return () => {
      clearTimeout(timeout);
    };
  }, [input, duration]);

  return debouncedValue;
};

Exemple d’utilisation

On veut afficher un message dans la console au changement de valeur du champ de formulaire,
mais limiter ce traitement avec un debounce.

import React, { useEffect, useState } from 'react';
import { useDebouncedValue } from './utils/debounce';

export default (() => {
  const [myValue, setMyValue] = useState('');
  const myDebouncedValue = useDebouncedValue(myValue, 500);

  useEffect(() => {
    console.log('This log is displayed when the value of debouncedFilterNom changes', myDebouncedValue);
  }, [myDebouncedValue]);

  const handleValueChange = async (event: any) => {
    setMyValue(event.target.value);
  };

  return (
    <form>
      <input type="text" onChange={handleValueChange} value={myValue} />
    </form>
  );
}) as React.FC;

Marque-page Construire une queryString

Voici une fonction pour construire une queryString à partir d’un objet.

Version JavaScript :

export const buildQueryString = (parameters) => {
  const paramParts = Object.entries(parameters)
    .map(([key, value]) => `${key}=${encodeURI(value)}`);

  return paramParts.length > 0 ? `?${paramParts.join('&')}` : '';
};

Version TypeScript :

export const buildQueryString = (parameters: { [key: string]: string }) => {
  const paramParts = Object.entries(parameters)
    .map(([key, value]) => `${key}=${encodeURI(value)}`);

  return paramParts.length > 0 ? `?${paramParts.join('&')}` : '';
};

Explications :

  • Chaque propriété de l’objet devient une clé de la queryString, dont la valeur échappée.
  • Les paramètres sont séparés par & et ? est ajouté en préfixe de la chaîne.

Astuce Modifier l'auteur des commits Git

Il est possible de modifier l’auteur de tous les anciens commits d’un dépôt Git.

Pour cela, utilisez la commande suivante, après l’avoir personnalisée selon vos besoins :

git filter-branch --env-filter '
WRONG_EMAIL="wrong@email.com"
NEW_NAME="John Doe"
NEW_EMAIL="j.doe@email.com"

if [ "$GIT_COMMITTER_EMAIL" = "$WRONG_EMAIL" ]
then
    export GIT_COMMITTER_NAME="$NEW_NAME"
    export GIT_COMMITTER_EMAIL="$NEW_EMAIL"
fi
if [ "$GIT_AUTHOR_EMAIL" = "$WRONG_EMAIL" ]
then
    export GIT_AUTHOR_NAME="$NEW_NAME"
    export GIT_AUTHOR_EMAIL="$NEW_EMAIL"
fi
' --tag-name-filter cat -- --branches --tags

Explications :

On indique l’adresse email utilisée par tous les commits que l’on souhaite modifier, ainsi que le nom d’utilisateur et l’adresse email correcte que l’on souhaite utiliser à la place.

Astuce Analyser l’occupation de l’espace disque

Sous Linux, l’utilitaire ncdu (pour NCurses Disk Usage) permet de trouver quel fichier ou répertoire vous bouffe tout votre espace disque.

Il est disponible sur les dépôts officiels Debian et Ubuntu et donc facile à installer.

sudo apt install ncdu
ncdu

Note : ncdu a été réécrit dans une v2 encore récente. Selon la version des dépôts, c’est peut-être la v1 qui sera installée.

Astuce SessionStorage vs LocalStorage

Dans une application ou un site web, on peut stocker des informations dans le navigateur de l'utilisateur, pour un nom de domaine spécifique.

Deux approches principales : les cookies et le Storage. Pour la seconde, deux possibilités : sessionStorage ou localStorage.

cookie vs Storage

La principale différence, c'est que les cookies sont accessibles côté client et côté serveur. À l'inverse, il n'y a aucun moyen côté serveur d'accéder au contenu ni du localStorage ni du sessionStorage.

Remarque :

Quand on parle de "sessions" pour le sessionStorage, il n'y a aucun rapport avec les sessions entre client/serveur comme on peut les manipuler en PHP, par exemple.

session vs local

Les données stockées en sessionStorage sont rattachées à un onglet.
Si on recharge la page au sein d'un même onglet, les données sont conservées. En revanche, elles expirent à sa fermeture.

Les données stockées en sessionStorage sont rattachées à un onglet.

Remarque :

Selon le navigateur, si on restaure un onglet fermé précédemment, on récupère le sessionStorage tel qu'il était avant fermeture. C'est le comportement pour les navigateurs basés sur Chrome. De même, si on duplique un onglet ouvert, la copie hérite d'une copie du sessionStorage de l'onglet original.

Pour aller plus loin

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.

Erreur Impossible de charger les dépendances depuis https://jcenter.bintray.com

Si vous construisez des APK Android avec Cordova, vous pouvez rencontrer ce genre d'erreur :

[...]
> Task :app:preReleaseBuild FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Could not resolve all files for configuration ':app:releaseRuntimeClasspath'.
> Could not resolve com.android.support:support-v4:27.+.
  Required by:
      project :app
   > Failed to list versions for com.android.support:support-v4.
      > Unable to load Maven meta-data from https://jcenter.bintray.com/com/android/support/support-v4/maven-metadata.xml.
         > Could not get resource 'https://jcenter.bintray.com/com/android/support/support-v4/maven-metadata.xml'.
            > Could not GET 'https://jcenter.bintray.com/com/android/support/support-v4/maven-metadata.xml'.
               > Read timed out
[...]

Cela est dû à la coupure du dépôt jcenter.

La solution pour le remplacer est décrite dans plein de tickets sur StakOverflow (ex: Question #67418153).
En bref : il faut modifier le fichier build.gradle.

Malheureusement, vous n'avez peut-être pas accès à ce fichier, si c'est une Plateforme d'Intégration Continue qui se charge du build.
Dans ce cas, ce fichier n'est probablement pas versionné, mais générée automatiquement dans un répertoire nommé platforms/.

Entre chaque build, veillez à bien supprimer ce répertoire platforms/. Ainsi, le fichier build.gradle sera régénéré en utilisant des dépôts corrects.

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