Урок 05

Валидация в API

Отличия от web

Web: ошибки → редирект с flash-сессией. API: ошибки → JSON с кодом 422.

Ответ:

{
    "message": "The title field is required. (and 2 more errors)",
    "errors": {
        "title": ["The title field is required."],
        "body": ["The body field is required."],
        "tags.0": ["The selected tags.0 is invalid."]
    }
}

message — общее. errors — по полям.

Form Request

Тот же подход, что и для web.

php artisan make:request StorePostRequest
namespace App\Http\Requests;

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

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

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'min:3', 'max:255'],
            'slug' => ['nullable', 'string', 'max:255', Rule::unique('posts', 'slug')],
            'body' => ['required', 'string', 'min:10'],
            'status' => ['required', Rule::in(['draft', 'published'])],
            'published_at' => ['nullable', 'date', 'after:today'],
            'tags' => ['array'],
            'tags.*' => ['integer', 'exists:tags,id'],
        ];
    }

    public function messages(): array
    {
        return [
            'title.required' => 'Заголовок обязателен',
            'title.min' => 'Минимум 3 символа',
            'body.required' => 'Текст обязателен',
        ];
    }

    public function attributes(): array
    {
        return [
            'title' => 'заголовок',
            'body' => 'текст поста',
        ];
    }
}

Контроллер:

public function store(StorePostRequest $request)
{
    $post = $request->user()->posts()->create(
        $request->validated(),
    );

    return new PostResource($post);
}

Разные правила для store и update

Проблема: при update unique на slug не должен срабатывать на самого себя.

Решение: Rule::unique()->ignore().

class UpdatePostRequest extends FormRequest
{
    public function rules(): array
    {
        $postId = $this->route('post')?->id;

        return [
            'title' => ['sometimes', 'string', 'min:3', 'max:255'],
            'slug' => ['sometimes', 'string', Rule::unique('posts', 'slug')->ignore($postId)],
            'body' => ['sometimes', 'string', 'min:10'],
        ];
    }
}

sometimes — валидировать, только если поле есть в запросе. PATCH — частичное обновление.

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

Rule::requiredIf:

'body' => Rule::requiredIf(fn () => $this->status === 'published'),

required_if:

'body' => 'required_if:status,published',

Rule::when:

'title' => Rule::when($isAdmin, 'required|max:255', 'required|max:100'),

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

php artisan make:rule Uppercase
namespace App\Rules;

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

class Uppercase implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (strtoupper($value) !== $value) {
            $fail('Поле :attribute должно быть в верхнем регистре');
        }
    }
}

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

'title' => ['required', new Uppercase()],

Сообщения для клиента

По умолчанию — на английском.

Русские — в lang/ru/validation.php:

php artisan lang:publish

Или кастомные сообщения в Form Request.

Формат ошибок

Один стиль во всём API:

{
    "message": "Ошибка валидации",
    "errors": {
        "title": ["Заголовок обязателен"]
    }
}

Настроить глобально — bootstrap/app.php:

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (ValidationException $e, $request) {
        if ($request->is('api/*')) {
            return response()->json([
                'message' => 'Ошибка валидации',
                'errors' => $e->errors(),
            ], 422);
        }
    });
})

Проверка через PHPStan

Использование $request->validated() даёт типизированный массив.

$data = $request->validated();
// $data — array<string, mixed>

С кастомным Form Request:

/**
 * @property string $title
 * @property string $body
 */
class StorePostRequest extends FormRequest { }

Массивы

Простой:

'tags' => ['required', 'array', 'min:1'],
'tags.*' => ['string', 'max:50'],

Массив объектов:

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

Валидация с transform

Иногда данные нужно преобразовать:

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

Пример: post

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

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

class StorePostRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user() !== null;
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'min:3', 'max:255'],
            'slug' => ['nullable', 'string', 'max:255', Rule::unique('posts', 'slug')],
            'body' => ['required', 'string', 'min:10', 'max:50000'],
            'status' => ['required', Rule::in(['draft', 'published'])],
            'published_at' => ['nullable', 'date', 'after:today'],
            'tags' => ['nullable', 'array', 'max:10'],
            'tags.*' => ['integer', 'exists:tags,id'],
        ];
    }

    public function messages(): array
    {
        return [
            'title.required' => 'Заголовок обязателен',
            'title.min' => 'Минимум 3 символа',
            'title.max' => 'Максимум 255 символов',
            'body.min' => 'Текст минимум 10 символов',
            'status.in' => 'Статус может быть draft или published',
            'published_at.after' => 'Дата должна быть в будущем',
            'tags.max' => 'Максимум 10 тегов',
            'tags.*.exists' => 'Такого тега не существует',
        ];
    }

    public function attributes(): array
    {
        return [
            'title' => 'заголовок',
            'body' => 'текст',
            'published_at' => 'дата публикации',
        ];
    }

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

Практика

Создай Form Request:

php artisan make:request StorePostRequest

Опиши правила, сообщения, атрибуты.

Используй в контроллере:

public function store(StorePostRequest $request)
{
    return new PostResource(
        Post::create($request->validated())
    );
}

Проверь:

curl -X POST http://localhost:8000/api/posts \
    -H "Content-Type: application/json" \
    -d '{"title": "a"}'
# 422 с ошибками

Частые ошибки

1. $request->all() вместо validated():

Post::create($request->all());   // лишние поля

// Правильно:
Post::create($request->validated());

2. authorize возвращает false по умолчанию:

public function authorize(): bool { }
// → 403

// Правильно:
return true;   // или проверка прав

3. unique без ignore при update:

Rule::unique('posts', 'slug')   // не даст обновить

// Правильно:
Rule::unique('posts', 'slug')->ignore($postId)

4. sometimes забыли:

// PATCH с одним полем — остальные обязательны
'title' => 'required'   // ошибка

// Правильно:
'title' => 'sometimes|required'

5. Массивы без .*:

'tags' => 'integer'   // сработает на массив

// Правильно:
'tags' => 'array',
'tags.*' => 'integer'

Итог

  • Form Request — правила в классе.
  • 422 — код ошибки валидации.
  • messages, attributes — кастомизация.
  • prepareForValidation —预处理.
  • Rule::unique()->ignore — для update.
  • sometimes — для PATCH.
  • Единый формат ошибок — важно для клиента.

Дальше — пагинация в API.