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
- laravel-architect - When extracting to package
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
- laravel-module-builder - Internal modules
- laravel-cicd - CI/CD setup
- laravel-testing - Test generation
</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