HardПрактика9 min

Валидация данных

Методы валидации, Form Requests, встроенные правила, кастомные правила, условная валидация, сообщения об ошибках, вложенная валидация в Laravel 11

Валидация данных в Laravel

Валидация - одна из ключевых тем на сертификации Laravel. Фреймворк предоставляет мощную систему валидации с десятками встроенных правил, возможностью создания собственных, условной валидацией и удобной интеграцией с формами.

Методы валидации

Метод validate() в контроллере

Самый простой способ валидации - метод validate() объекта Request:

declare(strict_types=1);

namespace App\Http\Controllers;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

final class ArticleController extends Controller
{
    public function store(Request $request): RedirectResponse
    {
        // Throws ValidationException if fails
        // Returns validated data on success
        $validated = $request->validate([
            'title' => ['required', 'string', 'max:255'],
            'body' => ['required', 'string', 'min:10'],
            'category_id' => ['required', 'integer', 'exists:categories,id'],
            'tags' => ['nullable', 'array', 'max:10'],
            'tags.*' => ['string', 'max:50'],
            'published_at' => ['nullable', 'date', 'after:today'],
        ]);

        // $validated contains ONLY the validated fields
        $article = Article::create($validated);

        return redirect()
            ->route('articles.show', $article)
            ->with('success', 'Article created!');
    }
}

::alert{type="warning"} Ключевой момент для экзамена: Метод validate() возвращает только те данные, которые прошли валидацию. Если в запросе были дополнительные поля, не указанные в правилах валидации, они НЕ попадут в результат. Это важная мера безопасности. ::

Фасад Validator

Для более гибкого контроля используйте фасад Validator:

use Illuminate\Support\Facades\Validator;

$validator = Validator::make($request->all(), [
    'email' => ['required', 'email:rfc,dns'],
    'password' => ['required', 'min:8', 'confirmed'],
]);

// Check if validation fails
if ($validator->fails()) {
    return redirect()
        ->back()
        ->withErrors($validator)
        ->withInput();
}

// Get validated data
$validated = $validator->validated();

// Get only safe data (intersection of rules and input)
$safe = $validator->safe()->only(['email', 'password']);
$except = $validator->safe()->except(['password']);
$all = $validator->safe()->all();

Хуки after validation

$validator = Validator::make($request->all(), [
    'email' => ['required', 'email'],
    'password' => ['required', 'min:8'],
]);

$validator->after(function ($validator) {
    if ($this->isBlacklistedDomain($validator->getData()['email'])) {
        $validator->errors()->add(
            'email',
            'Registration from this email domain is not allowed.'
        );
    }
});

if ($validator->fails()) {
    // Handle failure
}

Метод validate() на валидаторе

// Throws ValidationException automatically
$validated = Validator::make($request->all(), [
    'title' => 'required|string|max:255',
])->validate();

Form Requests

Form Request - это кастомный класс запроса с встроенной валидацией. Рекомендуемый подход для production-приложений:

php artisan make:request StoreArticleRequest
declare(strict_types=1);

namespace App\Http\Requests;

use App\Enums\ArticleStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;

final class StoreArticleRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        // Authorization check - return false to get 403
        return $this->user()->can('create', Article::class);
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, mixed>
     */
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255', 'unique:articles,title'],
            'slug' => ['nullable', 'string', 'max:255', 'alpha_dash', 'unique:articles,slug'],
            'body' => ['required', 'string', 'min:50'],
            'excerpt' => ['nullable', 'string', 'max:500'],
            'category_id' => ['required', 'exists:categories,id'],
            'status' => ['required', new Enum(ArticleStatus::class)],
            'tags' => ['nullable', 'array', 'max:10'],
            'tags.*' => ['string', 'max:50', 'distinct'],
            'meta' => ['nullable', 'array'],
            'meta.description' => ['nullable', 'string', 'max:160'],
            'meta.keywords' => ['nullable', 'string', 'max:255'],
            'cover_image' => ['nullable', 'image', 'max:5120', 'dimensions:min_width=800,min_height=400'],
            'published_at' => ['nullable', 'date', 'after_or_equal:today'],
        ];
    }

    /**
     * Custom validation messages.
     *
     * @return array<string, string>
     */
    public function messages(): array
    {
        return [
            'title.required' => 'Please provide an article title.',
            'title.unique' => 'An article with this title already exists.',
            'body.min' => 'Article body must be at least :min characters.',
            'cover_image.dimensions' => 'Cover image must be at least 800x400 pixels.',
            'tags.max' => 'You can assign a maximum of :max tags.',
            'tags.*.distinct' => 'Duplicate tags are not allowed.',
        ];
    }

    /**
     * Custom attribute names for error messages.
     *
     * @return array<string, string>
     */
    public function attributes(): array
    {
        return [
            'category_id' => 'category',
            'meta.description' => 'SEO description',
            'meta.keywords' => 'SEO keywords',
            'cover_image' => 'cover image',
            'published_at' => 'publication date',
        ];
    }

    /**
     * Prepare the data for validation (runs BEFORE validation).
     */
    protected function prepareForValidation(): void
    {
        $this->merge([
            'slug' => $this->slug ? Str::slug($this->slug) : Str::slug($this->title),
            'body' => strip_tags($this->body, '<p><br><strong><em><ul><ol><li><a><h2><h3><h4><blockquote><code><pre>'),
        ]);
    }

    /**
     * Handle a passed validation attempt (runs AFTER validation succeeds).
     */
    protected function passedValidation(): void
    {
        // Optionally transform data after validation
        $this->replace([
            ...$this->validated(),
            'author_id' => $this->user()->id,
        ]);
    }
}

Использование в контроллере:

public function store(StoreArticleRequest $request): RedirectResponse
{
    // Validation already passed - FormRequest is auto-resolved
    $article = Article::create($request->validated());

    return redirect()->route('articles.show', $article);
}

Form Request для обновления

declare(strict_types=1);

namespace App\Http\Requests;

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

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

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

        return [
            'title' => ['required', 'string', 'max:255', Rule::unique('articles')->ignore($articleId)],
            'slug' => ['nullable', 'string', Rule::unique('articles')->ignore($articleId)],
            'body' => ['required', 'string', 'min:50'],
            'status' => ['required', 'in:draft,published,archived'],
        ];
    }
}

Основные правила валидации

Правила существования и уникальности

// exists - value must exist in database
'category_id' => ['required', 'exists:categories,id'],

// exists with additional where clause
'category_id' => [
    'required',
    Rule::exists('categories', 'id')->where('is_active', true),
],

// unique - value must not exist
'email' => ['required', 'email', 'unique:users,email'],

// unique ignoring current record (for updates)
'email' => ['required', 'email', Rule::unique('users', 'email')->ignore($user->id)],

// unique with soft deletes
'email' => [
    'required',
    Rule::unique('users', 'email')->withoutTrashed(),
],

Строковые правила

'name' => ['required', 'string', 'min:2', 'max:100'],
'slug' => ['required', 'alpha_dash'],       // Letters, numbers, dashes, underscores
'code' => ['required', 'alpha_num'],         // Letters and numbers only
'username' => ['required', 'regex:/^[a-z][a-z0-9_]{2,29}$/'],
'url' => ['required', 'url'],
'email' => ['required', 'email:rfc,dns'],   // Strict email validation
'ip' => ['required', 'ip'],                 // IPv4 or IPv6
'uuid' => ['required', 'uuid'],
'ulid' => ['required', 'ulid'],
'json' => ['required', 'json'],

Числовые правила

'age' => ['required', 'integer', 'min:18', 'max:120'],
'price' => ['required', 'numeric', 'min:0.01', 'max:999999.99'],
'quantity' => ['required', 'integer', 'between:1,100'],
'discount' => ['required', 'decimal:2'],     // Exactly 2 decimal places
'rating' => ['required', 'integer', 'in:1,2,3,4,5'],
'percentage' => ['required', 'numeric', 'between:0,100'],

Правила дат

'birthday' => ['required', 'date', 'before:today'],
'start_date' => ['required', 'date', 'after:today'],
'end_date' => ['required', 'date', 'after:start_date'],
'published_at' => ['required', 'date_format:Y-m-d H:i:s'],
'expires_at' => ['required', 'date', 'after_or_equal:today', 'before_or_equal:+1 year'],

Правила файлов

'avatar' => ['required', 'image', 'max:2048'],  // max 2MB
'document' => ['required', 'file', 'mimes:pdf,doc,docx', 'max:10240'],
'photo' => [
    'required',
    'image',
    'dimensions:min_width=100,min_height=100,max_width=4000,max_height=4000',
    'max:5120',
],
'photos' => ['required', 'array', 'max:5'],
'photos.*' => ['image', 'max:5120'],

Правила массивов

'tags' => ['required', 'array', 'min:1', 'max:10'],
'tags.*' => ['string', 'max:50', 'distinct'],
'items' => ['required', 'array'],
'items.*.name' => ['required', 'string', 'max:100'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
'items.*.price' => ['required', 'numeric', 'min:0'],

Правило password

use Illuminate\Validation\Rules\Password;

'password' => [
    'required',
    'confirmed',
    Password::min(8)
        ->letters()
        ->mixedCase()
        ->numbers()
        ->symbols()
        ->uncompromised(), // Check against haveibeenpwned.com
],

// Set default password rules for the entire application
// In AppServiceProvider::boot()
Password::defaults(fn () => Password::min(8)
    ->letters()
    ->mixedCase()
    ->numbers()
    ->symbols()
    ->uncompromised()
);

// Then use:
'password' => ['required', 'confirmed', Password::defaults()],

Правило enum

use Illuminate\Validation\Rules\Enum;

// Validate against PHP Enum
'status' => ['required', new Enum(OrderStatus::class)],

// With only specific cases
'priority' => [
    'required',
    Rule::enum(Priority::class)->only([Priority::High, Priority::Critical]),
],

// Exclude specific cases
'role' => [
    'required',
    Rule::enum(UserRole::class)->except([UserRole::SuperAdmin]),
],

Условная валидация

Метод sometimes()

$validator = Validator::make($request->all(), [
    'email' => ['required', 'email'],
    'name' => ['required', 'string'],
]);

// Add rule only when condition is true
$validator->sometimes('phone', ['required', 'string'], function ($input) {
    return $input->contact_method === 'phone';
});

// Multiple fields
$validator->sometimes(['city', 'state', 'zip'], 'required', function ($input) {
    return $input->has_address === true;
});

Условия в правилах (Rule::when)

'password' => [
    Rule::when($isCreating, ['required', 'min:8', 'confirmed']),
    Rule::when(!$isCreating, ['nullable', 'min:8', 'confirmed']),
],

// With closure condition
'notify_email' => [
    Rule::when(
        fn () => $request->boolean('email_notifications'),
        ['required', 'email'],
        ['nullable']
    ),
],

required_if / required_unless / required_with / required_without

'state' => ['required_if:country,US,CA'],          // Required if country is US or CA
'reason' => ['required_unless:status,approved'],     // Required unless status is approved
'city' => ['required_with:street,zip'],              // Required if street OR zip is present
'email' => ['required_without:phone'],               // Required if phone is NOT present
'backup_email' => ['required_with_all:email,phone'], // Required if ALL listed fields present
'phone' => ['required_without_all:email,fax'],       // Required if NONE of listed fields present

exclude_if / exclude_unless

'company_name' => ['exclude_if:is_individual,true', 'required', 'string'],
'vat_number' => ['exclude_unless:is_company,true', 'required', 'string'],

Вложенная валидация (Nested data)

// Validate nested objects
$rules = [
    'user' => ['required', 'array'],
    'user.name' => ['required', 'string', 'max:100'],
    'user.email' => ['required', 'email', 'unique:users'],
    'user.address' => ['required', 'array'],
    'user.address.street' => ['required', 'string'],
    'user.address.city' => ['required', 'string'],
    'user.address.zip' => ['required', 'string', 'regex:/^\d{5}(-\d{4})?$/'],

    // Validate array of objects
    'products' => ['required', 'array', 'min:1'],
    'products.*.name' => ['required', 'string'],
    'products.*.variants' => ['required', 'array', 'min:1'],
    'products.*.variants.*.size' => ['required', 'string', 'in:S,M,L,XL,XXL'],
    'products.*.variants.*.color' => ['required', 'string'],
    'products.*.variants.*.stock' => ['required', 'integer', 'min:0'],
];

Кастомные правила валидации

Класс правила

php artisan make:rule Uppercase
declare(strict_types=1);

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

final class Uppercase implements ValidationRule
{
    /**
     * Run the validation rule.
     */
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (strtoupper($value) !== $value) {
            $fail('The :attribute must be uppercase.');
        }
    }
}

// Usage:
'code' => ['required', 'string', new Uppercase],

Правило с зависимостями

declare(strict_types=1);

namespace App\Rules;

use App\Services\ProfanityChecker;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

final class NoProfanity implements ValidationRule
{
    public function __construct(
        private readonly ProfanityChecker $checker,
    ) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if ($this->checker->containsProfanity($value)) {
            $fail('The :attribute contains inappropriate language.');
        }
    }
}

// Usage with DI:
'comment' => ['required', 'string', app(NoProfanity::class)],

Implicit правило (срабатывает даже для пустых полей)

declare(strict_types=1);

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Contracts\Validation\ImplicitRule;

final class RequiredOnWeekdays implements ValidationRule, ImplicitRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (now()->isWeekday() && empty($value)) {
            $fail('The :attribute is required on weekdays.');
        }
    }
}

Inline правило через замыкание

'email' => [
    'required',
    'email',
    function (string $attribute, mixed $value, Closure $fail) {
        if (str_ends_with($value, '@disposable.com')) {
            $fail("Disposable email addresses are not allowed.");
        }
    },
],

Отображение ошибок

В Blade-шаблонах

{{-- Display all errors --}}
@if ($errors->any())
    <div class="alert alert-danger">
        <ul>
            @foreach ($errors->all() as $error)
                <li>{{ $error }}</li>
            @endforeach
        </ul>
    </div>
@endif

{{-- Display error for specific field --}}
@error('email')
    <span class="text-red-500">{{ $message }}</span>
@enderror

{{-- Error for specific error bag --}}
@error('email', 'login')
    <span class="text-red-500">{{ $message }}</span>
@enderror

{{-- Conditional CSS class --}}
<input
    type="email"
    name="email"
    @class(['form-control', 'is-invalid' => $errors->has('email')])
    value="{{ old('email') }}"
>

Named Error Bags

$validator = Validator::make($request->all(), [
    'email' => 'required|email',
])->validateWithBag('login');

// In Blade:
@error('email', 'login')
    <span>{{ $message }}</span>
@enderror

Валидация для API

declare(strict_types=1);

namespace App\Http\Requests\Api;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;

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

    public function rules(): array
    {
        return [
            'items' => ['required', 'array', 'min:1'],
            'items.*.product_id' => ['required', 'exists:products,id'],
            'items.*.quantity' => ['required', 'integer', 'min:1', 'max:100'],
            'shipping_address_id' => ['required', 'exists:addresses,id'],
            'coupon_code' => ['nullable', 'string', 'exists:coupons,code'],
        ];
    }

    /**
     * Handle a failed validation attempt for API.
     * By default, FormRequest returns JSON for requests that expect JSON.
     */
    protected function failedValidation(Validator $validator): void
    {
        throw new HttpResponseException(
            response()->json([
                'message' => 'Validation failed.',
                'errors' => $validator->errors(),
            ], 422)
        );
    }
}

::alert{type="info"} Для API-запросов (с заголовком Accept: application/json) Laravel автоматически возвращает JSON-ответ с ошибками валидации (HTTP 422). Переопределение failedValidation() нужно только для кастомного формата ответа. ::


Проверь себя

Что делает интерфейс ImplicitRule в кастомном правиле валидации?

Когда вызывается метод prepareForValidation() в Form Request?

Какой HTTP-код возвращает Laravel при неудачной валидации для API-запроса (Accept: application/json)?

Чем правило required_with отличается от required_with_all?

Что возвращает метод $request->validate() при успешной валидации?