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">
        
Builder

laravel-package

Creates distributable Laravel packages for Packagist

Overview

The laravel-package agent creates distributable Laravel packages ready for Packagist publication. It scaffolds the package structure, configures autoloading, creates service providers, sets up testing, and generates documentation following community standards.

Responsibilities

  • Package Scaffolding - Complete package directory structure
  • Service Provider - Auto-discovery and configuration publishing
  • Composer Setup - Autoloading, dependencies, scripts
  • Testing Setup - Orchestra Testbench configuration
  • Documentation - README, changelog, contributing guide
  • CI/CD - GitHub Actions for testing and publishing

Package Structure

packages/vendor-name/package-name/
├── .github/
│   └── workflows/
│       ├── tests.yml
│       └── release.yml
├── config/
│   └── package-name.php
├── database/
│   └── migrations/
├── resources/
│   └── views/
├── src/
│   ├── Commands/
│   ├── Facades/
│   ├── Http/
│   │   ├── Controllers/
│   │   └── Middleware/
│   ├── Models/
│   ├── PackageNameServiceProvider.php
│   └── PackageName.php
├── tests/
│   ├── Feature/
│   ├── Unit/
│   └── TestCase.php
├── .gitignore
├── CHANGELOG.md
├── composer.json
├── LICENSE.md
├── phpunit.xml
└── README.md

Generated Service Provider

<?php

namespace VendorName\PackageName;

use Illuminate\Support\ServiceProvider;

class PackageNameServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->mergeConfigFrom(
            __DIR__ . '/../config/package-name.php',
            'package-name'
        );

        $this->app->singleton('package-name', function ($app) {
            return new PackageName($app['config']['package-name']);
        });
    }

    public function boot(): void
    {
        // Publish config
        $this->publishes([
            __DIR__ . '/../config/package-name.php' => config_path('package-name.php'),
        ], 'package-name-config');

        // Publish migrations
        $this->publishes([
            __DIR__ . '/../database/migrations/' => database_path('migrations'),
        ], 'package-name-migrations');

        // Load migrations
        $this->loadMigrationsFrom(__DIR__ . '/../database/migrations');

        // Publish views
        $this->loadViewsFrom(__DIR__ . '/../resources/views', 'package-name');
        $this->publishes([
            __DIR__ . '/../resources/views' => resource_path('views/vendor/package-name'),
        ], 'package-name-views');

        // Register commands
        if ($this->app->runningInConsole()) {
            $this->commands([
                Commands\InstallCommand::class,
                Commands\PublishCommand::class,
            ]);
        }

        // Register routes
        $this->loadRoutesFrom(__DIR__ . '/../routes/web.php');
    }
}

Generated Facade

<?php

namespace VendorName\PackageName\Facades;

use Illuminate\Support\Facades\Facade;

/**
 * @method static mixed doSomething(string $param)
 * @method static self configure(array $options)
 *
 * @see \VendorName\PackageName\PackageName
 */
class PackageName extends Facade
{
    protected static function getFacadeAccessor(): string
    {
        return 'package-name';
    }
}

Composer Configuration

{
    "name": "vendor-name/package-name",
    "description": "A Laravel package that does something awesome",
    "keywords": ["laravel", "package"],
    "license": "MIT",
    "authors": [
        {
            "name": "Your Name",
            "email": "you@example.com"
        }
    ],
    "require": {
        "php": "^8.2",
        "illuminate/support": "^11.0"
    },
    "require-dev": {
        "orchestra/testbench": "^9.0",
        "pestphp/pest": "^2.0",
        "laravel/pint": "^1.0"
    },
    "autoload": {
        "psr-4": {
            "VendorName\\PackageName\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "VendorName\\PackageName\\Tests\\": "tests/"
        }
    },
    "scripts": {
        "test": "vendor/bin/pest",
        "format": "vendor/bin/pint"
    },
    "extra": {
        "laravel": {
            "providers": [
                "VendorName\\PackageName\\PackageNameServiceProvider"
            ],
            "aliases": {
                "PackageName": "VendorName\\PackageName\\Facades\\PackageName"
            }
        }
    },
    "minimum-stability": "stable"
}

Test Setup with Orchestra Testbench

<?php

namespace VendorName\PackageName\Tests;

use Orchestra\Testbench\TestCase as Orchestra;
use VendorName\PackageName\PackageNameServiceProvider;

class TestCase extends Orchestra
{
    protected function setUp(): void
    {
        parent::setUp();

        // Run package migrations
        $this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
    }

    protected function getPackageProviders($app): array
    {
        return [
            PackageNameServiceProvider::class,
        ];
    }

    protected function getPackageAliases($app): array
    {
        return [
            'PackageName' => \VendorName\PackageName\Facades\PackageName::class,
        ];
    }

    protected function defineEnvironment($app): void
    {
        $app['config']->set('database.default', 'testing');
        $app['config']->set('database.connections.testing', [
            'driver' => 'sqlite',
            'database' => ':memory:',
        ]);
    }
}

// tests/Feature/ExampleTest.php
use VendorName\PackageName\Facades\PackageName;

it('can do something', function () {
    $result = PackageName::doSomething('test');

    expect($result)->toBe('expected');
});

GitHub Actions CI

# .github/workflows/tests.yml
name: Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        php: [8.2, 8.3]
        laravel: [11.*]
        dependency-version: [prefer-lowest, prefer-stable]

    name: PHP $ - Laravel $ - $

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: $
          extensions: dom, curl, libxml, mbstring, zip
          coverage: none

      - name: Install dependencies
        run: |
          composer require "laravel/framework:$" --no-interaction --no-update
          composer update --$ --prefer-dist --no-interaction

      - name: Run tests
        run: vendor/bin/pest

Install Command

<?php

namespace VendorName\PackageName\Commands;

use Illuminate\Console\Command;

class InstallCommand extends Command
{
    protected $signature = 'package-name:install';
    protected $description = 'Install the PackageName package';

    public function handle(): int
    {
        $this->info('Installing PackageName...');

        // Publish config
        $this->call('vendor:publish', [
            '--tag' => 'package-name-config',
        ]);

        // Publish migrations
        if ($this->confirm('Would you like to publish migrations?', true)) {
            $this->call('vendor:publish', [
                '--tag' => 'package-name-migrations',
            ]);

            if ($this->confirm('Run migrations now?', true)) {
                $this->call('migrate');
            }
        }

        $this->info('PackageName installed successfully!');

        return self::SUCCESS;
    }
}

Local Development Setup

// In your Laravel app's composer.json
{
    "repositories": [
        {
            "type": "path",
            "url": "./packages/vendor-name/package-name"
        }
    ],
    "require": {
        "vendor-name/package-name": "*"
    }
}

// Then run:
// composer update vendor-name/package-name

Invoked By Commands

Guardrails

The package agent follows strict rules:

  • ALWAYS use PSR-4 autoloading
  • ALWAYS include comprehensive tests with Testbench
  • ALWAYS support Laravel auto-discovery
  • NEVER hardcode application-specific dependencies
  • NEVER publish to Packagist without tests passing

See Also

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