Backend

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

</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

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