sitesource/laravel-polymorphic-settings
Laravel Polymorphic Settings lets you attach flexible key-value settings to any Eloquent model through a polymorphic relationship, with a simple API for storing, retrieving, updating, and managing configuration-like data per record.
Install and publish
composer require sitesource/laravel-polymorphic-settings
php artisan vendor:publish --provider="SiteSource\PolymorphicSettings\PolymorphicSettingsServiceProvider"
php artisan migrate
The publish step should give you a config file (config/polymorphic-settings.php) and a migration creating the settings table. Check both before running anything: the package is at 0 stars / single-digit adoption, so the migration's table name and the config's cache store key are things you want to confirm rather than assume.
Wire a model
Add the package's trait to any Eloquent model that needs settings:
use SiteSource\PolymorphicSettings\HasSettings; // verify namespace in src/
class Tenant extends Model
{
use HasSettings;
}
Then the three public methods from the changelog are your whole API surface:
$tenant->put('commerce', ['checkout' => true, 'foo' => false]);
$tenant->get('commerce.foo'); // false
$tenant->forget('commerce'); // bool
get() is the one you'll call most; put() writes a whole value at a key; forget() drops it and always invalidates cache (since v0.1.3).
Where to look first
config/polymorphic-settings.php — cache store, table name, encryption toggle.src/ — the trait, the cache-key builder (cacheKey()), and the value (de)serializer. Read these; they're small and they answer every question this README won't.CHANGELOG / release/v0.1.3.md — this is where the real behavioural contract lives.First use case
Per-tenant feature toggles and UI preferences. It's a key-value store scoped to a model, so it maps naturally to "settings that belong to this tenant/user/team" and change without a deploy:
$tenant->put('notifications', ['digest' => 'weekly', 'mentions' => true]);
$digest = $tenant->get('notifications.digest', 'daily'); // default as 2nd arg
Prefer this over a JSON column when settings are edited one at a time from different admin screens and you want per-key cache invalidation rather than a full-row rewrite.
Nest at the root, read at the leaf. Write a whole array under one root key and read children through dots. This keeps one cache entry per root and gets correctly busted by a single put('commerce', [...]):
$tenant->put('commerce', [
'checkout' => true,
'currency' => 'EUR',
'tax' => ['inclusive' => true],
]);
$tenant->get('commerce.tax.inclusive'); // true
Don't mix dotted and nested stores for the same key. put('a.b', x) and put('a', ['b' => x]) are distinct rows, and resolution is nested-first with a literal fallback. The v0.1.2 → v0.1.3 migration story exists precisely because someone had both. Pick nested-root writes as your house style and enforce it in review.
Wrap it in one settings object per model. You don't want get('key.name', ...) string literals scattered across controllers. A thin class gives you typed accessors and defaults in one place:
class TenantSettings
{
public function __construct(private Tenant $tenant) {}
public function digest(): string
{
return $this->tenant->get('notifications.digest', 'daily');
}
public function setDigest(string $value): void
{
// read-modify-write the root, not the leaf
$current = $this->tenant->get('notifications', []);
$this->tenant->put('notifications', [...$current, 'digest' => $value]);
}
}
Encrypt only the leaves that need it. Encryption is opt-in, so treat it as a deliberate decision per key: API tokens, webhook secrets, SMTP passwords. Anything you want to WHERE-filter, sort, or report on in SQL must stay plaintext — encrypted values are opaque to the database.
Bulk-read the root when rendering a form. A page that renders 15 individual toggles via 15 get() calls is 15 cache hits at best and 15 queries on a cold cache. Read get('notifications', []) once and hand the array to the view.
Testing. Set the cache store to array in phpunit.xml, use RefreshDatabase, and flush the cache in setUp() — a stale entry from a previous test on the same morph/id pair is the classic false-green:
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
Filament/admin integration. There's a companion laravel-polymorphic-settings-filament package; the v0.1.3 release notes were driven by its integration, so that's the best-trodden admin path. If you're not using Filament, roll your own form over the TenantSettings wrapper above.
When you outgrow it. If you need history/audit of setting changes, per-key permissions, or queryable setting values, this package is the wrong shape — a dedicated settings table with typed columns is cheaper than bolting on an events layer.
Upgrade to v0.1.3 or disable the cache. Two real bugs are fixed only there. Pre-0.1.3, get('commerce.foo') cached the literal lookup under the dotted key, while put('commerce', [...]) only invalidated cacheKey('commerce') — leaving you serving a stale literal until TTL expiry. If you're pinned below 0.1.3 and can't move:
POLYMORPHIC_SETTINGS_CACHE_ENABLED=false
Settings reads aren't a hot path; per-request queries are fine as a stopgap.
Dotted-key literal reads are uncached by design (0.1.3+). The fix skips the cache for the literal fallback when the key contains a dot, so get('a.b') may cost a DB query every call. The nested root traversal path is still cached and still busted by put('root', ...). Don't build a hot loop over literal dotted keys.
forget() returns false on a successful cache bust. Since 0.1.3, forget() always invalidates cache but the boolean still reflects "did a DB row get deleted". Code like if (!$model->forget('x')) abort(404); will now spuriously 404 after an external deletion. Check existence separately if you care.
No merge semantics — concurrent root writes clobber. Read-modify-write on a root array is last-write-wins with no row locking. Two admins toggling different keys under notifications simultaneously will lose one change. Either keep admin surfaces single-key-per-form, or wrap the read-modify-write in a transaction with a lock on the settings row.
Morph aliases and class renames invalidate your cache silently. The cache key is built from morph type + id + key. Introducing Relation::enforceMorphMap() or renaming a model class doesn't migrate existing rows — you get orphaned settings and cold caches that look like "settings disappeared". Decide the morph map before you ship.
Hard-deleting a model leaves its settings behind. If the table's PK reuses the freed id, the new record inherits the old settings. Add a deleting hook (or a cleanup job) that calls forget() on the relevant roots — soft deletes keep settings intentionally, which is usually what you want.
Encryption key rotation is a one-way door. Values are decrypted through Laravel's encrypter; rotating APP_KEY without re-encrypting makes ciphertext unreadable, and the package exposes no re-key command in the docs. Either keep the settings encryption key out of routine rotation, or write your own decrypt-with-old-key / re-encrypt script before you rotate.
The cache store is shared infrastructure, not a detail. Use redis/memcached shared across web and queue workers, not array or file on ephemeral containers. If a queue worker writes a setting and the web tier's cache isn't invalidated, you get the 0.1.3 bug shape again — but self-inflicted.
Debugging checklist when a value "doesn't stick":
POLYMORPHIC_SETTINGS_CACHE_ENABLED=false and retry — isolates cache vs. write.model_type, model_id, key) — check for a literal dotted row shadowing a nested one.get() is returning decrypted output at all (a wrong key throws, but a wrong config toggle can leave plaintext that later reads try to decrypt).Pin the version and read the source. Zero stars, zero dependents, a very recent last release. It's a small, well-scoped package — a couple of hours reading src/ buys you a full mental model and the ability to fork if it goes unmaintained. That's a fine trade for a settings store, but budget for it before it's load-bearing across a dozen models.
How can I help you explore Laravel packages today?