laravel-lazy-settings maintained by timadey
Laravel Lazy Settings
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
}
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.