Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Settings Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  • The published migration — it defines the 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.
  • The facade/helper surface — 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.
  • Your cache store — 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.


Implementation Patterns

Pattern 1: Config fallback defaults (the highest-value pattern)

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.

Pattern 2: Group by product domain, not by page

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').

Pattern 3: Bulk writes for admin forms

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).

Pattern 4: Per-user preferences via 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.

Pattern 5: Per-tenant / per-entity scoping with 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:

  • Always pass a concrete model or null. settings()->for(null) and settings()->for() deliberately clear the scope back to global.
  • Never mix scoped and unscoped writes in the same method. If a method might receive null, name it so callers know they're writing global config.

Pattern 6: Soft-delete lifecycle as an admin "archive" feature

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

Pattern 7: Seeding defaults

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.

Pattern 8: Feature flags, honestly scoped

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.

Pattern 9: Testing and local dev

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.


Gotchas and Tips

The remember() contract has flipped between versions

The 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 trap

  • settings('key') → read
  • settings('key', 'default') → read with default
  • settings(['key' => 'value']) → write

The 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 invalidation is only as good as your cache store

"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:

  • Redis/Memcached/database store → fine. Invalidation is global.
  • 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).

Everything is (probably) a string — cast at the boundary

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 guests

Guest 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.

Fluent scope state is sticky within a chain

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.

No validation, no permissions, no audit trail, no encryption

Be explicit about this with whoever owns the admin panel:

  • Permissions are entirely yours — the package has no concept of who may write. Gate your settings controller.
  • Audit is yours — if compliance or a post-incident review needs "who changed billing.currency on Tuesday," add a model observer or log the diff in your controller. There's no built-in history.
  • Secrets — do not store API keys, payment credentials, or tokens here. Values are plain rows; there's no encryption at rest in the package. Keep those in env/vault, or wrap the value in Crypt::encryptString() yourself and accept that you can no longer read them in the DB console.

Table growth and indexing

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.

Low-maturity risk — mitigate structurally

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:

  1. Pin the version exactly in composer.json and treat upgrades as reviewed changes, not composer update noise.
  2. Keep the wrapper. The 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.
  3. Read the migration after every upgrade. The package explicitly warns that prior upgrades required re-running migrations; auto-publishing won't update a migration you've already run.

The honest build-vs-borrow call

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect
youbar/easy-crud
c-point-labs/laravel-hostyourai
dbravoan/dbcms-admin-theme-dbcms
dbravoan/dbcms-theme-db-cyber
dbravoan/dbcms-blocks-basic
dbravoan/dbcms-module-content-audit
dbravoan/dbcms-module-ai-assistant
paygate-to/paygate-php
padosoft/askmydocs-connector-base
defstudio/supercharged-carbon
bernskioldmedia/laravel-matomo