Validazione in Laravel 12: da closure inline a Rule Objects con ValidationRule per regole testabili, riutilizzabili e type-safe
In un progetto per un'azienda del settore servizi digitali, un applicativo Laravel 10 con 18 Form Request aveva accumulato 23 closure di validazione inline - regole di business come la verifica del formato di codici fiscali, la validazione di IBAN con checksum, e l'unicità condizionale di nomi prodotto per categoria. Ogni closure era duplicata in 2-3 Form Request diverse, con variazioni sottili introdotte da sviluppatori diversi nel tempo. Nessuna delle 23 regole aveva un test unitario - la copertura si limitava a test feature che verificavano la risposta HTTP 422, senza isolare quale regola specifica avesse causato il fallimento. L'OWASP Input Validation Cheat Sheet lo dice chiaramente: la validazione dell'input è una difesa necessaria ma insufficiente - deve essere strutturata, coerente e testabile per essere efficace.
Cosa cambia con l'interfaccia ValidationRule rispetto alla vecchia Rule?
L'interfaccia ValidationRule, introdotta in Laravel 10, ha un singolo metodo validate(string $attribute, mixed $value, Closure $fail) che sostituisce la coppia passes()/message() dell'interfaccia Rule e il __invoke() di InvokableRule - entrambe deprecate da Laravel 10. Il vantaggio è strutturale: il callback $fail può essere chiamato più volte nella stessa regola per segnalare errori multipli, e i parametri del costruttore rendono la regola configurabile senza sottoclassi:
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Contracts\Validation\DataAwareRule;
class UniqueInTenant implements ValidationRule, DataAwareRule
{
protected array $data = [];
public function __construct(
private readonly string $table,
private readonly string $column,
private readonly ?int $ignoreId = null,
) {}
public function setData(array $data): static
{
$this->data = $data;
return $this;
}
public function validate(string $attribute, mixed $value, \Closure $fail): void
{
$tenantId = $this->data['tenant_id'] ?? auth()->user()?->tenant_id;
if (! $tenantId) {
$fail('Impossibile determinare il tenant per la validazione di :attribute.');
return;
}
$query = \DB::table($this->table)
->where($this->column, $value)
->where('tenant_id', $tenantId);
if ($this->ignoreId) {
$query->where('id', '!=', $this->ignoreId);
}
if ($query->exists()) {
$fail("Il valore di :attribute esiste già per questo tenant.");
}
}
}
/* Utilizzo nella Form Request */
public function rules(): array
{
return [
'sku' => ['required', 'string', new UniqueInTenant('products', 'sku', $this->route('product')?->id)],
'name' => ['required', 'string', 'max:255'],
'price' => ['required', 'numeric', 'min:0.01'],
];
}DataAwareRule inietta tutti i dati della richiesta nel Rule Object tramite setData() - indispensabile per regole che dipendono da altri campi (date incrociate, unicità condizionale, validazioni che confrontano più attributi). Il pattern rende la Form Request dichiarativa: elenca le regole, non le implementa.
Come testare i Rule Objects in isolamento?
Il test unitario di un Rule Object non richiede una richiesta HTTP - si istanzia la classe, si chiama validate(), e si verifica se $fail è stato invocato:
use App\Rules\UniqueInTenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class UniqueInTenantTest extends TestCase
{
use RefreshDatabase;
public function test_passes_when_value_is_unique_in_tenant(): void
{
\DB::table('products')->insert(['sku' => 'EXIST-001', 'tenant_id' => 1, 'name' => 'Test', 'price' => 10]);
$rule = new UniqueInTenant('products', 'sku');
$rule->setData(['tenant_id' => 1]);
$failed = false;
$rule->validate('sku', 'NEW-001', function () use (&$failed) { $failed = true; });
$this->assertFalse($failed);
}
public function test_fails_when_duplicate_in_same_tenant(): void
{
\DB::table('products')->insert(['sku' => 'EXIST-001', 'tenant_id' => 1, 'name' => 'Test', 'price' => 10]);
$rule = new UniqueInTenant('products', 'sku');
$rule->setData(['tenant_id' => 1]);
$message = null;
$rule->validate('sku', 'EXIST-001', function ($msg) use (&$message) { $message = $msg; });
$this->assertNotNull($message);
$this->assertStringContainsString('esiste già', $message);
}
public function test_passes_when_ignoring_own_id(): void
{
$id = \DB::table('products')->insertGetId(['sku' => 'EXIST-001', 'tenant_id' => 1, 'name' => 'Test', 'price' => 10]);
$rule = new UniqueInTenant('products', 'sku', ignoreId: $id);
$rule->setData(['tenant_id' => 1]);
$failed = false;
$rule->validate('sku', 'EXIST-001', function () use (&$failed) { $failed = true; });
$this->assertFalse($failed);
}
}Ogni test verifica un singolo scenario in isolamento - senza bootstrap di route, middleware o controller. I test automatici dei Rule Objects girano in millisecondi perché non c'è overhead HTTP, e coprono esattamente la logica di business che le closure inline rendevano impossibile isolare.
Errori comuni nella validazione Laravel
Il primo errore è duplicare regole di business come closure in Form Request diverse. Se la regola per il formato di un codice fiscale cambia, bisogna trovare e aggiornare ogni closure - e le variazioni introdotte nel tempo rendono impossibile sapere quale versione sia quella corretta. Un Rule Object è la single source of truth.
Il secondo è confondere validazione di input con validazione di business. Laravel offre oltre 90 regole built-in per la validazione di formato (string, email, numeric, date), ma le regole di business (unicità per tenant, compatibilità tra date, checksum di codici) richiedono Rule Objects dedicati. Mischiare le due cose nella stessa closure produce regole illeggibili che validano formato e business nella stessa funzione.
Il terzo è non usare Rule::when() e Rule::excludeIf() per la validazione condizionale. Un campo che è obbligatorio solo in determinati scenari (ad esempio, shipping_address obbligatorio solo se shipping_method !== 'digital') non dovrebbe essere gestito con if nella Form Request - Rule::when($condition, $rules) rende la condizionalità dichiarativa e leggibile.
Il quarto è validare solo nel controller senza Form Request. La validazione inline con $request->validate() funziona per endpoint semplici, ma per API REST con payload complessi, la Form Request separa autorizzazione (authorize()), regole (rules()), e messaggi (messages()) dal controller - mantenendo il controller focalizzato sull'orchestrazione.
La validazione strutturata è il primo livello di difesa di un applicativo - prima del middleware, prima della logica di business, prima del database. Il refactoring del codice legacy che include la migrazione delle closure di validazione a Rule Objects è un investimento che si ripaga in manutenibilità e in bug evitati. Per conoscere il mio approccio alla modernizzazione di applicativi Laravel, visita la mia pagina professionale. Se le regole di validazione del tuo applicativo sono sparse in closure duplicate e non testate, contattami per una consulenza dedicata - partiamo dall'inventario delle regole e dalla definizione dei Rule Objects.