laravel-pennant
Creates feature flags using Laravel Pennant
Overview
The laravel-pennant agent creates feature flags using Laravel Pennant. It generates feature classes with resolution logic, sets up A/B testing, configures gradual rollouts, and integrates feature flags into Blade, Middleware, and API responses.
Responsibilities
- Feature Classes - Create typed feature flag definitions
- Resolution Logic - User/team-based feature targeting
- Gradual Rollouts - Percentage-based feature activation
- A/B Testing - Multi-variant feature experiments
- Blade Integration - Feature directives in templates
- Middleware - Gate access based on features
What It Creates
| Component | Location | Purpose |
|---|---|---|
| Feature Classes | app/Features/ |
Feature flag definitions |
| Middleware | app/Http/Middleware/ |
Feature-gated routes |
| Tests | tests/Feature/ |
Feature flag tests |
Generated Feature Class
<?php
namespace App\Features;
use Illuminate\Support\Lottery;
use Laravel\Pennant\Feature;
class NewDashboard
{
/**
* Resolve the feature's initial value.
*/
public function resolve(mixed $scope): bool
{
// Enable for specific users
if ($scope instanceof \App\Models\User) {
// Beta testers always get new features
if ($scope->is_beta_tester) {
return true;
}
// Gradual rollout to 20% of users
return Lottery::odds(1, 5)->choose();
}
return false;
}
}
// Usage in controller
public function index()
{
if (Feature::active(NewDashboard::class)) {
return view('dashboard.new');
}
return view('dashboard.classic');
}
A/B Testing with Variants
<?php
namespace App\Features;
use App\Models\User;
use Illuminate\Support\Arr;
class CheckoutFlow
{
/**
* Return a variant for A/B testing.
*/
public function resolve(User $user): string
{
// Consistent variant per user based on ID
return match ($user->id % 3) {
0 => 'control', // Original flow
1 => 'simplified', // Fewer steps
2 => 'one-page', // Single page checkout
};
}
}
// Usage
$variant = Feature::value(CheckoutFlow::class);
return match ($variant) {
'simplified' => view('checkout.simplified'),
'one-page' => view('checkout.one-page'),
default => view('checkout.control'),
};
Team-Based Features
<?php
namespace App\Features;
use App\Models\Team;
class AdvancedAnalytics
{
public function resolve(Team $team): bool
{
// Only for Pro and Enterprise plans
return in_array($team->plan, ['pro', 'enterprise']);
}
}
// Scoped feature check
Feature::for($user->currentTeam)->active(AdvancedAnalytics::class);
// In service provider - set default scope
Feature::resolveScopeUsing(fn () => auth()->user()?->currentTeam);
Blade Directives
@feature('new-dashboard')
<x-new-dashboard-widget />
@else
<x-classic-dashboard-widget />
@endfeature@feature(App\Features\NewDashboard::class)
<div class="new-design">
laravel-passport
Auto-invoked skill
Implement full OAuth2 server with all grant types
Trigger Keywords
This skill automatically activates when Claude detects these keywords:
passport
oauth2 server
authorization code
client credentials
access token
refresh token
oauth provider
Overview
The laravel-passport skill provides expertise for building a complete OAuth2 authorization server. It covers all OAuth2 grant types, token scopes, client management, and third-party API access.
What This Skill Provides
- OAuth2 Grant Types - Authorization Code, Client Credentials, PKCE
- Token Management - Access tokens, refresh tokens, revocation
- Client Management - Create and manage OAuth clients
- Token Scopes - Fine-grained permission control
- Personal Access Tokens - User-generated API tokens
- Third-Party Access - Allow external apps to access your API
When to Use
Use Passport when:
- Building an OAuth2 authorization server
- Third-party applications need to access your API
- Need all OAuth2 grant types
- Require refresh tokens with rotation
Use Sanctum instead when:
- Building first-party SPAs or mobile apps
- Simple token authentication is sufficient
- Don't need OAuth2 complexity
Quick Start
composer require laravel/passport
php artisan migrate
php artisan passport:install
User Model Setup
<?php
use Laravel\Passport\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens;
}
Configure Auth Guard
<?php
// config/auth.php
return [
'guards' => [
'api' => [
'driver' => 'passport', // Changed from 'token'
'provider' => 'users',
],
],
];
Authorization Code Grant
Most secure option for third-party web applications.
Create Client
php artisan passport:client
Authorization Request
GET /oauth/authorize?client_id=1
&redirect_uri=https://client-app.com/callback
&response_type=code
&scope=read-posts write-posts
&state=random_state_string
Exchange Code for Token
POST /oauth/token
Content-Type: application/json
{
"grant_type": "authorization_code",
"client_id": "1",
"client_secret": "abc123...",
"redirect_uri": "https://client-app.com/callback",
"code": "def456..."
}
Client Credentials Grant
For machine-to-machine communication without user context.
# Create machine client
php artisan passport:client --client
POST /oauth/token
{
"grant_type": "client_credentials",
"client_id": "2",
"client_secret": "xyz789...",
"scope": "read-data"
}
// Protect routes with client middleware
Route::middleware(['client'])->group(function () {
Route::get('/api/stats', [StatsController::class, 'index']);
});
Authorization Code with PKCE
Recommended for SPAs and mobile apps (no client secret needed).
// Generate code_verifier and code_challenge
const codeVerifier = generateRandomString(128);
const codeChallenge = await sha256(codeVerifier);
// Authorization request with PKCE
GET /oauth/authorize?client_id=5
&redirect_uri=https://app.com/callback
&response_type=code
&scope=read-posts
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
POST /oauth/token
{
"grant_type": "authorization_code",
"client_id": "5",
"redirect_uri": "https://app.com/callback",
"code": "def456...",
"code_verifier": "original_verifier"
}
Token Scopes
<?php
// app/Providers/AuthServiceProvider.php
use Laravel\Passport\Passport;
public function boot(): void
{
Passport::tokensCan([
'read-posts' => 'Read posts',
'write-posts' => 'Create and edit posts',
'delete-posts' => 'Delete posts',
'admin' => 'Full administrative access',
]);
Passport::setDefaultScope(['read-posts']);
}
Check Scopes in Controllers
<?php
public function store(Request $request)
{
if (! $request->user()->tokenCan('write-posts')) {
return response()->json(['error' => 'Insufficient permissions'], 403);
}
return Post::create($request->validated());
}
Scope Middleware
// Require any of these scopes
Route::middleware(['auth:api', 'scopes:write-posts,admin'])->group(function () {
Route::post('/posts', [PostController::class, 'store']);
});
// Require all scopes
Route::middleware(['auth:api', 'scope:write-posts,admin'])->group(function () {
Route::delete('/posts/{post}', [PostController::class, 'destroy']);
});
Personal Access Tokens
<?php
// Create token via code
$token = $user->createToken(
'My API Token',
['read-posts', 'write-posts']
);
return response()->json([
'token' => $token->accessToken,
]);
Token Lifetimes
<?php
// app/Providers/AuthServiceProvider.php
public function boot(): void
{
// Access tokens expire in 15 days
Passport::tokensExpireIn(now()->addDays(15));
// Refresh tokens expire in 30 days
Passport::refreshTokensExpireIn(now()->addDays(30));
// Personal access tokens expire in 6 months
Passport::personalAccessTokensExpireIn(now()->addMonths(6));
// Prune revoked tokens
Passport::pruneRevokedTokens();
}
Revoking Tokens
// Revoke current access token
$request->user()->token()->revoke();
// Revoke all user tokens
$user->tokens->each->revoke();
// Revoke specific token
$token = $user->tokens()->find($tokenId);
$token->revoke();
Refresh Tokens
async function refreshAccessToken(refreshToken) {
const response = await fetch('/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: clientId,
client_secret: clientSecret,
}),
});
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
};
}
Testing OAuth Flows
<?php
use Laravel\Passport\Passport;
it('allows access with correct scope', function () {
$user = User::factory()->create();
Passport::actingAs($user, ['write-posts']);
$response = $this->postJson('/api/posts', [
'title' => 'Test Post',
'body' => 'Content',
]);
$response->assertCreated();
});
it('denies access without required scope', function () {
$user = User::factory()->create();
Passport::actingAs($user, ['read-posts']);
$response = $this->postJson('/api/posts', [
'title' => 'Test Post',
]);
$response->assertForbidden();
});
Common Pitfalls
- Not running passport:install - Must run after migration to generate keys
- Wrong auth guard - Must use
'driver' => 'passport' not 'token'
- Missing HasApiTokens trait - Use
Laravel\Passport\HasApiTokens
- Exposing client secrets - Never expose in frontend, use PKCE instead
- No token lifetimes - Always configure expiration in production
- Password grant for third-party - Only for first-party apps (deprecated)
- Not validating redirect URIs - Whitelist exact URIs to prevent token theft
- Forgetting to revoke on security events - Revoke on password change
- Not using HTTPS in production - OAuth2 requires HTTPS
- Not pruning revoked tokens - Schedule
passport:purge daily
Best Practices
- Use Authorization Code + PKCE for SPAs and mobile apps
- Use Client Credentials for machine-to-machine
- Never use Password Grant for third-party apps
- Always use HTTPS in production
- Set reasonable token lifetimes
- Implement token refresh logic
- Revoke tokens on security events (password change)
- Whitelist exact redirect URIs per client
- Use scopes to limit token permissions
- Prune revoked tokens regularly
- Rate limit token endpoints
- Store refresh tokens securely
- Never expose client secrets in frontend
Related Commands
# Install Passport
php artisan passport:install
# Create OAuth client
php artisan passport:client
# Create password grant client
php artisan passport:client --password
# Create client credentials client
php artisan passport:client --client
# Purge revoked/expired tokens
php artisan passport:purge
# Generate encryption keys
php artisan passport:keys
Related Skills
- laravel-sanctum - Lightweight API authentication
- laravel-api - Building REST APIs
- laravel-auth - Authentication and authorization
- laravel-security - Security best practices
</div>
@endfeature@featureValue(App\Features\CheckoutFlow::class, 'one-page')
<x-one-page-checkout />
@endfeatureValue
Feature Middleware
<?php
namespace App\Http\Middleware;
use Closure;
use Laravel\Pennant\Feature;
class EnsureFeatureActive
{
public function handle($request, Closure $next, string $feature)
{
if (!Feature::active($feature)) {
abort(404);
}
return $next($request);
}
}
// routes/web.php
Route::middleware(['feature:new-dashboard'])->group(function () {
Route::get('/dashboard/v2', [DashboardController::class, 'v2']);
});
// Or with class-based features
Route::get('/checkout/express', [CheckoutController::class, 'express'])
->middleware('feature:' . \App\Features\ExpressCheckout::class);
Gradual Rollout Configuration
<?php
namespace App\Features;
use Illuminate\Support\Lottery;
class NewPaymentFlow
{
/**
* Rollout schedule:
* Week 1: 10% of users
* Week 2: 25% of users
* Week 3: 50% of users
* Week 4: 100% of users
*/
public function resolve($user): bool
{
$rolloutPercentage = match (true) {
now()->lt(carbon('2024-02-01')) => 10,
now()->lt(carbon('2024-02-08')) => 25,
now()->lt(carbon('2024-02-15')) => 50,
default => 100,
};
return Lottery::odds($rolloutPercentage, 100)->choose();
}
}
// Force enable/disable for testing
Feature::activate(NewPaymentFlow::class); // Enable for current scope
Feature::deactivate(NewPaymentFlow::class); // Disable for current scope
Feature::forget(NewPaymentFlow::class); // Reset to default resolution
API Response Integration
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
use Laravel\Pennant\Feature;
class UserResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
// Include feature flags for frontend
'features' => [
'new_dashboard' => Feature::for($this->resource)->active('new-dashboard'),
'dark_mode' => Feature::for($this->resource)->active('dark-mode'),
'checkout_variant' => Feature::for($this->resource)->value('checkout-flow'),
],
];
}
}
Testing Feature Flags
<?php
use Laravel\Pennant\Feature;
use App\Features\NewDashboard;
it('shows new dashboard for beta testers', function () {
Feature::activate(NewDashboard::class);
$response = $this->actingAs($user)->get('/dashboard');
$response->assertViewIs('dashboard.new');
});
it('shows classic dashboard when feature is disabled', function () {
Feature::deactivate(NewDashboard::class);
$response = $this->actingAs($user)->get('/dashboard');
$response->assertViewIs('dashboard.classic');
});
it('returns 404 when feature-gated route is disabled', function () {
Feature::deactivate('express-checkout');
$response = $this->actingAs($user)->get('/checkout/express');
$response->assertNotFound();
});
Invoked By Commands
- /laravel-agent:feature-flag:make - Create feature flags
Guardrails
The Pennant agent follows strict rules:
- ALWAYS use class-based features for type safety
- ALWAYS include tests for feature flag behavior
- ALWAYS document rollout schedules in code
- NEVER leave stale feature flags in production
- NEVER use feature flags for permanent configuration
See Also
- laravel-feature-builder - Full-stack feature building
- laravel-testing - Test generation