Database schema & model
Every attribute is one row in a single polymorphic table — no column per attribute, no migration per new field. Table name from table:
| Column | Type | Notes |
|---|---|---|
id | bigint | Auto-incrementing key. |
owner_type, owner_id | morph, nullable | The owning model. owner_id is typed by key_type. |
name | string | Attribute name. |
value | text, nullable | Storage string — JSON for arrays, UTC ISO-8601 for datetimes, ciphertext when encrypted. |
value_type | string, nullable | AttributeType value (defaults to string). |
is_encrypted | boolean | Whether value holds ciphertext. |
unique_hash | string(64), nullable, unique | Hash of a value under a unique definition — a keyed blind index for encrypted values; null otherwise. Hidden when the model is serialized. |
meta | jsonb, nullable | Metadata, cast to a Collection. |
created_at, updated_at, deleted_at | timestamps | Soft deletes — removals are recoverable until pruned. |
attributes carries a unique index on (owner_type, owner_id, name) — soft-deleted rows included — so each owner holds exactly one row per attribute name: attaching an existing name updates that row in place, and attaching a detached name restores its row with a fresh value and meta. unique_hash has its own unique index, which makes unique definitions a database guarantee (see Unique, required & defaults). The attribute_revisions table (history.table) holds the optional audit trail, indexed on the same three columns — see History & audit trail.
Owner key type
key_type types the owner_id column on both tables, so attributes can hang off models with non-integer keys. All your owner models must share one key type:
| key_type | Owner columns | Use when owner models… |
|---|---|---|
bigint | nullableMorphs | use Laravel’s default auto-increment keys (the default; id is accepted as an alias) |
uuid | nullableUuidMorphs | use HasUuids |
ulid | nullableUlidMorphs | use HasUlids |
Set it before you run the migrations:
ATTRIBUTES_KEY_TYPE=uuiduse Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Attributes\Contracts\HasAttributes as HasAttributesContract;
use RoundlyConsulting\Attributes\Traits\HasAttributes;
class Product extends Model implements HasAttributesContract
{
use HasAttributes;
use HasUuids; // pairs with ATTRIBUTES_KEY_TYPE=uuid
}The value is case-insensitive. An unset or blank key means bigint; an unrecognized value throws InvalidConfigurationException when the migrations run instead of falling back.
Swapping the attribute model
Point attributes.model at your own subclass to add casts, scopes or model events. Every read, write, scope, command and Attribute::collect() resolves the model through this key:
// app/Models/CustomAttribute.php
namespace App\Models;
use RoundlyConsulting\Attributes\Models\Attribute;
class CustomAttribute extends Attribute
{
protected static function booted(): void
{
parent::booted(); // keeps the package's unique-slot release on soft delete
static::saved(function (self $attribute): void {
// host-side model events, casts or scopes
});
}
}
// config/attributes.php
'model' => App\Models\CustomAttribute::class,The class must be RoundlyConsulting\Attributes\Models\Attribute or a subclass of it — anything else throws the toolkit’s InvalidConfigurationException naming the key; a foreign class is never silently replaced with the packaged one. The subclass inherits the attributes.table lookup, so there is no $table to set. If it overrides booted(), call parent::booted() first: the packaged model uses it to free a soft-deleted value’s unique slot.
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 cryptoBy 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.