armincms/option
Armincms Option is a Laravel package for managing application options and settings through a flexible key-value store, with simple helpers for storing, retrieving, and organizing configurable values in PHP and Blade views.
The package is a thin key-value store persisted in the database, typically exposed through a facade and a global option() helper. The first thing to do after composer require armincms/option is publish the migration and run it — without the backing table, every lookup silently returns null and you'll waste an hour debugging.
composer require armincms/option
php artisan vendor:publish --provider="Armincms\\Option\\OptionServiceProvider"
php artisan migrate
Read the following in this order:
config/option.php (if published) — this is where the table name, cache key, and TTL usually live.Option facade / helper signature — confirm whether get takes a default as the second argument or requires a separate call.value as text, which bites you with large serialized payloads).The canonical first use case is site settings: your app needs editable values (site name, support email, analytics ID, feature toggles) that change without a deploy.
use Armincms\Option\Facades\Option;
Option::set('site.name', 'Acme');
Option::set('site.support_email', '[email protected]');
// later, anywhere
echo Option::get('site.name', 'Default Site');
Once that round-trip works, you've validated the migration, the service provider binding, and the caching layer in one shot.
Namespaced keys via dot notation. Treat the key namespace as a convention, not a feature — site.*, mail.*, features.*. This makes Option::all() dumps readable and makes it trivial to later filter by prefix when you build an admin settings screen.
Defaults belong at the call site, not the database. Don't seed every option up front; that creates drift between environments. Call Option::get('mail.from', config('mail.from.address')) so the config file remains the source of truth until an editor overrides it.
Wrap the facade in a typed settings class. Direct option() calls scattered through views and controllers make it impossible to change storage later. A small service is the single best investment:
class Settings
{
public function __construct(private Option $option) {}
public function siteName(): string
{
return (string) $this->option->get('site.name', config('app.name'));
}
public function featureEnabled(string $flag): bool
{
return (bool) $this->option->get("features.{$flag}", false);
}
}
Bind it as a singleton and inject it — you gain type coercion, a centralized cache, and one place to mock in tests.
Bulk reads for request-scoped lookups. If a layout needs five settings, don't issue five calls. Fetch Option::all() once into a local array (or into a request-scoped cache) and read from that. Even when the package caches, the deserialization cost per call adds up in hot loops.
Admin screen pattern. For an editable settings page, define a schema array of key => [label, type, default], render a form from it, and on submit loop the schema (array_intersect_key) so only whitelisted keys are written. This prevents arbitrary key injection from the request and gives you a single place to add new settings.
Feature flags / kill switches. Store booleans as true/false and guard with if (Settings::featureEnabled('new_checkout')). Because values are database-backed, ops can flip a flag without a deploy — this is the highest-value day-to-day use beyond static config.
Blade integration. Register a small directive or just expose the Settings class to views: @if($settings->featureEnabled('banner')). Never call the raw facade from Blade — it hides cache-busting bugs.
Cache invalidation is the #1 failure mode. Most key-value packages read through a cache layer. If you write to the options table directly (seeders, Tinker, another service), the facade will keep serving stale values. Always write through the package API, or explicitly flush the cache after a direct write. In tests, call the package's cache-clearing method in setUp().
Type round-tripping is inconsistent. A value stored as "0", "false", or "[]" may come back as a string, a boolean, or an array depending on whether the package JSON-encodes. Never trust the return type — always cast ((bool), (int), (array)) or normalize through your Settings wrapper.
Publishing the migration is not optional. This is the most common first-run trap: the facade resolves, calls succeed, and you get null for everything because vendor:publish wasn't run. Add a smoke test that writes and reads a key to catch this in CI.
Key naming collisions with multi-tenancy. A single global options table is shared across all tenants. If you have multi-tenant requirements, either prefix keys with a tenant ID (tenant.{id}.site.name) or fork the storage. Do this before you have production data; retrofitting is painful.
Last release is 2020 — pin and verify compatibility. A package that hasn't shipped since Dec 2020 may still install on Laravel 8/9 but can break on Laravel 10/11 (service provider signatures, Str helpers, PHP 8.1 deprecations). Check composer.json constraints, and if you need to bump, fork or use a maintained replacement like spatie/laravel-settings.
Watch column size. If value is text (64KB), storing structured JSON (menus, translations, form schemas) will eventually truncate — usually silently, mid-write. Prefer the option store for scalars and small arrays; use a dedicated table for anything hierarchical.
Concurrency on read-modify-write. set followed by set across two requests is last-write-wins with no locking. For counters or accumulated lists, use a DB transaction or atomic increment rather than get then set.
Testing. In feature tests, RefreshDatabase keeps the table clean but does not clear the cache. If you use DatabaseTransactions instead, remember options written in one test can leak if cached. A helper trait that clears the option cache in tearDown is worth the five lines.
Extension points. The service provider typically binds an interface to a concrete repository. If you need a different backend (Redis, per-tenant tables), rebind that contract in a service provider rather than editing vendor code. Also check whether the config allows a custom table name — that's the easiest way to isolate test data.
Debugging. When a value looks wrong, dump the raw table row (DB::table('options')->where('key', ...)->first()) alongside the facade result. If they disagree, it's the cache; if they agree but your app disagrees, it's a type-coercion issue in your wrapper.
How can I help you explore Laravel packages today?