La validazione dei dati in input è una pietra miliare nella costruzione di applicazioni web sicure e robuste. In Laravel, il sistema di validazione è estremamente potente e flessibile, offrendo una vasta gamma di regole predefinite e diversi modi per definire logiche custom. Tuttavia, in applicazioni Laravel 9 o Laravel 10 con una certa storia o complessità, è facile che le logiche di validazione più specifiche del business finiscano per essere implementate tramite closure all'interno delle Form Request o dei controller, oppure come metodi custom sparsi qua e là. Sebbene questi approcci funzionino, possono portare a codice meno leggibile, difficilmente riutilizzabile e complicato da testare in isolamento.

Per le imprese che puntano a un codice di alta qualità, manutenibile e scalabile in un contesto Laravel 12, è il momento di considerare un refactoring strategico verso l'uso estensivo dei Rule Objects dedicati. Questo approccio, sebbene i Rule Objects esistano in Laravel da versioni precedenti, rappresenta una best practice per incapsulare logiche di validazione complesse in classi specifiche, promuovendo i principi SOLID (in particolare la Single Responsibility Principle - SRP) e migliorando drasticamente la testabilità.

Questo articolo tecnico ti guiderà, con abbondanti esempi di codice, attraverso il processo di identificazione delle logiche di validazione "legacy" e il loro refactoring in Rule Objects riutilizzabili.

Se vuoi approfondire, continua a leggere. Se hai una domanda specifica a riguardo di questo articolo, contattami per una consulenza dedicata. Dai anche un'occhiata al mio profilo per capire come posso aiutare concretamente la tua azienda o startup a crescere e a modernizzarsi.

Validazione in Laravel 9/10: approcci comuni (e i loro limiti)

Prima di vedere come modernizzare, riconosciamo alcuni pattern di validazione comuni in applicazioni Laravel 9/10 che potrebbero beneficiare di un refactoring.

1. Validazione nel Controller

Per validazioni semplici o in endpoint specifici, è comune trovare la logica direttamente nel controller utilizzando il metodo validate dell'oggetto $request.

// Esempio: app/Http/Controllers/LegacyProductController.php (Laravel 9/10 style)
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; // Per una regola custom che interroga il DB
use Illuminate\Validation\Rule;   // Per regole più complesse come 'unique'

class LegacyProductController extends Controller
{
    public function store(Request $request)
    {
        $validatedData = $request->validate([
            'name' => 'required|string|max:255',
            'sku' => [
                'required',
                'string',
                'alpha_dash',
                Rule::unique('products', 'sku')->where(function ($query) use ($request) {
                    // Esempio di unicità condizionale basata su un altro campo
                    return $query->where('tenant_id', $request->user()->tenant_id);
                }),
            ],
            'price' => 'required|numeric|min:0',
            'custom_code' => [
                'nullable',
                'string',
                function (string $attribute, mixed $value, \Closure $fail) {
                    // Logica di validazione custom inline tramite closure
                    if (strtoupper(substr($value, 0, 2)) !== 'CC') {
                        $fail('Il campo :attribute deve iniziare con "CC".');
                    }
                    if (strlen($value) < 5) {
                        $fail('Il campo :attribute deve essere lungo almeno 5 caratteri.');
                    }
                }
            ],
        ]);

        // ... logica per creare il prodotto ...
        return response()->json(['message' => 'Prodotto creato con successo!'], 201);
    }
}

Problemi di questo approccio:

  • Controller "gonfi": i controller dovrebbero orchestrare, non contenere logica di business complessa come la validazione.
  • Non riutilizzabile: se la stessa logica per custom_code o la regola sku unica per tenant serve altrove, bisogna duplicarla.
  • Difficile da testare isolatamente: testare la logica della closure richiede di effettuare una richiesta HTTP completa al controller.

2. Validazione nelle Form Request

Le Form Request sono un ottimo modo per separare la logica di validazione (e autorizzazione) dai controller. Tuttavia, anche qui, logiche complesse possono finire in closure all'interno del metodo rules().

// Esempio: app/Http/Requests/StoreProductFormRequest.php (Laravel 9/10 style)
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;

class StoreProductFormRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true; // O $this->user()->can('create', Product::class);
    }

    public function rules(): array
    {
        return [
            'name' => 'required|string|max:255',
            'sku' => [
                'required',
                'string',
                'alpha_dash',
                Rule::unique('products', 'sku')->where(function ($query) {
                    return $query->where('tenant_id', $this->user()->tenant_id);
                })->ignore($this->product?->id), // Gestione per l'update
            ],
            'price' => 'required|numeric|min:0',
            'complex_field' => [
                'required',
                'string',
                function (string $attribute, mixed $value, \Closure $fail) {
                    // Immagina una logica complessa qui:
                    // 1. Chiama un servizio esterno per validare 'value'
                    // 2. Controlla 'value' contro altri campi del $this->input()
                    // 3. Esegue una query DB specifica
                    if (strlen($value) < 10) {
                        $fail('Il campo :attribute è troppo corto.');
                    }
                    if (!preg_match('/[A-Z]/', $value) || !preg_match('/[0-9]/', $value)) {
                        $fail('Il campo :attribute deve contenere almeno una maiuscola e un numero.');
                    }
                    // ... e così via
                }
            ],
        ];
    }
}

Limiti (quando si abusa delle closure per logiche complesse):

  • FormRequest verbosa: può diventare difficile da leggere se contiene molte closure lunghe.
  • Testabilità della logica interna alla closure: ancora legata al contesto della FormRequest.
  • Riutilizzabilità limitata: se la logica di complex_field serve per un altro attributo o in un'altra FormRequest, si tende a duplicare.

Verso Laravel 12: la potenza e la chiarezza dei Rule Objects dedicati

Un Rule Object è una semplice classe PHP che incapsula la logica per validare un singolo attributo. Questo approccio promuove codice pulito, riutilizzabile e facilmente testabile. Laravel supporta i Rule Objects da diverse versioni, ma il loro uso sistematico per il refactoring di logiche complesse è una best practice per applicazioni Laravel 12 moderne.

Interfacce coinvolte:

  • Illuminate\Contracts\Validation\Rule: l'interfaccia "classica", richiede metodi passes($attribute, $value) e message().
  • Illuminate\Contracts\Validation\ValidationRule: introdotta in Laravel 10, più moderna, richiede un singolo metodo validate(string $attribute, mixed $value, Closure $fail). Il messaggio di fallimento viene gestito tramite il callback $fail. Questa è spesso preferita oggi.
  • Illuminate\Contracts\Validation\InvokableRule: una regola semplice che implementa solo il metodo __invoke(string $attribute, mixed $value, Closure $fail).
  • Illuminate\Contracts\Validation\DataAwareRule: permette al Rule Object di accedere a tutti gli altri dati della richiesta in validazione, utile per regole dipendenti.
  • Illuminate\Contracts\Validation\ValidatorAwareRule: permette al Rule Object di accedere all'istanza del validatore.

Guida pratica al refactoring: creare e usare Rule Objects

Passo 1: Identificare logiche di validazione complesse o ripetute Analizza i tuoi controller e le tue Form Request. Ci sono closure di validazione lunghe? Regole che si ripetono? Logiche che richiedono query al database o interazioni con altri servizi? Questi sono ottimi candidati per il refactoring in Rule Objects.

Passo 2: Creare un Rule Object con php artisan make:rule Laravel fornisce un comodo comando Artisan per generare lo stub di un Rule Object. Di default, crea una classe che implementa ValidationRule.

php artisan make:rule IsValidBusinessRegistrationNumber
php artisan make:rule UniqueProductNameInCategory

Passo 3: Implementare la logica di validazione nel Rule Object

Esempio 1: IsValidBusinessRegistrationNumber (refactoring di custom_code dall'esempio controller)

// app/Rules/IsValidBusinessRegistrationNumber.php
namespace App\Rules;

use Illuminate\Contracts\Validation\ValidationRule;
use Closure; // Per il callback di fallimento $fail

class IsValidBusinessRegistrationNumber implements ValidationRule
{
    protected string $prefix;
    protected int $minLength;

    /**
     * Crea una nuova istanza della regola.
     * Possiamo passare parametri al costruttore per rendere la regola più flessibile.
     */
    public function __construct(string $prefix = 'CC', int $minLength = 5)
    {
        $this->prefix = $prefix;
        $this->minLength = $minLength;
    }

    /**
     * Esegue la regola di validazione.
     *
     * @param  \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString  $fail
     */
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (!is_string($value)) {
            // È buona norma controllare il tipo, anche se Laravel di solito lo fa prima
            // con regole come 'string'.
            $fail("Il campo :attribute fornito non è una stringa valida.");
            return;
        }

        if (strtoupper(substr($value, 0, strlen($this->prefix))) !== strtoupper($this->prefix)) {
            $fail("Il campo :attribute deve iniziare con \"{$this->prefix}\".");
        }

        if (strlen($value) < $this->minLength) {
            $fail("Il campo :attribute deve essere lungo almeno {$this->minLength} caratteri.");
        }

        // Qui potresti aggiungere logiche più complesse,
        // ad esempio un checksum o una chiamata a un servizio esterno (con cautela per le performance).
        // if (!$this->externalCheckService->isValid($value)) {
        //     $fail('Il codice di registrazione fornito non è valido secondo il nostro sistema esterno.');
        // }
    }
}

Esempio 2: UniqueProductNameInCategory (refactoring della logica SKU unico per tenant/categoria) Questo Rule Object avrà bisogno di accedere ad altri dati della richiesta (la categoria), quindi implementerà DataAwareRule.

// app/Rules/UniqueProductNameInCategory.php
namespace App\Rules;

use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Contracts\Validation\DataAwareRule; // Per accedere ad altri dati
use Illuminate\Support\Facades\DB;
use Closure;

class UniqueProductNameInCategory implements ValidationRule, DataAwareRule
{
    protected array $data = []; // Qui verranno iniettati tutti i dati della richiesta
    protected ?int $ignoreId = null;
    protected string $categoryFieldName;

    /**
     * @param int|null $ignoreId L'ID del record da ignorare (utile per gli update)
     * @param string $categoryFieldName Il nome del campo nella richiesta che contiene l'ID/nome della categoria
     */
    public function __construct(?int $ignoreId = null, string $categoryFieldName = 'category_id')
    {
        $this->ignoreId = $ignoreId;
        $this->categoryFieldName = $categoryFieldName;
    }

    /**
     * Imposta i dati aggiuntivi per la validazione.
     */
    public function setData(array $data): static
    {
        $this->data = $data;
        return $this;
    }

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $productName = (string) $value;
        $categoryId = $this->data[$this->categoryFieldName] ?? null;

        if (is_null($categoryId)) {
            // Potremmo decidere di non validare se la categoria non è fornita,
            // o far fallire la validazione se la categoria è obbligatoria (gestito da altre regole 'required').
            // Per questo esempio, se la categoria non c'è, non possiamo verificare l'unicità *in* quella categoria.
            return;
        }

        $query = DB::table('products')
                    ->where('name', $productName)
                    ->where('category_id', $categoryId); // Assumendo una colonna category_id

        if ($this->ignoreId) {
            $query->where('id', '!=', $this->ignoreId);
        }

        if ($query->exists()) {
            $fail("Il nome prodotto \"{$productName}\" esiste già in questa categoria.");
        }
    }
}

Passo 4: Utilizzare i Rule Objects nelle Form Request (o nel Validator) Ora refactoriamo la StoreProductFormRequest dell'esempio precedente:

// app/Http/Requests/StoreProductFormRequest.php (Laravel 11/12 style con Rule Objects)
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use App\Rules\IsValidBusinessRegistrationNumber; // Importa la regola
use App\Rules\UniqueProductNameInCategory;   // Importa la regola
use Illuminate\Validation\Rule as IlluminateRule;

class StoreProductFormRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        $productIdToIgnore = $this->route('product') ? $this->route('product')->id : null;

        return [
            'name' => [
                'required',
                'string',
                'max:255',
                // Passiamo l'ID da ignorare e il nome del campo categoria
                new UniqueProductNameInCategory($productIdToIgnore, 'category_id_field'),
            ],
            'sku' => [ /* ... la regola unique standard di Laravel è spesso sufficiente ... */ ],
            'price' => 'required|numeric|min:0',
            'category_id_field' => 'required|integer|exists:categories,id', // Campo per la categoria
            'registration_code' => [
                'nullable',
                'string',
                new IsValidBusinessRegistrationNumber(prefix: 'REG', minLength: 8), // Passiamo argomenti!
            ],
            'complex_field' => [ /* ... potrebbe diventare un altro Rule Object ... */ ],
            'release_date' => 'required|date',
            'expiry_date' => [ // Esempio di regola che dipende da un altro campo, usando un Rule Object DataAware
                'nullable',
                'date',
                'after_or_equal:release_date', // Regola standard, ma se fosse più complessa...
                // new EndDateAfterStartDateRule('release_date'), // ...potrebbe diventare un Rule Object
            ],
        ];
    }

    public function messages(): array
    {
        return [
            'name.required' => 'Il nome del prodotto è un campo obbligatorio.',
            // I messaggi per i Rule Objects possono essere definiti nel Rule Object stesso
            // o sovrascritti qui se necessario, ma è meno comune.
        ];
    }
}

Passo 5: Testare i Rule Objects Uno dei maggiori vantaggi è la testabilità unitaria dei Rule Objects.

// tests/Unit/Rules/UniqueProductNameInCategoryTest.php
namespace Tests\Unit\Rules;

use App\Rules\UniqueProductNameInCategory;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase; // Utile se il rule object interagisce col DB

class UniqueProductNameInCategoryTest extends TestCase
{
    use RefreshDatabase; // Pulisce il DB per ogni test

    protected function setUp(): void
    {
        parent::setUp();
        // Popola il DB con dati di test se necessario
        DB::table('categories')->insert(['id' => 1, 'name' => 'Elettronica']);
        DB::table('products')->insert([
            'name' => 'Laptop SuperFast',
            'category_id' => 1,
            // ... altri campi
        ]);
    }

    public function test_new_product_with_unique_name_in_category_passes(): void
    {
        $rule = new UniqueProductNameInCategory(null, 'category_id'); // ignoreId è null per un nuovo prodotto
        $rule->setData(['category_id' => 1, 'name' => 'Mouse Wireless']); // Dati della richiesta

        $passes = true;
        $rule->validate('name', 'Mouse Wireless', function ($message) use (&$passes) {
            $passes = false;
        });
        $this->assertTrue($passes);
    }

    public function test_new_product_with_existing_name_in_same_category_fails(): void
    {
        $rule = new UniqueProductNameInCategory(null, 'category_id');
        $rule->setData(['category_id' => 1, 'name' => 'Laptop SuperFast']);

        $validationResult = 'passed';
        $rule->validate('name', 'Laptop SuperFast', function ($message) use (&$validationResult) {
            $validationResult = $message;
        });
        $this->assertStringContainsString('esiste già in questa categoria', $validationResult);
    }

    public function test_updating_product_with_same_name_in_category_passes_if_ignoring_self(): void
    {
        $existingProduct = DB::table('products')->where('name', 'Laptop SuperFast')->first();
        $rule = new UniqueProductNameInCategory($existingProduct->id, 'category_id'); // Ignora l'ID esistente
        $rule->setData(['category_id' => 1, 'name' => 'Laptop SuperFast']);

        $passes = true;
        $rule->validate('name', 'Laptop SuperFast', function ($message) use (&$passes) {
            $passes = false;
        });
        $this->assertTrue($passes);
    }
}

Benefici del refactoring a Rule Objects

L'adozione sistematica dei Rule Objects per la validazione complessa porta a:

  • Riutilizzabilità del codice: la stessa regola può essere applicata in più punti della tua applicazione (diverse Form Request, validatori manuali).
  • Testabilità migliorata: ogni regola può essere testata unitariamente in completo isolamento, garantendo la sua correttezza e rendendo i test più veloci e affidabili.
  • Leggibilità del codice: le Form Request e i controller diventano più snelli e focalizzati. La logica di validazione è chiaramente incapsulata nel suo Rule Object con un nome auto-esplicativo.
  • Adesione al principio di singola responsabilità (SRP): ogni Rule Object ha un solo compito: validare un attributo secondo una specifica logica.
  • Migliore organizzazione: le regole di validazione specifiche del dominio della tua impresa sono ben organizzate nella directory app/Rules, facili da trovare e modificare.
  • Manutenibilità: quando una regola di business cambia, modifichi un solo Rule Object, e la modifica si riflette ovunque venga utilizzato.

Il ruolo del programmatore Laravel esperto

Identificare le opportunità di refactoring, progettare Rule Objects efficaci e testabili, e integrarli in una base di codice esistente, specialmente in applicazioni aziendali di una certa dimensione, è un compito che beneficia grandemente dell'esperienza. Come sviluppatore laravel con una profonda conoscenza del framework e dei principi di software design, posso aiutare la tua impresa a:

  • Analizzare le attuali logiche di validazione e identificare i candidati per il refactoring.
  • Progettare e implementare Rule Objects robusti, riutilizzabili e performanti.
  • Scrivere unit test completi per ogni Rule Object.
  • Integrare le nuove regole nel flusso di validazione esistente.

Il mio approccio è sempre orientato a fornire soluzioni ingegneristicamente solide che portino valore tangibile al tuo business. Per maggiori dettagli sulla mia filosofia di lavoro, ti invito a visitare la pagina Chi Sono.

Investire nel refactoring della logica di validazione verso i Rule Objects non è solo un miglioramento tecnico; è un passo verso un'applicazione Laravel 12 più resiliente, più facile da far evolvere e che protegge meglio l'integrità dei dati della tua impresa.

Se senti che la gestione della validazione nella tua applicazione Laravel è diventata un punto dolente e vuoi esplorare come modernizzarla, contattami per una consulenza approfondita.

Ultima modifica: Venerdì 21 Febbraio 2025, alle 10:37