NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Attributes for Laravel

Unique, required & defaults

Unique values

A unique definition rejects a value that another owner already holds:

use RoundlyConsulting\Attributes\DataTransferObjects\AttributeDefinitionData;
use RoundlyConsulting\Attributes\Enums\AttributeType;
use RoundlyConsulting\Attributes\Enums\UniqueScope;
use RoundlyConsulting\Attributes\Facades\Attributes;

Attributes::define(new AttributeDefinitionData('sku', AttributeType::String_, unique: UniqueScope::Global_));

Attributes::for($a)->set('sku', 'ABC');
Attributes::for($b)->set('sku', 'ABC'); // throws DuplicateAttributeValueException
Attributes::for($a)->set('sku', 'ABC'); // re-saving the owner's own value is fine

Attributes::for($a)->forget('sku');
Attributes::for($b)->set('sku', 'ABC'); // fine — detaching freed the value
Config valueEnum caseEnforces
none, false, blank or omittedUniqueScope::NoneNothing — the default.
owner or trueUniqueScope::OwnerUnique among owners of the same model type — across every team or tenant.
scopeUniqueScope::ScopeUnique among owners of the same model type that resolve the same scope model — a team, a tenant.
globalUniqueScope::Global_Unique across every owner, whatever its type.

Re-saving the same owner’s own value is idempotent, detaching an attribute — or deleting its owner for good — frees its value, and null values are never checked. DuplicateAttributeValueException exposes attributeName and scope.

Uniqueness is a database guarantee, not just a check before the write: each unique value carries a deterministic hash in the unique_hash column, which has a unique index — two concurrent writers of the same value cannot both commit, and the loser gets DuplicateAttributeValueException. It works for encrypted values too: their hash is a keyed blind index (an HMAC under a key derived from APP_KEY), so the database never holds the plaintext. Rotating APP_KEY changes that key — re-save encrypted unique values afterwards.

Unique per team or tenant

owner makes a value unique among every owner of the model type — in a multi-tenant app, across every tenant. 'unique' => 'scope' narrows that to owners that resolve the same scope model, so two teams can both use SKU ABC, and nobody learns what another team has stored. Name the scope per definition with unique_scope, for every scoped definition at once with the unique_scope setting, or with a closure:

// config/attributes.php — one tenant key for every scoped definition…
'unique_scope' => 'team_id',
'definitions' => [
    'sku' => ['type' => 'string', 'unique' => 'scope'],
    // …or per definition: an attribute/relation name, or a ResolvesUniqueScope class-string
    'code' => ['type' => 'string', 'unique' => 'scope', 'unique_scope' => 'team'],
],

// At runtime (or in a model's attributeDefinitions()) a closure works too
Attributes::define(new AttributeDefinitionData('sku', AttributeType::String_,
    unique: UniqueScope::Scope, uniqueScope: fn (Model $owner) => $owner->team_id));

Attributes::for($productOfTeamA)->set('sku', 'ABC');
Attributes::for($productOfTeamB)->set('sku', 'ABC');  // fine — another team
Attributes::for($otherProductOfTeamA)->set('sku', 'ABC'); // throws DuplicateAttributeValueException

One row per owner and name already makes a value unique within one owner, so per-owner-instance uniqueness needs no scope of its own. The scope resolves in this order — the definition’s unique_scope, else the unique_scope setting, else InvalidConfigurationException:

  • A closure receives the owner.
  • A class-string must implement RoundlyConsulting\Attributes\Contracts\ResolvesUniqueScope — __invoke(Model $owner): Model|int|string|null, resolved from the container; any other class throws InvalidConfigurationException.
  • Any other string is read from the owner as an attribute or relation. An unloaded relation is queried directly, so it works under Model::preventLazyLoading(); name the foreign key (team_id) to skip the query.
  • A saved model counts as its morph class and key, a non-empty int or string as itself.
  • Null, an empty string, an unsaved model or anything else fails closed: AttributesException (“… is unique per scope, but its owner resolved no scope”) and nothing is written — an owner without a tenant never joins a shared pool.
use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Attributes\Contracts\ResolvesUniqueScope;

final class OwnerTenant implements ResolvesUniqueScope
{
    public function __invoke(Model $owner): Model|int|string|null
    {
        return $owner->tenant_id; // a saved model or a non-empty key; null is refused
    }
}

// config/attributes.php
'unique_scope' => App\Attributes\OwnerTenant::class,

The scope key goes into unique_hash, so the existing unique index is the guarantee: concurrent writers in one scope still get DuplicateAttributeValueException, and no migration is needed. The check before the write also counts plain rows stored before the definition became unique, by resolving their owners’ scopes. Changing a definition’s scope — owner to scope, say — does not rehash stored rows; re-save their values so the new hash applies.

DuplicateAttributeValueException names only the attribute and the scope (Attribute [sku] must be unique (scope); the given value already exists.) — never the other owner, its scope model or the value. Under owner or global, the fact that the value exists elsewhere is itself visible to the caller; scope confines that to owners sharing the scope model.

Required attributes

required is enforced on demand, not on every attach — call validateAttributes() when the model should be complete, for example at the end of an import or a checkout step:

use RoundlyConsulting\Attributes\Exceptions\MissingRequiredAttributeException;

Attributes::define(new AttributeDefinitionData('rating', AttributeType::Integer, required: true));

try {
    $product->validateAttributes();
} catch (MissingRequiredAttributeException $e) {
    $e->attributeName; // 'rating'
}

validateAttributes() walks every definition that applies to the model, global and model-declared. It throws MissingRequiredAttributeException for the first required attribute that is absent, and re-validates every present value against its type and rules — with validation.whole_set on, the stored set as one, after the required check.

Default values

A definition’s default is returned by reads when the attribute is not attached. Defaults apply on read only — they are never persisted, and bulk getAttachedAttributes() lists stored rows only:

Attributes::define(new AttributeDefinitionData('retries', AttributeType::Integer, default: 3));

Attributes::for($product)->get('retries'); // 3 when not attached, the stored value when set
$product->attributeInt('retries');         // 3
$product->attr('retries')->int();          // 3

Attributes::for($product)->set('retries', null);
Attributes::for($product)->get('retries'); // null — an explicitly stored null is not replaced

An attribute explicitly stored as null reads as null — the default fills in only for an attribute that isn’t attached.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.