solomon-ochepa/laravel-settings
Laravel package for storing settings as cached key-value pairs in the database. Access them via the settings() helper or Settings facade, with automatic cache refreshing on write, trash, restore, or delete. Supports user-scoped settings.
1. Install and migrate (5 minutes)
composer require solomon-ochepa/laravel-settings
php artisan vendor:publish --provider="SolomonOchepa\Settings\SettingsServiceProvider" --tag="migrations"
php artisan migrate
On Laravel 5.5+ the provider is auto-discovered and the Settings facade is aliased for you. On 5.4 and below you must register SolomonOchepa\Settings\SettingsServiceProvider::class in config/app.php providers and "Settings" => SolomonOchepa\Settings\Facades\Settings::class in aliases.
2. Look first at these three things
settings table (key/value plus polymorphic settable columns and soft deletes). Read it before writing anything; it tells you what's indexable, whether value is nullable, and what a group column looks like. If the package adds grouping or settable columns in a later release, re-publishing/updating the migration is a manual step you own.settings() and Settings::* are the same object, so pick one convention for the codebase and enforce it. Mixing helper-style and facade-style calls in the same codebase makes grep-based audits painful later.config/cache.php. This package's entire value proposition ("zero SQL reads") depends on a shared, persistent cache store. Verify you're on Redis/Memcached/database, not array (default in tests) or a per-server file store behind a load balancer.3. First real use case
Before touching anything, prove the read path with a throwaway tinker session:
settings()->set('support_email', '[email protected]');
settings('support_email'); // '[email protected]'
settings('support_email', 'fallback'); // '[email protected]'
settings('does_not_exist', 'fallback'); // 'fallback'
settings()->all(); // collection of all settings
Then wire the smallest genuinely useful thing: an admin-configurable value that today is hardcoded — support email, onboarding banner copy, a kill-switch flag. That's the one-afternoon win this package is built for. Do not start by migrating your entire config/ tree.
A sane day-one scaffold is a thin typed wrapper in app/Support/Settings.php so the rest of the app never calls the package directly:
namespace App\Support;
use SolomonOchepa\Settings\Facades\Settings;
class AppSettings
{
public static function supportEmail(): string
{
return (string) Settings::get('support_email', config('mail.from.address'));
}
public static function onboardingEnabled(): bool
{
return filter_var(Settings::get('onboarding_enabled', true), FILTER_VALIDATE_BOOL);
}
}
That single indirection layer is your escape hatch if you later swap the package for spatie/laravel-settings or roll your own.
Treat the settings table as an override layer on top of config(). Never let a missing row break the app:
$limit = settings('promo.max_redemptions', config('promo.max_redemptions'));
This gives you zero-downtime behaviour: ship the default in config, let ops override in the DB. It also means a fresh database works identically to production before anyone has opened the admin panel.
group() is a light namespace, so use coarse domains — billing, mail, features, branding — not admin_general_tab. Grouped reads across multiple groups return a nested structure keyed by group name, which is the right shape for an admin screen with tabs:
settings()->group(['billing', 'mail', 'branding'])->all();
// => ['billing' => [...], 'mail' => [...], 'branding' => [...]]
Write grouped values one group at a time: settings()->group('billing')->set('currency', 'NGN').
set() accepts an array, which makes a settings controller trivial:
public function update(SettingsRequest $request)
{
settings()->group('billing')->set($request->validated());
return back()->with('status', 'Billing settings saved.');
}
$request->validated() is the important half — the package does no validation and no type coercion. Your FormRequest is the only thing standing between a user and a boolean stored as the string "false" (which is truthy when naively cast).
user() / my()settings()->user()->set('table_density', 'compact'); // scoped to auth()->user()
settings()->my('table_density', 'comfortable'); // read scoped to auth()->user()
Both degrade gracefully: user() on a guest request falls back to global, unscoped settings rather than throwing. That's convenient for rendering, but see the gotcha below about writing from guest context.
for()settings()->for($team)->set('brand_color', '#0f172a');
settings()->for($tenant)->group('billing')->get('invoice_prefix', 'INV-');
This is genuinely the most underrated part of the package — it makes multi-tenant branding and per-team limits a data problem instead of a schema problem. Two things to enforce in your codebase:
null. settings()->for(null) and settings()->for() deliberately clear the scope back to global.null, name it so callers know they're writing global config.The trash() → restore() / delete() triplet maps cleanly onto an admin UI with an archive tab: disable a setting without losing its value, restore it when the feature returns. Cache invalidation fires on all four operations (write, trash, restore, delete), so you don't need to think about it.
settings()->trash('legacy_flag'); // hidden, recoverable
settings()->restore('legacy_flag'); // back
settings()->delete('legacy_flag'); // gone for good — only meaningful on a trashed row
Two valid approaches:
// A) Seed explicit rows so the admin UI has something to list
settings()->remember('support_email', '[email protected]');
// B) Seed nothing; rely on config fallbacks at read time
Prefer (B) for most keys. The catch with remember() is that its falsy-handling semantics have changed more than once across releases (see gotchas) — depending on it for false/0/'' defaults is a version-bump hazard. Use set() when you genuinely need a row to exist.
if (settings()->group('features')->get('new_checkout', false)) {
// ...
}
This is a fine coarse kill-switch. It is not an experiment platform — no percentage rollout, no targeting, no exposure logging. If product asks for "5% of users," that's the signal to stop stretching this package.
Since reads are cache-backed, tests must not leak state between cases:
protected function setUp(): void
{
parent::setUp();
Cache::flush(); // or point the test env at the array/redis-test store
}
Combine with RefreshDatabase for the table. Write one integration test per settings-dependent feature that asserts the default path (no rows present), since that's what a fresh production deploy hits.
remember() contract has flipped between versionsThe README currently states that an existing falsy value (null, false, 0, '') is treated as present and returned as-is, while the v12.7.22 release note states remember() updates the setting if the value is falsy. Those are opposite behaviours. Whatever your pinned version does, write a test that pins it before you rely on remember() for boolean or numeric settings. If you can't be bothered, use has() + set() explicitly and skip the ambiguity.
settings() is overloaded, and arrays are the trapsettings('key') → readsettings('key', 'default') → read with defaultsettings(['key' => 'value']) → writeThe last one means settings($someVariable) silently writes if $someVariable happens to be an array. Always be explicit: settings()->get(...) / settings()->set(...) in application code, and treat the array form as an admin-controller convenience only.
"Cache is refreshed automatically whenever a setting is written" is true for the node that did the write and true for all nodes if they share a cache store. Concretely:
file store on multiple app servers → stale reads on other nodes. Each server has its own cache files.array store (test default) + queue workers + Octane → long-lived processes can hold values for the life of the worker. If a worker boots before a flag flip, don't assume it sees the flip. Prefer Redis, and if you're on Octane or Horizon, add a cache flush to your deploy step.Also audit your deploy pipeline: php artisan cache:clear after applying settings-table migrations, especially when upgrading the package (the v12.x line changed the migration to allow nullable values).
The package stores key-value pairs; it does not do typed values, casts, or schema. get('enabled') can hand you true, 1, "1", or "true" depending on how it was written. filter_var($v, FILTER_VALIDATE_BOOL) or an explicit wrapper method is mandatory. This is the single most common source of "it worked in tinker" bugs.
Related: check how you expect get('key', 'default') to behave when a row exists with a null value (possible since the nullable-migration release). Confirm empirically — an existing null may or may not fall through to your default, and that determines whether "clear this field" means "back to default" or "explicitly blank."
user() failing open to global on guestsGuest requests silently read and write global settings. A preferences controller that does settings()->user()->set($key, $value) behind a route with no auth middleware will overwrite application-wide configuration with an anonymous visitor's preferences. Guard with auth()->check() or middleware, and never rely on user() alone as an access control.
for() and group() mutate the instance's current scope. If you assign a scoped instance to a variable and pass it around, later calls inherit the scope:
$s = settings()->for($team);
$s->set('a', 1);
$s->for(null)->set('b', 2); // must clear explicitly
Prefer fresh settings()->... chains per logical operation; don't thread a settings instance through a service method's parameters.
delete() is not a general-purpose "remove"The lifecycle is set → trash → (restore | delete). Calling delete() on a live (untrashed) setting may not do what you expect. If "remove this setting" is a user-facing action, implement it as trash() and provide a separate permanent-purge action — which also gives you an undo and an audit trail for free.
Be explicit about this with whoever owns the admin panel:
billing.currency on Tuesday," add a model observer or log the diff in your controller. There's no built-in history.Crypt::encryptString() yourself and accept that you can no longer read them in the DB console.Every key × every settable entity is a row. Per-user preferences in a 100k-user product means 100k+ rows per preference key. Confirm the published migration indexes the settable_type/settable_id (and key) columns; if not, add an index in your own migration before the table gets big. Reads are cached so you won't feel it at request time, but writes and any direct DB reporting will.
Two stars, zero dependents, a CHANGELOG that's mostly release-note boilerplate, and a method contract that has changed semantics between minor versions. That's not a reason to avoid it; it is a reason to:
composer.json and treat upgrades as reviewed changes, not composer update noise.App\Support\AppSettings class from Getting Started is the whole mitigation — if the package stalls, you reimplement ~200 lines behind the same interface in an afternoon.For an internal admin toggle, a support-email override, a maintenance banner, or per-tenant branding — adopt it and move on. It's a day saved.
For a revenue-critical surface (pricing, plan entitlements, checkout switches) where you'll need validation, typed values, an audit trail, and permissioned editing anyway, you will end up writing all four of those yourself — at which point you've written 80% of the package. Consider copying the pattern into your app rather than depending on it, or going straight to spatie/laravel-settings if you want those features maintained by someone else.
How can I help you explore Laravel packages today?