Валидация данных в 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() нужно только для кастомного формата ответа.
::