Treat this package as a small, typed key–value store with two independent scopes (global and per-model) plus a thin feature-flag layer. Your first 10 minutes:
php artisan vendor:publish --tag=settings-config
php artisan vendor:publish --tag=settings-migrations
# only if you plan on per-model preferences:
php artisan vendor:publish --tag=settings-model-migrations
php artisan migrate
Before publishing anything, check for an existing config/settings.php, already-published migrations, and models that use the HasSettings trait — this package is usually added incrementally to an app that already has config. Publish only what's missing.
Then seed your defaults as literal dotted keys in config/settings.php:
'defaults' => [
'site.name' => 'My Application',
'billing.enabled' => false,
'uploads.limit' => 10,
],
Your first use case should be the read path, not the write path: render something in a Blade layout.
<title>@setting('site.name')</title>
@hasFlag('billing.enabled')
<x-billing-panel />
@endhasFlag
That gives you an immediately deployable win: a site name or feature toggle you can change without a release. Only after the read path works should you wire up writes (admin screen, Artisan, tinker).
Pick one access style per layer and stick to it. The helper, facade, and injected manager all resolve the same service, so consistency is about testability, not behavior:
settings()->set('site.name', 'Customer Portal'); // helpers, tinker, closures
Settings::get('site.name'); // facades, controllers
public function __construct(private SettingsManager $settings) {} // services, jobs
Prefer constructor injection in anything you unit test — it lets you swap or mock the manager and makes the dependency visible.
Declare types when writing from untrusted or string sources. Inference is fine for native PHP values; be explicit at the boundaries (forms, Artisan, imports):
settings()->set('uploads.limit', '10', 'int'); // string → int: 10
settings()->set('billing.enabled', true, 'bool');
settings()->set('notifications.channels', ['mail', 'database']); // json
Prefer forget() over writing a default explicitly. forget() restores lookup of the config default, so a later change to settings.defaults actually propagates:
settings()->forget('site.name');
Model settings: use the trait and $model->settings(). Don't inject the model manager and forget to call for():
$user->settings()->set('ui.theme', 'dark');
$theme = $user->settings()->get('ui.theme', 'light');
$user->settings()->forget('ui.theme');
Express inheritance explicitly — model settings deliberately do not fall back to global settings or config defaults. If you want "per-user override, global default, hardcoded fallback", write the chain:
$theme = $user->settings()->get('ui.theme', settings()->get('ui.theme', 'light'));
Wrap that in a single method on the model or a small UserPreferences service so the chain lives in one place instead of being copy-pasted across views.
Feature flags: store PHP booleans, not strings. Use the flag helper for global flags and the scoped manager with a cast for model flags:
flag('billing.enabled', false);
Flag::enabled('billing.enabled', false);
$enabled = (bool) $user->settings()->get('notifications.enabled', true);
React to changes with the three events, filtering on both model and key. $event->model === null means global scope. Listen to SettingCreated as well as SettingUpdated if you care about any newly stored value, and remember that a creation matching a config default still emits SettingCreated.
Event::listen(SettingUpdated::class, function (SettingUpdated $event): void {
if ($event->model === null && $event->key === 'billing.enabled') {
Log::info('Billing availability changed', ['enabled' => $event->newValue]);
}
});
Use the Artisan commands for ops and hotfixes, and always pass --type for anything non-string:
php artisan settings:get site.name
php artisan settings:set site.name "Customer Portal"
php artisan settings:set billing.enabled false --type=bool
php artisan settings:clear-cache
Testing pattern that actually catches bugs: use the real database with the package migrations, then cover the four things that break in production: native types round-tripping, stored override beating the config default, forget() restoring fallback, and stored null behaving differently from a missing key. For model settings, create two persisted owners and assert isolation (including isolation from global values). For caching, prime the cache with a read, write, then assert the next read is fresh. Test listeners by dispatching real events; only fake the dispatcher when you're asserting emission alone.
Dotted keys are literal. site.name is one key, not a path into a nested array. There's no settings()->get('site')['name'] and no way to read a subtree. If you need grouped settings, either fetch the whole stored set with all() and filter, or store a single JSON blob and read its members in your own code.
Scopes don't inherit. The most common surprise: a user has no ui.theme stored, the global setting says dark, and the user sees light because the fallback argument was 'light'. Always route through an explicit resolution helper as shown above.
Stored null is a real value. It wins over config defaults and over your fallback. settings()->has($key) returns true for a stored null, but @hasSetting($key) fails because it checks the resolved value for non-null. Test these two paths separately — this is the single easiest place to ship a subtle bug.
all() excludes config defaults. all() returns stored values only, while has() includes stored null. If you're building an admin screen that lists "everything configurable," merge config('settings.defaults') with all() yourself; don't assume all() is the full picture.
'false' is truthy. Feature flags cast to PHP boolean, and PHP's (bool) 'false' is true. The Artisan --type=bool flag exists precisely to avoid this — but a form field, a CSV import, or a .env-adjacent habit will bite you. Validate and cast at the input boundary.
Artisan writes are strings by default. settings:set billing.enabled false without --type=bool stores the string "false", which then evaluates truthy. Make this a standing code-review rule.
Direct DB writes, Eloquent writes, and repository bypasses break everything. You lose cache invalidation and events. Any migration, seeder, or one-off DB::table('settings') statement must be treated as an out-of-band change requiring a manual clearCache(). Use settings()->set() inside seeders.
Cache invalidation is scope-specific. php artisan settings:clear-cache clears only the global cache. Model caches are keyed by key_prefix plus owner identity and must be cleared via $model->settings()->clearCache(). Also note ttl => null means cache forever — correct for correctness (writes invalidate), but it makes a bypassed write effectively permanent until someone manually clears.
Events are post-commit. With the built-in repositories, events dispatch after the settings transaction commits and are discarded on rollback. Two consequences: listeners see the new value after other transactions may already have read it, so don't build read-your-writes logic on top of a listener; and in tests, an enclosing test transaction that never commits will silently defer every event. Explicitly commit or roll back the settings connection when testing transaction-sensitive behavior.
Don't put boot-critical config or secrets here. Anything needed before the database is available (queue names, mail transport credentials, encryption keys, service credentials) belongs in config/ + environment variables. The database round-trip in a service provider's boot() is a real deployment hazard, and storing secrets in a table that admins edit via a UI is a compliance problem, not a convenience.
Blade directives are global-only. @setting, @hasSetting, and @hasFlag read global settings; there is no per-model directive. For user-scoped output, resolve in the controller or a view composer and pass a variable into the view.
Model settings require integer primary keys. The current implementation casts model IDs to integers, so UUID/ULID-keyed models are not supported. Verify this before designing "per-team preferences" around a UUID team model — it's a cheap check now and an expensive refactor later.
Maturity caveat for anything load-bearing. With a small user base and no dependents, plan for reading the source rather than the docs when behavior gets subtle (event ordering and cache keying are the two places to look). Pin the version, and wrap the package behind your own thin service (SiteSettings, UserPreferences) so that a bug or an abandonment doesn't leak into every Blade template and controller. That wrapper is also your extension point: it's where you put the inheritance chain, the string-to-bool coercion, and the cache-clear-after-seed logic — the three things this package deliberately leaves to you.
How can I help you explore Laravel packages today?