glorand/laravel-model-settings
Add flexible, persistent settings to any Laravel Eloquent model. Choose field, table, or Redis storage drivers or write your own, define defaults, and get, set, check, and remove per-model settings with a simple API. Requires PHP 8.2+ and Laravel 12+.
Requirements and install. The package targets PHP 8.2+ / Laravel 12+ on the current major (v9). Install it with Composer and pull in the config:
composer require glorand/laravel-model-settings
php artisan vendor:publish # pick the model_settings config entry
There is no service provider you need to register manually in a standard Laravel app.
Pick a storage driver before touching models. In v9 the trait is a thin proxy; the driver decides where settings live. The package ships three:
| Driver | Storage | Use when |
|---|---|---|
field (default) |
JSON column on the model's own table | You control the schema and want zero extra queries |
table |
One row per model in model_settings |
You cannot alter the model's table, or settings are large/sparse |
redis |
Redis keys | Ephemeral UI state, high write volume, loss tolerance |
Set the app-wide default via MODEL_SETTINGS_DRIVER (or config/model_settings.php → driver), and override per model with $settingsDriver.
Minimum wiring for the default driver.
php artisan model-settings:model-settings-field # creates the migration to add the JSON column
php artisan migrate
use Glorand\Model\Settings\Traits\HasSettings;
class User extends Model
{
use HasSettings;
}
If you use the table driver instead, run php artisan model-settings:model-settings-table after setting MODEL_SETTINGS_TABLE_NAME, because the generated migration hardcodes the name at generation time.
Where to look first. Read config/model_settings.php (driver entries, defaultSettings) and the HasSettings trait — the whole public API is roughly a dozen methods, so ten minutes of source reading replaces most of the docs. The README's driver sections are the only place the storage differences are explained.
First use case: per-user preferences. Notification toggles, locale, timezone, dashboard layout, feature opt-ins — anything you'd otherwise model as a user_preferences table with one column per flag. With the field driver this costs one JSON column and no additional queries, since the value loads with the model:
$user->settings()->set('notifications.email', false);
$user->settings()->set('notifications.digest', 'weekly');
$user->settings()->get('notifications.digest'); // 'weekly'
$user->settings()->get('timezone', 'UTC'); // default when missing
$user->settings()->all(); // full array
Verdict for day-to-day work. The package is a small, well-scoped utility: a JSON bag with dot-notation access, defaults, and optional validation. It is not a configuration or feature-flag system, and it does not try to be. Adopt it for model-scoped data, and keep the write path behind your own service class so the storage decision stays reversible.
Encapsulate writes behind a domain service. The fluent API is convenient but unstructured; sprinkling settings()->set(...) across controllers makes keys impossible to audit. Keep key names and defaults in one place:
final class UserPreferences
{
public const DEFAULTS = [
'notifications' => ['email' => true, 'digest' => 'daily'],
'locale' => 'en',
];
public function __construct(private User $user) {}
public function all(): array
{
return array_replace_recursive(self::DEFAULTS, $this->user->settings()->all());
}
public function update(array $validated): void
{
$this->user->settings()->apply($validated); // validated upstream
}
}
This also gives you a single seam to swap package, add caching, or emit events later.
Use model-level defaults, but understand the merge rule. Defaults can come from config('model_settings.defaultSettings.users') or from a public $defaultSettings property on the model. The property does not merge with the config — if it exists, the config entry is ignored entirely. Prefer the config file for values that differ between environments, and the property for values the model itself owns.
Dot notation is the whole ergonomics story. Nested arrays are flattened internally, so reads and writes address deep keys directly:
$user->settings()->setMultiple([
'notifications.email' => false,
'notifications.digest' => 'weekly',
'locale' => 'fr',
]);
$user->settings()->getMultiple(
['locale', 'timezone', 'notifications.digest'],
'fallback' // single default applied to any missing key
);
For bulk forms, apply($array) replaces at the addressed paths; set()/update() are aliases for a single key.
Layer validation with $settingsRules. Rules use standard Laravel syntax and are evaluated on write paths (set, apply, update, and their multiple-variants), throwing on failure:
public array $settingsRules = [
'locale' => 'string|in:en,fr,es|max:2',
'notifications.email' => 'boolean',
'notifications.digest' => 'string|in:daily,weekly,never',
];
This complements, and does not replace, FormRequest validation — rules here guard every write, including ones from jobs and console commands. Catch the exception at the boundary and convert it to a 422 in the API layer.
Rename the accessor to domain language. public $invokeSettingsBy = 'preferences'; makes $user->preferences() resolve to the same manager — useful when "settings" is overloaded in your domain (e.g., tenant billing settings vs. user preferences).
Use table + cache for schema you don't control. The table driver adds one query per model when hydrating a collection. It has a cache layer built in (since 3.5.0), so it is acceptable for indexed single-model reads, and painful for User::all()->map->settings() loops. If you must serialize settings across many models, eager-aggregate in one query instead.
Extend with a custom driver when storage is unusual. Register a class extending AbstractSettingsManager either statically in config/model_settings.php → drivers.<name>.class or at runtime:
app(SettingsManagerFactory::class)->extend('dynamodb', fn ($model) => new DynamoDbSettingsManager($model));
Then protected $settingsDriver = 'dynamodb';. This is the cleanest escape hatch if you later need versioned or encrypted settings — none of the built-in drivers encrypt.
Testing pattern. Use RefreshDatabase; settings writes through the field driver persist on the model record, so assertions are straightforward:
$user->settings()->set('locale', 'fr');
$this->assertSame('fr', $user->fresh()->settings()->get('locale'));
$this->assertTrue($user->settings()->has('locale'));
$this->assertFalse($user->fresh()->settings()->empty());
Prefer asserting via a fresh instance — it catches persistence misconfiguration early.
v9 is a rewrite of the trait surface. If you're upgrading from v8, the three storage-specific traits are gone. Replace them with the single HasSettings trait and declare protected $settingsDriver explicitly on models that were not on the default driver. Silent fallback to field is the most likely upgrade bug: writes will land in a JSON column that doesn't exist, or in the wrong place.
The field column must exist with the exact configured name. The default is settings, overridable via MODEL_SETTINGS_FIELD_NAME or public $settingsFieldName. A mismatch surfaces as a database "unknown column" error at write time, not at boot — easy to miss in CI if tests use SQLite with a hand-written schema.
Auto-persistence is on by default and is per-driver. For the field driver, set() saves the model for you unless $persistSettings = false, MODEL_SETTINGS_PERSISTENT=false, or drivers.field.persistent is false. When disabled, forgetting $user->save() silently discards the change — and it will look like it worked inside the same request. Decide once, centrally, and document it; mixing behaviour between models is a reliable source of "settings don't stick" bugs.
Concurrent writes are last-write-wins. set() is a read-modify-write on a single JSON blob. Two simultaneous requests setting different keys can clobber each other. For anything where loss matters, wrap in a transaction with a row lock (->lockForUpdate()), or move that data into real columns.
Defaults are not a safety net for shape. $defaultSettings supplies absent keys; it does not guarantee that a stored value has the right type. A stored "digest": null still returns null and bypasses your default. Read defensively (get('x.y') ?? config(...)) or validate on read at your service boundary.
Validation only guards writes, and only those methods. Direct DB edits, seeds, and any future bulk-import path bypass $settingsRules. Treat the rules as an application-level sanity check, not a database constraint.
empty(), exist(), has(), and clear() are four different questions. empty()/exist() describe the whole settings bag; has('a.b') answers for one key; clear() wipes everything while delete() removes a single path. clear() on the field driver triggers an immediate save — there is no undo and no soft-delete equivalent.
The table driver's cache can go stale. Cache invalidation happens through the package's own write path. Settings changed via raw SQL, another service, or a second application sharing the database will be invisible until the cache expires. When in doubt, clear the cache key namespace after external writes.
Redis is not durable, and it is not a config store. Do not put billing flags, entitlements, or anything whose loss requires a support ticket in the redis driver. Also verify the MODEL_SETTINGS_REDIS_CONNECTION and prefix are set correctly in multi-tenant apps — two tenants sharing a prefix and model ID will collide, since keys are derived from model identity alone.
Don't mass-assign the raw settings column. Keep settings out of $fillable; always go through settings(). Direct assignment skips validation, dot-notation normalisation, and — for the table driver — persistence entirely.
Dots in key names are unusable. A literal "app.name" key becomes indistinguishable from the path app → name. Enforce a naming convention (snake_case segments) and validate key names in your service class.
N+1 on the table driver is the main performance trap. Profile any endpoint that maps over a collection and reads settings. The field driver has no such cost, which makes it the right default for anything on a hot path — the schema change is usually cheaper than the query.
No encryption, no versioning, no audit trail. Stored values are plaintext JSON or plaintext table rows. Never put secrets, tokens, or PII in settings; use encrypted casts on real columns or a secret manager. If you need "what changed and when," add an observer on your service class rather than retrofitting history onto the package.
Keep a fallback plan. The API is small and the abstraction is shallow, so migrating off is a day's work if all access goes through one class. If it doesn't, it's a week. That single constraint is the most valuable thing you can enforce when adopting this package.
How can I help you explore Laravel packages today?