Database schema & key type
One table, options, stores every value — global and owned:
Schema::create('options', function (Blueprint $table) use ($keyType): void {
$table->id();
$table->morphKey('owner', $keyType, nullable: true); // owner_type + owner_id
// 'global' or a hash of owner type + id, filled by the model
$table->string('owner_scope', 32);
$table->string('key')->index();
$table->text('value')->nullable();
$table->jsonb('meta')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['owner_type', 'owner_id', 'key']);
$table->unique(['owner_scope', 'key']);
});| Column | Type | Notes |
|---|---|---|
id | bigint, auto-increment | Primary key. |
owner_type / owner_id | nullable morph (per key_type) | Both null = a global value; otherwise the owning model. |
owner_scope | string(32) | global, or a hash of owner type + id. The model fills it on every save; unique together with key. |
key | string, indexed | The option’s key(). |
value | text, nullable | The raw stored string — the value serialized through its cast, and encrypted for encrypted options. Cast back on every read. |
meta | jsonb, nullable | Free-form metadata, cast to a Collection on the model. |
created_at / updated_at | timestamps | — |
deleted_at | soft deletes | forget() soft-deletes the row. |
A row with a null owner_type and owner_id is a global value; any other row belongs to the model it morphs to. The composite index on (owner_type, owner_id, key) makes a scoped lookup a single indexed query.
The unique index on (owner_scope, key) holds the table to one row per option and scope. Two requests writing a new option at the same moment can’t store it twice — the second write updates the first one’s row — and a forgotten option’s soft-deleted row is reused by the next set() or import.
Owner key type
key_type types the polymorphic owner columns. It describes the models that own values — never the options table itself, which always keeps an auto-incrementing id:
| key_type | Owner columns | Use when your owners… |
|---|---|---|
bigint | nullableMorphs | use Laravel’s default auto-incrementing keys (the default) |
uuid | nullableUuidMorphs | use HasUuids |
ulid | nullableUlidMorphs | use HasUlids |
It’s read when the migration runs, so set it first. The value is case-insensitive; anything other than bigint, uuid or ulid throws InvalidConfigurationException when the migration runs instead of falling back.
The Option model
RoundlyConsulting\Options\Option is a regular Eloquent model with soft deletes, a factory and an owner() morphTo relation. The value column has no static cast — each option applies its own castAs() at read and write time. Useful members:
use RoundlyConsulting\Options\Option;
Option::query()->forOwner($user)->get(); // rows owned by $user
Option::query()->forOwner(null)->get(); // global rows only
$row = Option::query()->forOwner($user)->where('key', 'theme')->first();
$row->owner; // the owning model (null for a global row)
$row->owner_scope; // 'global', or a hash of owner type + id (set on every save)
$row->meta; // Collection|nullCustom model
Point options.model at your own subclass to add relations, scopes or accessors — every read, write, export and import goes through it:
namespace App\Models;
use RoundlyConsulting\Options\Option;
class TenantOption extends Option
{
// extra relations, scopes or accessors
}
// config/options.php
'model' => App\Models\TenantOption::class,A key that is not set — absent, null or blank — resolves the packaged model. Anything else must be Option or a subclass of it, or the toolkit’s InvalidConfigurationException is thrown naming the key — a foreign class is never silently replaced with the packaged model.
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.