Unique и Exists: валидация по базе данных в Laravel
Большинство правил валидации проверяют формат данных – длину строки, диапазон числа, формат даты. Но две задачи требуют обращения к базе: убедиться, что значение существует в таблице (внешний ключ, выбор из справочника), и убедиться, что значение уникально (email при регистрации, slug статьи). Laravel решает обе задачи правилами exists и unique, а fluent-билдер Rule::exists() и Rule::unique() добавляет фильтрацию, исключение записей и работу с soft deletes.
exists – проверка существования записи
Правило exists проверяет, что значение поля присутствует в указанной таблице. Классический пример – select или dropdown, где пользователь выбирает из списка:
$request->validate([
'country_id' => 'required|integer|exists:countries,id',
'category_id' => 'required|integer|exists:categories,id',
]);
Если столбец не указан, Laravel использует имя валидируемого поля как имя столбца. Запись exists:countries для поля country_id будет искать столбец country_id в таблице countries. Поскольку столбцы первичных ключей обычно называются id, а поля формы – country_id, несовпадение приведёт к провалу. Указывайте столбец явно.
Вместо имени таблицы можно передать класс Eloquent-модели:
'author_id' => 'required|exists:App\Models\User,id',
Laravel сам определит таблицу из модели. Это удобнее при переименовании таблиц – менять нужно только модель.
Кастомное соединение с БД
Если данные лежат в другой базе, укажите соединение через точку:
'warehouse_id' => 'required|exists:inventory.warehouses,id',
Здесь inventory – имя соединения из config/database.php, warehouses – таблица. Это полезно в проектах с микросервисной архитектурой, где разные данные живут в разных базах.
exists и безопасность
exists не заменяет проверку авторизации. Запись может существовать, но пользователь не должен иметь к ней доступ. Проверяйте права отдельно:
$request->validate([
'project_id' => [
'required',
Rule::exists('projects', 'id')->where('team_id', $user->team_id),
],
]);
Фильтр where('team_id', ...) гарантирует, что пользователь может выбрать только проект своей команды. Без этого условия пользователь мог бы подставить ID чужого проекта – запись существует, валидация пройдёт, но доступа быть не должно.
Rule::exists() – фильтрация запроса
Строковый синтаксис не позволяет добавить условия. Rule::exists() решает это через метод where():
use Illuminate\Database\Query\Builder;
use Illuminate\Validation\Rule;
$request->validate([
'department_id' => [
'required',
Rule::exists('departments', 'id')->where(function (Builder $query) {
$query->where('is_active', true);
}),
],
]);
Отдел должен существовать и быть активным. Иначе форма примет архивный отдел, который ещё есть в базе, но уже не принимает сотрудников.
Второй аргумент Rule::exists() – столбец. Если не указан, используется имя валидируемого поля:
'region_code' => Rule::exists('regions', 'code'),
Проверка массива значений
Когда поле содержит массив (мультиселект, список тегов), exists проверяет каждый элемент:
$request->validate([
'tag_ids' => 'required|array|min:1',
'tag_ids.*' => 'integer|exists:tags,id',
]);
Для оптимальной группировки запросов поставьте array и exists на родительское поле – Laravel объединит проверку в один WHERE IN (...):
'tag_ids' => ['required', 'array', Rule::exists('tags', 'id')],
С Rule::exists() можно добавить фильтр и к массиву:
'tag_ids.*' => Rule::exists('tags', 'id')->where('is_public', true),
Если поле может содержать один элемент или массив, добавьте обе проверки:
$request->validate([
'assignee_ids' => 'required|array|min:1|max:10',
'assignee_ids.*' => [
'integer',
Rule::exists('users', 'id')
->where('is_active', true)
->where('department_id', $request->integer('department_id')),
],
]);
Каждый назначенный пользователь должен быть активным и принадлежать тому же отделу.
unique – проверка уникальности
Правило unique проверяет, что значение не дублируется в таблице. Под капотом Laravel выполняет SELECT count(*) ... WHERE column = value и проваливает валидацию, если найдена хотя бы одна совпадающая запись. Правило работает с любым типом столбца – строки, числа, UUID:
$request->validate([
'email' => 'required|email|unique:users,email',
'slug' => 'required|string|unique:articles,slug',
]);
Как и с exists, столбец можно опустить – Laravel возьмёт имя поля. Можно указать класс модели:
'email' => 'required|email|unique:App\Models\User,email',
И кастомное соединение:
'code' => 'required|unique:warehouse.products,sku',
Ignore при обновлении – исключение текущей записи
Первый вопрос, с которым сталкивается каждый – «почему обновление ломает валидацию?». Сценарий: пользователь открывает форму редактирования профиля, ничего не меняет, нажимает «Сохранить» – и получает ошибку «email уже занят». Его собственным email. Правило unique находит существующую запись и считает её дубликатом.
Rule::unique()->ignore() исключает запись по ID:
use Illuminate\Validation\Rule;
// В контроллере или Form Request
$request->validate([
'email' => [
'required',
'email',
Rule::unique('users')->ignore($user->id),
],
]);
Вместо ID можно передать модель целиком – Laravel извлечёт ключ автоматически:
Rule::unique('users')->ignore($user),
Если первичный ключ называется не id, укажите столбец вторым аргументом ignore():
Rule::unique('users')->ignore($user->id, 'user_id'),
А если проверяемый столбец отличается от имени поля:
Rule::unique('users', 'email_address')->ignore($user->id),
Безопасность ignore
Никогда не передавайте в ignore() данные из запроса пользователя. Только системные ID – автоинкремент или UUID из модели. Иначе пользователь может подставить чужой ID и обойти проверку уникальности. Это прямой путь к SQL-инъекции.
// Опасно: ID из запроса
Rule::unique('users')->ignore($request->input('user_id'))
// Безопасно: ID из аутентифицированного пользователя или route model binding
Rule::unique('users')->ignore($request->user()->id)
Rule::unique('users')->ignore($this->route('user'))
Ignore в Form Request
В Form Request доступ к текущей модели через route model binding:
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateUserRequest extends FormRequest
{
public function rules(): array
{
return [
'email' => [
'required',
'email',
Rule::unique('users')->ignore($this->route('user')),
],
'username' => [
'required',
'string',
'max:30',
Rule::unique('users')->ignore($this->route('user')),
],
];
}
}
$this->route('user') возвращает модель User, привязанную через route model binding. Laravel сам возьмёт её первичный ключ. Подробнее о Form Request – в отдельной статье.
unique с дополнительными условиями
Метод where() добавляет фильтры к запросу уникальности. Например, уникальность в пределах организации (мультитенантность):
$request->validate([
'project_name' => [
'required',
'string',
'max:255',
Rule::unique('projects', 'name')
->where('organization_id', $user->organization_id)
->ignore($project),
],
]);
Имя проекта уникально в рамках организации, а не глобально. Без where() две разные компании не смогли бы создать проекты с одинаковым названием.
Можно комбинировать несколько условий:
Rule::unique('schedules', 'time_slot')
->where('room_id', $request->integer('room_id'))
->where('date', $request->input('date'))
->ignore($schedule),
Слот времени уникален для конкретной комнаты в конкретный день.
Замыкание в where() даёт доступ к Query Builder для сложных условий:
Rule::unique('promo_codes', 'code')->where(function (Builder $query) use ($request) {
$query->where('campaign_id', $request->integer('campaign_id'))
->where('expires_at', '>', now());
}),
Код акции уникален в рамках активной кампании. Истёкшие кампании не учитываются – тот же код можно использовать повторно.
Разделение правил: store и update
На практике правила для создания и обновления различаются. Есть два подхода: один Form Request с условной логикой или два отдельных класса.
Условная логика в одном классе:
class SaveArticleRequest extends FormRequest
{
public function rules(): array
{
$uniqueSlug = Rule::unique('articles', 'slug');
if ($this->route('article')) {
$uniqueSlug->ignore($this->route('article'));
}
return [
'title' => 'required|string|max:255',
'slug' => ['required', 'string', 'max:100', $uniqueSlug],
'body' => 'required|string',
];
}
}
Два отдельных класса (чище при разных наборах полей):
// StoreArticleRequest
'slug' => ['required', 'string', Rule::unique('articles', 'slug')],
// UpdateArticleRequest
'slug' => ['required', 'string', Rule::unique('articles', 'slug')->ignore($this->route('article'))],
Выбор зависит от сложности: если правила на 90% одинаковые – один класс с условием. Если наборы полей различаются – два класса. В обоих случаях ignore() появляется только в правилах обновления – при создании исключать нечего.
Третий вариант – метод isMethod() для различения HTTP-глаголов:
public function rules(): array
{
$emailRule = Rule::unique('users', 'email');
if ($this->isMethod('put') || $this->isMethod('patch')) {
$emailRule->ignore($this->route('user'));
}
return [
'email' => ['required', 'email', $emailRule],
];
}
Составная уникальность (composite key)
Laravel не имеет встроенного правила для составной уникальности. Но Rule::unique()->where() покрывает этот случай:
$request->validate([
'student_id' => 'required|exists:students,id',
'course_id' => [
'required',
'exists:courses,id',
Rule::unique('enrollments')
->where('student_id', $request->integer('student_id'))
->ignore($enrollment),
],
]);
Студент не может записаться на один курс дважды. Проверка уникальности идёт по комбинации student_id + course_id в таблице enrollments.
При обновлении ignore() исключит текущую запись, чтобы сохранение без изменений не вызывало ошибку.
Для трёх и более столбцов – цепочка where():
Rule::unique('bookings')
->where('room_id', $request->integer('room_id'))
->where('date', $request->input('date'))
->where('time_slot', $request->input('time_slot')),
Убедитесь, что в миграции есть составной уникальный индекс – он одновременно защищает от гонок на уровне БД и ускоряет проверку:
Schema::table('enrollments', function (Blueprint $table) {
$table->unique(['student_id', 'course_id']);
});
Без индекса в базе Rule::unique()->where() предотвращает дубли только на уровне приложения. Два параллельных запроса могут пройти валидацию одновременно, и оба вставят запись. Составной индекс – последний рубеж.
При нарушении составного индекса на уровне БД Laravel бросит QueryException. Если гонки вероятны (высоконагруженные формы, создание записей через API), оберните вставку в try/catch или используйте firstOrCreate().
unique и soft deletes
По умолчанию unique учитывает мягко удалённые записи. Если пользователь удалил аккаунт (soft delete), его email блокирует регистрацию нового аккаунта с тем же адресом.
withoutTrashed() исключает записи с заполненным deleted_at:
Rule::unique('users')->withoutTrashed(),
Если столбец мягкого удаления называется иначе:
Rule::unique('users')->withoutTrashed('removed_at'),
Комбинация с ignore() и where():
Rule::unique('users', 'email')
->ignore($user)
->withoutTrashed()
->where('tenant_id', $tenantId),
Цепочка читается слева направо: уникальный email, исключая текущего пользователя, без учёта удалённых, в рамках тенанта.
Обратная ситуация тоже бывает: нужно учитывать только удалённые записи. Например, проверить, что email доступен для восстановления аккаунта:
Rule::exists('users', 'email')->where(function (Builder $query) {
$query->whereNotNull('deleted_at');
}),
Здесь exists ищет запись среди удалённых пользователей – для формы восстановления.
unique в массивах
Когда форма принимает список элементов, иногда нужно, чтобы значения были уникальны не только в базе, но и внутри самого запроса. Правило distinct решает вторую задачу:
$request->validate([
'emails' => 'required|array|min:1|max:5',
'emails.*' => 'required|email|distinct|unique:users,email',
]);
distinct проверяет, что в массиве emails нет повторов. unique:users,email проверяет, что ни один из адресов не занят в базе. Два одинаковых новых адреса пройдут проверку unique (их ещё нет в таблице), но distinct поймает дубль внутри самого запроса.
distinct по умолчанию чувствителен к регистру. Для email лучше привести к нижнему регистру в prepareForValidation():
protected function prepareForValidation(): void
{
if ($this->has('emails')) {
$this->merge([
'emails' => array_map('mb_strtolower', $this->input('emails', [])),
]);
}
}
Так distinct корректно найдёт дубли вроде [email protected] и [email protected]. Работа с prepareForValidation() разобрана в статье про Form Request.
Валидация внешних ключей
Внешние ключи – основная область применения exists. Форма создания заказа с выбором клиента, способа доставки и товаров:
$request->validate([
'customer_id' => [
'required',
Rule::exists('customers', 'id')->where('is_active', true),
],
'shipping_method_id' => 'required|exists:shipping_methods,id',
'items' => 'required|array|min:1',
'items.*.product_id' => [
'required',
Rule::exists('products', 'id')->where('in_stock', true),
],
'items.*.quantity' => 'required|integer|min:1',
'coupon_code' => [
'nullable',
'string',
Rule::exists('coupons', 'code')
->where('is_active', true)
->where(fn (Builder $query) => $query->where('expires_at', '>', now())),
],
]);
Клиент должен быть активным, товар – в наличии, купон – действующим. Без where() пользователь мог бы оформить заказ на заблокированного клиента или использовать просроченный купон. Каждый Rule::exists()->where() добавляет условие в SQL – это мощнее, чем отдельная проверка после валидации, потому что ошибка привязана к конкретному полю и попадает в стандартный формат ответа валидации.
Проверка «не существует»
Когда нужно убедиться, что запись не существует (например, пользователь ещё не подписан на канал), встроенного правила not_exists нет. Можно использовать unique – по сути, это и есть проверка на отсутствие записи. Если запись найдена, unique провалит валидацию, что эквивалентно «такой записи быть не должно»:
// not_exists нет в Laravel, но unique решает ту же задачу
'channel_id' => [
'required',
Rule::unique('subscriptions')
->where('user_id', $user->id),
],
Если логика не укладывается в exists/unique, создайте собственное правило валидации.
not_in и different – исключение значений
not_in проверяет, что значение не входит в заданный список:
use Illuminate\Validation\Rule;
$request->validate([
'username' => [
'required',
'string',
'max:30',
Rule::notIn(['admin', 'root', 'system', 'moderator']),
],
]);
Запрет регистрации с зарезервированными именами. Rule::notIn() принимает массив – удобнее, чем строковый not_in:admin,root,system. Список можно хранить в конфиге или константе, а не хардкодить в правилах:
'username' => [
'required',
'string',
Rule::notIn(config('app.reserved_usernames')),
],
different проверяет, что два поля содержат разные значения:
$request->validate([
'current_password' => 'required|string',
'new_password' => 'required|string|min:8|different:current_password',
]);
Новый пароль не должен совпадать со старым. Правило сравнивает значения двух полей запроса, а не значения в базе. О валидации пароля по сложности и утечкам – в статье про валидацию паролей.
in и not_in не обращаются к базе – они работают со статическим списком значений. Для проверки по таблице используйте exists. Но для небольших справочников Rule::in() с кэшированным массивом быстрее, чем запрос к БД на каждый запрос.
Связанное правило – in_array:anotherfield.*, которое проверяет, что значение есть среди значений другого поля запроса (не в БД):
$request->validate([
'allowed_sizes' => 'required|array',
'allowed_sizes.*' => 'string|in:S,M,L,XL,XXL',
'default_size' => 'required|in_array:allowed_sizes.*',
]);
Размер по умолчанию должен входить в список разрешённых размеров из того же запроса.
Запросы к БД в правилах валидации
exists и unique генерируют SQL-запросы. Для сложных проверок, которые не укладываются в where(), есть замыкания в кастомных правилах:
use Illuminate\Support\Facades\DB;
$request->validate([
'invite_code' => [
'required',
'string',
function (string $attribute, mixed $value, \Closure $fail) {
$invite = DB::table('invites')
->where('code', $value)
->where('used_at', null)
->where('expires_at', '>', now())
->first();
if (null === $invite) {
$fail('Приглашение недействительно или уже использовано.');
}
},
],
]);
Здесь проверка сложнее, чем позволяет Rule::exists()->where(): нужно проверить NULL в used_at и сравнение дат. Замыкание даёт полный доступ к Query Builder.
Обратите внимание на whereNull() – Rule::exists()->where() не поддерживает синтаксис where('column', null). Для проверки на NULL используйте замыкание:
Rule::exists('invites', 'code')->where(function (Builder $query) {
$query->whereNull('used_at')
->where('expires_at', '>', now());
}),
Для повторяющейся логики вынесите проверку в Rule-класс:
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\DB;
class ValidInviteCode implements ValidationRule
{
public function validate(string $attribute, mixed $value, \Closure $fail): void
{
$exists = DB::table('invites')
->where('code', $value)
->whereNull('used_at')
->where('expires_at', '>', now())
->exists();
if (false === $exists) {
$fail('Приглашение недействительно или уже использовано.');
}
}
}
Кастомные сообщения об ошибках
Стандартные сообщения для unique и exists – технические и не всегда понятны пользователю. Переопределите их:
public function messages(): array
{
return [
'email.unique' => 'Этот email уже зарегистрирован.',
'customer_id.exists' => 'Выбранный клиент не найден или неактивен.',
'coupon_code.exists' => 'Купон недействителен.',
'items.*.product_id.exists' => 'Товар #:position не найден в каталоге.',
];
}
Placeholder :position подставляет номер элемента массива (начиная с 1).
Производительность
Каждое правило exists и unique выполняет SQL-запрос. В форме с десятком полей и массивом товаров это может означать 15-20 запросов на одну валидацию.
Несколько приёмов для оптимизации:
Массивы с exists. Когда array и exists стоят на одном поле, Laravel объединяет проверку в один запрос:
// Один запрос WHERE IN, даже если массив содержит 50 элементов
'tag_ids' => ['required', 'array', Rule::exists('tags', 'id')],
Индексы. Правила exists и unique ищут по столбцу – убедитесь, что на нём есть индекс. Для составных проверок (where() с несколькими условиями) нужен составной индекс:
// Миграция
$table->unique(['organization_id', 'slug']);
Кэширование справочников. Если список значений небольшой и редко меняется (страны, валюты, роли), замените exists на in с кэшированным массивом:
use Illuminate\Support\Facades\Cache;
$validCurrencies = Cache::remember('currencies', 3600, fn () =>
DB::table('currencies')->where('is_active', true)->pluck('code')->all()
);
$request->validate([
'currency' => ['required', Rule::in($validCurrencies)],
]);
Вместо запроса к БД на каждую валидацию – один закэшированный массив.
Дебаг количества запросов. Если нужно понять, сколько SQL-запросов генерирует валидация, включите лог:
DB::enableQueryLog();
$request->validate([...]);
dd(DB::getQueryLog());
Так можно увидеть, какие правила порождают запросы и где группировка работает, а где нет.
bail для раннего прерывания. Если поле не проходит формат, нет смысла проверять уникальность:
'email' => 'bail|required|email|unique:users',
bail остановит цепочку на первом провале. Без bail Laravel выполнит unique-запрос даже для невалидного email.
Lazy loading правил. Если набор exists-проверок зависит от условий, вычисляйте Rule::exists() лениво, а не заранее – замыкание в where() выполняется только когда валидатор доходит до этого правила. Если более раннее правило (с bail) провалилось, запрос к БД не выполнится.
Типичные ошибки
Забытый ignore при обновлении. Ошибка номер один с unique. При создании всё работает, при редактировании – ошибка уникальности на собственных данных. Решение: всегда добавляйте ignore() в правилах обновления.
ID из запроса в ignore(). Передача $request->input('id') в ignore() – уязвимость. Пользователь подставит чужой ID и обойдёт проверку. Используйте только $this->route('model') или $request->user().
exists без столбца. exists:users ищет по столбцу, совпадающему с именем поля. Если поле называется user_id, а столбец в таблице id – валидация всегда провалится. Указывайте столбец явно: exists:users,id.
unique на nullable-полях. unique считает NULL за уникальное значение – два NULL не конфликтуют (стандарт SQL). nullable|unique:users,phone позволит нескольким пользователям не указывать телефон, и это корректное поведение.
Строковый синтаксис unique с ignore. Исторический формат unique:table,column,except,idColumn поддерживается, но неудобен и подвержен ошибкам. Fluent-синтаксис Rule::unique()->ignore() читаемее и безопаснее – используйте его.
Soft deletes и unique. По умолчанию unique учитывает удалённые записи. Удалённый пользователь блокирует регистрацию с тем же email. Если это нежелательно – withoutTrashed().
N+1 при валидации массивов. exists с массивом автоматически группирует запрос. Но если добавить where() с динамическим значением, группировка ломается:
// Каждый элемент вызовет отдельный запрос
'items.*.product_id' => Rule::exists('products', 'id')->where('warehouse_id', $warehouseId),
Для больших массивов вынесите проверку в after(), где сможете выполнить один запрос:
public function after(): array
{
return [
function (\Illuminate\Validation\Validator $validator) {
$productIds = collect($this->input('items', []))->pluck('product_id')->filter();
if ($productIds->isEmpty()) {
return;
}
$validIds = DB::table('products')
->whereIn('id', $productIds)
->where('warehouse_id', $this->integer('warehouse_id'))
->pluck('id');
foreach ($productIds as $index => $id) {
if (false === $validIds->contains($id)) {
$validator->errors()->add(
"items.{$index}.product_id",
'Товар не найден на выбранном складе.'
);
}
}
},
];
}
Один запрос вместо N. Для массива из 50 товаров разница ощутима.
Тестирование
Правила exists и unique требуют реальной базы данных. Используйте RefreshDatabase или DatabaseTransactions:
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class OrderValidationTest extends TestCase
{
use RefreshDatabase;
public function test_order_requires_existing_customer(): void
{
$response = $this->postJson('/api/orders', [
'customer_id' => 999,
'items' => [['product_id' => 1, 'quantity' => 1]],
]);
$response->assertJsonValidationErrors(['customer_id']);
}
public function test_order_accepts_active_customer(): void
{
$customer = Customer::factory()->create(['is_active' => true]);
$product = Product::factory()->create(['in_stock' => true]);
$response = $this->postJson('/api/orders', [
'customer_id' => $customer->id,
'items' => [['product_id' => $product->id, 'quantity' => 2]],
]);
$response->assertJsonMissingValidationErrors(['customer_id']);
}
public function test_email_unique_allows_own_email_on_update(): void
{
$user = User::factory()->create(['email' => '[email protected]']);
$this->actingAs($user);
$response = $this->putJson("/api/users/{$user->id}", [
'email' => '[email protected]',
'name' => 'Updated Name',
]);
$response->assertJsonMissingValidationErrors(['email']);
}
public function test_email_unique_rejects_taken_email(): void
{
User::factory()->create(['email' => '[email protected]']);
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->putJson("/api/users/{$user->id}", [
'email' => '[email protected]',
]);
$response->assertJsonValidationErrors(['email']);
}
public function test_composite_unique_prevents_duplicate_enrollment(): void
{
$student = Student::factory()->create();
$course = Course::factory()->create();
Enrollment::factory()->create([
'student_id' => $student->id,
'course_id' => $course->id,
]);
$response = $this->postJson('/api/enrollments', [
'student_id' => $student->id,
'course_id' => $course->id,
]);
$response->assertJsonValidationErrors(['course_id']);
}
public function test_soft_deleted_user_email_available(): void
{
$deleted = User::factory()->create(['email' => '[email protected]']);
$deleted->delete();
$response = $this->postJson('/api/register', [
'email' => '[email protected]',
'name' => 'New User',
'password' => 'secretpassword',
]);
// Пройдёт, если правило использует withoutTrashed()
$response->assertJsonMissingValidationErrors(['email']);
}
}
Тесты для unique должны покрывать минимум три сценария: создание с уникальным значением, создание с дубликатом, обновление собственной записи. Для exists – существующая запись, несуществующая, и запись, не проходящая фильтр where().
При тестировании withoutTrashed() создайте soft-deleted запись и убедитесь, что её email (или другое поле) доступно для новых записей. При тестировании составной уникальности – проверьте, что одинаковые значения в разных комбинациях проходят (студент A на курсе 1 + студент B на курсе 1 = ок), а одинаковые комбинации – нет.
О правилах для строк, чисел и их диапазонов – в руководстве по валидации строк и чисел. Условная валидация exists и unique в зависимости от других полей – в статье про условную валидацию. Основы валидации – в руководстве по валидации.