torann/registry
Torann/Registry is a simple Laravel package for storing persistent key-value settings in the database with cache support. Use a clean registry API to get, set, and manage site-wide configuration values without extra boilerplate.
The package is a thin runtime key/value store with four methods that matter — get, set, has, forget — layered over a pluggable driver (database, json, memory, cache) with an in-memory read-through cache. That's the whole mental model. Everything else is discipline you bring.
Install and stand it up:
composer require torann/registry
php artisan vendor:publish --provider="Torann\Registry\RegistryServiceProvider"
php artisan migrate
Auto-discovery handles the facade (Torann\Registry\Facades\Registry) and the registry() helper on 5.5+. Publishing drops config/registry.php and the migration for the registry table.
Read these three things before writing a call site, in this order:
config/registry.php — the entire configuration surface. Driver, table name, cache key, cache toggle. If you don't know which cache key it uses, you cannot debug a stale read later.vendor/torann/registry/src/RegistryServiceProvider.php — confirm which facade alias and helper are bound in your installed version. Small packages drift between releases, and the docs lag.Start with something read-mostly and low-stakes. A support banner is the ideal first key: it's read on every page, written once a month, and nothing breaks if it's thirty seconds stale.
use Torann\Registry\Facades\Registry;
$banner = Registry::get('site.banner', 'Welcome!'); // Blade, controller, middleware, job
Registry::set('site.banner', $request->input('banner'));
Registry::has('site.banner'); // row exists?
Registry::forget('site.banner');
Dot notation is cosmetic — site.banner is a single key string with a dot in it, not a nested structure. That distinction matters more than it sounds (see Gotchas).
Then immediately write a wrapper and never import the facade again. This is the highest-leverage hour in the whole adoption. Bind it to an interface, inject the interface, and keep the facade private to one class:
namespace App\Support;
use Torann\Registry\Facades\Registry;
class Settings
{
public function get(string $key, mixed $default = null): mixed
{
return Registry::get($key, $default);
}
public function bool(string $key, bool $default = false): bool
{
return filter_var($this->get($key, $default), FILTER_VALIDATE_BOOLEAN);
}
public function int(string $key, int $default = 0): int
{
return (int) $this->get($key, $default);
}
public function array(string $key, array $default = []): array
{
$value = $this->get($key, $default);
return is_array($value) ? $value : $default;
}
public function set(string $key, mixed $value): void
{
Registry::set($key, $value);
}
public function forget(string $key): void
{
Registry::forget($key);
}
}
Why now and not later: the wrapper is where you put type coercion, audit logging, tenant prefixing, and the eventual swap to spatie/laravel-settings or Pennant. If thirty call sites import the facade first, that migration is a grep-and-pray exercise.
Seed every known key on install. An empty table is nondeterministic — every get() silently falls through to whatever default that particular call site remembered to pass. Ship a seeder or artisan settings:seed and guard each write with has() so it's re-runnable and never clobbers operator edits:
$defaults = [
'site.banner' => 'Welcome!',
'site.maintenance' => false,
'limits.uploads.mb' => 25,
'onboarding.steps' => ['profile', 'team', 'billing'],
];
foreach ($defaults as $key => $value) {
if (! Registry::has($key)) {
Registry::set($key, $value);
}
}
This makes fresh installs, CI, and production behave identically, which is the precondition for every other pattern below.
Keep a single key registry as the source of truth. A config/settings.php listing legal keys, types, and defaults gives you form-request validation, an autocomplete list for the admin UI, and a way to detect orphans:
return [
'site.banner' => ['type' => 'string', 'default' => 'Welcome!'],
'site.maintenance' => ['type' => 'boolean', 'default' => false],
'limits.uploads.mb' => ['type' => 'integer', 'default' => 25],
];
Validate writes against this in the form request before they reach Settings::set(). The package does no schema validation, so this file is your only guard against someone storing "25" where an int belongs.
Always read through the typed accessor. Registry::get() returns whatever the driver deserialized, and boolean round-trips vary by driver and column type — 1, "0", true. This bug is ugly because it's silent: if (Settings::get('site.maintenance')) is truthy for the string "false". Wrap every read in bool()/int()/array() and never let a raw get() reach a conditional.
Share to Blade via a view composer, not the facade in templates. One composer on the layout beats a facade call in every partial:
View::composer('layouts.app', fn ($view) => $view->with([
'banner' => $this->settings->get('site.banner'),
'maintenance' => $this->settings->bool('site.maintenance'),
]));
Feature flags — with an expiry policy. For pre-PMF rollouts a registry key is a perfectly adequate flag: if ($this->settings->bool('features.new_checkout', false)). Write down, in the standard, that this is a staging area: a flag that survives more than a release or two graduates to Laravel Pennant or gets deleted. Without that sentence you have a flag swamp by end of quarter.
Per-tenant scoping is your job, not the package's. There's no tenant concept. Namespace keys (tenant.{id}.branding.name) and centralize the prefix inside the wrapper so no call site concatenates it by hand:
public function tenant(string $key, mixed $default = null): mixed
{
return $this->get('tenant.'.tenant()->id.'.'.$key, $default);
}
Do it before tenants exist in the wild — retrofitting prefixes onto already-stored keys is a data migration, not a refactor.
Test against the memory driver. In phpunit.xml, set the driver to memory and the registry becomes an in-process array — no database, no teardown ordering surprises:
<env name="REGISTRY_DRIVER" value="memory"/>
Assert against your Settings interface, not the facade, then the tests survive a backend swap.
Audit writes in the wrapper from day one. The package gives you no trail. A registry_audit table or a log line with actor, key, and old/new value costs an hour and is the difference between "who disabled uploads at 3am" being a five-minute query versus an incident review.
Never touch the registry during boot. Not in service providers, config loaders, route files, or middleware constructors. A database blip then becomes a boot failure that takes the whole app down, instead of a feature degrading. Resolve lazily in controllers or use a view composer. Treat this as a hard rule.
Multi-node staleness is the real production failure. The package caches on read; if any node caches on write, siblings keep serving the old value after a change. Options in order of preference: a shared store (Redis) all nodes read; a short TTL plus an accepted stale window; or write access restricted to a single admin process. Validate with two app containers in staging — a single-node dev environment will never reproduce this.
memory is not persistent and json is not multi-node. memory resets each request (correct for tests, useless for settings). json writes to the local filesystem, so it silently diverges across containers and breaks on read-only filesystems. Neither belongs in production; default to database plus a cache store.
config:clear does not clear the registry cache. config:cache is fine — the config file is read normally. But when you change a value and don't see it, the culprit is the registry's own cache key (find the name in config/registry.php), or the write path didn't invalidate. Registry::forget() then re-set() is the quickest sanity check.
Type drift is silent and cumulative. No schema, no serialization contract. One developer writes set('limits.uploads.mb', '25'); a strict comparison elsewhere silently misbehaves forever. Enforce types in the wrapper, validate in the form request, and if you ever change the storage column, round-trip every type explicitly: bool, int, float, string, array, null.
has() and get($key, $default) answer different questions. has() is "does a row exist". get() returns your default when the row is absent — and also when the stored value is null, which surprises people. If null is a legitimate stored state for you, either check has() first or store a sentinel.
Dots are structural only in appearance. site.banner and site.banner.text can both exist and will confuse any tooling you build on top of the store. Pick a flat domain.subject.setting convention and enforce it in the key registry — you'll catch collisions at validation time instead of at 2am.
Orphan keys accumulate into archaeology. Deleted features leave rows behind forever. Sweep quarterly: diff SELECT key FROM registry against your config/settings.php allow-list and delete what's unreferenced.
Secrets do not go here. Values are plaintext, land in every mysqldump, and are visible in whatever admin UI you build. API keys belong in .env or a secrets manager. Put that sentence in the standard so nobody "helpfully" migrates one.
Read the migration before your first production migrate. Check key column length and uniqueness against your naming convention — a 64-char varchar with a unique index is a wall you hit at scale once keys grow a tenant prefix. Adjust before the table exists in production.
Verify your Laravel/PHP matrix yourself. Ecosystem signals here are thin — no stars, no listed dependents, an unrated score. Treat compatibility as an unproven assumption: pin the version in composer.json, then on your actual Laravel/PHP version run vendor:publish, migrate, one set, one get, and a cache-clear. Watch the logs for PHP 8.2+ deprecation noise during that spike, and budget for the realistic outcome that you fork it — the whole surface is a provider, a facade, a driver, and a migration.
Debug by layer, not by logging. Registry::all() (or DB::table('registry')->get()) shows what's actually stored. Storage correct but reads wrong → cache. Storage wrong → write path or serialization. php artisan tinker against the facade is faster than adding log lines and reverting them.
How can I help you explore Laravel packages today?