qcod/laravel-settings
Laravel package for storing key-value settings in the database. All saved settings are cached, reducing SQL queries to zero. Use the settings() helper or Settings facade with methods for get, set, has, remove, and grouped settings.
Install and confirm the table
composer require qcod/laravel-settings
php artisan vendor:publish --provider="QCod\Settings\SettingsServiceProvider" --tag="migrations"
php artisan migrate
Publish the migration rather than relying on the vendor copy — you own the schema, and you'll want to review the diff on upgrades. On Laravel 5.5+ the provider and the Settings facade are auto-discovered; only 5.4 and below need config/app.php entries. Note the facade alias is Settings (plural, since 1.0.2) — Setting::get() will not resolve.
Where to look first, in this order
vendor/qcod/laravel-settings/src/Settings.php — the whole read path lives here. Confirm three things before you trust it: which cache store backs the settings array, what the cache key is, and whether set()/remove() invalidate it or merely refresh a local property.value and the default for group determine everything you can safely store.vendor/qcod/laravel-settings/src/Models/Setting.php — whether value has a cast (array, json, boolean) decides whether you can store structured data or must encode it yourself.First use case
A single runtime-editable value read on every page: site name, support email, footer copy.
// config/app.php stays the source of truth for defaults
'site_name' => env('APP_NAME', 'Acme'),
// read with the config value as permanent fallback
$name = settings('site_name', config('app.site_name'));
Seed it once so behaviour is identical before and after adoption:
settings()->set([
'site_name' => config('app.site_name'),
'support_email' => '[email protected]',
'maintenance_note' => '',
]);
Ship with writes disabled until you have an authorized admin screen. The read path is the low-risk half; the write path is where incidents come from.
Wrap it — the highest-value hour of the project
Call the package from exactly one class so a future swap, fork, or replacement is a one-file change. The wrapper also gives you the typing and scoping the package deliberately omits.
namespace App\Support;
use QCod\Settings\Settings;
class AppSettings
{
public function __construct(private Settings $settings) {}
public function string(string $key, string $default = ''): string
{
return (string) $this->settings->get($key, $default);
}
public function bool(string $key, bool $default = false): bool
{
return filter_var($this->settings->get($key, $default), FILTER_VALIDATE_BOOL);
}
public function int(string $key, int $default = 0): int
{
return (int) $this->settings->get($key, $default);
}
public function json(string $key, array $default = []): array
{
$raw = $this->settings->get($key);
return $raw === null ? $default : (json_decode($raw, true) ?: $default);
}
public function forTenant(int $tenantId): Settings
{
return $this->settings->group("tenant.{$tenantId}");
}
}
Bind it as a singleton, and forbid direct settings() calls outside this class in code review.
Config-fallback dual read
Every migrated key should resolve as "DB if present, code default otherwise." This makes the migration flag-day-free and means a cache wipe or empty table degrades to known-good values instead of nulls.
$supportEmail = settings('support_email', config('mail.support.address'));
Keep secrets and deploy-time config (queue names, driver credentials, API keys) in .env/config. Settings is for values a human edits at runtime without a deploy.
Groups for tenant and section scoping
The group() method returns the same singleton with its group mutated, so chain immediately and never hold a grouped reference across a request boundary.
settings()->group('tenant.42')->set('theme', 'dark');
settings()->group('tenant.42')->get('theme');
Settings::group('tenant.42')->get('theme'); // facade equivalent
Skipping group() silently reads and writes the default group. Use a helper in your wrapper that requires an explicit scope for any tenant-aware key so the omission is a type error rather than a production bug.
Admin write path
One FormRequest per settings screen, a policy on the write route, and a bulk set() from validated input:
public function update(UpdateSettingsRequest $request)
{
settings()->group($request->validated('group'))->set(
collect($request->validated('settings'))->only([
'site_name', 'support_email', 'maintenance_note',
])->all()
);
return back()->with('status', 'Settings saved');
}
Bulk set([...]) only touches the keys you pass, so per-field forms are safer than an implicit full-set overwrite. Wrap in DB::transaction if you need all-or-nothing; the package's multi-key write is a sequence of per-key upserts, not a transaction.
Seeding and environment parity
Seed from config() so a fresh environment boots to the same values as production, and add a small artisan command that re-primes the cache after a restore or manual DB fix:
// php artisan settings:refresh
settings()->all($fresh = true);
Testing
Settings are cached, and RefreshDatabase does not clear the cache — settings leak between test cases. Force the array driver in phpunit.xml and flush in setUp:
<env name="CACHE_STORE" value="array"/>
protected function setUp(): void
{
parent::setUp();
Cache::flush();
settings()->all(true);
}
Assert on the observable value through your wrapper, not on the package, so a fork stays invisible to the suite.
The singleton is stateful — group() leaks
settings()->group('tenant.1') mutates the shared instance. In a queue worker, an Octane worker, or any long-lived process, the next unit of work inherits that group and reads another tenant's data. Always call group() inline on a fresh chain, and consider listing the Settings binding in config/octane.php under flush so the container rebuilds it per request. This is the single most dangerous behaviour in the package.
Cached reads go stale, and not just on this node
The "zero SQL on read" promise means writes must invalidate the cache — and whether that cache is per-process or the shared Laravel store determines multi-node behaviour. Verify in your first hour:
config('cache.default') vs an in-process property).set() invalidates the store or just repopulates a local array.Then write the runbook: your settings:refresh command, plus a manual cache forget of the package's key for incident response. Direct DB writes — tinker, SQL, a seeder using the model, a data migration — all bypass invalidation and leave every node serving stale values until cleared.
all() loads the entire table on every read
The cache holds the whole settings set, not per-key entries. That's excellent for tens of rows and wrong for thousands. Keep the table small; if you need per-entity configuration at scale, that belongs in its own table, not here. High write volume is also the wrong shape — writes invalidate and re-read the full set. Design centre is read-heavy, write-rare.
Everything comes back as a string
There are no column types and no casts beyond whatever the model declares. settings('maintenance_mode') returning the string "false" is truthy, and (bool) "0" is false while (bool) "false" is true. Never branch on a raw setting; always go through a typed accessor. Verify boolean/array/null round-trips explicitly before relying on them — if the model has no cast, JSON-encode structured values yourself and decode in the wrapper.
Never store secrets
No encryption at rest, no audit trail, no versioning, no approval flow. API keys, webhook secrets, and tokens go in a secrets manager or .env. Write this down as a rule, not a habit — a settings table is one admin-screen screenshot away from being a compliance finding.
Group typos fail silently
settings()->group('tenat.42') writes to a group nobody reads; a missing group() reads default. Both produce plausible-looking values with no error. Enforce scoping in the wrapper, and add a test that asserts a tenant's settings are invisible to another tenant.
get() on a null-valued key
Confirm whether a key present with a null value returns null or falls through to your $default. It changes behaviour on every optional field, and the answer depends on ?? versus array_key_exists in the source.
The write path is ungoverned
No audit columns, no optimistic locking, no validation. Two admins editing different fields of the same full-set form will clobber each other. If you need "who changed what, when," that's your work — add an observer or a setting_changes table in your wrapper, or extend the published migration with updated_by.
Debugging recipe
settings()->all(true); // bypass cache, see what's actually live
settings()->has('site_name'); // key presence
DB::table('settings')->get(); // ground truth vs cache
If the value differs between those three, it's a cache coherence problem, not a data problem — go check the driver and the invalidation path.
Upgrades and bus factor
One maintainer, ~229 stars, zero dependents, and a release history that is almost entirely "add support for the next Laravel major" through 13. Pin a caret constraint, never dev-master, run your suite against the next Laravel major in CI before adopting it, and treat a fork as a planned contingency. The wrapper is what makes that contingency cheap; the migration you published is what makes it reversible. MIT license means vendoring is a non-event.
Verify before you commit — a half-day spike: multi-node cache behaviour against your actual driver, boolean/array round-trip, all($fresh = true) as an invalidation lever, and whether the shipped tests are meaningful enough that maintaining a fork is safe.
How can I help you explore Laravel packages today?