tomatophp/tomato-settings
A Laravel settings package for managing editable key-value configuration in the database, with a simple API for reading and updating app settings at runtime—ideal for dynamic options and admin panels.
Install and publish
composer require tomatophp/tomato-settings
php artisan vendor:publish --tag=tomato-settings-config
php artisan vendor:publish --tag=tomato-settings-migrations
php artisan migrate
Tag names vary by release — if --tag returns nothing, run php artisan vendor:publish and pick the TomatoSettings provider from the list. Also confirm the entry in config/app.php (or the bootstrap/providers.php array on Laravel 11) contains the package service provider; most Tomato packages auto-register via package discovery, but verify before debugging anything else.
Where to look first, in this order
composer.json — Laravel/PHP version constraints. Last release was May 2024, so check it against your framework major before writing any code.database/migrations/* — this tells you the real schema (key/value columns, group, type, indexes). Everything about caching and typing follows from this file.src/Models/ — the Eloquent model exposes scopes and cast behavior. Read it rather than guessing.src/Helpers/ (or src/helpers.php) — the global helper function signatures. Confirm the exact name (settings(), setting(), or a Settings facade) and the argument order.src/Providers/ — where the helper is registered, where cache is warmed or flushed, and any config() merge defaults.First use case — a two-key slice, end to end
Pick one operational value and one toggle: site.name and features.beta_signup.
// seeding / tinker — verify helper name against src/Helpers first
settings()->set('site.name', 'Acme Portal');
settings()->set('features.beta_signup', true);
// in a view or controller
$siteName = settings('site.name', config('app.name'));
$betaOn = (bool) settings('features.beta_signup', false);
Then prove the loop a non-engineer cares about: change features.beta_signup through the admin GUI, reload the app, confirm the UI changes with no deploy. That single loop is the whole value proposition — if that doesn't work in your stack, stop here.
Validate before committing
php artisan tinker
>>> settings('site.name'); // seeded value
>>> settings('does.not.exist', 'fallback'); // default path
>>> cache()->flush(); settings('site.name'); // re-read after flush
Check whether the second call hits the database or the cache (DB::listen or DB::getQueryLog()), and check whether writes invalidate the cache. Those two behaviors drive every pattern below.
Wrap it — do not scatter the helper
The package is a small, low-adoption dependency. Insulate application code behind one class so a fork, patch, or replacement is a one-file change:
final class AppSettings
{
public function __construct(private array $defaults = []) {}
public function get(string $key, mixed $default = null): mixed
{
return settings($key, $this->defaults[$key] ?? $default);
}
public function bool(string $key, bool $default = false): bool
{
return filter_var($this->get($key, $default), FILTER_VALIDATE_BOOL);
}
public function int(string $key, int $default = 0): int
{
return (int) $this->get($key, $default);
}
public function set(string $key, mixed $value): void
{
settings()->set($key, $value);
}
}
Bind it as a singleton with your default map, and inject or resolve it via a facade. The defaults map doubles as documentation of every runtime knob the app has.
Two-tier resolution: setting → config → hardcoded
The highest-value pattern is a deterministic fallback chain. The default should be the "known good" deploy-time value so the application still behaves correctly when the settings table is empty, unreachable, or mid-migration:
$pageSize = app(AppSettings::class)->int(
'ui.page_size',
(int) config('app.page_size', 25) // config remains the safety net
);
This is also your rollback plan: if the package breaks, delete the wrapper and the app runs on config defaults.
Cache deliberately on the read path
Settings are read on nearly every request, so treat reads as hot. Two viable shapes:
set() busts it. Trust but verify with DB::listen.Cache::rememberForever("settings:{$key}") and clear that key inside AppSettings::set(). This keeps you safe even if package caching changes.For multi-key screens (an admin dashboard, a checkout page), prefer one grouped read over N helper calls:
$all = Cache::rememberForever('settings:checkout', fn () => settings()->group('checkout')->all());
Seed through migrations, not tinker
Every setting a deploy depends on should exist after php artisan migrate on a fresh database. Use an idempotent upsert so re-running is safe:
DB::table('settings')->upsert([
['key' => 'site.name', 'value' => config('app.name'), 'group' => 'site'],
['key' => 'features.beta_signup', 'value' => '0', 'group' => 'features'],
], ['key'], ['value', 'group']);
Never overwrite a value an operator may have edited — upsert on new keys only, or insertOrIgnore.
Feature-toggle pattern with a kill switch
For beta gating, put the toggle behind the wrapper and gate at one boundary (middleware, policy, or Blade directive), not inline in twenty controllers:
class EnsureFeatureEnabled
{
public function handle($request, Closure $next, string $feature)
{
abort_unless(app(AppSettings::class)->bool("features.{$feature}"), 404);
return $next($request);
}
}
Routes then read ->middleware('feature:beta_signup'). Flipping the setting is instantaneous; removing the middleware later is the cleanup step.
Validate on write, not on read
Anything an operator can type must be validated before it lands in the table. Hook the admin form (Filament ->rules() / a FormRequest) and, if you expose a write API, validate in a dedicated action class. Reads should never need defensive parsing beyond the typed accessors in your wrapper — if a malformed value reaches a read path, that's a bug in the write path.
Test against a real table
The package's behavior is its persistence and caching. Stub nothing:
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush(); // critical: cached settings leak between tests
app(AppSettings::class)->set('features.beta_signup', true);
}
public function test_beta_route_is_reachable(): void
{
$this->get('/beta')->assertOk();
}
Add explicit tests for: default returned when key missing, true stored as "1" still evaluates truthy, and cache invalidated after set().
Environment promotion
If you run staging and production, settings drift. Keep a console command that dumps non-secret groups to JSON and one that imports with --only= filters, and treat the JSON as reviewable config in the repo. This is the closest thing to a migration path back to config files if you ever need to exit the package.
Cache invalidation across servers is the #1 failure mode
If the package caches and something clears the cache only on the node that served the write, other app servers serve stale settings until TTL expiry. Consequences range from "a toggle didn't apply" to "customer-visible wrong limit." Mitigations: use a shared cache store (Redis), verify the package's invalidation actually calls Cache::forget, or own the cache keys yourself as shown above. Test with two processes: write in one tinker session, read in a second without restarting.
Don't put secrets here — at all
Settings are stored in plain columns, often appear in the admin UI, and may be dumped during debugging or export. API keys, webhook signing secrets, and anything rotateable belong in a secrets manager or .env. If you must reference a credential, store an identifier or a vault path, not the value.
Type coercion bites in both directions
Values round-trip through the database as strings. settings('x') returning "0" is truthy in PHP, so if (settings('flag')) is a classic bug — always cast through your wrapper. Conversely, storing booleans may persist as 1/0 or true/false depending on the column type; pick one convention at seed time and enforce it on write.
Missing key vs. null value are different things
Confirm what the package returns for an absent key (likely null, possibly the default), and what it returns for a key stored as empty string. Build your wrapper so an absent key and an empty value are distinguishable if that distinction matters — otherwise you'll silently fall back to defaults when someone intentionally blanked a field.
Authorization is your job, not the package's
"Full GUI" means anyone who reaches the admin route can rewrite application behavior. Enforce server-side: a Gate/policy, can: middleware on the settings routes, and role checks. Do not rely on hiding the menu item. Also decide whether an operator may change features.* (usually yes) versus limits that affect billing (usually no, or only with approval).
Multi-node deployments and Octane
Under Laravel Octane, a long-lived worker may hold values in memory if the package caches in a static property. Verify by changing a setting and hitting the same worker twice. If values persist stale, flush between requests or avoid static caches.
config:cache and .env interaction
Settings read at boot (inside a service provider's register/boot, or a config file) are evaluated before the settings table is guaranteed reachable. Never let a settings lookup block application boot — wrap boot-time reads in a try/catch that falls back to config, or you'll get a hard 500 on a fresh database or during php artisan migrate itself.
Query cost on uncached reads is invisible until it isn't
An unindexed key column means every settings('x') is a table scan. Check the migration for an index on the key column; if missing, add one in your own migration. Then confirm reads are cached — a page rendering 30 helpers with no cache is 30 queries.
Filament/TomatoPHP coupling
The GUI is only free if you already run that stack. Adopting the package in a plain Laravel app means building your own CRUD over the table. Also pin the Filament major version alongside this package — Filament upgrades (v3 → v4 style jumps) are the realistic source of breakage, not the settings table itself.
Pin the version and plan an exit
Two stars, zero dependents, one release in 2024. Commit to an exact version in composer.json, review changelogs manually, and keep the wrapper class thin enough that replacing the backend with a 60-line Eloquent model is a day's work. That contingency is the real safety net, not the package's roadmap.
Debugging recipe
When a setting "isn't applying," check in this order: (1) settings('key') in tinker — does the raw value come back? (2) DB::table('settings')->where('key', ...)->first() — is it actually persisted? (3) cache()->get($key) / cache()->flush() then re-read — cache staleness? (4) second PHP process to rule out node-local state. That four-step ladder resolves nearly every report.
Auditability gap
Low-adoption packages generally don't ship change logs. If settings control limits, pricing, or access, wrap writes in an observer or action that records who/what/when/old/new. You'll need it for support ("why did this change?") far more often than you'd expect.
How can I help you explore Laravel packages today?