Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 013 / 100

Laravel validation with Form Requests: a practical guide

Validation and authorization in one class.

Expona Admin8 min read

Validation and authorization: two concerns, one class

Every Laravel application validates form input. Most also check whether the user has permission to perform an action. Traditionally, you'd scatter validation rules across your controllers and authorization logic across middleware or policies. Laravel validation with Form Requests: a practical guide consolidates both into a single, reusable class that keeps your controllers clean and your business logic centralized.

We've seen too many controllers bloated with validation chains and gate checks. Form Requests eliminate that friction by providing a dedicated home for both concerns. By day 13 of your Laravel journey, understanding Form Requests transforms how you structure validation and authorization, making your code more maintainable and your controllers more focused.

Generating a Form Request with php artisan make:request

Laravel ships with a handy artisan command that scaffolds a Form Request class in seconds:

php
php artisan make:request StoreProductRequest

This creates app/Http/Requests/StoreProductRequest.php. Inside, you'll find two methods: authorize() and rules(). Both are stubs by default, waiting for you to implement your business logic.

In a real e-commerce application we worked on, we generated requests for every major action: storing products, updating inventory, processing refunds. Each request class became the single source of truth for what data was acceptable and who could perform the action. Over time, this prevented dozens of bugs because validation logic lived in one predictable place.

You can also generate multiple requests at once or target a specific namespace. The flexibility is there, but start simple: one request per action. If you're building a REST API with many endpoints, consider grouping related requests in subdirectories:

bash
app/Http/Requests/Products/StoreProductRequest.php
app/Http/Requests/Products/UpdateProductRequest.php
app/Http/Requests/Orders/StoreOrderRequest.php
app/Http/Requests/Orders/UpdateOrderRequest.php

This organization scales well as your application grows and makes it easier to locate request logic when you need to modify validation rules.

Defining validation rules with the rules() method

The rules() method returns an array where keys are field names and values are validation rule strings or arrays:

php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreProductRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name' => 'required|string|max:255',
            'sku' => 'required|string|unique:products,sku',
            'price' => 'required|numeric|min:0.01',
            'description' => 'nullable|string|max:2000',
            'category_id' => 'required|integer|exists:categories,id',
            'images' => 'nullable|array|max:5',
            'images.*' => 'image|mimes:jpeg,png,webp|max:2048',
        ];
    }
}

Notice that we define array field rules with dot notation (images.*). This pattern validates each image file individually. Also observe unique:products,sku—this checks the database without writing raw queries.

In our trading platform, we had complex product pricing rules. Instead of scattering those rules across multiple controllers, we centralized them in a single request class. When business requirements changed, we updated one file. Every endpoint that used that request automatically got the new rules.

You can also override the attributes() and messages() methods to customize field names and error messages:

php
public function attributes(): array
{
    return [
        'name' => 'product name',
        'sku' => 'stock keeping unit',
    ];
}

public function messages(): array
{
    return [
        'sku.unique' => 'This SKU is already in our system.',
        'price.min' => 'Price must be at least one cent.',
    ];
}

Real-world example: in a travel booking system, we had complex date range validation. Instead of repeating the same date logic in multiple request classes, we created a custom rule:

php
public function rules(): array
{
    return [
        'check_in_date' => 'required|date|after:today',
        'check_out_date' => 'required|date|after:check_in_date',
        'guests' => 'required|integer|min:1|max:10',
    ];
}

public function messages(): array
{
    return [
        'check_in_date.after' => 'You cannot book a date in the past.',
        'check_out_date.after' => 'Check-out must be after check-in.',
        'guests.max' => 'Maximum 10 guests per booking.',
    ];
}

Another edge case: conditional rules. In an update request, you might skip the unique check for the current record:

php
public function rules(): array
{
    return [
        'email' => [
            'required',
            'email',
            Rule::unique('users', 'email')
                ->ignore($this->user()->id),
        ],
    ];
}

Checking authorization with the authorize() method

The authorize() method returns a boolean. If it returns false, Laravel throws a 403 Forbidden exception:

php
public function authorize(): bool
{
    // Only allow admins or users with product-creation role
    return auth()->user()?->can('create products');
}

This is cleaner than scattering checks throughout your controller. Consider a SaaS platform where teams manage billing cycles. You might write:

php
public function authorize(): bool
{
    $billCycle = BillCycle::findOrFail($this->route('bill_cycle_id'));
    
    return auth()->user()->belongsToTeam($billCycle->team_id) 
        && auth()->user()->hasRole('finance_lead');
}

Now your controller doesn't need to repeat this logic. The authorization happens automatically before your controller method even runs.

We once refactored a ride-hailing application where drivers were making unauthorized requests to update rides they didn't own. Using authorize() in the request class, we plugged that hole in one place. Every endpoint using that request immediately became secure.

A critical edge case: what if the resource being authorized doesn't exist? Your authorize() method should handle that gracefully. We recommend throwing a 404 rather than a 403:

php
public function authorize(): bool
{
    $order = Order::find($this->route('order_id'));
    
    if (!$order) {
        abort(404);
    }
    
    return auth()->user()->id === $order->user_id;
}

Another pattern we use: separating authentication from authorization. Always check that the user is authenticated first:

php
public function authorize(): bool
{
    if (!auth()->check()) {
        return false;
    }
    
    $project = Project::find($this->route('project_id'));
    
    return auth()->user()->canEditProject($project);
}

Accessing validated data with $request->validated()

After validation and authorization pass, you retrieve the validated data using validated():

php
namespace App\Http\Controllers;

use App\Http\Requests\StoreProductRequest;
use App\Models\Product;

class ProductController extends Controller
{
    public function store(StoreProductRequest $request)
    {
        $data = $request->validated();
        
        $product = Product::create($data);
        
        return response()->json($product, 201);
    }
}

$request->validated() returns only fields that passed validation. This is a security feature: it prevents mass assignment vulnerabilities. If a malicious actor sends an admin field in the request, it won't appear in validated() unless you explicitly included it in your rules.

You can also retrieve specific fields from validated data:

php
$name = $request->validated('name');
$images = $request->validated('images', []);

In a CRM system, we used validated() to pass data directly to service classes. The service class never had to question whether data was clean—it already was. This separation of concerns made testing easier: mock the request, stub validation, and test business logic in isolation.

A practical pattern: merging or transforming validated data before persisting:

php
public function store(StoreProductRequest $request)
{
    $data = $request->validated();
    
    // Transform nested array into separate fields
    $data['image_primary'] = $data['images'][0] ?? null;
    $data['created_by_user_id'] = auth()->id();
    
    $product = Product::create($data);
    
    return response()->json($product, 201);
}

Another scenario: using validated data across multiple database operations. In a travel system, booking a flight also needs to reserve a seat:

php
public function book(BookFlightRequest $request)
{
    $data = $request->validated();
    
    $booking = Booking::create([
        'user_id' => auth()->id(),
        'flight_id' => $data['flight_id'],
        'seat_class' => $data['seat_class'],
    ]);
    
    Seat::findOrFail($data['seat_id'])
        ->update(['booking_id' => $booking->id]);
    
    return response()->json($booking, 201);
}

Handling edge cases and common mistakes

Forgetting to type-hint the request in your controller: Without the type hint, Laravel can't auto-inject the request, and validation won't trigger. Always write public function store(StoreProductRequest $request), never just $request. We've debugged this issue more times than we'd like to admit.

Calling validate() after using a Form Request: Form Requests validate automatically. Calling $request->validate() again is redundant and confusing. Use the request object as-is.

Overcomplicating authorization: If your authorize() method becomes deeply nested or requires multiple database queries, consider moving it to a policy. Form Requests are for simple permission checks; policies handle complex authorization logic.

Not handling validation errors: By default, Laravel redirects back with errors on validation failure. For APIs, override failedValidation() to return JSON:

php
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;

protected function failedValidation(Validator $validator)
{
    throw new HttpResponseException(
        response()->json([
            'message' => 'Validation failed',
            'errors' => $validator->errors(),
        ], 422)
    );
}

Forgetting to handle empty inputs: In an update request, clients might send only fields they want to change. Use sometimes rule to validate conditionally:

php
public function rules(): array
{
    return [
        'email' => 'sometimes|required|email|unique:users',
        'phone' => 'sometimes|required|phone',
        'address' => 'sometimes|string|max:255',
    ];
}

Missing null checks in authorize(): When fetching a resource by route parameter, always verify it exists before checking authorization:

php
public function authorize(): bool
{
    $article = Article::find($this->route('id'));
    
    // Bad: can cause null pointer errors
    // return auth()->user()->id === $article->author_id;
    
    // Good: explicit null check
    if (!$article) {
        abort(404);
    }
    
    return auth()->user()->id === $article->author_id;
}

Key takeaways

  • Use php artisan make:request ClassName to scaffold Form Requests and eliminate scattered validation logic.
  • Implement rules() to return an array of Laravel validation rules; use dot notation for array fields.
  • Implement authorize() to return a boolean; return false to throw a 403 exception, and abort(404) for missing resources.
  • Always call $request->validated() to retrieve only the fields that passed validation and authorization.
  • Type-hint the Form Request in your controller method signature; Laravel injects and validates automatically.
  • Customize error messages and field names with messages() and attributes() for better user feedback.
  • For APIs, override failedValidation() to return JSON errors instead of redirects.
  • Keep authorization logic simple in Form Requests; move complex logic to dedicated policy classes.
  • Use conditional rules with sometimes in update requests to handle partial data.
  • Transform or merge validated data before passing to models or service classes.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles