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 в зависимости от других полей – в статье про условную валидацию. Основы валидации – в руководстве по валидации.