Unique and Exists: Database Validation in Laravel 13

Most validation rules work with the request data alone – they check formats, lengths, ranges. Two rules break that pattern: exists hits the database to confirm a value is present in a table, and unique does the opposite, confirming a value is absent. Together they handle foreign key checks, duplicate prevention, and all the scenarios where correctness depends on current database state.

Both rules support a string syntax (exists:table,column) and a fluent builder (Rule::exists(), Rule::unique()). The string syntax is fine for basic checks. Anything involving conditions, ignoring records, or soft deletes requires the builder.

For general validation concepts, see the validation basics.

Checking That a Record Exists

The exists rule validates that a value is present in a given table. Drop-downs, select inputs, relation IDs – anywhere the user picks from a predefined set:

$request->validate([
    'category_id' => 'required|integer|exists:categories,id',
    'currency'    => 'required|string|exists:currencies,code',
]);

If the column argument is omitted, Laravel uses the field name. So exists:categories for a field called category_id would look for a category_id column in the categories table – almost certainly not what you want. Always specify the column.

You can pass an Eloquent model class instead of a table name:

use App\Models\Author;

'author_id' => ['required', Rule::exists(Author::class, 'id')],

Laravel resolves the table from the model. When the table gets renamed, only the model needs updating. The ::class syntax plays well with IDE autocompletion and refactoring tools – the string form 'exists:App\Models\Author,id' works too but is invisible to static analysis.

Exists on a Different Database Connection

Projects that split data across multiple databases can prefix the table with a connection name:

'warehouse_id' => 'required|exists:inventory.warehouses,id',

Here inventory is the connection defined in config/database.php, and warehouses is the table on that connection.

Exists with Additional Conditions

String syntax stops at exists:table,column – there is no way to add a WHERE clause. Rule::exists() fills that gap:

use Illuminate\Database\Query\Builder;
use Illuminate\Validation\Rule;

$request->validate([
    'plan_id' => [
        'required',
        Rule::exists('plans', 'id')->where(function (Builder $query) {
            $query->where('is_active', true);
        }),
    ],
]);

The plan must exist and be active. Without the where, a user could submit the ID of an archived plan still sitting in the table.

For multi-tenant applications, the pattern is the same – scope the check to the current tenant:

'invoice_id' => [
    'required',
    Rule::exists('invoices', 'id')->where('tenant_id', $request->user()->tenant_id),
],

This is validation-level authorization: the record must belong to the same tenant. It does not replace policy checks, but it catches bad input before it reaches application logic.

Multiple where calls chain into a single query. An order form that validates a coupon code against the store and expiry:

'coupon_code' => [
    'nullable',
    'string',
    Rule::exists('coupons', 'code')->where(function (Builder $query) use ($request) {
        $query->where('store_id', $request->integer('store_id'))
              ->where('expires_at', '>', now());
    }),
],

The closure gives full Query Builder access – comparisons, whereNull, orWhere – anything beyond simple equality. Note that the simple ->where('column', value) shorthand works for equality checks and is enough for most cases. Reserve the closure form for operators like >, <, LIKE, or null checks.

Exists If Not Null – Handling Optional Fields

A nullable foreign key (say, an optional manager assignment) needs careful rule ordering. Without nullable, the exists rule runs on null and fails:

// exists fires on null, validation fails
'manager_id' => 'exists:users,id',

// null is accepted, non-null values are checked
'manager_id' => ['nullable', 'integer', 'exists:users,id'],

The nullable rule tells the validator to skip remaining rules when the value is null. Put it before exists. If the field might be absent from the request entirely, add sometimes as well – see conditional validation for details.

Validating an Array of IDs

Multi-select inputs and tag pickers send arrays. Place the exists rule on the parent field alongside array, and Laravel builds a single WHERE IN (...) query:

$request->validate([
    'tag_ids'   => ['required', 'array', 'min:1', Rule::exists('tags', 'id')],
]);

One query for the entire array. If you put exists on tag_ids.* instead, each element triggers a separate query – fine for three tags, painful for thirty.

When the array needs scoped validation, where conditions still work at the array level:

'recipient_ids' => [
    'required',
    'array',
    'max:20',
    Rule::exists('users', 'id')->where('is_active', true),
],
'recipient_ids.*' => 'integer',

Each recipient must be an active user. Laravel merges the where condition into the single WHERE IN (...) query, so there is no N+1 penalty. Keep basic type rules like integer on the per-element level, and database checks on the parent.

Enforcing Uniqueness on Create

The unique rule verifies that a value does not already exist in a table. Registration emails, article slugs, SKU codes:

$request->validate([
    'email' => 'required|email|unique:users,email',
    'slug'  => 'required|string|max:120|unique:posts,slug',
]);

Under the hood, Laravel runs SELECT count(*) FROM users WHERE email = ?. If the count is above zero, validation fails. The column argument is optional – omit it and the field name is used – but stating it explicitly avoids confusion.

Worth remembering: unique has no special handling for NULL. If a null value reaches the rule, Laravel builds a WHERE phone IS NULL query, which counts every existing NULL row and fails if any are found. To allow multiple users with no phone number, place nullable before the unique check: nullable|unique:users,phone. The nullable rule stops the validator from running subsequent rules when the value is null, so unique never fires on null at all.

As with exists, you can reference a model class or a custom connection:

'sku' => 'required|unique:App\Models\Product,sku',
'license_key' => 'required|unique:licensing.keys,key_value',

Unique on Update – The Self-Exclusion Problem

The single most common issue with unique validation: a user opens an edit form, changes nothing, saves – and gets “this email is already taken.” Their own email blocks the update. Every project hits this.

Rule::unique()->ignore() excludes a record by its primary key:

use Illuminate\Validation\Rule;

$request->validate([
    'email' => [
        'required',
        'email',
        Rule::unique('users')->ignore($user->id),
    ],
]);

Pass a model instance instead of an ID – Laravel extracts the key automatically:

Rule::unique('users')->ignore($user),

If the primary key column is not id, specify it in the second argument:

Rule::unique('users')->ignore($user->uuid, 'uuid'),

And if the checked column differs from the field name:

Rule::unique('users', 'email_address')->ignore($user->id),

Security Warning: Never Pass User Input to ignore()

This is not a theoretical risk. ignore() embeds the value into the rule’s internal string representation, which is later parsed back to build the query. Crafted input can manipulate that parsing and alter the excluded ID or column. If you write Rule::unique('users')->ignore($request->input('user_id')), an attacker can exploit this to bypass the uniqueness check entirely.

// attacker controls the excluded ID - injection risk
Rule::unique('users')->ignore($request->input('id'))

// ID from authenticated session or route model binding
Rule::unique('users')->ignore($request->user()->id)
Rule::unique('users')->ignore($this->route('user'))

Only pass system-controlled values: the authenticated user model, a route-bound model, or an auto-increment/UUID retrieved from your own code.

Ignore in a Form Request

In a Form Request class, access the current model through route model binding:

class UpdateProfileRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'email' => [
                'required',
                'email',
                Rule::unique('users')->ignore($this->route('user')),
            ],
            'handle' => [
                'required',
                'string',
                'max:40',
                Rule::unique('users', 'handle')->ignore($this->route('user')),
            ],
        ];
    }
}

$this->route('user') returns the User model resolved by route model binding. The controller receives the validated data with no uniqueness issues on the current record.

When the same form request serves both POST /products and PUT /products/{product}, the ignore() argument is $this->route('product')?->id, which is null on store and the current model id on update – behaviour identical to plain unique on create, skip-self on update. The form requests guide covers when sharing a class is worth it versus splitting into StoreProductRequest and UpdateProductRequest.

Multi-Column Uniqueness

Laravel has no built-in syntax for a unique combination of fields. The string form unique:table,column only checks one column. For composite uniqueness – say, a department name that must be unique within a company – use Rule::unique()->where():

$request->validate([
    'department_name' => [
        'required',
        'string',
        'max:100',
        Rule::unique('departments', 'name')
            ->where('company_id', $request->user()->company_id)
            ->ignore($department),
    ],
]);

Two companies can have an “Engineering” department. Within a single company, the name cannot repeat.

Three or more columns follow the same pattern:

Rule::unique('shift_assignments')
    ->where('employee_id', $request->integer('employee_id'))
    ->where('shift_date', $request->input('shift_date'))
    ->where('shift_type', $request->input('shift_type')),

An employee cannot be assigned to the same shift type on the same date twice.

Back It with a Database Index

Rule::unique()->where() prevents duplicates at the application level. Two concurrent requests can both pass validation and insert the same row. A composite unique index is the safety net:

Schema::table('departments', function (Blueprint $table) {
    $table->unique(['company_id', 'name']);
});

If the index catches a violation, Laravel throws a QueryException. In high-traffic scenarios, wrap the insert in a try/catch or use firstOrCreate():

try {
    Assignment::create($validated);
} catch (\Illuminate\Database\QueryException $e) {
    if (23000 === (int) $e->getCode()) {
        return back()->withErrors(['project_id' => 'This assignment already exists.']);
    }
    throw $e;
}

Error code 23000 is the SQLSTATE for integrity constraint violations. Checking it avoids catching unrelated query errors. The database index is the last line of defense – application-level validation with Rule::unique()->where() catches the majority of duplicates, and the index handles the rare race condition.

Unique with Soft Deletes

By default, unique counts soft-deleted records. A user who deleted their account with email [email protected] blocks new registrations with that address – the row is still in the table with a non-null deleted_at.

withoutTrashed() excludes soft-deleted rows:

Rule::unique('users')->withoutTrashed(),

If the soft delete column has a custom name:

Rule::unique('users')->withoutTrashed('removed_at'),

Chaining with ignore() and where():

Rule::unique('users', 'email')
    ->ignore($user)
    ->withoutTrashed()
    ->where('organization_id', $orgId),

The chain reads left to right: unique email, excluding the current user, ignoring trashed records, scoped to the organization.

Think carefully before enabling withoutTrashed. If a soft-deleted user still has orders, invoices, or audit trails linked to their email, allowing a new user with the same email creates ambiguity. In many systems, keeping deleted emails reserved is intentional.

Exists and Soft Deletes

The exists rule has the same issue in reverse. A form might reference a soft-deleted record that should no longer be valid:

'reviewer_id' => [
    'required',
    Rule::exists('users', 'id')->withoutTrashed(),
],

A deactivated reviewer cannot be assigned to a new document. Without withoutTrashed(), the ID passes validation because the row exists – it just has a deleted_at timestamp.

For the opposite case, checking that a record is among the deleted ones (a “restore account” form):

'email' => [
    'required',
    'email',
    Rule::exists('users', 'email')->where(function (Builder $query) {
        $query->whereNotNull('deleted_at');
    }),
],

String Syntax vs. Rule Builder

The string syntax unique:table,column,except,idColumn supports ignoring a record:

'email' => 'unique:users,email,'.$user->id.',id',

That trailing ,$user->id,id is the except value and the ID column. It works, but the parameter order is unintuitive – the third positional argument is the “except” value, the fourth is the column that except applies to. One missed comma and validation breaks silently.

The Rule builder is always clearer:

Rule::unique('users', 'email')->ignore($user->id, 'id'),

Use string syntax for the simplest cases: unique:users,email on a create form with no conditions. For anything involving ignore, where, or withoutTrashed, use the builder. Mixing styles across a project leads to confusion – pick one convention for the team.

A practical guideline: if the rule fits on a single short line without concatenation, string syntax is acceptable. The moment you reach for . to concatenate an ID, switch to the builder.

Validating Email Uniqueness End-to-End

Email validation with uniqueness checks is one of the most searched topics. Here is a full scenario – registration and profile update in the same application.

Registration (create):

class RegisterRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name'  => ['required', 'string', 'max:80'],
            'email' => [
                'required',
                'email:rfc,dns',
                Rule::unique('users')->withoutTrashed(),
            ],
            'password' => ['required', 'confirmed', 'min:8'],
        ];
    }

    protected function prepareForValidation(): void
    {
        if ($this->has('email')) {
            $this->merge([
                'email' => mb_strtolower($this->input('email')),
            ]);
        }
    }
}

The prepareForValidation hook normalizes the email to lowercase before the uniqueness check runs. Without it, [email protected] and [email protected] are treated as different values by unique, but most mail servers treat them as the same address.

Profile update:

class UpdateProfileRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name'  => ['required', 'string', 'max:80'],
            'email' => [
                'required',
                'email:rfc,dns',
                Rule::unique('users')
                    ->ignore($this->user())
                    ->withoutTrashed(),
            ],
        ];
    }

    protected function prepareForValidation(): void
    {
        if ($this->has('email')) {
            $this->merge([
                'email' => mb_strtolower($this->input('email')),
            ]);
        }
    }
}

The only difference: ignore($this->user()). The authenticated user can keep their own email. Someone else’s email still triggers a uniqueness failure.

Custom Error Messages

Default messages for unique and exists are functional but vague. “The selected category id is invalid” does not tell the user much. Override them in a Form Request:

public function messages(): array
{
    return [
        'email.unique'             => 'This email address is already registered.',
        'category_id.exists'       => 'The selected category does not exist or is no longer available.',
        'coupon_code.exists'       => 'This coupon is invalid or has expired.',
        'items.*.product_id.exists' => 'Product #:position was not found in the catalog.',
    ];
}

The :position placeholder inserts the 1-based array index – handy when the user submits multiple items and needs to know which one failed. The :attribute placeholder is also available but rarely useful for nested array fields where the raw attribute name looks like items.0.product_id.

For application-wide message overrides, edit lang/en/validation.php:

'unique' => 'The :attribute has already been taken.',
'exists' => 'The selected :attribute is invalid.',

Per-field overrides in a Form Request always take precedence over language file defaults.

Recipes for Common Scenarios

Unique Slug per Author

A blog where two authors can use the same slug, but one author cannot have duplicate slugs:

'slug' => [
    'required',
    'string',
    'max:120',
    'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/',
    Rule::unique('articles', 'slug')
        ->where('author_id', $request->user()->id)
        ->ignore($article),
],

Preventing Duplicate Array Entries

When a form sends an array of emails (inviting team members), duplicates can be both within the array and against the database:

$request->validate([
    'invites'   => ['required', 'array', 'min:1', 'max:10'],
    'invites.*' => [
        'required',
        'email',
        'distinct',
        Rule::unique('users', 'email'),
    ],
]);

distinct catches duplicates inside the request (['[email protected]', '[email protected]']). unique catches addresses already registered. Both are needed – two new identical emails pass the unique check because neither is in the database yet.

distinct uses loose comparison by default, which is case-sensitive for strings – [email protected] and [email protected] are treated as different values. You can add distinct:ignore_case to handle this, but normalizing email input in prepareForValidation() is a more reliable approach – it also ensures the unique check against the database compares lowercased values:

protected function prepareForValidation(): void
{
    if ($this->has('invites')) {
        $this->merge([
            'invites' => array_map('mb_strtolower', $this->input('invites', [])),
        ]);
    }
}

Without normalization, [email protected] and [email protected] pass the distinct check as different values but deliver to the same mailbox.

Not Exists – Checking Absence

There is no not_exists rule in Laravel. But unique is semantically the same thing – it fails when a matching record is found:

// Ensure the user is not already subscribed to this newsletter
'newsletter_id' => [
    'required',
    Rule::unique('subscriptions')
        ->where('user_id', $request->user()->id),
],

If a subscription record exists for this user and newsletter, unique fails – which is exactly the “not exists” behavior needed.

Conditional Exists Based on Another Field

When a field should only be validated against the database if another field has a certain value:

use Illuminate\Validation\Rule;

$request->validate([
    'payment_method' => ['required', 'in:card,bank_transfer,wallet'],
    'wallet_id'      => [
        Rule::when(
            'wallet' === $request->input('payment_method'),
            ['required', Rule::exists('wallets', 'id')->where('user_id', $request->user()->id)],
            ['nullable'],
        ),
    ],
]);

The wallet_id only needs to exist when the payment method is wallet. For other methods, it is ignored. More on conditional rules in the conditional validation article.

Foreign Key Validation in Nested Forms

Order forms with line items need exists validation at multiple levels:

$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('available', true),
    ],
    'items.*.quantity'   => 'required|integer|min:1|max:100',
]);

Every foreign key gets its own exists check: the customer must be active, the shipping method must be valid, and each product must be available. Error messages attach to the specific field that failed, so the API consumer or form knows exactly which line item has a problem.

Unlike a flat ID array (tag_ids), a wildcard field like items.*.product_id cannot be lifted to the parent – exists must sit on each element, triggering one query per item. For arrays larger than a dozen items, consider the after() hook approach described in the Common Mistakes section below.

Performance

Each exists and unique rule fires a SQL query. A form with ten foreign keys means ten queries just for validation.

Use bail to short-circuit. If the field fails a format check, skip the database hit:

'email' => ['bail', 'required', 'email', 'unique:users'],

Without bail, Laravel runs the unique query even on an obviously invalid email like not-an-email.

Array-level exists runs one query. When array and exists sit on the same field, Laravel builds a single WHERE IN:

'role_ids' => ['required', 'array', Rule::exists('roles', 'id')],

Cache small reference tables. For data that changes rarely (countries, timezones, statuses), replace exists with in and a cached array:

use Illuminate\Support\Facades\Cache;

$currencies = Cache::remember('active_currencies', 3600, fn () =>
    DB::table('currencies')->where('is_active', true)->pluck('code')->all()
);

$request->validate([
    'currency' => ['required', Rule::in($currencies)],
]);

Zero database queries per request for that field.

Index the columns. exists and unique search by column value. A missing index turns a constant-time lookup into a full table scan. For composite checks with where(), add a composite index matching the columns:

$table->index(['tenant_id', 'email']);

Testing

exists and unique need a real database. Use RefreshDatabase in your test class:

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class RegistrationValidationTest extends TestCase
{
    use RefreshDatabase;

    public function test_duplicate_email_rejected_on_registration(): void
    {
        User::factory()->create(['email' => '[email protected]']);

        $response = $this->postJson('/api/register', [
            'name'     => 'New User',
            'email'    => '[email protected]',
            'password' => 'secret1234',
            'password_confirmation' => 'secret1234',
        ]);

        $response->assertJsonValidationErrors(['email']);
    }

    public function test_own_email_passes_on_profile_update(): void
    {
        $user = User::factory()->create(['email' => '[email protected]']);

        $this->actingAs($user);

        $response = $this->putJson("/api/profile", [
            'name'  => 'Updated',
            'email' => '[email protected]',
        ]);

        $response->assertJsonMissingValidationErrors(['email']);
    }

    public function test_nonexistent_category_rejected(): void
    {
        $response = $this->postJson('/api/products', [
            'name'        => 'Widget',
            'category_id' => 9999,
            'price'       => 29.99,
        ]);

        $response->assertJsonValidationErrors(['category_id']);
    }

    public function test_soft_deleted_email_available_with_without_trashed(): void
    {
        $old = User::factory()->create(['email' => '[email protected]']);
        $old->delete();

        $response = $this->postJson('/api/register', [
            'name'     => 'New Owner',
            'email'    => '[email protected]',
            'password' => 'secret1234',
            'password_confirmation' => 'secret1234',
        ]);

        $response->assertJsonMissingValidationErrors(['email']);
    }

    public function test_composite_unique_blocks_duplicate_assignment(): void
    {
        $employee = Employee::factory()->create();
        $project  = Project::factory()->create();

        Assignment::factory()->create([
            'employee_id' => $employee->id,
            'project_id'  => $project->id,
        ]);

        $response = $this->postJson('/api/assignments', [
            'employee_id' => $employee->id,
            'project_id'  => $project->id,
        ]);

        $response->assertJsonValidationErrors(['project_id']);
    }
}

Cover at least three scenarios for unique: fresh value passes, duplicate fails, own value passes on update. For exists: valid ID passes, nonexistent ID fails, valid ID that does not match the where condition fails.

A subtle trap in tests: factory-generated records might collide with hardcoded values. If your factory produces random emails and your test checks uniqueness against a specific email, create the record explicitly rather than relying on factory defaults. Tests that depend on random factory output become flaky when the random seed happens to generate a collision.

For withoutTrashed() tests, create a soft-deleted record and confirm the value is available for new records. For composite uniqueness, check that the same value in different combinations passes – employee A on project 1 plus employee B on project 1 should both succeed, while employee A on project 1 twice should fail.

Common Mistakes

Forgetting ignore on update. Everything works on the create form. The edit form rejects the user’s own data. Add ignore() to every unique rule used in update contexts.

Passing request input to ignore(). Rule::unique('users')->ignore($request->input('user_id')) is an injection vector. The value is embedded into the rule’s string representation, and crafted input can manipulate how it is parsed. Only pass model instances or IDs from trusted sources.

Omitting the column in exists. exists:users for a field named user_id looks for a user_id column in the users table. The primary key column is id. Validation always fails. Write exists:users,id.

Exists on null without nullable. A nullable foreign key validated with just exists:table,id fails on null. Add nullable before exists so the validator skips null values.

Soft deletes blocking new records. unique counts trashed rows by default. A deleted user’s email prevents a new signup. Use withoutTrashed() when old records should not block new ones – but only when orphaned references are not a concern.

N+1 on array validation. Placing Rule::exists('products', 'id')->where(...) on items.*.product_id runs one query per element. For large arrays, move the check into an after() hook with a single whereIn query:

public function after(): array
{
    return [
        function (\Illuminate\Validation\Validator $validator) {
            $ids = collect($this->input('product_ids', []))->filter();

            if ($ids->isEmpty()) {
                return;
            }

            $valid = DB::table('products')
                ->whereIn('id', $ids)
                ->where('store_id', $this->integer('store_id'))
                ->pluck('id');

            foreach ($ids as $index => $id) {
                if (false === $valid->contains($id)) {
                    $validator->errors()->add(
                        "product_ids.{$index}",
                        'This product is not available in the selected store.'
                    );
                }
            }
        },
    ];
}

One query instead of fifty.

String syntax for complex rules. The positional format unique:table,column,except,idColumn breaks the moment you miscount commas. The Rule builder is type-safe and readable. Reserve string syntax for the simplest cases.

For building custom Rule classes when exists and unique are not enough, conditional rule application, and the full rules reference, see the linked articles.