Version: This guide targets Laravel 11. Everything here also works in Laravel 10 unless noted.

Why this matters

Think of any request hitting your app like a visitor at your gate:

  • authorize() asks: “Is this person even allowed to enter?” If no, show 403 Forbidden and stop.
  • rules() asks: “If allowed, is the stuff they’re carrying in proper shape?” If no, show 422 Unprocessable Entity with validation errors.

When we mix both, code becomes confusing. When we separate them, code becomes clean, secure, and easy to test.


Quick mental model

  • Permission decisionsauthorize() (use Policies/Gates).
  • Data shape and constraintsrules() (use validation rules).
  • Orderauthorize() runs first, then rules().

A complete example (update a Post)

// app/Http/Requests/UpdatePostRequest.php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class UpdatePostRequest extends FormRequest
{
public function authorize(): bool
{
// Keep this thin. Delegate to Policy/Gate.
return $this->user()->can('update', $this->route('post'));
}

public function rules(): array
{
$post = $this->route('post');

return [
'title' => ['required', 'string', 'max:160'],
'slug' => [
'required', 'string', 'max:160',
Rule::unique('posts', 'slug')
->where('user_id', $this->user()->id)
->ignore($post->id),
],
'body' => ['required', 'string'],
'tags' => ['sometimes', 'array'],
'tags.*' => ['integer', 'exists:tags,id'],
'publish_at' => ['nullable', 'date', 'after:now'],
];
}

// Normalise data before rules run
protected function prepareForValidation(): void
{
$slug = $this->input('slug') ?? $this->input('title');
$this->merge(['slug' => str($slug)->slug()]);
}

// Conditional rules
public function withValidator($validator): void
{
$validator->sometimes('tags', ['required', 'array', 'min:1'], function () {
return $this->boolean('publish_now');
});
}

// Side-effects that need valid data (optional)
protected function passedValidation(): void
{
// e.g., convert into a DTO if you like
// $this->dto = new UpdatePostData(...$this->validated());
}
}
// app/Policies/PostPolicy.php
public function update(User $user, Post $post): bool
{
    return $post->user_id === $user->id; // or role/permission logic
}
// app/Http/Controllers/PostController.php
public function update(UpdatePostRequest $request, Post $post)
{
    $post->update($request->validated());

    return to_route('posts.show', $post)->with('status', 'Updated!');
}

What to put where (thumb rules)

In authorize()

  • Ownership checks (this post belongs to the user)
  • Role/permission checks (can manage-posts?)
  • Tenancy checks (same company / tenant)
  • Feature flags (is this feature enabled for this user?)

Keep it declarative and short. Prefer $this->user()->can('ability', $model) and define real logic in the Policy.

In rules()

  • Required fields, types, sizes, formats
  • Uniqueness/existence constraints (with tenant/user scoping)
  • Cross-field relations (after:publish_at, required_if:publish_now,true)
  • Array item rules (tags.*)

Keep it about data shape, not permissions.


Error codes you should return (and why)

  • 403 Forbidden → User is not allowed to attempt this action. Comes from authorize() fail.
  • 422 Unprocessable Entity → User is allowed, but data is invalid. Comes from rules() fail.

This difference helps API clients and frontend teams debug fast.


Multi-tenant & soft delete aware rules

use Illuminate\Validation\Rule;

Rule::exists('projects', 'id')->where(fn ($q) =>
    $q->where('tenant_id', $this->user()->tenant_id)
);

Rule::unique('users', 'email')
    ->whereNull('deleted_at'); // if you use soft deletes on users table

API vs Blade forms (same request, different responses)

  • API: You’ll usually want JSON errors. Laravel already returns 422 JSON for validation failures if the request expects JSON (e.g., Accept: application/json).
  • Blade: The same Form Request will redirect back with old input and error bag. Just show @error('field').

You don’t need two different validators. One Form Request works for both.


Custom messages & attribute names

public function messages(): array
{
    return [
        'slug.unique' => 'This slug is already taken. Please try another.',
    ];
}

public function attributes(): array
{
    return [
        'publish_at' => 'publish date',
    ];
}

For localisation, place translations in lang/*/validation.php.


Useful lifecycle hooks (when to use)

  • prepareForValidation(): clean/merge input (trim, cast boolean, build a slug).
  • withValidator(): add conditional rules, complex sometimes().
  • passedValidation(): build a DTO or attach computed data for your controller/service.

Avoid heavy DB writes inside hooks. Keep side-effects for after the controller updates the model.


Common mistakes (and easy fixes)

  1. Mixing permission checks inside rules() with custom closures
    • Fix: Put permission in authorize() / Policy.
  2. Returning true in authorize() but blocking users using in: rule (e.g., role must be admin)
    • Fix: If they must not even attempt, deny in authorize() → 403.
  3. Forgetting tenant scope in unique/exists
    • Fix: Use Rule::unique()->where(...) / Rule::exists()->where(...).
  4. Doing heavy business logic in authorize()
    • Fix: Keep it a “may act?” check; complex stuff goes to services after validation.
  5. No route model binding
    • Fix: Use type-hinted models (Route::resource, {post}, Post $post) so your Policy gets the real model.

Testing strategy (fast and clear)

// tests/Feature/PostUpdateAuthorizationTest.php
public function test_user_cannot_update_someone_elses_post(): void
{
    $this->actingAs(User::factory()->create());
    $post = Post::factory()->for(User::factory())->create();

    $this->put(route('posts.update', $post), ['title' => 'X'])
         ->assertForbidden(); // 403
}

// tests/Feature/PostUpdateValidationTest.php
public function test_requires_title_and_unique_slug(): void
{
    $user = User::factory()->create();
    $this->actingAs($user);
    $post = Post::factory()->for($user)->create();

    $this->put(route('posts.update', $post), ['title' => ''])
         ->assertStatus(422)
         ->assertInvalid(['title']);
}

Tips:

  • One test file for authorization, one for validation → failures are obvious.
  • Use factories and policies just like production.

Patterns that scale

1) Use Policies everywhere

php artisan make:policy PostPolicy --model=Post

Wire them in AuthServiceProvider (Laravel auto-discovers if named correctly).
Then authorize() just does: $this->user()->can('update', $post).

2) Keep controllers skinny

Controller should mostly read $request->validated() and call a service/repo. Clean.

3) DTOs for clarity (optional)

If payload is big/complex, build a DTO in passedValidation() and consume it in service.

4) Resource-specific requests

Make separate requests for create/update if rules differ a lot: StorePostRequest vs UpdatePostRequest.


Advanced examples

Conditional uniqueness by status

Rule::unique('coupons', 'code')
    ->where(fn ($q) => $q->where('active', true));

Cross-field date validation

'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'],

Array of objects

'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'integer', 'exists:products,id'],
'items.*.qty'        => ['required', 'integer', 'min:1'],

When to not use a Form Request

  • Tiny endpoints with 1–2 trivial fields (you still can, but inline Validator::make() may be ok).
  • Background jobs / console commands (different context).
    Still, Form Requests are fine for consistency in HTTP controllers.

FAQ (short and practical)

Q: Can I check “max posts per user” in authorize()?
A: If the user shouldn’t be allowed to create more at all → yes (policy/business rule). If it’s a data constraint (e.g., limit reached but want 422), you may also express it as a custom validation rule. Choose based on UX: 403 = “not allowed”, 422 = “allowed but fails rule”.

Q: How do I return a custom 403 message?
A: Throw AuthorizationException::withMessages(['You cannot update this post.']) from authorize() or let the Policy return false and customize your exception handler if needed.

Q: How to reuse same rules for store & update?
A: Extract to a method/trait, or make two requests that share a base trait. For unique rules, remember to ->ignore($model->id) in update.


Ready-to-copy template

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class MyRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('ability', $this->route('model'));
    }

    public function rules(): array
    {
        return [
            // your rules here
        ];
    }

    protected function prepareForValidation(): void {}
    public function withValidator($validator): void {}
    protected function passedValidation(): void {}
    public function messages(): array { return []; }
    public function attributes(): array { return []; }
}

Final takeaway

  • authorize()Who can act? (403 on fail). Keep it thin; use Policies.
  • rules()Is data valid? (422 on fail). Keep it about input shape.
  • Use lifecycle hooks wisely; test authorization and validation separately.
  • This separation keeps your Laravel 11 code clean, secure, and easy to maintain.

If you share your exact use-case (CRUD type, multi-tenant rules, role matrix), I can drop in a tailored authorize() + rules() set for you—still keeping the separation clean.

Categorized in: