timadey / laravel-lazy-settings
Enum-driven, per-scope settings for Laravel with lazy persistence: definitions live in an enum, values are only stored when changed, reads fall back to declared defaults.
Requires
- php: ^8.2
- illuminate/cache: ^10.0|^11.0|^12.0
- illuminate/console: ^10.0|^11.0|^12.0
- illuminate/contracts: ^10.0|^11.0|^12.0
- illuminate/database: ^10.0|^11.0|^12.0
- illuminate/encryption: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- pestphp/pest: ^2.0|^3.0
README
Enum-driven settings for Laravel. Use it two ways: app-global admin settings (one shared copy) or per-entity settings (the same schema, isolated per user, vendor, or tenant). Define every setting once as a backed enum — type, default, allowed values, nullable, and encryption all live on one case. Rows are only written when a value actually changes; unset keys return the declared default.
| At a glance | |
|---|---|
| Lazy persistence | unset keys return the default and never hit the database. |
| Enum is the schema | type, default, allowed list, nullable, encryption in one place. |
| Global or per-entity | one shared copy, or isolated copies per user/vendor/tenant. |
| Strict, validated, typed | bad writes throw; bad declared defaults throw at first touch. |
| Encrypted at rest, per key | app Crypt (your APP_KEY), never leaked on a failed decrypt. |
Why not just spatie? spatie is excellent for app-global, typed settings. This package adds what spatie structurally can't: the enum as the schema (with display metadata for admin panels) and per-entity scoping (one enum, one entry, many isolated copies). Both still work for plain global settings — more at the end.
Contents
- Quick start
- Global app settings (admin panel)
- Per-entity settings (scoped)
- Declaring the schema
- Validation & strictness
- Encryption
- Cache & troubleshooting
- CLI
- API reference
- UI add-on, tests, license
Requirements
- PHP ^8.2
- Laravel ^10 | ^11 | ^12
Installation
composer require timadey/laravel-lazy-settings
Optionally publish the config:
php artisan vendor:publish --tag=lazy-settings-config
coercedisables strict throwing,store_path/enum_pathset wheremake:settings-storewrites stores and enums,cachetunes the cache.
Config & per-store overrides
Every config key can be overridden on an individual store — the store-level override always wins over the config value:
| Config key | Default | Override on a store |
|---|---|---|
coerce |
false |
protected static bool $coerce = true; |
store_path |
Models |
(codegen only — no store override) |
enum_path |
Enums |
(codegen only — no store override) |
cache.store |
null |
protected static ?string $cacheStore = 'redis'; |
cache.ttl |
864000 |
protected static int $cacheTtl = 60; |
class PlatformSettings extends SettingsStore { protected static string $table = 'platform_settings'; protected static string $enum = PlatformSettingsEnum::class; protected static bool $coerce = true; // strict -> coerce protected static ?string $cacheStore = 'redis'; // use redis, not the app default protected static int $cacheTtl = 60; // 60s instead of 10 days }
Published config
return [ 'coerce' => false, // best-effort coercion instead of throwing 'store_path' => 'Models', // where make:settings-store writes stores (relative to app/) 'enum_path' => 'Enums', // where make:settings-store writes enums (relative to app/) 'cache' => [ 'store' => null, // null = the app default cache store 'ttl' => 864000, // 10 days ], ];
Quick start
Build an app-global settings store for a settings page. Two commands, one enum, done.
php artisan make:settings-store PlatformSettings
This creates app/Models/PlatformSettings.php and app/Enums/PlatformSettingsEnum.php. Fill the enum cases:
namespace App\Enums; use Timadey\LazySettings\Attributes\Setting; use Timadey\LazySettings\Casts\SettingType; use Timadey\LazySettings\Concerns\HasSettingAttributes; use Timadey\LazySettings\Contracts\SettingKey; enum PlatformSettingsEnum: string implements SettingKey { use HasSettingAttributes; #[Setting(type: SettingType::String, default: 'My App')] case SITE_NAME = 'site_name'; #[Setting(type: SettingType::Bool, default: true)] case MAINTENANCE_MODE = 'maintenance_mode'; #[Setting(type: SettingType::Float, default: 1.50)] case REBATE_DEPLOY_FEE = 'rebate_deploy_fee'; #[Setting(type: SettingType::Int, default: 3)] case MAX_LOGIN_ATTEMPTS = 'max_login_attempts'; #[Setting(type: SettingType::Enum, allowed: ['zepalink', 'monnify'], default: 'zepalink')] case PAYMENT_PROVIDER = 'payment_provider'; #[Setting(type: SettingType::Json, default: ['buy_data', 'buy_airtime'])] case SPY_PAGE_RESTRICTIONS = 'spy_page_restrictions'; #[Setting(type: SettingType::String, default: '', encrypt: true)] case WEBHOOK_SECRET = 'webhook_secret'; }
Read and write — no scope argument anywhere:
use App\Enums\PlatformSettingsEnum; use App\Models\PlatformSettings; // Read PlatformSettings::get(PlatformSettingsEnum::MAINTENANCE_MODE); // true PlatformSettings::getByKey('site_name'); // 'My App' PlatformSettings::allSettings(); // every key: value or default // Write PlatformSettings::set(PlatformSettingsEnum::MAX_LOGIN_ATTEMPTS, 5); PlatformSettings::setByKey('maintenance_mode', false);
You're done. Unset keys return the declared default; set() inserts or updates lazily and busts the cache.
Global app settings (admin panel)
The quick start is the global case end-to-end. This section shows the details you'll touch as the panel grows.
The generated store
make:settings-store PlatformSettings writes (all global — no scope column):
namespace App\Models; use App\Enums\PlatformSettingsEnum; use Timadey\LazySettings\SettingsStore; class PlatformSettings extends SettingsStore { protected static string $table = 'platform_settings'; protected static string $enum = PlatformSettingsEnum::class; // protected static ?string $scopeColumn = null; // null = globally scoped }
And a migration file — run php artisan migrate:
Schema::create('platform_settings', function (Blueprint $table) { $table->id(); $table->string('key'); $table->text('value')->nullable(); $table->timestamps(); $table->unique(['key']); });
What the generator writes
The one command produces three files, fully wired so it works out of the box:
- Store —
app/Models/<Name>.php, extendsTimadey\LazySettings\SettingsStore, sets$tableand$enum(importing the enum, since it lives in theEnumsnamespace).$scopeColumnis set when you pass--scope=, otherwise written as a commented line (// protected static ?string $scopeColumn = null;) so the global intent is visible. The optional overrides ($coerce,$cacheStore,$cacheTtl) aren't written — add them yourself per the table above. - Enum —
app/Enums/<Name>Enum.php, importsSetting,SettingType,HasSettingAttributes, andSettingKey; includesuse HasSettingAttributes;and one starter case you replace with your own schema. Bothstore_pathandenum_pathconfig keys control where each file goes (defaultsModels/Enums). - Migration — the full table above;
unique(['key'])for global,unique([scope, 'key'])for scoped.
Render and save a settings form
allSettings() gives you every key with stored value or default — the whole page in one cached query:
class PlatformSettingsController extends Controller { public function edit() { return view('admin.settings', ['settings' => PlatformSettings::allSettings()]); } public function update(Request $request) { PlatformSettings::setByKey('site_name', $request->string('site_name')); PlatformSettings::set(PlatformSettingsEnum::MAINTENANCE_MODE, $request->boolean('maintenance_mode')); return back()->with('status', 'Saved.'); } }
setByKey()lets the form post plain strings — cast to the key's declared type at the model boundary, not by hand.- Writes validate:
PlatformSettings::set(PlatformSettingsEnum::MAX_LOGIN_ATTEMPTS, 'many')throwsInvalidArgumentException. forget()deletes a row; it returnstrueif a row existed.- Cache key is
lazy-settings.platform_settings— one flat map for the whole app, busted on every write.
Per-entity settings (scoped)
Need the same schema per user or vendor? Add a scope column.
php artisan make:settings-store UserPreferences --scope=user_id
The store now reads and writes through user_id:
use App\Enums\UserPreferencesEnum; use Timadey\LazySettings\SettingsStore; class UserPreferences extends SettingsStore { protected static string $table = 'user_preferences_settings'; protected static string $enum = UserPreferencesEnum::class; protected static ?string $scopeColumn = 'user_id'; }
The migration gains the scope column and merges it into the unique key:
$table->unsignedBigInteger('user_id')->nullable()->index(); $table->unique(['user_id', 'key']);
Read and write now take the scope value ($userId) as the trailing argument:
use App\Enums\UserPreferencesEnum; use App\Models\UserPreferences; UserPreferences::set(UserPreferencesEnum::THEME, 'dark', 1); UserPreferences::get(UserPreferencesEnum::THEME, 1); // 'dark' UserPreferences::get(UserPreferencesEnum::THEME, 2); // default — not 'dark'
Each scope value is fully isolated. A scoped read never falls back to another scope's data — only to the enum default. Each scope caches its own map:
lazy-settings.user_preferences_settings.user_id:1
lazy-settings.user_preferences_settings.user_id:2
Writes bust only that scope's cache, so user B's cached map is untouched when user A saves. Use whatever entity you scope by — user_id, vendor_id, tenant_id.
Declaring the schema
Attribute style (recommended)
#[Setting(...)] per case + HasSettingAttributes. Override any contract method and fall back to the attribute with a trait alias:
use HasSettingAttributes { default as staticDefault; } #[Setting(type: SettingType::String)] case MONTHLY_QUOTA = 'monthly_quota'; public function default(): mixed { return match ($this) { self::MONTHLY_QUOTA => now()->month, default => $this->staticDefault(), }; }
Method style
Write the contract by hand. type() must return a real SettingType case — the engine matches exhaustively, so a typo is impossible rather than a silent fallback.
enum UserPreferencesEnum: string implements SettingKey { case THEME = 'theme'; public function type(): SettingType { return SettingType::String; } public function default(): mixed { return 'light'; } public function allowed(): array { return []; } public function nullable(): bool { return false; } public function encrypt(): bool { return false; } }
Contract reference
| Method | Returns | Meaning |
|---|---|---|
type() |
SettingType |
Storage type; drives casting, validation and the sync prompt. |
default() |
mixed |
Value returned before the key is ever written. Never persisted. |
allowed() |
array |
Whitelist for Enum (and Json) keys; empty = nothing enforced. |
nullable() |
bool |
Whether null is a legal write. Default false. |
encrypt() |
bool |
Whether the stored value is encrypted at rest. Default false. |
// storage <-> PHP enum SettingType: string { case Int; case Float; case Bool; case String; case Json; case Enum; }
Validation & strictness
Declared defaults are validated at boot
On first touch, every declared default is pushed through the cast pipeline. A wrong-typed default throws immediately, naming the case:
Invalid declaration for App\Models\PlatformSettingsEnum::MAX_LOGIN_ATTEMPTS: Invalid value [lots] for App\Models\PlatformSettingsEnum::MAX_LOGIN_ATTEMPTS; expected int.
SchemaException at first touch beats a silent wrong value in production.
Writes are strict by default
A mismatched write throws InvalidArgumentException:
Invalid value [many] for App\Models\PlatformSettingsEnum::MAX_LOGIN_ATTEMPTS; expected int.
Opt out per store with protected static bool $coerce = true;, or globally with config('lazy-settings.coerce') — both cast best-effort instead of throwing.
Encryption
Per-case encrypt: true stores ciphertext using your app's Crypt (your APP_KEY):
#[Setting(type: SettingType::String, default: '', encrypt: true)] case WEBHOOK_SECRET = 'webhook_secret';
- The persisted row and cache hold ciphertext; plaintext only exists in memory between decrypt-on-read and use.
- Defaults are never encrypted — not persisted, readable in the enum.
- Fail hard: a value that isn't valid ciphertext throws
DecryptExceptionon read — no raw bytes leaked. - Migration caveat: only add
encrypt: trueto a key whose existing rows are already ciphertext. Existing plaintext rows throw on first read — callset()on those keys once first. allRaw()returns stored bytes as-is (ciphertext for encrypted keys). Key rotation is Laravel's built-inold_keyarray config.
Cache & troubleshooting
- TTL:
config('lazy-settings.cache.ttl')(default 10 days), orprotected static int $cacheTtlper store — see Config & per-store overrides. - Store:
config('lazy-settings.cache.store'), orprotected static ?string $cacheStore. - Busting: any write, or
flushCache()for a scope;php artisan cache:clearclears everything. - Stale cache after raw SQL: if you touch the settings table directly, flush that scope's cache before reading.
DecryptExceptionafter enabling encryption: rotate existing rows throughset()once; if a row is genuinely corrupt, delete and re-set().- Strict vs coerce:
'5'stored for an Int setting reads back as5in both modes — the difference is only whether a mismatched write throws up front.
CLI
# Interactive bootstrap of missing settings (per store, optional scope): php artisan settings:sync --store=App\Models\PlatformSettings # Include already-set settings and re-prompt: php artisan settings:sync --store=App\Models\UserPreferences --scope=7 --all # Generate a table migration: php artisan settings:table platform_settings php artisan settings:table user_preferences_settings --scope=user_id # Generate store + enum + migration in one command: php artisan make:settings-store PlatformSettings php artisan make:settings-store UserPreferences --scope=user_id
settings:sync prompts only for missing keys unless --all is passed, uses a setting's label() when the enum implements Timadey\LazySettings\Contracts\SettingLabels (else the case name), and refuses a non-interactive terminal (--no-interaction).
API reference
All methods are static. ...$scope is one scope value for a scoped store (e.g. $userId), or nothing for a global store.
| Method | Returns | Notes |
|---|---|---|
get(SettingKey $setting, ...$scope) |
mixed |
Typed value, or the cast default if unset. |
getByKey(string $key, ...$scope) |
mixed |
Same, keyed by storage string; no enum case required. |
getSettingsByKeys(array<int, SettingKey|string>, ...$scope) |
array<string, mixed> |
One cached read; keyed by storage string. |
allSettings(...$scope) |
array<string, mixed> |
Every declared key, stored value or default, cast. |
allRaw(...$scope) |
array<string, string|null> |
Raw stored bytes — no defaults, no casting (ciphertext if encrypted). |
set(SettingKey $key, mixed $value, ...$scope) |
bool |
updateOrInsert; validates, casts, encrypts, flushes cache. |
setByKey(string $key, mixed $value, ...$scope) |
bool |
Like set, keyed by string. |
forget(SettingKey|string $key, ...$scope) |
bool |
Deletes the row + busts cache (true if a row existed). |
flushCache(...$scope) |
void |
Forget this scope's cached map. |
isStrict() |
bool |
true when enforcement is on (neither store nor config coerces). |
enumCases() |
SettingKey[] |
Every enum case implementing the contract. |
caseForKey(string $key) |
SettingKey|null |
Reverse lookup by storage string. |
UI add-on, tests, license
-
UI add-on (planned, not yet published): a companion package for admin panels (display names, groups, dropdown options,
structure()) is on the roadmap. Core stays fully functional without it. The optionalTimadey\LazySettings\Contracts\SettingLabelsmarker already exists so a future add-on is detected automatically via interface checks. -
Why not just spatie (the details)? spatie gives you real PHP property typing, property-level locking, encryption, and versioned data migrations — real wins for app-global settings. This package takes a different trade: the enum is the schema (no per-setting migration, one source of truth that can also render an admin panel), and the same schema serves N isolated entities with zero extra tables. You give up native property typing and spatie's migration workflows in exchange for zero-schema-friction and per-entity scoping.
-
Tests:
composer install && ./vendor/bin/pest. -
License: MIT — see LICENSE.md.