turtlemilitia / laravel-featureflags
Laravel package for Turtle Militia feature flag evaluation
Package info
github.com/turtlemilitia/laravel-featureflags
pkg:composer/turtlemilitia/laravel-featureflags
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.0
- illuminate/cache: ^11.0|^12.0
- illuminate/http: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.91
- larastan/larastan: ^3.8
- orchestra/testbench: ^9.0|^10.0
- phpbench/phpbench: ^1.3
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.0
README
A Laravel package for feature flag evaluation with local caching. Designed to work with the Turtle Militia feature flags dashboard.
Requirements: PHP 8.2+ and Laravel 11 or 12.
Table of Contents
- Features
- Installation
- Quick Start
- Configuration
- Usage
- Targeting Rules
- Percentage Rollouts
- Experiments
- Segments
- Webhooks
- Fallback Behavior
- Conversion Tracking
- Error Tracking
- Local Development & Testing
- Observability
- Performance
- GDPR Compliance
Features
- Local evaluation - Flags are evaluated locally, no API call per check
- Smart caching - Syncs flag configuration and caches locally
- Targeting rules - User-based targeting with flexible conditions
- Percentage rollouts - Gradual rollouts with sticky bucketing
- Experiments - A/B testing with automatic variant assignment
- Segments - Reusable user groups
- Telemetry - Evaluation tracking sent to dashboard
- Error tracking - Correlate errors with feature flags (Sentry, Bugsnag, Flare)
- Conversion tracking - Automatic attribution to flag variants
- Blade directives -
@featuredirective for templates - Auto-context - Automatically resolves authenticated user context
- Cache warming -
featureflags:warmcommand for deployments - Events - Optional Laravel events for monitoring
- GDPR compliant - Hold telemetry until user consent
Installation
composer require turtlemilitia/laravel-featureflags
Quick Start
Add your API credentials to .env:
FEATUREFLAGS_API_URL=https://api.turtlemilitia.com/v1 FEATUREFLAGS_API_KEY=your-environment-api-key
Check a flag:
use FeatureFlags\Facades\Feature; if (Feature::active('dark-mode')) { // Show dark mode UI }
Configuration
Publish the config file to customize settings:
php artisan vendor:publish --tag=featureflags-config
Most settings work via environment variables:
| Variable | Description |
|---|---|
FEATUREFLAGS_API_URL |
API endpoint |
FEATUREFLAGS_API_KEY |
Your environment API key |
FEATUREFLAGS_WEBHOOK_ENABLED |
Enable webhook endpoint |
FEATUREFLAGS_WEBHOOK_SECRET |
Webhook signature secret |
FEATUREFLAGS_TELEMETRY_ENABLED |
Send evaluation data to dashboard |
FEATUREFLAGS_TELEMETRY_ASYNC |
Dispatch telemetry via queue jobs |
FEATUREFLAGS_TELEMETRY_QUEUE |
Queue name for async telemetry |
FEATUREFLAGS_LOCAL_MODE |
Use locally-defined flags |
FEATUREFLAGS_HOLD_UNTIL_CONSENT |
Hold telemetry until user consents |
FEATUREFLAGS_CONSENT_TTL_DAYS |
Days before consent expires (365) |
Usage
use FeatureFlags\Facades\Feature; // Check if a flag is active (boolean) if (Feature::active('dark-mode')) { // Show dark mode UI } // Get flag value (supports string, number, JSON) $limit = Feature::value('api-rate-limit'); // Get all flags $flags = Feature::all(); // Monitor critical code paths (tracks errors automatically) $result = Feature::monitor('new-payment-flow', fn ($enabled) => $enabled ? $this->newPayment() : $this->legacyPayment() ); // Track conversions for A/B analysis Feature::trackConversion('purchase', $user, ['revenue' => 99.99]);
In Blade templates:
@feature('new-dashboard') <x-new-dashboard /> @else <x-old-dashboard /> @endfeature @feature('premium-feature', $team) ... @endfeature
Context
Context is resolved automatically from the authenticated user. Implement HasFeatureFlagContext on your User model:
use FeatureFlags\Contracts\HasFeatureFlagContext; class User extends Authenticatable implements HasFeatureFlagContext { public function toFeatureFlagContext(): array { return [ 'id' => $this->id, 'email' => $this->email, 'plan' => $this->subscription?->plan, ]; } }
Any model can implement the interface and be passed as context:
// Team, Organization, etc. Feature::active('premium-feature', $team);
You can also pass context explicitly:
// Array shorthand Feature::active('new-checkout', [ 'id' => 'user-123', 'plan' => 'pro', ]); // Context object $context = new Context('user-123', ['plan' => 'pro']); Feature::active('new-checkout', $context);
Nested Traits
Context supports nested arrays with dot notation in targeting rules. This is useful for targeting based on relationships:
public function toFeatureFlagContext(): array { return [ 'id' => $this->id, 'subscription' => [ 'plan' => [ 'name' => $this->subscription?->plan?->name, 'tier' => $this->subscription?->plan?->tier, ], 'status' => $this->subscription?->status, ], ]; }
Then target with dot notation in your rules: subscription.plan.name equals "pro" or
subscription.status equals "active".
Targeting Rules
The dashboard supports these operators for targeting rules:
| Operator | Description | Example |
|---|---|---|
equals |
Exact match | plan equals "pro" |
not_equals |
Not equal | plan not_equals "free" |
contains |
String contains | email contains "@company.com" |
not_contains |
String does not contain | email not_contains "test" |
gt |
Greater than | age gt 18 |
gte |
Greater than or equal | orders gte 10 |
lt |
Less than | balance lt 100 |
lte |
Less than or equal | balance lte 1000 |
in |
Value in array | country in ["US","GB","CA"] |
not_in |
Value not in array | country not_in ["CN","RU"] |
matches_regex |
Regex pattern match | email matches_regex ".*@company\.com$" |
semver_gt |
Version greater than | app_version semver_gt "2.0.0" |
semver_gte |
Version greater or equal | app_version semver_gte "2.0.0" |
semver_lt |
Version less than | app_version semver_lt "3.0.0" |
semver_lte |
Version less or equal | app_version semver_lte "2.5.0" |
before_date |
Date is before | created_at before_date "2025-01-01" |
after_date |
Date is after | trial_end after_date "2025-01-01" |
percentage_of |
Percentage of attribute values | id percentage_of 50 |
Example rule (configured in dashboard):
{
"priority": 1,
"conditions": [
{
"trait": "plan",
"operator": "equals",
"value": "pro"
}
],
"value": true
}
Version-Based Targeting
Semver operators are useful when your Laravel app serves as an API backend for mobile apps or versioned frontends. To use them, create a version resolver that provides version traits to the context.
Create a class implementing ResolvesVersion:
<?php namespace App\FeatureFlags; use FeatureFlags\Contracts\ResolvesVersion; class VersionResolver implements ResolvesVersion { public function resolve(): array { return [ // From client request header 'client_version' => request()->header('X-App-Version'), // With fallback logic 'app_version' => request()->header('X-App-Version') ?? config('app.version'), ]; } }
Register it in your config:
// config/featureflags.php 'context' => [ 'version_resolver' => \App\FeatureFlags\VersionResolver::class, ],
The resolved traits are automatically merged into every context, so you can create rules like
app_version semver_gte "2.0.0" in your dashboard without passing the version explicitly.
Percentage Rollouts
Rollouts use sticky bucketing based on the flag key and context ID. The same context will always get the same result for a given flag.
Attribute-Based Rollouts
The percentage_of operator enables attribute-based percentage rollouts within targeting rules. Unlike global
percentage rollouts, this lets you target a percentage of users based on any attribute:
{
"conditions": [
{
"trait": "plan",
"operator": "equals",
"value": "pro"
},
{
"trait": "id",
"operator": "percentage_of",
"value": 50
}
],
"value": true
}
This rule matches 50% of pro users:
- Sticky bucketing: Same user always gets the same result for the same flag
- Per-flag bucketing: Users may be in different buckets for different flags
- Combinable: Use with other conditions for precise targeting (e.g., "25% of enterprise users in Europe")
Experiments
Experiments enable A/B testing with automatic variant assignment. When you create an experiment on a flag in the dashboard, the package automatically assigns users to variants deterministically.
How It Works
- Define variants on a flag in the dashboard (e.g.,
["blue", "red", "green"]for a button color test) - Create an experiment on that flag with a goal event (e.g.,
purchase) - Start the experiment - the package now randomly assigns users to variants
- Track conversions - conversions are automatically attributed to the variant each user saw
Variant Assignment
When an experiment is running, the package uses deterministic bucketing to assign users to variants:
// User sees their assigned variant automatically $buttonColor = Feature::value('checkout-button-color'); // Returns 'blue', 'red', or 'green' based on user's bucket
The assignment is:
- Deterministic: Same user always gets the same variant for a given flag
- Even distribution: Users are split evenly across all variants
- Sticky: Assignment persists across sessions and requests
Evaluation Priority
Flags are evaluated in this order:
- Disabled - Returns default value
- Targeting rules - If user matches a rule, returns rule value (rules always win)
- Experiment - If experiment is running, assigns user to a variant
- Percentage rollout - If configured, applies rollout logic
- Default value - Returns the flag's default
This means targeting rules take precedence over experiments. Use this to:
- Force specific users into a variant for QA testing
- Exclude certain users from experiments entirely
Traffic Percentage (Holdout Groups)
Experiments support traffic percentage for holdout groups:
Traffic: 80%
├── 80% of users: Randomly assigned to variants (blue/red/green)
└── 20% of users: Get the default value (control holdout)
Users in the holdout group are excluded from the experiment entirely and always see the default value.
Conversion Attribution
Conversions are automatically linked to the flags evaluated in the same request:
// User sees the checkout button (variant: 'red') $color = Feature::value('checkout-button-color'); // Later in the same request or session... Feature::trackConversion('purchase', $user, ['revenue' => 99.99]); // Conversion is automatically attributed to 'checkout-button-color' = 'red'
The package tracks all flag evaluations during a request and includes them when sending conversion events. No manual attribution needed.
Segments
Segments are reusable user groups defined in the dashboard:
{
"conditions": [
{
"type": "segment",
"segment": "beta-users"
}
],
"value": true
}
Segments support all the same operators as regular trait conditions.
Webhooks
Configure your dashboard to send webhooks to /webhooks/feature-flags. The package automatically invalidates and
refreshes the cache when flags change.
Set FEATUREFLAGS_WEBHOOK_SECRET in your .env for signature verification. The secret is required - requests are
rejected with 401 if unset.
To sync manually:
php artisan featureflags:sync
Or programmatically:
Feature::sync(); Feature::flush(); // Clear cache
Fallback Behavior
When the API is unavailable:
cache(default): Use cached flags, fail silentlydefault: Return configured default value for unknown flagsexception: ThrowFlagSyncException
Configure in config/featureflags.php:
'fallback' => [ 'behavior' => 'cache', // 'cache', 'default', or 'exception' 'default_value' => false, // Used when behavior is 'default' ],
Conversion Tracking
Track conversions for A/B test analysis:
Feature::trackConversion('purchase', $user, ['revenue' => 99.99]);
Automatic Flag Attribution
Every conversion automatically includes all flags evaluated during the current request. The dashboard uses this to:
- Attribute conversions to experiment variants
- Calculate conversion rates per variant
- Determine statistical significance
No manual flag specification needed - the package handles attribution automatically.
Explicit Attribution
For cases where you need to attribute a conversion to a specific flag (e.g., async jobs, webhooks):
Feature::trackConversion('purchase', $user, [ 'revenue' => 99.99, ], flagKey: 'checkout-button-color', flagValue: 'red');
Error Tracking
Errors are automatically correlated with evaluated feature flags, helping identify if a new feature is causing issues.
Requires FEATUREFLAGS_TELEMETRY_ENABLED=true.
Setup
Register the service provider:
// Laravel 11+ (bootstrap/providers.php) return [ // ... FeatureFlags\Integrations\ErrorTrackingServiceProvider::class, ]; // Laravel 10 (config/app.php) 'providers' => [ // ... FeatureFlags\Integrations\ErrorTrackingServiceProvider::class, ],
Once registered:
- Every flag evaluation is tracked during the request
- When an exception occurs, all evaluated flags are attached
- Third-party error trackers (Sentry, Bugsnag, Flare) receive the flag context via Laravel's Context facade
- Your dashboard shows error rates correlated by flag value
What Gets Tracked
{
"feature_flags": {
"new-checkout": true,
"dark-mode": false,
"api-v2": true
},
"feature_flags_count": 3,
"feature_flags_request_id": "01J5X..."
}
Monitor Wrapper
For critical code paths:
$result = Feature::monitor('new-payment-processor', function ($isEnabled) { if ($isEnabled) { return $this->processWithStripe(); } return $this->processWithLegacy(); });
If the callback throws, the error is tracked with the exact flag value before global exception handling.
Manual Tracking
try { if (Feature::active('risky-feature')) { $this->riskyOperation(); } } catch (\Exception $e) { Feature::trackError('risky-feature', $e, ['custom' => 'metadata']); throw $e; }
Configuration
'error_tracking' => [ 'enabled' => true, 'skip_exceptions' => [ \Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class, \Illuminate\Validation\ValidationException::class, \Illuminate\Auth\AuthenticationException::class, ], ],
Supported Error Trackers
| Provider | Package | Automatic |
|---|---|---|
| Laravel Nightwatch | laravel/nightwatch |
✅ |
| Sentry | sentry/sentry-laravel |
✅ |
| Bugsnag | bugsnag/bugsnag-laravel |
✅ |
| Flare | spatie/laravel-ignition |
✅ |
Automatic integration requires Laravel 11+ (uses the Context facade). On Laravel 10, use
Feature::getEvaluatedFlags() to wire up your error tracker manually.
Local Development & Testing
Enable local mode for offline development and testing:
FEATUREFLAGS_LOCAL_MODE=true
Define flags in config/featureflags.php under local.flags:
'local' => [ 'enabled' => true, 'flags' => [ 'new-checkout' => true, 'dark-mode' => false, 'api-rate-limit' => 100, 'beta-features' => ['value' => true, 'rollout' => 25], ], ],
Export current flags from the API for offline work:
php artisan featureflags:dump --format=php|json|yaml --output=flags.php
Mocking the Facade
For unit tests, mock specific flag checks:
use FeatureFlags\Facades\Feature; // Simple boolean flag Feature::shouldReceive('active') ->with('my-flag') ->andReturn(true); // Flag with context Feature::shouldReceive('active') ->with('premium-feature', Mockery::any()) ->andReturn(false); // Value flags Feature::shouldReceive('value') ->with('api-rate-limit') ->andReturn(100);
Testing with Context
To test that rules evaluate correctly for different contexts:
public function test_pro_users_see_feature(): void { config(['featureflags.local.enabled' => true]); config(['featureflags.local.flags' => [ 'new-dashboard' => [ 'value' => false, 'rules' => [ [ 'conditions' => [ ['trait' => 'plan', 'operator' => 'equals', 'value' => 'pro'], ], 'value' => true, ], ], ], ]]); $proUser = new Context('user-1', ['plan' => 'pro']); $freeUser = new Context('user-2', ['plan' => 'free']); $this->assertTrue(Feature::active('new-dashboard', $proUser)); $this->assertFalse(Feature::active('new-dashboard', $freeUser)); }
Testing Percentage Rollouts
Rollouts are deterministic - the same context ID always gets the same result for a given flag:
public function test_rollout_is_consistent(): void { $context = new Context('user-123', []); $first = Feature::active('gradual-rollout', $context); $second = Feature::active('gradual-rollout', $context); // Same context always gets same result $this->assertEquals($first, $second); }
Observability
Events
Optional Laravel events for monitoring. Disabled by default.
FEATUREFLAGS_EVENTS_ENABLED=true
Or per-event in config/featureflags.php:
'events' => [ 'enabled' => true, 'dispatch' => [ 'flag_evaluated' => true, 'flag_sync_completed' => true, 'telemetry_flushed' => true, ], ],
| Event | Payload |
|---|---|
FlagEvaluated |
flagKey, value, contextId, matchReason |
FlagSyncCompleted |
flagCount, segmentCount, durationMs |
TelemetryFlushed |
type, eventCount, success, durationMs |
Performance
Cache Warming
Pre-warm the cache on deployment:
php artisan featureflags:warm --retry=3 --retry-delay=2
Sampling
Reduce telemetry volume for high-traffic sites:
'telemetry' => [ 'sample_rate' => 0.1, // Track 10% of evaluations ],
Flush Rate Limiting
Queue telemetry flushes to avoid hitting your plan's rate limit:
'telemetry' => [ 'rate_limit' => [ 'enabled' => true, 'max_flushes_per_minute' => 60, ], ],
Async Mode (Recommended)
By default, telemetry is sent synchronously at the end of each request. Enable async mode to dispatch telemetry to a queue job instead, preventing API latency from affecting response times.
FEATUREFLAGS_TELEMETRY_ASYNC=true FEATUREFLAGS_TELEMETRY_QUEUE=telemetry # Optional: specify queue name
Or in config:
'telemetry' => [ 'async' => env('FEATUREFLAGS_TELEMETRY_ASYNC', false), 'queue' => env('FEATUREFLAGS_TELEMETRY_QUEUE', null), // null = default queue ],
Requirements:
- A configured queue driver (Redis, database, SQS, etc.)
- A running queue worker (
php artisan queue:work)
Benefits:
- Telemetry API calls don't block your response
- Failed sends are automatically retried (up to 3 times with 5-second backoff)
- Queue workers can handle load spikes better than inline HTTP calls
GDPR Compliance
For GDPR-compliant telemetry, enable "hold until consent" mode. Feature flag evaluation works immediately, but telemetry is queued until the user consents.
FEATUREFLAGS_HOLD_UNTIL_CONSENT=true
Usage
use FeatureFlags\Facades\Feature; // Flags work immediately (bucketing happens, telemetry is held) if (Feature::active('new-checkout')) { // Show new checkout } // When user accepts analytics/cookies: Feature::grantConsent(); // Flushes held events, sets consent cookie // If user declines: Feature::discardHeldTelemetry(); // Clears queued events without sending // To revoke consent later: Feature::revokeConsent(); // Future telemetry will be held again // Check current state: Feature::isHoldingTelemetry(); // true if holding without consent
How It Works
- First visit: Device ID cookie (
ff_device_id) is set for consistent bucketing - Flag checks: Evaluation works normally, telemetry events are queued
- User consents:
grantConsent()flushes queued events and sets consent cookie (ff_telemetry_consent) - Subsequent visits: Consent cookie is detected, telemetry flows normally
Configuration
'telemetry' => [ 'hold_until_consent' => env('FEATUREFLAGS_HOLD_UNTIL_CONSENT', false), 'consent_ttl_days' => env('FEATUREFLAGS_CONSENT_TTL_DAYS', 365), ],
After consent_ttl_days, the consent cookie expires and the user will need to re-consent.
License
MIT
Support
- Dashboard & docs: https://turtlemilitia.com
- Issues: https://github.com/turtlemilitia/laravel-featureflags/issues