Symfony : Validation des emails

Validation des emails

Symfony fournit une contrainte de validation pour les emails :

/**
 * @var string
 * @ORM\Column(name="email", type="string", length=255)
 * @Assert\Email()
 */
private $email;

De base cette constrainte n’est pas très évoluée et les adresses mail suivantes sont autorisées :

!~@test.*
@@test.a
...

Activer la vérification du MX du domaine de l’email

Le MX du domaine permet de s’assurer que :

  1. Le nom de domaine existe
  2. Un champ MX est défini pour ce domaine et qu’il est donc très probable qu’il y ait un serveur mail derrière

Pour activer la vérification du MX :

/**
 * @var string
 * @ORM\Column(name="email", type="string", length=255)
 * @Assert\Email(checkMX=true)
 */
private $email;

Il est possible également de vérifier le Host avec checkHost=true. Cette contrainte vérifie qu’il existe un champ A, un champ AAAA ou un champ MX. Mais je ne recommande pas l’utilisation de cette contrainte car checkMX permet de s’assurer qu’il existe au moins un champ MX et c’est ce qui est important pour envoyer un mail.

Activer la validation de l’email selon la RFC

La RFC 3696 définit précisément le format d’une adresse mail.

Pour activer la validation selon la RFC 3696 :

/**
 * @var string
 * @ORM\Column(name="email", type="string", length=255)
 * @Assert\Email(strict=true)
 */
private $email;

Il faut au préalable ajouter le bundle suivant : egulias/email-validator. Ce bundle permet de vérifier le format de l’adresse mail selon les préconisations de le RFC 3696.

composer require egulias/email-validator

Double validation

Avec ces deux contraintes, la validation est très correcte. On est sûr que le domaine existe et que l’adresse est au bon format. Pour utiliser les deux contraintes en même temps :

/**
 * @var string
 * @ORM\Column(name="email", type="string", length=255)
 * @Assert\Email(strict=true, checkMX=true)
 */
private $email;

Si tu veux personnaliser les messages d’erreur en fonction du type de contrainte :

/**
 * @var string
 * @ORM\Column(name="email", type="string", length=255)
 * @Assert\Email(strict=true, message="Le format de l'email est incorrect")
 * @Assert\Email(checkMX=true, message="Aucun serveur mail n'a été trouvé pour ce domaine")
 */
private $email;

Voir la documentation officielle

Full validation

Comment être sûr à 100% que l’adresse mail saisie est bien réelle ?

  • Envoyer un mail sur l’adresse mail saisie contenant un lien protégé qui, lorsqu’il est appelé, valide l’adresse mail
  • Envoyer un mail sur l’adresse mail saisie en indiquant un ReturnPath. Si l’adresse n’existe pas, tu recevra un mail de no-delivery sur cette adresse. Il faudra alors déclencher une opération à la réception de l’email pour invalider l’adresse mail.

Inconvénient : On ne peut pas vérifier au moment de la saisie.

Symfony : gestion des collections dans les formulaires

gestion des collections dans les formulaires

Prenons un exemple simple :

Nous avons des utilisateurs à qui il est possible d’ajouter des diplômes.

Notre entité User :

Entity/User.php :

class User
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;
    
    // ... Autres attributs : nom / prénom ...

    /**
     * @var Diplome
     *
     * @ORM\OneToMany(targetEntity="Diplome", mappedBy="user", cascade="all", orphanRemoval=true)
     * @Assert\Valid()
     * @OrderBy({"position" = "ASC"})
     */
    private $diplomes;
    
    
    /**
     * Constructor
     */
    public function __construct()
    {
        $this->diplomes = new ArrayCollection();
    }
    
    /**
     * Get id
     *
     * @return integer
     */
    public function getId()
    {
        return $this->id;
    }
    
    // ... Autres getter et setter
    
    /**
     * Add diplome
     *
     * @param Diplome $diplome
     *
     * @return User
     */
    public function addDiplome(Diplome $diplome)
    {
        $this->diplomes[] = $diplome;
        $diplome->setUser($this);

        return $this;
    }

    /**
     * Remove diplome
     *
     * @param Diplome $diplome
     */
    public function removeDiplome(Diplome $diplome)
    {
        $this->diplomes->removeElement($diplome);
    }

    /**
     * Get diplomes
     *
     * @return \Doctrine\Common\Collections\Collection
     */
    public function getDiplomes()
    {
        return $this->diplomes;
    }
}

On passe l’attribut orphanRemoval à true pour la relation OneToMany avec Diplome. OrphanRemoval va permettre de supprimer une entité Diplome lorsqu’elle est retirée de la collection de l’entité User. On indique aussi l’attribut cascade = all. Ceci permet qu’un évènement doctrine sur l’entité User déclanche en cascade le même évènement sur l’entité Diplome : on persite un utilisateur donc on persiste ses diplômes, on supprime un utilisateur donc on supprime ses diplômes.

Dans la fonction addDiplome, on ajoute la ligne :

$diplome->setUser($this);

On ajoute le diplôme à l’utilisateur, on doit alors informer le dipôme de l’utilisateur auquel il est lié.

Voici maintenant notre entité Diplome :

Entity/Diplome.php :

class Diplome
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(name="name", type="string", length=255)
     *
     * @Assert\NotBlank()
     * @Assert\Length(max="255")
     */
    private $name;

    /**
     * @var User
     *
     * @ORM\ManyToOne(targetEntity="User", inversedBy="diplomes")
     * @ORM\JoinColumn(nullable=false)
     * @Assert\NotNull()
     */
    private $user;
    
    /**
     * Constructor
     *
     * @param User $user
     */
    public function __construct(User $user = null)
    {
        $this->user = $user;
    }
    
    /**
     * Get id
     *
     * @return integer
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * Set user
     *
     * @param User $user
     *
     * @return Diplome
     */
    public function setUser(User $user)
    {
        $this->user = $user;

        return $this;
    }

    /**
     * Get user
     *
     * @return User
     */
    public function getUser()
    {
        return $this->user;
    }
}

Notre formulaire qui va permettre d’ajouter un utilisateur avec ses diplômes :

Form/UserType.php :

$builder
    // ->add('nom', TextType::class)
    // Autres champs de l'entité User ...
    ->add('diplomes', CollectionType:class, array(
        'type' => DiplomeType::class,
        'allow_add' => true,
        'allow_delete' => true,
        'prototype' => true,
    ));

On ajoute un champ diplome de type Collection :

type : le type de champ de la collection, ici ce sera un autre formulaire de type diplome

allow_add : On veut pouvoir en ajouter

allow_delete : On veut pouvoir en supprimer

prototype : On veut qu’un prototype soit défini afin de pouvoir gérer la collection en javascript côté client.

Pour les autres options, voir la documentation sur les CollectionType.

Et là quand j’envoie mon formulaire après avoir ajouté un diplome, j’ai la fameuse erreur :

Cette valeur ne doit pas être nulle.

Pour info, j’ai cette erreur parce que j’ai indiqué @Assert\NotNull() pour l’attribut user de l’entité Diplome, sans cette Assert, j’aurai eu une belle erreur Symfony : Intégrity constraint violation

Apparement, la fonction addDiplome de mon entité User n’est pas appelée …

Pour contrer cette erreur, on retrouve des codes du style :

Form/UserType.php :

$builder->addEventListener(
    FormEvents::SUBMIT,
    function (FormEvent $event) {
        
        foreach ($event->getData()->getDiplomes() as $diplome) {
            $diplome->setUser($event->getData());
        }
        
    }
);

Attention, ce code n’est pas recommandé, utilises plutôt la solution décrite plus bas.

Explication : Au submit, on fait une boucle sur les diplômes de la collection et, pour chacun, on appelle la méthode setUser qui va les lier à notre object User.

C’est pas génial comme code … (Et j’ai vu pire aussi ! Parfois c’est dans le contrôler qu’on peut trouver cette boucle sur les catégories).

Comment bien gérer cette collection ? L’attribut by_reference

Il existe un attribut by_reference qu’il est indiqué pour tout type de formulaire. Par défaut, cet attribut est passé à true.

En passant cet attribut à false, on force Symfony à appeler le setter de l’entité.

Dans le cas d’une collection, on force Symfony à appeler les méthodes add et remove de l’attribut.

Pour notre exemple, on rajoute by_reference = false.

Form/UserType.php :

$builder
    ->add('diplomes', CollectionType:class, array(
        'type' => DiplomeType::class,
        'allow_add' => true,
        'allow_delete' => true,
        'prototype' => true,
        'by_reference' => false
    ));

Maintenant, au submit, la méthode addDiplome de l’entité User sera appelée et ainsi la ligne suivante sera exécutée, permettant de lier le diplôme à l’utilisateur :

$diplome->setUser($this);

Autre explication sur la documentation de symfony.

Symfony : Editeur WIZIWIG CKEditor avec IvoryCKEdirorBundle

CKEditor Symfony

Attention, cet article est obsolète et ce tuto est rédigé pour Symfony 2, je te conseille d’utiliser maintenant ckeditor-bundle.

CKEditor est un éditeur WIZIWIG(What you see is what you get). Il est facilement personnalisable et de nombreux plugins sont disponibles.

Interface CKEditor

CKEditor avec IvoryCKEdirorBundle

Dans ce tuto, nous allons utiliser le bundle IvoryCKEditorBundle qui va nous simplifier l’intégration de CKEditor.

Instalation

composer.json

{
    "require": {
        "egeloen/ckeditor-bundle": "4.*",
    }
}

app/AppKernel.php

$bundles = array(
    // ...
    new Ivory\CKEditorBundle\IvoryCKEditorBundle(),
);

N’oublies pas d’installer les assets (si pas déjà fait par un composer update) :

php bin/console asset:install --symlink

Configuration

config.yml

ivory_ck_editor:
    default_config:                     my_custom_config    # Utiliser par defaut la configuration my_custom_config
    configs:
        my_custom_config:
            language:                   "%locale%"
            toolbar:                    "standard"          # Charger la toolbar tollbar_1 (voir plus bas)
            stylesSet:                  "my_styles"         # Chargement des styles personnalisables my_styles (voir plus bas)
            uiColor:                    "#FCFCFC"           # Couleur de fond de l'interface
            height:                     "600px"             # Hauteur par défaut
            contentsCss:                ['bundles/app/css/style.css'] # Charge les styles dans l'éditeur (permet de voir en temps réel le résultat)
    styles:         # Configuration des styles personnalisables
        my_styles:
            - { name: "Alert Success", element: "div",  attributes: { class: "alert alert-success", role: "alert" }}
            - { name: "Alert Info", element: "div",  attributes: { class: "alert alert-info", role: "alert" }}
            - { name: "Alert Warning", element: "div",  attributes: { class: "alert alert-warning", role: "alert" }}
            - { name: "Alert Danger", element: "div",  attributes: { class: "alert alert-danger", role: "alert" }}
            - { name: "Badge", element: "span",  attributes: { class: "badge" }}

Utilisation

Pour charger CKEditor dans un formBuilder :

->add('content', CKEditorType::class, array (
    'label' => 'Contenu',
))

Tu peux aussi passer des options supplémentaires directement dans le formBuilder :

->add('content', CKEditorType::class, array (
    'label'             => 'Contenu',
    'config_name'       => 'my_custom_config',
    'config' => array(
        'language'    => 'fr'
    ),
))

Configuration avancée

Utiliser un skin

Télécharges un skin sur le site officiel et ajoutes le dans un répertoire public (dans /web/bundles/…)

Tu peux aussi l’ajouter dans un répertoire public d’un bundle et installer les assets.

ivory_ck_editor:
    configs:
        my_custom_config:
            skin: "skin_name,/absolute/web/skin/path/"

Personnalises la barre de boutons

De base, 3 types de toolbars sont pré-configurés : full, standard, basic.

ivory_ck_editor:
    configs:
        my_custom_config:
            toolbar: "standard"

Tu peux aussi personnaliser toi-même la toolbar :

ivory_ck_editor:
    default_config: "my_custom_config"
    configs:
        my_custom_config:
            uiColor:                    "#FCFCFC"
            toolbar:                    [ [ 'Preview' ], [ 'Cut','Copy','Paste','PasteText','PasteFromWord','-','Undo','Redo' ], [ 'Find','Replace','-','SelectAll','-','SpellChecker', 'Scayt' ], [ 'Source' ], [ "About" ], "/", [ 'Bold','Italic','Underline','Strike', 'Blockquote','Subscript','Superscript','-','RemoveFormat' ], [ 'NumberedList','BulletedList','-','Outdent','Indent','-','-','JustifyLeft','JustifyCenter','JustifyRight','JustifyBlock' ], [ 'Link','Unlink','Anchor' ], [ 'pbckcode', 'Image', 'Video', 'Table','SpecialChar','Iframe' ], '/', [ 'Styles', 'Format','Font','FontSize' ], [ 'TextColor','BGColor' ], [ 'RemoveFormat' ], [ 'Maximize' ] ]

Tu peux ajouter ou enlever des boutons à souhait. Retrouves la liste complète des boutons disponibles.

Il est aussi possible de définir des toolbars personalisées et les partager entre plusieurs configurations. Exemple :

ivory_ck_editor:
    configs:
        my_config_1:
            toolbar: "my_toolbar_1"
            uiColor: "#000000"
            # ...
        my_config_2:
            toolbar: "my_toolbar_2"
            uiColor: "#ffffff"
            # ...
    toolbars:
        configs:
            my_toolbar_1: [ "@document", "/", "@link" , "/", "@tool" ]
            my_toolbar_2: [ [ "Source", "-", "Save" ], "/", [ "Anchor" ], "/", [ "Maximize" ] ]
        items:
            document: [ "Source", "-", "Save" ]
            link:     [ "Anchor" ]
            tool:     [ "Maximize" ]

Utiliser un plugin

Télécharges un plugin sur le site de CKEditor et ajoutes le dans un répertoire public de votre application.

Exemple pour le plugin Vidéo qui nous permet d’ajouter des vidéos HTML5 dans l’éditeur :

Je le place dans mon répertoire src/AppBundle/Ressources/public/ckeditor/video puis :

php bin/console asset:install --symlink

config.yml

ivory_ck_editor:
    plugins:
        video:
            path:                       "/bundles/app/ckeditor/video/"
            filename:                   "plugin.js"
    default_config:    "my_custom_config"
    configs:
        my_custom_config:
            toolbar:                    [ ['Video' ], '/', [ 'RemoveFormat' ], [ 'Maximize' ] ]
            extraPlugins:               "'video"

Plus de documentation

Consultes le github du bundle ou la documentation par symfony de CKEditor.

Gestionnaire de média avec FMElfinderBundle

Tout va bien jusque là, mais pour ajouter une image, ça devient vite compliqué si elle est en local par exemple. On doit l’envoyer sur un serveur, trouver son lien direct, le copier, etc …

Pour palier à ce problème, nous allons utiliser un bundle qui va intégrer tout seul un gestionnaire de média à l’interface CKEditor. Ainsi tu pourra ajouter, modifier, supprimer les images, vidéos, pdfs, etc … directement depuis l’interface.

Exemple :

Gestionnaire de média

Installation de FMElfinderBundle

composer.json

{
    "require": {
        "helios-ag/fm-elfinder-bundle": "6.*",    
    }
    "config": {
        "component-dir": "web/dossier_de_mon_choix" # Les ressources utilisés par fm-elfinder seront placées dans ce dossier
    }
}

app/AppKernel.php

$bundles = array(
    // ...
    new FM\ElfinderBundle\FMElfinderBundle(),
);

app/config/routing.yml

elfinder:
     resource: "@FMElfinderBundle/Resources/config/routing.yml"

Configuration

config.yml

fm_elfinder:
    assets_path: dossier_de_mon_choix # Le dossier configuré dans composer.json
    instances:
        ckeditor:
            locale:           "%locale%"
            editor:           "ckeditor"
            fullscreen:       false
            include_assets:   true
            connector:
                debug:        false
                roots:
                    uploads:
                        driver:           "LocalFileSystem"
                        path:             "your_path_in_public_directory"
                        upload_allow:     ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'application/zip', 'audio/mpeg', 'text/csv', 'video/mp4', 'video/webm', 'application/pdf']
                        upload_deny:      ['all']
                        upload_max_size:  "8M"
ivory_ck_editor:
    default_config:    my_custom_config
    configs:
        my_custom_config:
            toolbar:                    "full"
            filebrowserBrowseRoute:     "elfinder"
            filebrowserBrowseRouteParameters:
                instance: "ckeditor"

C’est tout ! Il ne reste plus qu’à essayer. Cliques sur ajouter une image et sur Explorer le serveur. N’oublies par de créer le répertoire ‘your_path_in_public_directory’ dans le répertoire web de votre application sinon CKEditor ne pourra pas envoyer les images et tu aura une alerte indiquant que votre configuration est invalide.

Utiliser le BBcode

Utiliser le BBcode permet d’éviter d’avoir du code html dans votre base de donnée. C’est plus ou moins une bonne pratique. Un code en BBcode ressemble à :

[b]Ceci est un texte d'exemple en gras[/b]

[url]http://www.domain.com[/url]

Ckeditor propose un plugin bbcode qu’il faut installer. (Revoir comment installer un plugin)

Ensuite un peu de configuration :

ivory_ck_editor:
    plugins:
        bbcode:
            path:                       '/bundles/app/js/ckeditor/bbcode/'
            filename:                   'plugin.js'
    configs:
        normal:
            # votre config sans bbcode ...
            # ...
        bbcode:
            toolbar:                    [[ 'Bold', 'Italic', 'Underline', 'BulletedList' ]]
            uiColor:                    "#FCFCFC"
            extraPlugins:               'bbcode'

Puis dans un formulaire, charges ckeditor en mode bbcode :

->add('content', 'ckeditor', array (
    'label'             => 'Contenu',
    'config_name'       => 'bbcode'
))

Afficher le BBcode

Pour afficher le BBcode, il va falloir le convertir en HTML. Pour celà, un bundle Symfony existe : FMBBCodeBundle.

Regardes la documentation du bundle pour l’installer et le configurer.Attention, il faut utiliser la version 7.* pour Symfony 3 et non la version 6 comme préconisée dans la documentation du bundle

Ce bundle ajoute un filtre twig qui te permet de rendre le BBcode. Exemple :

{{ content | bbcode_filter('default') }}

Et pour que le HTML soit interprété :

{{ content | bbcode_filter('default') | raw }}

Utiliser un colorateur syntaxique de code intégré à CKEditor

Nous allons voir ici comment utiliser un colorateur de code. J’utilise sur cette page la bibliothèque highligh.js pour colorer le code.

Commençons par installer le plugin pbckcode dans votre fichier config.yml :

ivory_ck_editor:
    plugins:
        pbckcode:
            path:                       '/bundles/unixeliaapp/ckeditor/pbckcode/'
            filename:                   'plugin.js'
    default_config:    my_custom_config
    configs:
        my_custom_config:
            toolbar:                    ['pbckcode']
            extraPlugins:               'pbckcode'
            pbckcode:                   { highlighter : 'HIGHLIGHT', tab_size : '4', theme : 'github',  modes :  [['Text', 'text'],['HTML', 'html'], ['CSS', 'css'], ['PHP', 'php'], ['JS', 'javascript'], ['YAML', 'yaml'], ['JSON', 'json'], ['SQL', 'sql'], ['Bash', 'bash']], js : "https://cdn.jsdelivr.net//ace/1.1.4/noconflict/" }

Sur votre page d’affichage du contenu, il va falloir ajouter le script highlight.js et l’éxécuter. Exemple :

<link rel="stylesheet" href="//cdn.jsdelivr.net/highlight.js/8.5/styles/default.min.css">

{{ content | raw }}

<script src="//cdn.jsdelivr.net/highlight.js/8.5/highlight.min.js"></script>

<script>
    $(document).ready(function() {
        $('code').each(function(i, block) {
            hljs.highlightBlock(block);
        });
    });
</script>

Symfony : Comment vérifier le rôle d’un utilisateur en respectant la hiérarchie des rôles

Vérifier rôle utilisateur hiérarchie

Imaginons une application dans laquelle on retrouve les rôles suivants :

  • ROLE_SUPER_ADMIN
  • ROLE_ADMIN
  • ROLE_CLIENT
  • ROLE_USER

Ainsi que cette hiérarchie :

  • ROLE_SUPER_ADMIN a aussi le rôle ROLE_ADMIN qui a aussi le rôle ROLE_USER.
  • ROLE_CLIENT a aussi le rôle ROLE_USER.

Dans votre fichier security.yml, on le définirait ainsi :

role_hierarchy:
    ROLE_CLIENT:      ROLE_USER
    ROLE_ADMIN:       ROLE_USER
    ROLE_SUPER_ADMIN: [ROLE_USER, ROLE_ADMIN]

La problématique

Imaginons que vous souhaitiez vérifier qu’un utilisateur a bien le ROLE_ADMIN. Très facile :

$this->container->get('security.authorization_checker')->isGranted('ROLE_ADMIN')

Ok mais cette fonction permet de vérifier le rôle de l’utilisateur actuellement connecté qui fait la demande.

Maintenant, comment faire pour vérifier qu’un autre utilisateur a bien le ROLE_ADMIN ? (Exemple, afficher une liste d’utilisateurs en affichant si oui ou non il est admin).

On pense tout de suite à :

if ($user->hasRole('ROLE_ADMIN'))

Mais un utilisateur SUPER_ADMIN retournerai « false » à cette fonction car la fonction hasRole ne vérifie pas la hiérarchie des rôles.

C’est le même problème si l’on veut vérifier que l’utilisateur a bien le ROLE_USER alors qu’il est admin ou client.

Résolution du problème

On peut utiliser un service présent dans le core de Symfony dans un controller :

use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;

class UserController extends AbstractController {

    /**
     * @Route("/{user}", name="app_user_view", requirements={"user"="\d+"})
     */
    public function view(AccessDecisionManagerInterface $accessDecisionManager, User $user): Response {
       $token = new UsernamePasswordToken($user, 'none', 'none', $user->getRoles());
       if ($accessDecisionManager->decide($token, 'ROLE_ADMIN')) {
            // L'utilisateur $user a le rôle ROLE_ADMIN
       }
...

Le service AccessDecisionManager permet de vérifier si l’utilisateur a bien les droits tout en vérifiant la hiérarchie des roles. Ce service permet aussi de vérifier les droits selon des règles spécifiques définies par les Voter.

Créer un service dédié à la vérification des droits

<?php

namespace App\Services;


use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;

class GrantedService
{
    private $accessDecisionManager;

    /**
     * Constructor
     *
     * @param AccessDecisionManagerInterface $accessDecisionManager
     */
    public function __construct(AccessDecisionManagerInterface $accessDecisionManager) {
        $this->accessDecisionManager = $accessDecisionManager;
    }

    public function isGranted(User $user, $attributes, $object = null) {
        if (!is_array($attributes))
            $attributes = [$attributes];

        $token = new UsernamePasswordToken($user, 'none', 'none', $user->getRoles());

        return ($this->accessDecisionManager->decide($token, $attributes, $object));
    }
}

C’est tout, cette fonction fonctionne maintenant exactement comme la fonction isGranted disponible de base dans les controller sauf qu’il faut spécifier l’utilisateur à checker :

class UserController extends AbstractController {

    /**
     * @Route("/{user}", name="app_user_view", requirements={"user"="\d+"})
     */
    public function view(GrantedService $grantedService, User $user): Response {
       if ($grantedService->isGranted($user 'ROLE_ADMIN')) {
            // L'utilisateur $user a le rôle ROLE_ADMIN
       }
...

C’est tout pour aujourd’hui ! N’hésitez pas à poser vos questions dans les commentaires et à partager cet article ! Merci

Symfony : Configuration des logs Monolog

Configuration des logs Monolog

Symfony utilise Monolog pour gérer les logs.

Les logs te permette de garder une trace de ce qui se passe sur ton application. Souvent ils se révèlent une source très précieuse d’informations lorsqu’un utilisateur soulève un bug ou un comportement anormal.

De plus, avec Symfony, tu peux mettre en place des mécanismes simples pour déclencher des actions en fonction du niveau d’alerte des logs, par exemple pour recevoir un mail lorsqu’un problème survient. N’hésites pas à logger le plus d’informations possible, Symfony te permet de gérer simplement la rotation des fichiers de log pour ne pas perdre indéfiniment en espace disque.

Niveaux de logs

Comme beaucoup de systèmes de log, Monolog utilise plusieurs niveaux. Par ordre croissant, du moins alertant au plus critique des logs :

  • DEBUG : Utilisé en général pour développer ou débugger une application afin de vérifier une valeur ou un bon déroulement.
  • INFO : Information sur un événement commun et normal (exemple : un utilisateur qui se connecte).
  • NOTICE : Comportement normal signifiant mais pas d’erreur.
  • WARNING : Événement exceptionnel mais sans erreur (exemple : Utilisation d’une fonction dépréciée).
  • ERROR :Erreur d’exécution qui ne demande pas d’intervention immédiate mais qui doit être enregistrée. (exemple : une erreur 404, un objet non trouvé en base avec tel identifiant …).
  • CRITICAL : Exception inattendue soulevée pendant l’exécution de l’application. Cette action est généralement accompagnée d’alerte mail. (exemple : un paramètre manquant dans la configuration d’un module).
  • ALERT : « Alerte rouge », tout le service ou sa base de données est indisponible. Cette action est généralement accompagnée d’alerte sms et / ou d’alerte monitoring sonore. (exemple : le site est inaccessible par votre outil de monitoring).
  • EMERGENCY : Le système est inutilisable, tout est complètement cassé et nécessite une grosse intervention pour tout remettre d’aplomb. Des données sont perdues / corrompues. Bref … Je vous laisse imaginer la catastrophe que ça peut être. Je vous souhaite de ne jamais voir apparaître ce genre de log ! (exemple : détection d’un hacking bien hard de votre site).

Logger avec Symfony

Pour utiliser le système de log de Symfony :

Dans un contrôler

public function index(Logger $logger): Response {
    $logger->info('Tout va bien');
    $logger->error('Je ne peux pas trouver la voiture n°53');
    $logger->critical('Ca ne marche pas !!');
}

Dans un service

namespace App\Mailer;
use Monolog\Logger;

class Mailer
{
    protected $logger;

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

    public function faireQuelqueChose() {
        $this->logger->info('Je fais quelque chose');
        $this->logger->critical('Mais je n\'ai pas réussi ...');
    }
}

Configuration du logger

La configuration par défaut que l’on trouve pour un environnement de production ressemble à celle-ci :

monolog:
    handlers:
        main:
            type: fingers_crossed
            action_level: error
            handler: nested
            excluded_http_codes: [404]
        nested:
            type: stream
            path: "%kernel.logs_dir%/%kernel.environment%.log"
            level: debug
        console:
            type: console
            process_psr_3_messages: false
            channels: ["!event", "!doctrine"]

Ici main, nested, console sont appelés des handlers (des gestionnaires), le nom donné est arbitraire. Pour chaque handler, on définit un type. Nous verrons les différents types de handler possible plus bas.

Chaque handler est ensuite appelé dans l’ordre défini (Attention, on a parfois des handlers imbriqués. Ils ne sont pas appelés par défaut ! C’est le cas ici puisque nested est imbriqué dans main).

Pour expliquer cette configuration :

On a ici un FingersCrossedHandler (handler qui en déclenche un autre, ici il s’agit de main) qui se déclenche seulement lorsque le niveau de log attendu est atteint (ici on attend un log de type error). Ce handler, une fois déclenché, appelle le handler nested. Nested est de type stream (handler qui écrit les logs) qui va écrire les logs dans un fichier à partir d’un level défini (ici tout les logs plus importants ou égaux à debug).

Le handler Nested n’est pas déclenché par défaut car il est imbriqué dans main.

Le handler console est déclenché quant à lui pour tous les logs, nous ne verrons pas ce handler dans cet article, si vous désirez en savoir plus, lisez cet article du blog de Symfony.

Différents types de handles

Il existe plusieurs types de handler avec chacun une fonctionnalité précise :

  • finders_crossed : Ce handler stocke dans un buffer tout les logs qui passe. Lorsqu’un des logs dépasse le niveau minimum requis, il appelle un autre handler avec tous les logs contenus dans son buffer.
  • stream : Ce handler écrit le log qu’il reçoit dans un fichier si son niveau dépasse le niveau minimum requis.
  • rotating_file : Ce handler fait la même chose que stream mais fait une rotation des fichiers pour effacer les logs anciens.
  • group : Ce handler envoit le log reçu à plusieurs handles (exemple : pour écrire le log ET l’envoyer par mail)
  • buffer : Ce handler stocke dans un buffer tout les logs qu’il reçoit puis envoit le buffer à un handler à la fin de l’exécution de la requête.
  • swit_mailler : Ce handle envoit par mail les logs (souvent passé par un handler de type buffer)
  • console : Ce handler permet de définir les niveaux d’affichage de log dans la console.

Nous avons vu avec l’exemple par défaut comment marche les handler finder_crossed et stream.

Nous verrons dans les exemples ci-dessous comment sont utilisés les handlers pour faire ce que l’on veut.

Envoyer les alertes par mail

monolog:
    handlers:
        mail:
            type:         fingers_crossed
            action_level: critical
            handler:      buffered
        buffered:
            type:    buffer
            handler: swift
        swift:
            type:       swift_mailer
            from_email: contact@domaine.com
            to_email:   error@domaine.com
            subject:    Une erreur critique est survenue
            level:      info

Ici on attend un log de niveau critical pour déclencher le handle buffered. Une fois déclenché, le handler buffered va stocker tout les logs et les passer à la fin de l’éxécution de la requête du client au handler swift. Ce dernier va envoyer un mail en triant les logs reçus et en ne gardant que ceux de niveau minimum info.

Contrairement à un handler de type finger_crossed, un handler de type buffer appelle un handler une seule fois avec le contenu de son buffer alors que finger_crossed appelle un autre handler pour chaque log qu’il rencontre.

Rotation des logs

Pour faire la rotation des logs, on va utiliser simplement le handler rotating_file au lieu de steam :

monolog:
    handlers:
        main:
            type: rotating_file
            max_files: 10
            path: "%kernel.logs_dir%/%kernel.environment%.log"
            level: debug

Ici on écrit tous les logs de niveau supérieur à debug dans un fichier en rajoutant la date du jour dans le nom du fichier. Au bout de 10 fichiers créés, le plus vieux est supprimé automatiquement dès qu’un nouveau est créé et ainsi de suite. On a donc au minimum 10 jours de logs derrière nous. Vous pouvez augmenter ce paramètre avec max_files.

Les channels

Les logs utilisent des channels pour s’identifier. Par exemple, les logs de doctrine sont sur le channel « doctrine » et ceux sur les authentifications sont sur le channel « security ».

Ainsi on peux lancer des handlers différents en fonction du type de channel.

monolog:
    handlers:
        main:
            type: stream
            path: /var/log/symfony.log
            channels: [!doctrine, !security]
        doctrine:
            type: stream
            path: /var/log/doctrine.log
            channels: doctrine
        login:
            type: stream
            path: /var/log/auth.log
            channels: security

Ici on écrit tous les logs qui ne viennent pas de doctrine, ni de security dans un fichier symfony.log (car le type de handler est stream). On écrit tous les logs de doctrine dans doctrine.log et tout ceux de security dans auth.log.

Vos propres channels

Vous pouvez bien évidemment créer vous aussi vos propres channel pour logger vos log comme vous le souhaitez. Pour cela, ajouter un tag monolog.logger dans vos services :

App\Services\MonService:
    arguments: [@logger]
    public: true
    tags:
        - { name: monolog.logger, channel: mon_channel }

Le channel utilisé pour tous les logs du service sera ici : mon_channel.

Appeler plusieurs handler à partir d’un seul

Imaginons que vous souhaitez que lorsqu’un log de type critical est lu, un mail soit envoyé et qu’il soit écrit dans un fichier avec rotation.

monolog:
    handlers:
        main_critical:
            type:           fingers_crossed
            action_level:   critical
            handler:        grouped
        grouped:
            type:           group
            members:        [streamed, buffered]
        streamed:
            type:           rotating_file
            max_files:      15
            path:           %kernel.logs_dir%/%kernel.environment%.critical.log
            level:          info
        buffered:
            type:           buffer
            handler:        swift
        swift:
            type:           swift_mailer
            from_email:     %email.from%
            to_email:       %email.super_admin%
            subject:        Critical Error Occurred
            level:          error

Et voilà ! Dès qu’un log critical est lu, le handler main_criticale appelle le handler grouped. Le handler grouped qui est de type group va envoyer chaque log vers le handler streamed et vers le handler buffered en même temps. Ces deux handler vont ensuite remplir leurs fonctions : l’un écrire avec rotation de fichier et l’autre stocker dans un buffer avant de l’envoyer au handler qui envoit un mail.

Un exemple complet

Voici un exemple que j’utilise généralement sur mes environnements de prod :

monolog:
    handlers:
        main:
            type:           rotating_file
            max_files:      3
            path:           %kernel.logs_dir%/%kernel.environment%.all.log
            level:          info

        login:
            type:           rotating_file
            max_files:      15
            path:           %kernel.logs_dir%/%kernel.environment%.auth.log
            level:          info
            channels:       security

        main_error:
            type:           fingers_crossed
            action_level:   error
            handler:        streamed_error
        streamed_error:
            type:           rotating_file
            max_files:      15
            path:           %kernel.logs_dir%/%kernel.environment%.error.log
            level:          info

        main_critical:
            type:           fingers_crossed
            action_level:   critical
            handler:        grouped_critical
        grouped_critical:
            type:           group
            members:        [streamed_critical, buffered_critical]
        streamed_critical:
            type:           rotating_file
            max_files:      15
            path:           %kernel.logs_dir%/%kernel.environment%.critical.log
            level:          info
        buffered_critical:
            type:           buffer
            handler:        swift_critical
        swift_critical:
            type:           swift_mailer
            from_email:     contact@domain.com
            to_email:       error@my-domain.com
            subject:        Une erreur critique est survenue !
            level:          info

Tu n’as pas compris ? Explications :

Le handler main sera déclenché pour tous les logs de niveau supérieur ou égal à info et il écrira à chaque fois le log dans un fichier dans app/logs/prod.all-2015-01-05.log (avec la date du jour).

Le handler login fera la même chose mais seulement pour le channel security (les authentifications) et stocke le tout dans un fichier prod.auth-2015-01-05.log

Les logs de niveau error déclencheront le handler main_error qui appellera streamed_error qui va écrire tout les logs du buffer dans un fichier prod.error-2015-01-05.log

Les logs de niveau critical déclencherons le handler main_critical qui va à la fois écrire tout les logs dans un fichier prod.critical-2015-01-05.log et à la fois envoyer un mail avec le buffer pour prévenir de l’erreur survenue (les deux handlers sont déclenchés par le handler group : grouped_critical).

C’est fini ! N’hésitez pas à poser vos questions dans les commentaires et à partager cet article ! Merci