panjinamjaelf/kaguya
Kaguya is a lightweight Laravel package that provides a simple, configurable way to add its functionality to PHP applications. It follows Laravel conventions, keeps installation minimal, and offers a straightforward API for common development tasks.
Minimal setup
composer require panjinamjaelf/kaguya
php artisan vendor:publish --provider="Panjinamjaelf\Kaguya\KaguyaServiceProvider"
php artisan migrate
Steps to verify, in order:
composer.json of the package for a extra.laravel.providers block. If it is absent, register the service provider manually in bootstrap/providers.php (Laravel 11+) or config/app.php (Laravel 10 and below). This is the single most common reason the package "does nothing" on install.defaults / groups array. The defaults array is your source of truth — treat it as configuration-as-code and commit it.src/ before trusting the README. At 10 stars the docs are likely thin and the public API may have shifted. Look for the facade class, the repository/manager class, and the migration stub. Five minutes here saves an hour of guessing method names (get/set vs. getValue/put, forget vs. remove).key (string, unique), value (text or json), optional group, optional type. If value is text, you own serialization semantics — see Gotchas.First use case
The highest-value first use is runtime-tunable, non-secret application settings that ops or admins should change without a deploy:
features.checkout_v2)Do not start with credentials, API keys, or anything per-user. Settings packages store plaintext in a database table that ends up in dumps, backups, and .sql fixtures. Secrets belong in config/ + .env (or a vault), with the DB row storing only a reference.
A good first commit: define defaults in the published config, seed them, expose a read helper in a Blade layout, and confirm a change round-trips.
Config-as-defaults, DB-as-override
The pattern that scales is a single accessor that layers DB over config over a hard fallback:
function setting(string $key, mixed $default = null): mixed
{
return Cache::rememberForever("setting.{$key}", function () use ($key, $default) {
return Kaguya::get($key) ?? config("settings.{$key}") ?? $default;
});
}
Then always call setting('site.name') in views and services, never the package facade directly. This gives you one place to swap the backend, add logging, or add tenant scoping later. It also means the rest of your codebase doesn't depend on a 10-star dependency's API surface.
Admin write path: FormRequest + typed cast + cache bust
Put writes behind a controller with validation, then normalize before saving:
public function update(UpdateSettingsRequest $request)
{
foreach ($request->validated('settings') as $key => $value) {
Kaguya::set($key, $value);
Cache::forget("setting.{$key}");
}
return back()->with('status', 'Settings saved.');
}
Wrap the loop in DB::transaction() if the package writes one row per key — a mid-loop failure otherwise leaves a half-applied settings page, which is confusing to debug. If the package ships a setMany() or update(array), prefer it and let it own the transaction.
Grouped settings for larger apps
Once you pass ~30 keys, organize by domain prefix (mail.from_name, mail.from_address, billing.currency) rather than a separate group column. Prefixes survive a change of settings backend; a group column does not. Load a whole group once if the package supports it, or eager-load into a cached array:
$mail = Cache::rememberForever('settings.mail', fn () =>
collect(config('settings.mail'))->mapWithKeys(fn ($d, $k) => [$k => setting("mail.{$k}", $d)])
);
Blade integration
Share settings that every page needs from a base controller or a view composer, not from inside Blade templates. Calling the settings helper 40 times in a layout is a cache hit each time, but the coupling hurts when you add tenant overrides.
View::composer('layouts.app', fn ($view) => $view->with([
'siteName' => setting('site.name'),
'features' => setting('features', []),
]));
Feature toggles
For toggles, read through a bool-casting accessor so string/int ambiguity never leaks into @if:
function feature(string $name): bool
{
return filter_var(setting("features.{$name}", false), FILTER_VALIDATE_BOOL);
}
Testing
Add RefreshDatabase, then seed the settings your tests depend on in setUp() against the in-memory cache. Flush the package's cache key namespace between tests (Cache::flush() in a test environment is acceptable; in CI with a shared Redis it is not — use a tagged or prefixed store).
Caching is the whole ballgame. Nearly every "I changed the setting and nothing happened" bug is a stale cache. Verify: (a) the TTL from config, (b) whether the package invalidates on set at all, and (c) whether your Cache::rememberForever wrapper is bypassing the package's invalidation. If you wrap, you must forget your key on every write — the package has no idea your key exists.
Multi-server / queue workers. With CACHE_STORE=file, server B and every queue worker keep serving the old value indefinitely. For anything running more than one process, CACHE_STORE=redis (or database) is effectively mandatory. Also remember long-running workers (queue:work, Octane) hold in-process state — restart them after changing settings if the package memoizes per-request.
Serialization is on you. If the value column is TEXT, then false, 0, "false", and null are all distinct and easily conflated. Booleans come back as "0"/"1" or "false" depending on driver. Always cast at the read site (filter_var, (int), json_decode) and never compare with === true against a raw stored value.
Arrays and JSON. Storing arrays works only if the package json_encodes on write and decodes on read. Confirm with a round-trip test before you build a whole feature-flag matrix on it; a package that stores Array via PHP's string cast will silently destroy your data.
config:cache and env drift. If the package's config file reads env() at the top level (many do), then after php artisan config:cache any later env() change is invisible until you re-cache. Standard Laravel rule, but settings packages are where people forget it, because they expect runtime tunability.
Migration column length. A key column of VARCHAR(255) with a unique index can exceed MySQL's index limit under utf8mb4 when combined with other indexed columns. If the migration fails on MySQL 5.7, shorten the key or drop the compound index — check the published stub before editing by hand so re-publishing stays clean.
Extension points worth knowing. Look for a contract/interface the manager is bound to in the service provider. Rebinding it ($this->app->bind(SettingsInterface::class, MySettings::class)) is the clean way to add tenant scoping, encryption, or an audit log without forking. If no interface exists, wrap the facade — don't subclass the concrete class.
Audit trail. Settings changes are production incidents waiting to happen. Log user_id, key, old value, new value in the write path. The package almost certainly won't do this for you, and "who turned off checkout?" is a question you will be asked.
Don't let it become a config dumping ground. If a value never changes without a code review, it belongs in config/. Settings rows drift, get copy-pasted between environments, and silently diverge from the code that expects them. Keep the table small, keep defaults committed in the config file, and write a test asserting that every key the app reads has a default.
Exit ramp. At this maturity level, budget for the package being abandoned. Because every read goes through your own setting() helper, migrating to spatie/laravel-settings or plain config() + a cache is a single-file change. Preserve that property deliberately.
How can I help you explore Laravel packages today?