Form Requests

Com crear Form Request classes per organitzar la lògica de validació i autorització.

Per què Form Requests?#

Quan els controladors creixen, el codi de validació pot ocupar una part significativa del mètode. Els Form Requests permeten extreure tota la lògica de validació i autorització a una classe dedicada, mantenint els controladors nets i la validació reutilitzable.

Un Form Request és una classe que encapsula les regles de validació, els missatges d'error personalitzats, la preparació de dades i la lògica d'autorització en un sol lloc.

Crear un Form Request#

php artisan make:request StoreArticleRequest

Això genera un fitxer a app/Http/Requests/StoreArticleRequest.php amb dos mètodes: authorize() per controlar l'accés i rules() per definir les regles de validació:

namespace App\Http\Requests;
 
use Illuminate\Foundation\Http\FormRequest;
 
class StoreArticleRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }
 
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'slug' => ['required', 'string', 'max:255', 'unique:articles,slug'],
            'body' => ['required', 'string', 'min:10'],
            'category_id' => ['required', 'exists:categories,id'],
            'tags' => ['nullable', 'array'],
            'tags.*' => ['string', 'max:50'],
            'published' => ['boolean'],
            'published_at' => ['nullable', 'date', 'after:now'],
        ];
    }
}

Utilitzar al controlador#

Per utilitzar un Form Request, simplement substitueix Request pel nom de la teva classe al type-hint del mètode del controlador:

use App\Http\Requests\StoreArticleRequest;
 
class ArticleController extends Controller
{
    public function store(StoreArticleRequest $request)
    {
        // Si arriba aquí, la validació ja ha passat
        $article = Article::create($request->validated());
 
        return redirect()->route('articles.show', $article)
            ->with('success', 'Article creat correctament!');
    }
}

Laravel resol automàticament el Form Request a través del contenidor de serveis. Abans que el mètode del controlador s'executi, Laravel valida les dades. Si la validació falla, redirigeix l'usuari amb els errors. Si passa, el mètode s'executa amb les dades ja validades.

El mètode validated() retorna un array amb només els camps que han passat la validació. Això és important perquè descarta qualsevol camp que no estigui definit a les regles, oferint una capa addicional de seguretat.

Autorització#

El mètode authorize() determina si l'usuari actual pot fer l'acció que el formulari representa. Si retorna false, Laravel retorna una resposta 403 (Forbidden):

public function authorize(): bool
{
    // Qualsevol usuari autenticat pot crear articles
    return auth()->check();
}

Per a actualitzacions, sovint necessites comprovar que l'usuari és el propietari del recurs. Pots accedir als paràmetres de la ruta amb $this->route():

class UpdateArticleRequest extends FormRequest
{
    public function authorize(): bool
    {
        $article = $this->route('article');
        return $this->user()->id === $article->user_id;
    }
 
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'slug' => [
                'required',
                'string',
                Rule::unique('articles')->ignore($this->route('article')),
            ],
            'body' => ['required', 'string', 'min:10'],
        ];
    }
}

Si la lògica d'autorització és complexa, és recomanable delegar-la a una Policy i simplement cridar-la des del Form Request:

public function authorize(): bool
{
    return $this->user()->can('update', $this->route('article'));
}

Missatges personalitzats#

Sobreescriu el mètode messages() per personalitzar els missatges d'error:

public function messages(): array
{
    return [
        'title.required' => 'El títol és obligatori.',
        'title.max' => 'El títol no pot superar els :max caràcters.',
        'body.required' => 'El contingut no pot estar buit.',
        'body.min' => 'El contingut ha de tenir almenys :min caràcters.',
        'category_id.required' => 'Has de seleccionar una categoria.',
        'category_id.exists' => 'La categoria seleccionada no existeix.',
        'published_at.after' => 'La data de publicació ha de ser futura.',
    ];
}

Noms d'atributs personalitzats#

Per defecte, Laravel utilitza el nom del camp als missatges d'error (per exemple, "The category_id field is required"). Pots canviar-ho amb el mètode attributes():

public function attributes(): array
{
    return [
        'title' => 'títol',
        'body' => 'contingut',
        'category_id' => 'categoria',
        'published_at' => 'data de publicació',
        'tags.*' => 'etiqueta',
    ];
}

Ara els missatges dirien "El camp títol és obligatori" en lloc de "The title field is required".

Preparar les dades#

De vegades vols normalitzar o transformar les dades abans de validar-les. El mètode prepareForValidation() s'executa just abans de la validació:

protected function prepareForValidation(): void
{
    $this->merge([
        'slug' => Str::slug($this->title),
        'published' => $this->boolean('published'),
    ]);
}

Això és útil per generar slugs automàticament, normalitzar formats de data, convertir strings buits a null, o qualsevol transformació que vulguis aplicar abans de validar.

Per manipular les dades després de la validació, utilitza passedValidation():

protected function passedValidation(): void
{
    $this->merge([
        'body' => clean($this->body), // Sanititzar HTML
    ]);
}

Regles condicionals#

Les regles poden variar segons les dades de la petició o el context. Pots construir l'array de regles dinàmicament:

public function rules(): array
{
    $rules = [
        'title' => ['required', 'string', 'max:255'],
        'body' => ['required', 'string'],
        'type' => ['required', 'in:article,video,podcast'],
    ];
 
    // Camps específics segons el tipus
    if ($this->type === 'video') {
        $rules['video_url'] = ['required', 'url'];
        $rules['duration'] = ['required', 'integer', 'min:1'];
    }
 
    if ($this->type === 'podcast') {
        $rules['audio_file'] = ['required', 'file', 'mimes:mp3,wav'];
    }
 
    return $rules;
}

Per a condicions basades en el mètode HTTP (diferenciar entre crear i actualitzar):

public function rules(): array
{
    $rules = [
        'title' => ['required', 'string', 'max:255'],
        'body' => ['required', 'string'],
    ];
 
    // El password és obligatori al crear, opcional al actualitzar
    if ($this->isMethod('POST')) {
        $rules['password'] = ['required', 'string', 'min:8', 'confirmed'];
    } else {
        $rules['password'] = ['nullable', 'string', 'min:8', 'confirmed'];
    }
 
    return $rules;
}

Compartir Form Requests entre Create i Update#

Un patró habitual és crear un Form Request base per compartir regles entre les accions de crear i actualitzar:

class StoreArticleRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }
 
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'slug' => ['required', 'string', 'unique:articles,slug'],
            'body' => ['required', 'string', 'min:10'],
            'category_id' => ['required', 'exists:categories,id'],
        ];
    }
}
 
class UpdateArticleRequest extends StoreArticleRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('update', $this->route('article'));
    }
 
    public function rules(): array
    {
        $rules = parent::rules();
 
        // Modificar la regla unique per ignorar el registre actual
        $rules['slug'] = [
            'required',
            'string',
            Rule::unique('articles')->ignore($this->route('article')),
        ];
 
        return $rules;
    }
}

L'herència de classes permet reutilitzar les regles base i només sobreescriure les que canvien. Això evita duplicar regles entre els dos Form Requests.

Exemple complet#

Un Form Request complet per a la creació d'un article amb totes les funcionalitats:

namespace App\Http\Requests;
 
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
 
class StoreArticleRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Article::class);
    }
 
    protected function prepareForValidation(): void
    {
        $this->merge([
            'slug' => Str::slug($this->title),
            'user_id' => $this->user()->id,
        ]);
    }
 
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'slug' => ['required', 'string', 'max:255', 'unique:articles'],
            'body' => ['required', 'string', 'min:10'],
            'category_id' => ['required', Rule::exists('categories', 'id')->where('active', true)],
            'tags' => ['nullable', 'array', 'max:10'],
            'tags.*' => ['string', 'max:50'],
            'published' => ['boolean'],
            'published_at' => ['nullable', 'date', 'after:now'],
            'cover_image' => ['nullable', 'image', 'max:2048'],
        ];
    }
 
    public function messages(): array
    {
        return [
            'title.required' => 'El títol és obligatori.',
            'body.min' => 'El contingut ha de tenir almenys :min caràcters.',
            'category_id.exists' => 'La categoria seleccionada no és vàlida.',
            'tags.max' => 'No pots afegir més de :max etiquetes.',
            'cover_image.max' => 'La imatge no pot superar els 2MB.',
        ];
    }
 
    public function attributes(): array
    {
        return [
            'title' => 'títol',
            'body' => 'contingut',
            'category_id' => 'categoria',
            'published_at' => 'data de publicació',
            'cover_image' => 'imatge de portada',
        ];
    }
}

Al controlador, el codi queda mínim:

public function store(StoreArticleRequest $request)
{
    $article = Article::create($request->validated());
 
    if ($request->hasFile('cover_image')) {
        $article->update([
            'cover_image' => $request->file('cover_image')->store('covers', 'public'),
        ]);
    }
 
    return redirect()->route('articles.show', $article);
}