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

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:

ColumnTypeNotes
idbigintAuto-incrementing key.
owner_type, owner_idmorph, nullableThe owning model. owner_id is typed by key_type.
namestringAttribute name.
valuetext, nullableStorage string — JSON for arrays, UTC ISO-8601 for datetimes, ciphertext when encrypted.
value_typestring, nullableAttributeType value (defaults to string).
is_encryptedbooleanWhether value holds ciphertext.
unique_hashstring(64), nullable, uniqueHash of a value under a unique definition — a keyed blind index for encrypted values; null otherwise. Hidden when the model is serialized.
metajsonb, nullableMetadata, cast to a Collection.
created_at, updated_at, deleted_attimestampsSoft 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_typeOwner columnsUse when owner models…
bigintnullableMorphsuse Laravel’s default auto-increment keys (the default; id is accepted as an alias)
uuidnullableUuidMorphsuse HasUuids
ulidnullableUlidMorphsuse HasUlids

Set it before you run the migrations:

ATTRIBUTES_KEY_TYPE=uuid
use 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 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.