mrshanebarron/toggle
mrshanebarron/toggle is a lightweight Laravel/PHP package for adding simple toggle behavior to your app—switch features, settings, or states on and off with minimal setup. Useful for lightweight feature flags and runtime controls.
Install and publish the config
composer require mrshanebarron/toggle
php artisan vendor:publish --tag=sb-config
The sb- prefix is not cosmetic — v1.0.1 was a fix release specifically for config file naming. The published file lands at config/sb-toggle.php. If --tag=sb-config reports "no publishable resources," run php artisan vendor:publish interactively or read the provider's publishes() array; don't guess at tag variants beyond that.
Where to look first, in this order
config/sb-toggle.php — for this package the config file is the feature registry. Every toggle name you can check lives here.mergeConfigFrom() call — tells you the default config key the package resolves to before publishing. That key is the real name to use in config() calls.active, enabled, isActive…). The docs describe "a clean helper or facade API" without pinning the signature, so read the class rather than inferring from the README.Define your first toggle
// config/sb-toggle.php
return [
'toggles' => [
'new-checkout' => env('FEATURE_NEW_CHECKOUT', false),
'beta-dashboard' => env('FEATURE_BETA_DASHBOARD', false),
],
];
Note the env() default of false; see the boolean-coercion gotcha below before you rely on it.
First use case — gate one code path
if (toggle('new-checkout')) {
return $this->newCheckout($request);
}
return $this->legacyCheckout($request);
Resolve via the facade or helper rather than instantiating anything: the container-bound checker picks up your published config, and it's the seam you'll swap in tests.
Confirm the values actually loaded before debugging anything else
php artisan config:show sb-toggle
A toggle that "doesn't work" is almost always a flag that isn't where you think it is, not a broken check.
Single source of truth for flag names. Toggle names are bare strings, so a typo doesn't throw — it falls through to a default. Centralise them:
final class Feature
{
public const NEW_CHECKOUT = 'new-checkout';
public const BETA_DASHBOARD = 'beta-dashboard';
public static function all(): array
{
return [self::NEW_CHECKOUT, self::BETA_DASHBOARD];
}
}
Then toggle(Feature::NEW_CHECKOUT). You can also assert in a test that every constant exists as a key in config('sb-toggle.toggles'), which turns silent typos into red builds.
Resolve flags at the edge, not in the domain. Check the toggle once in the controller/middleware/composer and pass a boolean inward. Domain services that call toggle() internally become impossible to test without touching global config and are painful to delete once the flag ships.
public function store(StoreOrderRequest $request, CheckoutService $checkout)
{
return $checkout->handle($request->validated(), feature: toggle(Feature::NEW_CHECKOUT));
}
Route-level gating for whole features (an entire beta dashboard, an admin tool):
Route::middleware(['web', 'can:access-beta'])->group(function () {
// ...
});
Put the toggle() call inside a small gate/middleware rather than repeating it per route — when the flag is removed you delete one class instead of twelve route definitions.
Blade usage: wrap markup, not decisions. Toggles in views are fine for showing/hiding UI, but the authorisation-equivalent decision should already have been made upstream. Otherwise the controller assumes the feature is on while the view renders the old UI, and you get half-migrated pages.
@toggle('new-checkout')
<x-checkout-v2 :cart="$cart" />
@else
<x-checkout-v1 :cart="$cart" />
@endtoggle
Gradual rollout. This package is config/ENV driven with no database, which means a flag flips only on deploy or config change — there is no runtime kill switch and no per-user targeting. If you need percentage rollouts, compose it yourself and keep the package as the on/off master switch:
function rollout(string $flag, ?int $userId = null, int $percent = 0): bool
{
if (! toggle($flag)) {
return false;
}
if ($percent >= 100) {
return true;
}
$userId ??= auth()->id() ?? 0;
return (crc32($flag . ':' . $userId) % 100) < $percent;
}
Hash on something stable (user ID), never on rand() — otherwise a user sees the new checkout on page one and the old one on page two. Put the percentage in config next to the flag so it moves through the same deploy path.
Environment layering. Flags differ per environment by design: on in staging, off in production, on for one canary. Drive that from .env only — FEATURE_NEW_CHECKOUT=true — and keep the config file committed and identical everywhere. Never edit config/sb-toggle.php per server.
Testing pattern. Override config per test; never touch .env or set real environment variables.
protected function setUp(): void
{
parent::setUp();
config(['sb-toggle.toggles.new-checkout' => false]);
}
public function test_legacy_checkout_used_when_flag_off(): void
{
config(['sb-toggle.toggles.new-checkout' => false]);
// assert v1 behaviour
}
public function test_new_checkout_used_when_flag_on(): void
{
config(['sb-toggle.toggles.new-checkout' => true]);
// assert v2 behaviour
}
Because the package is a pure config read, there's no fake to install and nothing to mock — which is its main strength. If the facade is macroable or the checker is bound to a contract, bind a double in a shared test case instead, so a future switch to a DB-backed driver doesn't ripple through every test.
Flag lifecycle discipline. Every toggle is debt with a removal date. Keep them in one config file, name them after the behaviour (new-checkout) not the ticket (PROJ-1423), and record the intended removal in the config comment. A feature-flag package without a cleanup habit becomes a permanent pile of dead branches — this is the single biggest long-term risk with a package this thin.
Deployment order.
php artisan config:cache
php artisan queue:restart
php artisan view:clear # only after a package upgrade that changes directives
queue:restart matters here more than usual: long-running workers hold the cached config from before the deploy, so a flag flipped today won't take effect in queued jobs until they restart.
v1.0.1 renamed the config file to the sb- prefix — this is the release to watch. Anything referencing the old unprefixed name now silently returns null. After upgrading:
rm config/toggle.php # or whatever the old published name was
php artisan vendor:publish --tag=sb-config --force
grep -rn "config('toggle" app/ config/ resources/ # rewrite to sb-toggle
A null from a renamed config key does not throw. Your toggle() call returns the default, which is false, which means the feature quietly never turns on. The failure mode is silence, so grep rather than test.
A config filename change in a patch release is a red flag. Pin to ^1.0.1, roll to staging first, and read the changelog for key renames that weren't called out. For a 0-star package, treat patch releases as potentially breaking until proven otherwise.
env() booleans are strings. env('FEATURE_NEW_CHECKOUT', false) returns the string "false" if someone writes FEATURE_NEW_CHECKOUT=false in .env, and "false" is truthy in PHP. Cast explicitly:
'new-checkout' => filter_var(env('FEATURE_NEW_CHECKOUT', false), FILTER_VALIDATE_BOOL),
This is the most likely way a toggle gets stuck permanently on in production, and it looks exactly like a package bug.
config:cache is mandatory after every config change. In production config() reads the cached array. Editing config/sb-toggle.php on the server without re-caching has literally zero effect. Combine that with the boolean-string issue above and you have the two most common 30-minute debugging detours.
env() outside config files returns null. Calling env('FEATURE_X') in a controller or Blade template returns null once the config is cached. Flags must be read through config().
mergeConfigFrom merges shallowly. If your published file defines toggles as an array, it replaces the package's default array entirely rather than merging key by key. Only relevant if future versions ship default toggles — but if they do, your local file will silently drop them.
Unknown toggle names fail closed and silent. toggle('typo-name') returns the default (false) with no warning. Mitigate with the constants-class test above, or a debug-only assertion in a wrapper:
function feature(string $name): bool
{
if (app()->hasDebugModeEnabled() && ! array_key_exists($name, config('sb-toggle.toggles', []))) {
throw new InvalidArgumentException("Unknown feature toggle [{$name}]");
}
return toggle($name);
}
No database means no runtime kill switch. You cannot turn a broken feature off without a deploy or an SSH session plus config:cache. For anything genuinely risky, pair the config flag with your own runtime check (a cache-stored override, a short-TTL "enabled" key) so you can disable it in seconds rather than minutes.
Blade directive caching. If a package upgrade changes or renames a directive, the compiled views in storage/framework/views still contain the old code. php artisan view:clear after any upgrade that touches the Blade surface.
Facade resolution failures. "A facade root has not been set" means the binding key changed or the provider isn't discovered — typically a stale bootstrap/cache/services.php, not a code problem. php artisan optimize:clear clears it.
Debugging checklist, in order:
php artisan config:show sb-toggle — are the values right, and are booleans real booleans?bootstrap/cache/config.php)php artisan about / bootstrap/cache/packages.php — is the provider auto-discovered?php artisan vendor:publish --list — does the sb-config tag still exist after the upgrade?toggle() call itself.Extension points before forking. If you need DB-backed flags or user targeting, look for a contract bound in the container you can rebind, or a manager class you can extend(). Wrapping the package behind your own Feature service from day one costs an hour now and makes that swap a one-file change later; the thin API is exactly the kind of thing worth wrapping early.
How can I help you explore Laravel packages today?