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 un projet React avec l’outil CRA (Create React App). Il crée l’arborescence de base, via une simple commande comme npx create-react-app my-app --template typescript.

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.

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 Debounce en React

Régulièrement en Javascript, on veut écouter les changements sur un élément, mais ne les prendre en compte qu’à intervalle régulier.
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;

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

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

Marque-page C4 - Modèle de visualisation d'architecture logicielle

C4 est une proposition d'organisation graphique pour représenter une architecture logicielle sous forme de schémas.

Documentation : https://c4model.com

Échelles

Le premier principe propose d'utiliser différentes échelles, pour différents schémas, plutôt qu'un seul, tentaculaire et incompréhensible.

Les 4 échelles, de la plus générale à la plus détaillée constituent les 4C.

4 niveaux

Context

C'est le niveau le plus haut, le contexte : ce qui se passe autour de l'application.
Celle-ci est représentée en un bloc au centre, autour duquel gravitent :

  • les utilisateurs
  • les autres systèmes interagissant avec elle

Containers

C'est le niveau représentant les différentes briques logicielles de l'application : les conteneurs.
C'est un peu le même diagramme que le précédent, mais avec un zoom dans la boîte centrale.

Exemples de conteneurs :

  • une webapp Angular
  • une API Symfony
  • Une BDD PostgreSQL

Components

Les composants représentent les différents besoins fonctionnels auxquels répond un conteneur.
Un schéma de ce niveau permet de visualiser l'organisation générale du conteneur.

Par exemple, on pourra trouver :

  • l'inscription
  • la gestion des utilisateurs
  • l'affichage des actualités
  • la newsletter

Cela correspond souvent aux premiers niveaux de namespace/package/répertoires/etc.

Code

C'est le niveau qui zoome sur l'un des composant. Il est beaucoup plus anecdotique que les précédents, et pourrait représenter un diagramme de classes, par exemple.

Conclusion

Le but du modèle n'est pas d'imposer un nombre d'échelles. De toute façon, et particulièrement en informatique, tout est poupées russes. On peut toujours zoomer et créer un nouveau schéma augmentant le niveau de détails.

Le but est plutôt de savoir découper ses schémas pour qu'ils restent pertinents et lisibles.
C'est ainsi seulement, qu'ils ajoutent de la valeur.

Représentation

Le second aspect important du modèle soutien ce même objectif. On peut le résumer ainsi : un schéma doit se suffire à lui-même.

Il doit donc contenir suffisamment de texte (dans les blocs et sur leurs liaisons), un titre et une légende.

La comparaison avec une carte de géographie est très bien trouvée, car c'est exactement la même méthode. Une carte doit montrer quelque chose, sans besoin d'une page d'explication à côté.

Outils

Pour faciliter la création de schémas, le site officiel recense plusieurs outils, dont :

  • PlantUML : un langage permettant de générer des schémas à partir de fichiers textuels
  • diagrams.net : une interface graphique gratuite en ligne

Erreur Google Map vide

Si vous intégrez une Google Map mais qu'elle reste vide, vérifiez bien :

  • que les scripts javascript Google sont bien chargés
  • que l'initialisation de la google Map est correcte, sans oublier les paramètres center et zoom.

Voici l'exemple fourni par Google dans la documentation.

Astuce Créer un patch avec TortoiseSVN

TortoiseSVN permet de générer des patchs pour passer d'une version à une autre du projet.

Il y a deux types de patch :

  • Les patchs contenant toutes les lignes des fichiers modifiés qui ne sont pas encore "commités".
  • Les patchs contenant l'arborescence de tous les fichiers modifiés entre deux tags.

Description

Le premier type génère un fichier .patch, qui ressemble à quelque chose comme ça :

Index: settings/siteaccess/bo/site.ini.append.php
===================================================================
--- settings/siteaccess/bo/site.ini.append.php    (révision 864)
+++ settings/siteaccess/bo/site.ini.append.php    (copie de travail)
@@ -37,4 +37,23 @@
 CachedViewPreferences[full]=admin_navigation_content=1;admin_children_viewmode=list;admin_list_limit=1
 TranslationList=

+[DebugSettings]
+DebugOutput=enabled
+DebugToolbar=enabled
+DebugRedirection=disabled
+
+[TemplateSettings]
+Debug=disabled
+
+[DatabaseSettings]
+SQLOutput=disabled
+
 */ ?>
\ No newline at end of file

On peut y voir pour chaque fichier modifié, les lignes ajoutées et supprimées depuis le dernier commit.

Le second type génère l'arborescence et les fichiers entiers.

Utilisation

Pour générer un fichier .patch, il suffit de :

  • Faire un clic-droit sur l'arborescence
  • > TortoiseSVN
  • > Create patch...
  • Sélectionner les fichiers à patcher
  • Choisir (via le bouton Options) si on considère les modifications d'espaces blancs.

Pour utiliser ce fichier, il faut :

  • Le placer à l'endroit d'où on l'a généré
  • Faire un clic-droit sur le fichier
  • > TortoiseSVN
  • > Apply patch...

La liste des fichiers modifiés apparait, et on peut voir pour chacun d'eux les lignes impactées. On peut alors choisir de patcher tous les fichiers ou seulement certains.

Pour générer toute l'arborescence des fichiers modifiés entre deux tags, il faut :

  • Faire un clic-droit sur l'arborescence
  • > TortoiseSVN
  • > Repo-browser
  • Faire un clic-droit dans l'arborescence, sur la version la plus récente à comparer
  • > Mark for comparison
  • Faire un clic-droit dans l'arborescence, sur la version la plus ancienne à comparer
  • > Compare URLs

La liste des fichiers modifiés entre les deux versions apparaît. Il suffit alors de :

  • Les sélectionner tous
  • Faire un clic-droit sur un fichier sélectionné
  • > Export selection to...
  • Choisir où générer les fichiers

Astuce Supprimer le menu contextuel créé lors de l'installation de Git

Lors de l'installation de Git sous Windows, vous avez la possibilité d'activer un menu contextuel proposant des raccourcis vers les tâches les plus courantes de Git (commit, pull, ...).

Le problème c'est que TortoiseGit propose la même fonctionnalité en mieux. Si vous utilisez ces deux logiciels, vous obtenez donc deux menus contextuels identiques...

Pour supprimer celui créé par Git, utilisez une des commandes suivantes.

Pour Windows 32 bits :

cd C:\Program Files\Git\git-cheetah
regsvr32 /u git_shell_ext.dll

Pour Windows 64 bits :

cd C:\Program Files\Git\git-cheetah
regsvr32 /u git_shell_ext64dll

Erreur Docker écrase le réseau VPN

Lorsque docker crée des réseaux virtuels entre ses conteneurs, il utilise des plages IP.
En général pas de problème, il utilise des plages non couramment utilisées.

Par contre, si ses plages habituelles ne sont pas disponibles (ou pour d'autres raisons ?), il est possible qu'il en utilise une autre, par exemple 192.168.x.x.
Cela peut alors être problématique, surtout si vous utilisez un VPN ou des ressources réseaux en parallèle, car cette plage est classiquement utilisée. On peut alors avoir un conflit d'adressage et les ressources réseaux ou du VPN peuvent ne plus être accessibles.

Pour corriger ça, une première approche consiste à killer le réseau (créé par docker) qui pose problème puis à le recréer.
Pour cela :

# Listage des réseaux créés par docker
docker network list

# Identifiez le réseau correspondant au conteneur qui a causé le problème (copiez son ID)
# Pour vérifier la plage IP qu'il utilise :
docker network inspect <ID>

# Suppression du réseau qui pose problème
docker network rm <ID>

Si cela ne suffit pas, essayez la suite de la procédure proposée ici.

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 Bypasser un runlevel

Pendant le lancement du système, des services sont démarrés.
L'ordre de lancement a une importance, car beaucoup de services ont besoin d'autres services pour fonctionner.

Sous linux il y a donc la notion de runlevel (cf. wikipédia). Les runlevels (niveau d'exécution) vont de 0 à 6 et se font un à un dans l'ordre croissant.

Il arrive qu'une erreur se produise lors du lancement d'un service à un certain runlevel, bloquant ainsi le démarrage.
Il est possible de forcer le passage au niveau suivant, même si tout n'est pas encore terminé.

Pour cela appuyez sur les touches suivantes durant l'initialisation : CTRL + ALT + F<NIVEAU>
Avec <NIVEAU> le niveau de runlevel vers lesquels passer (ex: CTRL + ALT + F4 pour passer au niveau 4).

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 Supprimer les images/conteneurs/volumes docker

Pour supprimer tous les conteneurs docker :

docker rm $(docker ps -a -q)

Pour supprimer toutes les images docker :

docker rmi $(docker images -q)

Pour supprimer tous les volumes docker :

docker volume prune

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 Savoir quel processus utilise un port

Sous Linux, vous pouvez utiliser la commande suivante pour savoir quel processus utilise un port :

# Pour le port 80
netstat -tlnp | grep 80

Cela retournera par exemple :

tcp6       0      0 :::80                   :::*                    LISTEN      32198/apache2

L'ID du processus étant ici 32198.

Remarque : Pour voir tous les processus, il sera peut-être nécessaire de lancer la commande en tant que root (ou avec sudo).

Astuce Les nouveaux opérateurs introduits par PHP 7

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

Null coalescent

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

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

peut maintenant s'écrire :

$value = $x ?? $defaultValue;

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

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

$value = $x ?: $valueIfXIsFalse;

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

Spaceship

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

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

peut maintenant s'écrire :

$comparison = $a <=> $b;

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

Astuce La fonction __invoke()

Utilisation

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

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

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

Ce qui affiche :

int(5)
bool(true)

Explication :

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

Utilité

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

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

Imaginons qu'on veuille trier ce tableau :

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

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

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

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

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

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

class Comparator {
    protected $key;

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

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

et ainsi choisir au moment de l'appel :

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

Source : stackoverflow.com

Astuce Utiliser les promesses

Introduction

Lorsqu'on fait un appel à un webservice ou qu'on exécute une grosse requête en base de données, cela prend un certain temps. On sait ce qu'on est sensé récupérer mais pas quand, ce qui semble plutôt gênant pour lancer la suite du traitement (ex: afficher les résultats).

Ce qu'on appelle une promesse, c'est ce qu'on espère recevoir comme résultat (un tableau, un flux, un objet, ... ce qu'on veut). On code la suite du traitement à réaliser comme si on avait ce résultat, sauf qu'on part de l'objet Promise à la place.

Exemples

Si on n'utilise pas les promesses et qu'on utilise une fonction asynchrone, cela peut ressembler à ça :

function getNumberFromWebservice(successCllback, errorCallback) {
    try {
        // On récupère les données
        let number = xxx();
        successCllback(number);
    } catch {
        errorCallback('Impossible de récupérer le nombre.');
    }
}

getResultsFromWebservice(function (number) {
    console.log('Affichage du résultat :');
    console.log(number);
}, function (error) {
    console.error(error);
});

Cette méthode fonctionne bien, mais à quoi cela ressemblerait-il si on appelait une seconde fonction asynchrone au retour de la première ?

function getNumberFromWebservice1(successCllback, errorCallback) {
    try {
        // On récupère les données
        let number = xxx();
        successCllback(number);
    } catch {
        errorCallback('Impossible de récupérer le nombre.');
    }
}

function getNumberFromWebservice2(number1, successCllback, errorCallback) {
    try {
        // On récupère les données
        let number2 = yyy();
        successCllback(number1 + number2);
    } catch {
        errorCallback('Impossible de récupérer le nombre.');
    }
}

function displayResult(sum) {
    console.log('Affichage de la somme :');
    console.log(sum);
}

function displayError(error) {
    console.error(error);
}

// /!\ Début de callback hell /!\
getResultsFromWebservice1(function (number1) {
    getResultsFromWebservice2(number1, function (sum) {
        displayResult(sum);
    }, function (error) {
        displayError(error);
    });
}, function (error) {
    displayError(error);
});

Ca devient vraiment peu lisible dès qu'on multiplie les fonctions asynchrones à chaîner. On a des callback de callback de callback (cf. Callback hell).

Les promesses permettent une syntaxe plus lisible :

// Pas de callback hell, on évite l'écriture en imbrication
getResultsFromWebservice1()
    .then(function(number1) { 
        return getResultsFromWebservice2(number1);
    }),
    .then(function(sum) {
        console.log('Affichage de la somme :');
        console.log(sum);
    }),
    .catch(function (error) {
        console.error(error);
    });

// Ou même plus court avec la syntaxe allégée
getResultsFromWebservice1()
    .then((number1) => getResultsFromWebservice2(number1)),
    .then((sum) => displayResult(sum)),
    .catch((error) => displayError(error));

Le principe est le même sauf qu'on n'a plus besoin de passer les callback en paramètres à chaque fonction. Par contre pour que ça marche, il faut modifier un peu chaque fonction pour qu'elle ne retourne non plus un nombre mais un objet Promise :

function getNumberFromWebservice1() {
    return new Promise((resolve, reject) => {
        // On récupère les données
        let number = xxx();
        resolve(number);
    });
}

function getNumberFromWebservice2(number1) {
    return new Promise((resolve, reject) => {
        // On récupère les données
        let number2 = yyy();
        resolve(number1 + number2);
    });
}

Remarque :

Les try/catch ont été retirés dans les fonctions. Cela veut dire qu'en a cas d'erreur levée par l'appel aux webservices (xxx() et yyy()), ce sera le message de l'erreur qui sera affiché, et non plus le message 'Impossible de récupérer le nombre.'.

Aller plus loin

Les promesses existent depuis un moment en javascript. Il y a pas mal de cas d'utilisation :

  • exécuter trois tâches asynchrones une à une
  • exécuter trois tâches asynchrones une à une et en transmettant à chaque fois le résultat à la suivante
  • exécuter plusieurs tâches asynchrones en parallèle, puis une dernière quand elles sont toutes terminées
  • exécuter 2 séries de 3 tâches asynchrones en parallèle
  • ...

La plupart des framework proposent une couche supplémentaire pour gérer ces cas d'utilisation. Dans le monde de NodeJS par exemple, on utilise très souvent la lib async.

Documentation Mozilla Développeur :

Marque-page Introduction à NodeJS

Exercices

Pour faire ses premiers pas avec NodeJS, le site nodeschool.io propose des ateliers de développement.

Concrètement, il s'agit de petites applications NodeJS qui affichent un énoncé, et sont capables d'éxécuter votre programme pour vérifier qu'il correspond aux consignes. L'un des ateliers utiles pour débuter s'appelle learnyounode.

Accueil learnyounode

Pour l'utiliser il vous faut NodeJS :

curl -sL https://deb.nodesource.com/setup_8.x | sudo -E bash -
sudo apt-get install -y nodejs

Ensuite ça se passe avec npm (inclus vec NodeJS) :

npm install -g learnyounode
learnyounode

Express

Le framework Express semble très largement répandu dans l'univers de NodeJS, dès lors qu'on souhaite créer une application web (API REST, site front, ...).

Mozilla Developpeur propose un bon tutoriel pour démarrer avec Express.
Il s'agît d'un cas concret de création d'appplication web, pour gérer une bibliothèque (de livres).
Une partie front interroge un back REST full qui stocke les données dans une base MongoDB.

(Il y a également un atelier sur le sujet proposé sur nodeschool.io)

Mongoose

Pour utiliser une base MongoDB avec NodeJS, il existe l'API Mongoose.
(Elle est utilisée dans le tutoriel précédent.)

Astuce Centrer une popin Boostrap 3

Sur le site de Bootstrap, voici le code pour afficher un bouton ouvrant une popin :

<button type="button" class="btn btn-primary" data-toggle="modal" data-target=".bs-example-modal-lg">Large modal</button>

<div class="modal fade bs-example-modal-lg" tabindex="-1" role="dialog" aria-labelledby="myLargeModalLabel">
  <div class="modal-dialog modal-lg" role="document">
    <div class="modal-content">
      ...
    </div>
  </div>
</div>

Par défaut la popin sera placée en haut de la fenêtre, ce qui n'est pas toujours très joli :

Popin Bootstrap 3 non centrée verticalement

Si vous préférez que vos popin soient centrées verticalement, vous pouvez ajouter ces quelques lignes à votre feuille de style :

.modal.in .modal-dialog {
    -webkit-transform: translate(0, calc(50vh - 50%));
    -ms-transform: translate(0, 50vh) translate(0, -50%);
    -o-transform: translate(0, calc(50vh - 50%));
    transform: translate(0, 50vh) translate(0, -50%);
}

Vous obtenez alors ce résultat :

Popin Bootstrap 3 centrée verticalement

Astuce [eZ5] Authentifier un utilisateur programmatiquement

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

<?php

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

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

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

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

Marque-page Ouvrir un PSD sans Photoshop

Vous êtes sous Linux, ou vous n'avez pas Photoshop ? Vous avez besoin d'ouvrir correctement un fichier psd ?

Si c'est juste pour du découpage et pas de la créa, la version online fera très bien l'affaire !

C'est ce que propose le site https://www.photopea.com/.

Astuce Virtual host avec contexte d'URL

Si vous voulez accéder à votre application via un contexte spécifique, il faut ajouter cette ligne dans votre virtual host apache :

Alias /mon-contexte /chemin/vers/mon_appli/web

Si votre virtual host répond au nom de domaine www.mon-site.com par exemple, votre site sera maintenant accessible via l'URL www.mon-site.com/mon-contexte.

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

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

$ drush updb -y
The following updates are pending:

views module : 
  Fix table names for revision metadata fields.

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

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

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

# Schema for the views plugins of the my_module module.

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

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

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

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

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

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

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

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

Astuce Installer un patch via composer

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

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

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

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

Explications :

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

Remarque :

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

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

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

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

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

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

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

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

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

Explication :

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

Remarque :

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

Marque-page Étendre la taille d'un disque virtuel

Si vous avez créé un disque avec VirtualBox et qu'il est trop petit, vous pouvez l'agrandir.

Vous pouvez voir les disques d'une VM en cliquant-droit dessus : Configuration... > Stockage.

Configuration des disques

Modifications côté Virtual box

Avant toute chose, prenez le temps de créer un clone intégral de votre VM. En cas de problème vous n'aurez ainsi rien perdu.

Prérequis

Tout d'abord, il faut que votre disque soit au format vdi (et pas vmdk). Si ce n'est pas le cas, utilisez la commande suivante :

VBoxManage clonehd "source.vmdk" "cloned.vdi" --format vdi

Si votre disque a une taille fixe et non pas allouée dynamiquement, ajoutez l'option --variant Standard à la commande précédente pour en changer (vous pourrez revenir à l'état précédent par la suite).

Ajout d'espace disque

Il faut ensuite modifier la taille du disque, via la commande :

VBoxManage modifyhd "cloned.vdi" --resize 51200

51200 est la nouvelle taille en Mo.

Retour au format initial

Vous avez maintenant un disque au format vdi, avec une taille dynamique. Pour retrouver un disque au format vmdk, et/ou de taille fixe, inversez la première commande :

VBoxManage clonehd "cloned.vdi" "source.vmdk" --format vmdk --variant Fixed

Modification côté VM

Votre disque a maintenant la taille souhaitée, mais la partition n'a pas changé. Vous avez donc tout un espace disque non alloué.

  • Téléchargez un iso de gparted
  • Insérez-le dans le lecteur virtuel de votre VM
  • Bootez dessus
  • Suivez ce tutoriel
  • Redémarrez votre VM

Vous devriez maintenant avoir un disque dur plus grand et fonctionnel !

Astuce Installation de PHP

Installation

PHP et ses extensions

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

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

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

Configuration de PHP

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

Vérification

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

Composer

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

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

# Vérification
composer --version

Astuce Installation de PostgreSQL

Remarque :

Si vous préférez MySQL, vous pouvez suivre ce tutoriel à la place.

Il vous faudra peut-être également ajouter la ligne suivante au fichier /etc/apache2/apache2.conf puis redémarrer Apache :

Include /etc/phpmyadmin/apache.conf

Installation

  • Exécutez simplement la commande suivante :
sudo apt-get install postgresql postgresql-client postgresql-doc
  • Vérifiez votre version de postgres (ex: 9.4)
    ls /etc/postgresql/
  • Forcez postgreSQL à utiliser l'UTF-8. (Adaptez les commandes suivantes avec la bonne version) :
sudo  pg_dropcluster --stop 9.4 main
sudo  pg_createcluster --start -e UTF-8 9.4 main

Configuration générale

Connexion distante

Par défaut, PostgreSQL n'est pas accessible à distance (ex: avec votre IDE sur votre poste de dev).

Pour l'autoriser, modifiez le fichier le fichier de configuration /etc/postgresql/<version>/main/pg_hba.conf, en remplaçant la ligne suivante par celle du dessous :

#local   all         all                               peer
local   all         all                               trust

Redémarrez le service postgresql :

sudo /etc/init.d/postgresql reload

Adminer

Quand on utilise MySQL, on le couple souvent avec PHPMyAdmin, pour pouvoir l'administrer via une interface web. Côté PostgreSQL, il n'y a pas d'outil aussi abouti.

Adminer ne s'en tire tout de même pas si mal. D'ailleurs, il permet aussi de se connecter à plein d'autres SGBD, dont MySQL.

Pour l'installer, suivez les commandes suivantes :

sudo mkdir /var/www/adminer
sudo cd /var/www/adminer
sudo wget https://www.adminer.org/latest.php
sudo mv latest.php index.php
sudo chown -R phpuser:phpuser .

Ensuite si vous utilisez les virtual host pour votre site, ajoutez-en un pour adminer. Pour cela créez par exemple le fichier /etc/sites-available/adminer.conf :

<VirtualHost *:80>
    ServerName db.adminer.dev
    ServerAdmin webmaster@localhost
    DocumentRoot /var/www/adminer

    ErrorLog ${APACHE_LOG_DIR}/adminer.error.log
    CustomLog ${APACHE_LOG_DIR}/adminer.access.log combined
</VirtualHost>

Remarques :

  • Vous pouvez bien sur adapter ces configurations, en particulier le nom de domaine db.adminer.dev.
  • Vous devrez probablement ajouter ce nom de domaine dans le fichier hosts de votre poste de dev pour pouvoir l'utiliser.

Activez maintenant ce virtual host et redémarrez Apache :

sudo a2ensite adminer
sudo service apache2 reload

Configuration pour votre site

Par défaut, la base de données postgres a été créée. Son propriétaire s'appelle aussi postgres. Un nouvel utilisateur système postgres a lui aussi été créé.

  • Connectez vous en tant qu'utilisateur postgres (qui est administrateur postgreSQL) :
sudo su - postgres
  • Créez un nouvel utilisateur :
createuser --interactive mypguser

Avec les options suivantes :

  • Super-utilisateur : Non
  • Créateur de base de données : Oui
  • Création de nouveaux rôles : Non

Modifiez son mot de passe :

psql
> ALTER USER mypguser WITH PASSWORD 'new_password';
  • Créez une nouvelle base de données :
createdb -O mypguser mypgdatabase
  • Si besoin, connectez-vous-y pour rendre votre utilisateur propriétaire du schéma public :
psql mypgdatabase
> ALTER SCHEMA public OWNER TO mypguser;
  • Redémarrez le service postgresql :
sudo /etc/init.d/postgresql reload

Utilisation

Connectez-vous à la base avec le nouvel utilisateur :

psql -d mypgdatabase -U mypguser

Si vous n'avez pas d'erreur, alors c'est prêt (\q pour quitter la console postgreSQL).