0

Unique and exists rules — how they hit the database

Intermediate5 min read·lv-11-003
sql

Concept

The unique and exists validation rules both issue real SQL queries against your database during the validation pass. Understanding exactly what queries they generate — and how to constrain them — is critical for writing correct, secure validation.

unique:table,column checks that no row in the specified table has the given value in the specified column. The underlying implementation is in Illuminate\Validation\DatabasePresenceVerifier::getCount(), which builds a raw SELECT COUNT(*) AS aggregate FROM {table} WHERE {column} = ? query using a separate database connection instance (not Eloquent). The validator passes if the count is zero.

exists:table,column does the opposite: it passes if the count is one or more. It is used to verify that a foreign key value actually exists in a related table before saving it.

Both rules accept additional constraint parameters in their fluent Rule::unique() / Rule::exists() API, which is far more readable and safe than the raw string form. The fluent builder lets you add extra where clauses and, crucially for unique, ignore a specific row by its primary key — essential for update operations where the current record's own value should not be treated as a conflict.

The ignore($id) method in Rule::unique() appends AND id != ? to the generated query. Internally it calls DatabasePresenceVerifier::addWhere() with a NOT IN clause. Never pass user-controlled input directly to ignore() — always use the authenticated user's ID or a model instance, since a crafted ignore value could bypass the uniqueness check entirely.

Rule formGenerated SQL (approximate)
unique:users,emailSELECT COUNT(*) FROM users WHERE email = ?
unique:users,email + ->ignore($user)SELECT COUNT(*) FROM users WHERE email = ? AND id != ?
exists:countries,codeSELECT COUNT(*) FROM countries WHERE code = ?
exists:roles,id + ->where('active', 1)SELECT COUNT(*) FROM roles WHERE id = ? AND active = ?

Code Example

php
<?php

use Illuminate\Validation\Rule;
use App\Models\User;

// --- CREATE: email must not already exist ---
$request->validate([
    'email' => [
        'required',
        'email',
        Rule::unique('users', 'email'),
    ],
]);

// --- UPDATE: ignore the current user's own row ---
// $user is the authenticated User model instance (never pass $request->input('id'))
$user = auth()->user();

$request->validate([
    'email' => [
        'required',
        'email',
        Rule::unique('users', 'email')->ignore($user->id),
        // or pass the model itself — Laravel uses $model->getKey()
        // Rule::unique('users', 'email')->ignore($user),
    ],
    'username' => [
        'required',
        'string',
        Rule::unique('users', 'username')
            ->ignore($user->id)
            ->whereNull('deleted_at'), // honour soft deletes
    ],
]);

// --- exists: verify foreign key references real, active record ---
$request->validate([
    'country_id' => [
        'required',
        'integer',
        Rule::exists('countries', 'id')->where('active', true),
    ],
    'role_id' => [
        'required',
        Rule::exists('roles', 'id'),
    ],
]);

// --- Custom table + column via string shorthand (simple cases only) ---
$request->validate([
    'invite_code' => 'required|exists:invite_codes,code',
]);

Interview Q&A

Q: Why is it unsafe to pass user-supplied input directly to Rule::unique()->ignore()?

The ignore($id) method appends AND {primaryKey} NOT IN (?) to the uniqueness query. If an attacker controls the ignored ID — for example by sending ?id=1 in the request body and passing $request->id to ignore() — they can bypass the uniqueness check entirely for any existing row, effectively allowing them to steal another user's email address. Always source the ignored ID from a trusted, server-side value such as auth()->id() or the model fetched via route model binding, never from user-supplied input.


Q: The unique and exists rules use a DatabasePresenceVerifier. How does this relate to Eloquent, and what are the implications?

The DatabasePresenceVerifier (bound in ValidationServiceProvider) uses a raw Illuminate\Database\Connection instance, not Eloquent models. This means global scopes, model observers, and SoftDeletes are bypassed. A unique:users,email rule will find soft-deleted rows and count them as conflicts. To exclude soft-deleted records you must add an explicit constraint: Rule::unique('users', 'email')->whereNull('deleted_at'). This is a common production bug: restoring a soft-deleted user whose email "already exists" fails uniqueness validation even though the active-user table has no conflict.


Q: How does Laravel's exists rule handle array inputs in nested validation?

When validating an array such as tag_ids.*, each element is validated individually by the presence verifier. The query is still a SELECT COUNT(*) WHERE column = ? per element — there is no batching. For large arrays with many foreign key checks this can generate N queries, one per element. A more performant alternative is a custom rule that fetches the full set in one WHERE id IN (?) query and validates the entire array at once, or using Rule::exists() combined with the array wildcard (tag_ids.*), accepting the N-query cost.