Implementare da zero un sistema di validazione in PHP (stile Laravel)

Implementare da zero un sistema di validazione in PHP (stile Laravel)

In questo articolo creiamo un validatore minimale ma potente in PHP che usa regole in formato stringa come title => "required|max:255". Supporta dot-notation (user.email), messaggi personalizzati, regole comuni (required, max, min, email, numeric, in, regex, …), comportamenti tipo sometimes/nullable e persino estensioni personalizzate, il tutto senza dipendenze esterne.

Risultato finale


// Esempio d'uso
$input = [
    'title' => 'Hello',
    'email' => 'john@example.com',
    'age'   => '21',
    'user'  => ['name' => 'Jane', 'birthdate' => '2000-01-01'],
];

$rules = [
   'title'        => 'required|string|max:255',
   'email'        => 'required|email',
   'age'          => 'nullable|numeric|min:18|max:120',
   'user.name'    => 'sometimes|required|string|min:2',
   'user.birthdate' => 'date|after:1990-01-01',
];

$messages = [
    'title.required' => 'Il titolo è obbligatorio.',
    'age.min'        => 'Devi avere almeno :min anni.',
];

$validator = new Validator();
if ($validator->validate($input, $rules, $messages)) {
    // dati validi
} else {
    // stampa errori
    print_r($validator->errors());
} 

Implementazione

Il seguente codice PHP implementa il validatore con: parsing delle regole, dot-notation, gestione messaggi, regole integrate e un meccanismo per aggiungere regole custom.


class Validator
{
    protected array $errors = [];
    protected static array $customRules = [];       // ['rule' => callable]
    protected static array $customMessages = [];    // ['rule' => 'messaggio di default']

    public function errors(): array
    {
        return $this->errors;
    }

    public function validate(array $data, array $rules, array $messages = []): bool
    {
        $this->errors = [];

        foreach ($rules as $attribute => $ruleDef) {
            $parsed = $this->parseRules($ruleDef);
            $bail = in_array('bail', array_column($parsed, 'name'), true);

            $exists = false;
            $value = $this->getValue($data, $attribute, $exists);

            $hasSometimes = $this->hasRule($parsed, 'sometimes');
            if ($hasSometimes && !$exists) {
                continue; // se non c'è il campo, non validare
            }

            $isNullable = $this->hasRule($parsed, 'nullable');
            if ($isNullable && $exists && $value === null) {
                continue; // null è permesso, skip altre regole
            }

            foreach ($parsed as $r) {
                $name = $r['name'];
                $params = $r['params'];

                if (in_array($name, ['sometimes', 'nullable', 'bail'], true)) {
                    continue;
                }

                $passed = true;
                $message = null;

                // Regole integrate
                switch ($name) {
                    case 'required':
                        $passed = $exists && !$this->isEmpty($value);
                        break;

                    case 'string':
                        $passed = is_string($value);
                        break;

                    case 'array':
                        $passed = is_array($value);
                        break;

                    case 'boolean':
                        $passed = is_bool($value) || $value === 0 || $value === 1 || $value === '0' || $value === '1';
                        break;

                    case 'integer':
                        $passed = filter_var($value, FILTER_VALIDATE_INT) !== false;
                        break;

                    case 'numeric':
                        $passed = is_numeric($value);
                        break;

                    case 'email':
                        $passed = filter_var($value, FILTER_VALIDATE_EMAIL) !== false;
                        break;

                    case 'min':
                        $min = $this->expectParam($attribute, $name, $params, 0);
                        $passed = $this->checkMin($value, $min, $parsed);
                        break;

                    case 'max':
                        $max = $this->expectParam($attribute, $name, $params, 0);
                        $passed = $this->checkMax($value, $max, $parsed);
                        break;

                    case 'between':
                        $min = $this->expectParam($attribute, $name, $params, 0);
                        $max = $this->expectParam($attribute, $name, $params, 1);
                        $passed = $this->checkMin($value, $min, $parsed) && $this->checkMax($value, $max, $parsed);
                        break;

                    case 'in':
                        $passed = in_array((string)$value, array_map('strval', $params), true);
                        break;

                    case 'regex':
                        $pattern = $this->expectParam($attribute, $name, $params, 0);
                        $passed = @preg_match($pattern, (string)$value) === 1;
                        break;

                    case 'date':
                        $passed = $this->parseDate($value) instanceof DateTimeInterface;
                        break;

                    case 'after':
                        $other = $this->expectParam($attribute, $name, $params, 0);
                        $passed = $this->compareDates($data, $attribute, $value, $other, '>');
                        break;

                    case 'before':
                        $other = $this->expectParam($attribute, $name, $params, 0);
                        $passed = $this->compareDates($data, $attribute, $value, $other, '<');
                        break;

                    default:
                        // Regola custom
                        if (isset(self::$customRules[$name])) {
                            $callback = self::$customRules[$name];
                            $result = $callback($attribute, $value, $params, $data);
                            if ($result === true) {
                                $passed = true;
                            } elseif ($result === false) {
                                $passed = false;
                            } elseif (is_string($result)) {
                                $passed = false;
                                $message = $result; // messaggio diretto dalla regola
                            }
                        } else {
                            throw new InvalidArgumentException("Regola sconosciuta: {$name}");
                        }
                }

                if (!$passed) {
                    $this->addError(
                        $attribute,
                        $name,
                        $message ?? $this->makeMessage($attribute, $name, $params, $messages, $value),
                        $params
                    );

                    if ($bail) {
                        break; // interrompi alla prima violazione per questo campo
                    }
                }
            }
        }

        return empty($this->errors);
    }

    /** ------------------------------- Helper: parsing & accesso ------------------------------- */

    protected function parseRules(string|array $ruleDef): array
    {
        if (is_array($ruleDef)) {
            $chunks = $ruleDef;
        } else {
            // Supporta | e : escapati
            $chunks = preg_split('/(?<!)|/', $ruleDef) ?: [];
            $chunks = array_map(fn($c) => str_replace(['|', ':'], ['|', ':'], trim($c)), $chunks);
        }

        $out = [];
        foreach ($chunks as $chunk) {
            if ($chunk === '') continue;
            $parts = preg_split('/(?<!):/', $chunk, 2);
            $name = strtolower(trim($parts[0]));
            $params = [];
            if (isset($parts[1])) {
                $params = array_map('trim', explode(',', $parts[1]));
            }
            $out[] = ['name' => $name, 'params' => $params];
        }
        return $out;
    }

    protected function hasRule(array $parsed, string $rule): bool
    {
        foreach ($parsed as $r) {
            if ($r['name'] === $rule) return true;
        }
        return false;
    }

    protected function getValue(array $data, string $key, ?bool &$exists = null)
    {
        $segments = explode('.', $key);
        $cur = $data;
        foreach ($segments as $seg) {
            if (is_array($cur) && array_key_exists($seg, $cur)) {
                $cur = $cur[$seg];
            } else {
                $exists = false;
                return null;
            }
        }
        $exists = true;
        return $cur;
    }

    protected function isEmpty($value): bool
    {
        if ($value === null) return true;
        if (is_string($value)) return trim($value) === '';
        if (is_array($value)) return count($value) === 0;
        return false;
    }

    /** ------------------------------- Helper: min/max/between ------------------------------- */

    protected function isStringContext(array $parsed): bool
    {
        return $this->hasRule($parsed, 'string');
    }

    protected function isArrayContext(array $parsed): bool
    {
        return $this->hasRule($parsed, 'array');
    }

    protected function isNumericContext(array $parsed): bool
    {
        return $this->hasRule($parsed, 'numeric') || $this->hasRule($parsed, 'integer');
    }

    protected function checkMin($value, $min, array $parsed): bool
    {
        if ($this->isNumericContext($parsed)) {
            return is_numeric($value) && $value + 0 >= (float)$min;
        }
        if ($this->isArrayContext($parsed) && is_array($value)) {
            return count($value) >= (int)$min;
        }
        // default: lunghezza stringa
        return is_string($value) && mb_strlen($value) >= (int)$min;
    }

    protected function checkMax($value, $max, array $parsed): bool
    {
        if ($this->isNumericContext($parsed)) {
            return is_numeric($value) && $value + 0 <= (float)$max;
        }
        if ($this->isArrayContext($parsed) && is_array($value)) {
            return count($value) <= (int)$max;
        }
        // default: lunghezza stringa
        return is_string($value) && mb_strlen($value) <= (int)$max;
    }

    /** ------------------------------- Helper: date & confronti ------------------------------- */

    protected function parseDate($value): ?DateTimeInterface
    {
        if ($value instanceof DateTimeInterface) return $value;
        if (!is_string($value)) return null;

        // prova ISO e fallback a strtotime
        $dt = DateTime::createFromFormat('Y-m-d', $value) ?: DateTime::createFromFormat(DateTime::ATOM, $value);
        if ($dt instanceof DateTimeInterface) return $dt;

        $ts = strtotime($value);
        return $ts ? (new DateTime())->setTimestamp($ts) : null;
    }

    protected function compareDates(array $data, string $attribute, $value, string $other, string $op): bool
    {
        $left = $this->parseDate($value);
        if (!$left) return false;

        if ($other === 'today') {
            $right = (new DateTime('today'));
        } elseif (str_contains($other, '-')) {
            $right = $this->parseDate($other);
        } else {
            $exists = false;
            $otherVal = $this->getValue($data, $other, $exists);
            if (!$exists) return false;
            $right = $this->parseDate($otherVal);
        }

        if (!$right) return false;

        return match ($op) {
            '>' => $left > $right,
            '<' => $left < $right,
            default => false
        };
    }

    /** ------------------------------- Messaggi ------------------------------- */

    protected function addError(string $attribute, string $rule, string $message, array $params): void
    {
        $this->errors[$attribute][$rule][] = $message;
    }

    protected function makeMessage(string $attribute, string $rule, array $params, array $overrides, $value): string
    {
        // Messaggi per-campo
        $key = "{$attribute}.{$rule}";
        if (isset($overrides[$key])) {
            return $this->interpolate($overrides[$key], $attribute, $params, $value);
        }

        // Messaggi predefiniti (per-regola)
        $defaults = $this->defaultMessages();
        if (isset($defaults[$rule])) {
            return $this->interpolate($defaults[$rule], $attribute, $params, $value);
        }

        // Messaggio custom registrato
        if (isset(self::$customMessages[$rule])) {
            return $this->interpolate(self::$customMessages[$rule], $attribute, $params, $value);
        }

        return "Il campo :attribute non è valido.";
    }

    protected function interpolate(string $message, string $attribute, array $params, $value): string
    {
        // parametri comuni
        $repl = [
            ':attribute' => $attribute,
            ':value'     => is_scalar($value) ? (string)$value : '',
        ];

        // parametri posizionali :min, :max, :other ecc.
        $names = [':min', ':max', ':other', ':1', ':2'];
        foreach ($names as $i => $name) {
            if (isset($params[$i])) {
                $repl[$name] = (string)$params[$i];
            }
        }

        return strtr($message, $repl);
    }

    protected function defaultMessages(): array
    {
        return [
            'required' => 'Il campo :attribute è obbligatorio.',
            'string'   => 'Il campo :attribute deve essere una stringa.',
            'array'    => 'Il campo :attribute deve essere un array.',
            'boolean'  => 'Il campo :attribute deve essere booleano.',
            'integer'  => 'Il campo :attribute deve essere un intero.',
            'numeric'  => 'Il campo :attribute deve essere numerico.',
            'email'    => 'Il campo :attribute deve essere un indirizzo email valido.',
            'min'      => 'Il campo :attribute deve essere almeno :min.',
            'max'      => 'Il campo :attribute non può superare :max.',
            'between'  => 'Il campo :attribute deve essere tra :min e :max.',
            'in'       => 'Il campo :attribute deve essere uno dei valori consentiti.',
            'regex'    => 'Il formato di :attribute non è valido.',
            'date'     => 'Il campo :attribute deve essere una data valida.',
            'after'    => 'Il campo :attribute deve essere successivo a :other.',
            'before'   => 'Il campo :attribute deve essere precedente a :other.',
        ];
    }

    protected function expectParam(string $attribute, string $rule, array $params, int $index): string
    {
        if (!isset($params[$index])) {
            throw new InvalidArgumentException("La regola {$rule} per {$attribute} richiede un parametro alla posizione {$index}");
        }
        return $params[$index];
    }

    /** ------------------------------- Estensioni custom ------------------------------- */

    public static function extend(string $name, callable $callback, ?string $defaultMessage = null): void
    {
        self::$customRules[$name] = $callback;
        if ($defaultMessage) {
            self::$customMessages[$name] = $defaultMessage;
        }
    }
}

// ------------------------------- Esempio di estensione: starts_with -------------------------------

Validator::extend('starts_with', function (string $attribute, $value, array $params) {
    $prefix = $params[0] ?? '';
    if (!is_string($value)) return false;
    return str_starts_with($value, $prefix);
}, 'Il campo :attribute deve iniziare con :1.');

// ------------------------------- Esempio di utilizzo -------------------------------

$input = [
    'title' => 'Hello world',
    'email' => 'john@example.com',
    'age'   => '17',
    'slug'  => 'post-1',
];

$rules = [
    'title' => 'required|string|between:3,255|starts_with:Hel',
    'email' => 'required|email',
    'age'   => 'nullable|numeric|min:18', // fallirà
    'slug'  => 'regex:/^[a-z0-9-]+$/',
];

$messages = [
    'slug.regex' => 'Lo slug può contenere solo lettere minuscole, numeri e trattini.',
];

$validator = new Validator();
$ok = $validator->validate($input, $rules, $messages);

if (!$ok) {
    print_r($validator->errors());
}

Dettagli importanti del design

  • Ordine delle regole: viene rispettato; con bail interrompi alla prima violazione per quel campo.
  • Contesto di tipo: min/max operano su numeri se presenti numeric/integer, sulla lunghezza se string, sul conteggio se array.
  • sometimes: valida il campo solo se presente nell’input.
  • nullable: se il valore è null, salta le altre regole del campo.
  • Dot-notation: consente user.email senza dover navigare manualmente l’array.
  • Messaggi: override per campo.regola e template predefiniti con placeholder :attribute, :min, :max, :other, :value.
  • Regole custom: con Validator::extend() aggiungi nuove regole riusabili e un messaggio di default.

Test rapidissimi

Puoi creare dei test veloci in puro PHP per assicurarti che i casi principali siano coperti.


// "happy path"
assert((new Validator())->validate(
    ['name' => 'Anna'], ['name' => 'required|string|min:2']
) === true);

// required fallisce
$v = new Validator();
assert($v->validate(['name' => ''], ['name' => 'required']) === false);
assert(isset($v->errors()['name']['required'])); 

Estendere il sistema

  • Aggiungi file rules (dimensione, mime) per upload.
  • Supporta wildcard tipo items.*.id (si può iterare ricorsivamente sugli array).
  • Internazionalizzazione dei messaggi tramite file di lingua.
  • Raccogli solo il primo errore per campo quando vuoi errori “compatti”.

Conclusioni

Con poche centinaia di righe abbiamo un validatore flessibile, espressivo e facilmente estendibile, con una sintassi familiare a chi usa Laravel. Puoi inserirlo nei tuoi progetti legacy o micro-servizi dove non vuoi importare l’intero framework ma desideri regole di validazione coerenti e leggibili.