Imported from offload-project/laravel-toggle (
skills/SKILL.md). Install upstream withnpx skills add offload-project/laravel-toggle --skill skills. Copyright stays with the author.
Context
offload-project/laravel-toggle is a Laravel 11/12/13 package (PHP 8.3+) for global on/off feature flags. It ships:
- A
ToggleManagerresolved via theTogglefacade (OffloadProject\Toggle\Facades\Toggle). - A
Drivercontract with three implementations:ConfigDriver(read-only, env-driven),DatabaseDriver(mutable at runtime with config fallback), andPerFlagDriver(routes each flag to the config or database driver based onflags/database_flagslists). - A
ToggleEloquent model (OffloadProject\Toggle\Models\Toggle) backing the database driver. - Blade directives
@toggle,@elsetoggle,@endtoggle. ShareTogglesWithInertiamiddleware that exposes all flags to the frontend as aflagsInertia prop (keys camelCased).- Artisan commands
toggle:list,toggle:create,toggle:cache-clear. - Exceptions
ToggleNotFoundExceptionandReadOnlyDriverException.
Apply this skill when working in a Laravel app that has offload-project/laravel-toggle in composer.json, or when the user asks for help with Toggle::, @toggle, the Toggle model, feature flags, or driver/cache wiring in this package.
Rules
Calling toggles
- Always check toggle state through the
Togglefacade (Toggle::active($name)/Toggle::inactive($name)). Do not readconfig('toggle.flags.foo')or query thetogglestable directly — those paths bypass per-flag routing and the cache. - Pass either a string key (kebab-case) or a backed-string enum case. Prefer enums in application code for type safety; reserve raw strings for Blade or quick scripts.
- Use
Toggle::inactive($name)instead of! Toggle::active($name)so intent is explicit in diffs.
Enabling / disabling at runtime
Toggle::enable($name),Toggle::disable($name), andToggle::delete($name)only work when the flag resolves through the database driver. If you call them on a config-only flag, the underlyingConfigDriverthrowsReadOnlyDriverException. To make a flag mutable, list it indatabase_flags(or set the global driver todatabase).enable()/disable()/delete()automatically forget the cache entry for that flag. Do not add manualToggle::forgetCache()calls after them.- Modifying the
ToggleEloquent model directly (e.g.Toggle::updateOrCreate(...)) also clears the cache because the model'ssavedanddeletedhooks callToggle::forgetCache(). Prefer the facade methods, but the model is safe when you need to seed or bulk-write.
Per-flag driver routing
- Put read-only / env-driven flags in
config('toggle.flags')with anenv()default. These are baked into the deploy. - Put runtime-mutable flag names in
config('toggle.database_flags'). Listed flags always resolve through the database driver (with config fallback), regardless ofTOGGLE_DRIVER. - Don't list the same flag in both
flagsanddatabase_flagsunless you intend the config value to be the fallback when the database row is missing —database_flagswins for resolution, andflagsonly contributes the fallback value. - For unlisted flags, the global
TOGGLE_DRIVER(configordatabase) decides which driver handles them. Avoid relying on unlisted flags in production code; declare every flag explicitly.
Defaults for unknown flags
- Set
TOGGLE_DEFAULT=false(default),true, orexception. Useexceptionin development to catch typos early; keep production atfalse(ortruefor safe-by-default flags). - When
TOGGLE_DEFAULT=exception, callers must be prepared forToggleNotFoundException. Don't wrap everyToggle::active()in try/catch — instead, declare the flag.
Caching
- Caching is on by default (
TOGGLE_CACHE_ENABLED=true) with a 1-hour TTL. The cache key prefix istoggle:. Leave caching on in production. - If the cache store is unavailable,
Toggle::active()still resolves correctly by falling through to the driver — there's no need to wrap calls in defensive try/catch. - After bulk-changing many flags out-of-band (e.g. a seed or migration), call
Toggle::flushCache()once rather thanforgetCache()per flag. - Tests that exercise toggle changes should either use the
arraycache driver or callToggle::flushCache()inbeforeEachto avoid cross-test bleed.
Blade directives
- Use
@toggle('flag-name') ... @elsetoggle ... @endtogglefor template-side conditionals. The directive accepts a string or an enum case (passed via{{ Feature::NewCheckout->value }}or directly if your Blade compiler supports it). - Do not call
Toggle::active()inline in Blade unless you need to combine it with another expression —@toggleis the canonical form.
Inertia integration
- To expose flags to the frontend, replace
HandleInertiaRequestsinbootstrap/app.phpwithShareTogglesWithInertia(or extend it). The middleware shares all flags as aflagsprop with camelCased keys (new-checkout→newCheckout). - Anything exposed via the Inertia middleware is public to the browser. Do not put sensitive kill-switches (rate-limit bypasses, internal admin features) in the same toggle namespace as user-facing flags; route those through a separate authorization check.
Artisan
- Use
php artisan toggle:create <name>to scaffold a new config-driven flag. Add--activeto default it to true,--dbto also create a database row. The command editsconfig/toggle.phpand.env; review the diff before committing. php artisan toggle:listshows all defined flags and current state — useful for verifying a deploy or debugging which driver a flag is resolving through.php artisan toggle:cache-clear [name]clears the cache for a specific flag or all flags.
Naming
- Use kebab-case for flag names (
new-checkout,dark-mode). The Inertia middleware converts these to camelCase for the frontend; mixing snake_case or PascalCase breaks that contract. - Prefix
TOGGLE_env vars and shout-case the rest (TOGGLE_NEW_CHECKOUT). Thetoggle:createcommand follows this convention; match it for hand-added flags.
Examples
Defining flags in config
// config/toggle.php
'flags' => [
// Config-driven, read-only — controlled by .env
'new-checkout' => env('TOGGLE_NEW_CHECKOUT', false),
'dark-mode' => env('TOGGLE_DARK_MODE', true),
],
'database_flags' => [
// Database-driven, mutable at runtime via Toggle::enable() / Toggle::disable()
'maintenance-banner',
'beta-access',
],
Checking a flag
use OffloadProject\Toggle\Facades\Toggle;
if (Toggle::active('new-checkout')) {
return new NewCheckoutController()->show($request);
}
if (Toggle::inactive('dark-mode')) {
// Light mode only
}
Using a backed enum
enum Feature: string
{
case NewCheckout = 'new-checkout';
case DarkMode = 'dark-mode';
case BetaAccess = 'beta-access';
}
use App\Enums\Feature;
use OffloadProject\Toggle\Facades\Toggle;
if (Toggle::active(Feature::NewCheckout)) {
// ...
}
Toggle::enable(Feature::BetaAccess);
Blade
@toggle('new-checkout')
<x-new-checkout-form />
@elsetoggle
<x-legacy-checkout-form />
@endtoggle
Mutating at runtime (database-routed flag)
use OffloadProject\Toggle\Facades\Toggle;
// 'maintenance-banner' is listed in config('toggle.database_flags')
Toggle::enable('maintenance-banner');
Toggle::disable('maintenance-banner');
Toggle::delete('maintenance-banner'); // removes the row, falls back to config
Sharing flags with Inertia
// bootstrap/app.php
use OffloadProject\Toggle\Middleware\ShareTogglesWithInertia;
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
ShareTogglesWithInertia::class,
]);
})
// resources/js/Pages/Checkout.vue (or React equivalent)
const {flags} = usePage().props
if (flags.newCheckout) {
// ...
}
Testing pattern
use OffloadProject\Toggle\Facades\Toggle;
beforeEach(function () {
config()->set('toggle.driver', 'database');
Toggle::flushCache();
});
it('shows the new checkout when enabled', function () {
Toggle::enable('new-checkout');
$this->get('/checkout')->assertSee('New Checkout');
});
Anti-patterns
- ❌ Reading
config('toggle.flags.foo')directly — bypasses per-flag routing and the cache; database overrides will be ignored. - ❌ Querying the
togglestable with raw Eloquent in feature code — use the facade so the cache and config fallback stay consistent. - ❌ Calling
Toggle::enable()/disable()/delete()on a flag that's only inconfig('toggle.flags')—ReadOnlyDriverExceptionwill be thrown. Add the flag todatabase_flags(or switchTOGGLE_DRIVERtodatabase). - ❌ Wrapping every
Toggle::active()in try/catch. WithTOGGLE_DEFAULTset tofalseortrue, undefined flags don't throw; withexceptiononly undefined flags throw and the right fix is to declare the flag. - ❌ Manually clearing the cache after
Toggle::enable()/disable()— those facade methods already do it. - ❌ Putting sensitive admin kill-switches in the same flag namespace as user-facing flags when
ShareTogglesWithInertiais enabled — they will leak to the browser. - ❌ Subclassing
OffloadProject\Toggle\Models\Toggleto add behavior. Use events on the model or a separate service; the model is part of the driver wiring. - ❌ Mixing snake_case / camelCase flag names. The Inertia middleware camelCases kebab-case names — anything else produces inconsistent prop keys.
- ❌ Editing files inside
vendor/offload-project/laravel-toggle. All extension points (driver, cache store, default behavior, flags) are exposed viaconfig/toggle.phpand theDrivercontract.
References
- Repository: https://github.com/offload-project/laravel-toggle
- README: https://github.com/offload-project/laravel-toggle/blob/main/README.md
- Pennant comparison (when to prefer Pennant): https://github.com/laravel/pennant