willvincent/laravel-unique
willvincent/laravel-unique provides a simple trait for generating unique values on Eloquent models, making it easy to assign distinct IDs, codes, or tokens to records with minimal configuration in any Laravel application.
This package extends Laravel's database-backed validation rules so a single exists() check can carry a set of constraint fields — extra column/value pairs that narrow the lookup — without hand-rolling Rule::exists(...)->where(...) closures for every variation.
Install and confirm the version
composer require <vendor>/<package> "^1.2"
php artisan vendor:publish --tag=<package>-config # only if the README lists a config file
Where to look first
exists() — this is the whole package. Read it before anything else; it is short and tells you exactly how constraint fields are normalized, quoted, and appended to the query.tests/ — specifically the cases covering exists() with more than one constraint. Release 1.2 fixed a bug in exactly that area, so the tests are the best documentation of intended behaviour.First use case
Scope a uniqueness/exists check to the current tenant instead of writing a closure:
$request->validate([
'email' => [
'required',
'email',
// instead of: Rule::unique('users')->where('tenant_id', $tenantId)
new ConstrainedExists('users', 'email', ['tenant_id' => $tenantId]),
],
]);
If that validates correctly and the generated SQL shows where tenant_id = ?, you have the mental model right and everything else is composition.
Declarative constraints in Form Requests
The main day-to-day win is that constraint data becomes data, not closures. Build the array from the request or the authenticated user and pass it in:
public function rules(): array
{
return [
'slug' => ['required', 'string', new ConstrainedExists('pages', 'slug', [
'site_id' => $this->route('site')->id,
'locale' => $this->input('locale', 'en'),
])],
'parent_id' => ['nullable', 'integer', new ConstrainedExists('pages', 'id', [
'site_id' => $this->route('site')->id,
])],
];
}
Reusable rule factories
Wrap the common scopes once, so controllers and requests never repeat column names:
class TenantExists
{
public static function make(string $table, string $column): ConstrainedExists
{
return new ConstrainedExists($table, $column, [
'tenant_id' => auth()->user()->tenant_id,
]);
}
}
This also gives you a single place to change when the tenancy column is renamed.
Mirror rule and mirrored query
Every constrained exists() should have a matching write path. If validation says "a page with this slug exists in this site", the lookup you do afterwards must apply the same two predicates — otherwise you validate one row and mutate another. Keep both in the same class (a form request plus a small repository method, or an action class).
Pair with unique() for update flows
For update endpoints, the same constraint set usually applies but the current record must be excluded. Keep the constraint array identical and add the ignore, so the two rules can't drift:
$constraints = ['site_id' => $site->id, 'locale' => $locale];
'email' => [
'required',
new ConstrainedExists('users', 'email', $constraints),
Rule::unique('users', 'email')->where($constraints)->ignore($user->id),
],
Testing
Assert on behaviour, not SQL: assertSessionHasNoErrors() for the happy path, and assertSessionHasErrors('slug') for a row that exists in a different tenant. The second assertion is the one that catches regressions in constraint handling.
1.2 changes matching behaviour — re-run your suite
The fix is that constraint fields were being wrongly applied in one of the exists() checks. If you worked around the old behaviour (for example by adding a redundant whereRaw, or by duplicating a field inside the constraints array to force it through), those workarounds will now over-constrain or be silently ignored. After upgrading:
DB::listen() or DB::enableQueryLog() in a single test is enough.Column ambiguity
Constraints are appended as plain where clauses. If the rule is ever used against a query with joins or a table alias, qualify the constraint keys (users.tenant_id) or you will get an ambiguous-column error that points at the validator rather than at your query.
Soft deletes are not implied
An exists() check sees soft-deleted rows unless the constraint set (or the package's config, if it has one) excludes them. Decide deliberately per rule; getting this wrong means a "deleted" record blocks a slug forever.
Null handling
Constraints with a null value are a common source of silent widening: depending on the builder, where('col', null) may become is null or be dropped. If a field is nullable, skip it from the constraint array explicitly rather than passing null and hoping.
Connection and table prefixes
Constraints run on the connection the rule resolves, not necessarily the default one. For multi-connection apps, confirm which connection the lookup uses; table prefixes are applied by the grammar, so don't hand-prefix the table name.
Validation order and cost
Each constrained exists() is a round trip. Combine constraints into one rule instead of chaining several exists() rules on the same field, and put cheap rules (required, string, max) before it so invalid input never reaches the database.
Extension points
If the package exposes a rule class you can subclass or a where builder hook, prefer extending it over post-processing the rule in the form request. The 1.2 bug is a reminder that constraint assembly is the fragile part — keep your customisations in one adapter class so a future fix is a one-line change for you.
How can I help you explore Laravel packages today?