Les filtres en PHP

Traiter les informations reçues par le client via formulaire, ou plus généralement via COOKIE, GET ou POST. Un vrai calvaire miniature bien souvent tant la succession de conditions rend le code difficile à lire et donc à maintenir, surtout quand les tests de validité deviennent compliqués. On peut le voir sur l'exemple ci-dessous du traitement d'un formulaire d'inscription à un site.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?php
if($_SERVER['REQUEST_METHOD'] != 'POST') {
   // On ne peut pas nous avoir envoyé notre formulaire
}
else {
    if(empty($_POST['login'])) {
        // Identifiant d'authentification non-renseigné
    }

    if(empty($_POST['passwd'])) {
       // Mot de passe associé non-renseigné
    }
    else if(empty($_POST['passwd2'])) {
       // Répétition du mot de passe non-renseignée
    }
    else if($_POST['passwd'] != $_POST['passwd2']) {
       // Les mots de passe ne correspondent pas
    }

    if(empty($_POST['email'])) {
       // E-mail non-renseigné
    }
    else if(preg_match('¤^[a-z] (?: (?:\w|[.-])* [a-z0-9])? @ [a-z] (?: (?:\w|[.-])* [a-z0-9])?$¤ix', $_POST['email'])) {
       // Syntaxe e-mail invalide
    }
    else if(empty($_POST['email2'])) {
       // Répétition de l'e-mail non-renseignée
    }
    else if($_POST['email'] != $_POST['email2']) {
       // Les adresses e-mail ne correspondent pas
    }

    if(empty($_POST['dateNaissance'])) {
        // Mettre la valeur NULL histoire que ne pas avoir de Notice
        $_POST['dateNaissance'] = null;
    }
    else {
        $_POST['dateNaissance'] = DateTime::createFromFormat('d/m/Y', $_POST['dateNaissance']);
        if($_POST['dateNaissance'] === false) {
            // Date invalide
        }
    }
}

Les filtres PHP représentent un outil très efficace pour résoudre ce problème récurrent, en définissant les règles de validation avant de les appliquer. Tout s'articule autour de la fonction filter_var() et de ses variantes : filter_var_array(), qui filtre un tableau d'une traite, filter_input() qui filtre un élément d'une super-globale comme $_GET ou $_POST, et filter_input_array() qui filtre la super-globale en entier d'une traite.

Filtrer une variable avec filter_var()

Il existe 2 types de filtres de base : les filtres de validation, qui renvoient la valeur qu'on leur a donné ou false, et les filtres de nettoyage ("sanitize") qui renvoient la valeur qu'on leur a donné privée de certains éléments. FILTER_VALIDATE_URL renverra false si une URL contient des caractères incompatibles, alors que FILTER_SANITIZE_URL retirera les caractères interdits et renverra l'URL ainsi nettoyée.

Chaque filtre dispose d'une liste d'options et de drapeaux ("flags") qu'on peut ajouter pour altérer son comportement. Sans rentrer dans les détails techniques, un drapeau est une constante contenant une valeur entière, et on peut combiner les drapeaux entre eux avec l'opérateur |, ce qui nous permet d'activer des options qui n'attendent pas de valeur particulières. Par exemple, si on veut indiquer à FILTER_VALIDATE_URL qu'on veut dans notre URL un chemin et qu'on veut aussi des variables (partie "query string"), on combinera les 2 flags correspondant : FILTER_FLAG_PATH_REQUIRED | FILTER_FLAG_QUERY_REQUIRED. Il existe un drapeau nommé différemment, qui est compatible avec tous les filtres (bien qu'il ne semble avoir effet que sur ceux de validation) : FILTER_NULL_ON_FAILURE forcera un filtre de validation à rendre NULL au lieu de false en cas d'échec.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<?php
$options = array(
    'options' => array(
        'min_range' => 0 // Valeur minimum acceptable
    ),
    'flags' => FILTER_FLAG_ALLOW_OCTAL, // autorise les valeurs en octal
);
filter_var('0755', FILTER_VALIDATE_INT, $options); // Retourne 0775
filter_var('machin', FILTER_VALIDATE_INT, $options); // Retourne false : échec

// On veut changer la valeur par défaut à retourner en cas d'échec
$options['options']['default'] = 3;
filter_var('machin', FILTER_VALIDATE_INT, $options); // Retourne 3 : nouvelle valeur pour l'échec

filter_var('machin@truc', FILTER_VALIDATE_EMAIL); // Retourne 'machin@truc' : e-mail OK
filter_var('???', FILTER_VALIDATE_EMAIL); // Retourne false : échec

Traiter un formulaire complet avec filter_input() et filter_input_array()

La petite différence entre filter_var() et filter_input() est qu'avec cette dernière on indique sous forme de constance INPUT_* le nom de la super-globale à filtrer ainsi que l'index (chaîne en général) à regarder, de ce fait il se peut qu'on tombe sur un index qui n'existe pas, mais on n'aura pas de notice "undefined index" pour autant. Le filtre renverra tout simplement NULL s'il ne trouve pas l'élément.

1
2
3
4
5
<?php
// La ligne ci-dessous illustre le contenu de $_POST pour l'exemple
// $_POST = ['pseudo' => 'Marc', 'passwd' => 'kjhksdnksn'];
filter_input(INPUT_POST, 'pseudo', FILTER_SANITIZE_STRING); // Renvoie 'Marc'
filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL); // Non-trouvé, renvoie NULL sans provoquer de notice.

Ce faisant, on peut utiliser ceci à notre avantage pour traiter tout notre formulaire d'une traite à l'aide de la variante filter_input_array(). Le tout est de créer un (gros) array définissant les filtres à utiliser. Ses index seront ceux à aller chercher, et les valeurs définiront le filtre et ses options et flags à appliquer. On récupérera en sortie un nouvel array, contenant toutes les réponses de filtres. Ceci combiné à une utilisation de FILTER_NULL_ON_FAILURE nous permet de savoir si le formulaire a été correctement rempli juste en regardant si l'array de réponse contient NULL : in_array().

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?php
$filter_def = [
    'login' => FILTER_SANITIZE_STRING, // Pas d'option ni de flag, on peut préciser directement le filtre
    'passwd' => FILTER_UNSAFE_RAW, // Filtre "on ne change rien". Sert juste à avoir l'entrée "passwd" dans le résultat.
    'passwd2' => FILTER_UNSAFE_RAW,
    'email' => [
        'filter' => FILTER_VALIDATE_EMAIL,
        'flags' => FILTER_NULL_ON_FAILURE
    ],
    'email2' => FILTER_UNSAFE_RAW, // Pas besoin de valider l'email2, on va juste le comparer à email.
    'dateDeNaissance' => [ // On va voir une autre façon de valider la date de naissance plus tard
        'filter' => FILTER_VALIDATE_REGEXP, // Expression régulières
        'options' => [
            'regexp' => '#^\d{2}/\d{2}/\d{4}$#' // format de date : dd/MM/YYYY
            // Pas d'option default donc pas d'autre option.
        ],
        'flags' => FILTER_NULL_ON_FAILURE
    ]
];

$resultat = filter_input_array(INPUT_POST, $filter_def);
/*
 * contenu de $resultat :
 * 
 * [
 *      'login' => 'Marc',
 *      'passwd' => 'lkqsjflqj',
 *      'passwd2' => 'lkqsjflqj',
 *      'email' => NULL
 *      'email2' => 'moi_toi.com'
 *      'dateDeNaissance' => NULL
 * ]
 */

if(in_array(null, $resultat, true) 
        or $resultat['email'] != $resultat['email2']
        or $resultat['passwd'] != $resultat['passwd2']) {
    // Formulaire invalide
} else {
    // Formulaire OK
}

Filtres callback et classe de traitement

Pour aller plus loin, on peut se rendre compte de 2 soucis sur le traitement précédent : d'une part on n'obtient qu'un message unique en cas de formulaire invalide, dans la mesure où on ne regarde pas quel champs du formulaire a planté, et en plus on est obligé de faire à la main certaines vérifications, comme les 2 mots de passe qui doivent être identiques, ou les 2 e-mails. Ou le format de la date de naissance qui n'est pas vérifié du tout.

Pour pallier au second problème, on est amené à créer nos propres filtres. Il s'agira de filtres "callback", où on créera une fonction de notre cru qui servira de filtre. Dans le cas présent, on peut s'en servir pour faire le test de la date de naissance, et pour incorporer les tests relatifs à l'email2 et au passwd2 à l'intérieur de ceux de l'e-mail et du passwd. Rien ne sert d'avoir 2 fois une même information à la sortie.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<?php
$filter_def = [
    'login' => FILTER_SANITIZE_STRING, // Pas d'option ni de flag, on peut préciser directement le filtre
    'passwd' => [
        'filter' => FILTER_CALLBACK,
        'options' => function($input) {
            $reponse_filtre = NULL; // Valeur de départ, pour reproduire FILTER_NULL_ON_FAILURE
            $passwd = $input; // ligne peu utile mais qui illustre l'équivalent de FILTER_UNSAFE_RAW
            $passwd2 = filter_input(INPUT_POST, 'passwd2', FILTER_UNSAFE_RAW); // On récupère le 2e pass
            if($passwd != null and $passwd == $passwd2) {
                $reponse_filtre = $passwd;
            }
            return $reponse_filtre;
        }
    ],
    'email' => [
        'filter' => FILTER_CALLBACK,
        'options' => function($input) {
            $reponse_filtre = NULL; // Valeur de départ, pour reproduire FILTER_NULL_ON_FAILURE
            $email = filter_var($input, FILTER_VALIDATE_EMAIL); // Validation de l'email principal
            if($email !== false) { // Donc l'email est valide
                $email2 = filter_input(INPUT_POST, 'email2', FILTER_UNSAFE_RAW); // On récupère l'e-mail 2
                if(!is_null($email2) and ($email == $email2)) {
                    $reponse_filtre = $email;
                }
            }
            return $reponse_filtre;
        }
    ],
    'dateDeNaissance' => [
        'filter' => FILTER_CALLBACK,
        'options' => function($input) {
            $reponse_filtre = null;
            // Première étape : récupérer le jour, le mois et l'année
            // Plusieurs possibilités. Je vais rester sur la regexp.
            if(preg_match('#^\d{2}/\d{2}/\d{4}$#', $input, $matches)) {
                // Maintenant, on vérifie qu'il s'agit d'une date valide
                if(checkdate($matches[2], $matches[1], $matches[3])) {
                    // Et maintenant il faut vérifier qu'elle est dans le passé
                    $ddn = DateTime::createFromFormat('d/m/Y', $input);
                    if($ddn < (new DateTime())) {
                        $reponse_filtre = $ddn; // On peut renvoyer directement l'objet DateTime pour manipulations
                    }
                }
            }
            return $reponse_filtre;
        }
    ]
];

$resultat = filter_input_array(INPUT_POST, $filter_def);
/*
 * contenu de $resultat :
 * 
 * [
 *      'login' => 'Marc',
 *      'passwd' => 'lkqsjflqj',
 *      'email' => NULL
 *      'dateDeNaissance' => NULL
 * ]
 */

if(in_array(null, $resultat, true)) {
    // Formulaire invalide
} else {
    // Formulaire OK
}

Ce faisant, nous avons toujours le problème du message unique. Qui plus est, la définition du formulaire constitue toujours un gros pavé au milieu de notre code PHP. Les 2 problèmes peuvent se résoudre en mettant en place une classe qui extériorise tout celà, et qui propose ses propres méthodes de validation, afin de remplir un array de messages d'erreur en cas de soucis.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
<?php

/**
 * FilterClass_FormulaireInscription
 * Traitement du formulaire d'inscription d'un site web
 *
 * @author Darth Killer
 */
class FilterClass_FormulaireInscription {

    // Message d'erreur pré-rédigés
    private static $ERREURS_ELEMVIDE = [
        'login' => "Vous devez renseigner un pseudonyme.",
        'passwd' => "Vous devez renseigner un mot de passe.",
        'passwd2' => "Vous devez répéter votre mot de passe.",
        'email' => "Vous devez renseigner un e-mail valide.",
        'email2' => "Vous devez répéter votre e-mail."
    ];
    private static $ERREURS_ELEMINVALID = [
        'passwd2' => "Les mot de passe ne coïncident pas.",
        'email' => "L'e-mail est invalide.",
        'email2' => "Les adresses e-mail ne coïncident pas.",
        'dateDeNaissance' => "La date de naissance n'est pas renseignée sous un format valide dd/MM/aaaa."
    ];

    private $errors = array();
    private $definitions = array();

    public function __get($var) {
        // Implémentée pour accéder à $errors en publique en lecture seule
        // Il faudrait faire plus complexe pour le faire bien, mais hors sujet.
        if ($var != 'errors') {
            throw new BadMethodCallException(__CLASS__ . "::$var : inaccessible ou inexistant.");
        }
        return $this->errors;
    }

    public function __construct() {
        $this->definitions = [
            'login' => [
                'filter' => FILTER_CALLBACK,
                'options' => [$this, 'filter_login'] // la callback est $this->filter_login()
            ],
            'passwd' => [
                'filter' => FILTER_CALLBACK,
                'options' => [$this, 'filter_passwd']
            ],
            'email' => [
                'filter' => FILTER_CALLBACK,
                'options' => [$this, 'filter_email']
            ],
            'dateDeNaissance' => [
                'filter' => FILTER_CALLBACK,
                'options' => [$this, 'filter_ddn']
            ]
        ];
    }

    public function filter() {
        $reponse_filtre = filter_input_array(INPUT_POST, $this->definitions);
        foreach(array_keys($reponse_filtre, NULL, true) as $key) { // On parcours les index dont la valeur est NULL
            if(empty($this->errors[$key]) and !empty(self::$ERREURS_ELEMVIDE[$key])) {
                // Donc l'erreur n'a pas déjà été gérée, c'est un élément absent du formulaire de départ
                // Et pourtant considéré obligatoire.
                $this->errors[$key] = self::$ERREURS_ELEMVIDE[$key];
            }
        }
    }

    private function filter_passwd($input) {
        $reponse_filtre = NULL; // Valeur de départ, pour reproduire FILTER_NULL_ON_FAILURE
        $passwd = $input; // ligne peu utile mais qui illustre l'équivalent de FILTER_UNSAFE_RAW
        $passwd2 = filter_input(INPUT_POST, 'passwd2', FILTER_UNSAFE_RAW); // On récupère le 2e pass
        if($passwd2 === null) {
            $this->errors['passwd2'] = self::$ERREURS_ELEMVIDE['passwd2'];
        }
        else if ($passwd != $passwd2) {
            $this->errors['passwd2'] = self::$ERREURS_ELEMINVALID['passwd2'];
        }
        else {
            $reponse_filtre = empty($passwd) ? null : $passwd; // Chaînes vides refusées
        }
        return $reponse_filtre;
    }

    private function filter_login($input) {
        $reponse_filtre = filter_var($input, FILTER_SANITIZE_STRING);
        return empty($reponse_filtre)?null:$reponse_filtre;
    }

    private function filter_email($input) {
        $reponse_filtre = NULL; // Valeur de départ, pour reproduire FILTER_NULL_ON_FAILURE
        $email = filter_var($input, FILTER_VALIDATE_EMAIL); // Validation de l'email principal
        if($email === false) {
            $this->errors['email'] = self::$ERREURS_ELEMINVALID['email'];
        }
        else {
            $email2 = filter_input(INPUT_POST, 'email2', FILTER_UNSAFE_RAW); // On récupère l'e-mail 2
            if($email2 === NULL) {
                $this->errors['email2'] = self::$ERREURS_ELEMVIDE['email2'];
            }
            else if($email != $email2) {
                $this->errors['email2'] = self::$ERREURS_ELEMINVALID['email2'];
            }
            else {
                $reponse_filtre = $email;
            }
        }
        return $reponse_filtre;
    }

    private function filter_ddn($input) {
        $reponse_filtre = null;
        // Première étape : récupérer le jour, le mois et l'année
        // Plusieurs possibilités. Je vais rester sur la regexp.
        // Amélioration possible de la regexp : gérer différents séparateurs possibles
        if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $input, $matches)) {
            // Maintenant, on vérifie qu'il s'agit d'une date valide
            if (checkdate($matches[2], $matches[1], $matches[3])) {
                // Et maintenant il faut vérifier qu'elle est dans le passé
                $ddn = DateTime::createFromFormat('d/m/Y', $input);
                if ($ddn < (new DateTime())) {
                    $reponse_filtre = $ddn; // On peut renvoyer directement l'objet DateTime pour manipulations
                }
            }
        }
        if($reponse_filtre == NULL) {
            $this->errors['dateDeNaissance'] = self::$ERREURS_ELEMINVALID['dateDeNaissance'];
        }
        return $reponse_filtre;
    }

    public function hasErrors() {
        return !empty($this->errors);
    }

}

/** Utilisation (normalement la classe est dans un fichier différent) **/
// require_once('FilterClass_FormulaireInscription.class.php');
$filtre = new FilterClass_FormulaireInscription();
$resultat = $filtre->filter();
if($filtre->hasErrors()) {
    // Il y a des erreurs
    $listeErreurs = $filtre->errors; // array associatif
}
else {
    // Le formulaire est OK
}

On pourrait aller plus loin en mettant en place une classe mère et des méthodes pour construire en interne un array de définition. Mais ce sera pour une autre fois.


Au final, nous avons extériorisé notre définition des filtres, et donc n'avons dans notre code principal plus que quelques lignes qui font tout le traitement à notre place. La lisibilité est grandement accrue, ce qui augmentera d'autant la facilité et rapidité de notre maintenance.

Je remercie btw03 et Trasphere pour leur relecture privée, et Coyote pour la mise en place de ce superbe logo.

Logo réalisé par PICOL et mis à disposition sous licence CC BY-SA.

2 commentaires

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte