laravel-hoist maintained by offload-project
Laravel Hoist
Feature discovery and management extension for Laravel Pennant. Automatically discover, manage, and serve feature flags with custom metadata and routing.
Requirements
- PHP 8.3+
- Laravel 11/12/13
- Laravel Pennant 1+
Installation
composer require offload-project/laravel-hoist
Configuration
Publish the configuration file:
php artisan vendor:publish --tag=hoist-config
Edit config/hoist.php:
return [
'feature_directories' => [
app_path('Features') => 'App\\Features',
],
];
The configuration uses an associative array where keys are directory paths and values are their corresponding namespaces.
Optionally, publish the stub files for customization:
php artisan vendor:publish --tag=hoist-stubs
Features
Feature Discovery
Automatically discover and manage Laravel Pennant features with custom metadata and routing information.
Create a Feature
php artisan hoist:feature NewFeature
This will create a new feature class in your configured feature directory (default: app/Features).
Feature Class Example
Features can define metadata using PHP attributes (recommended) or class properties. Attributes take precedence over properties when both are present.
<?php
declare(strict_types=1);
namespace App\Features;
use OffloadProject\Hoist\Attributes\Description;
use OffloadProject\Hoist\Attributes\FeatureSet;
use OffloadProject\Hoist\Attributes\Label;
use OffloadProject\Hoist\Attributes\Route;
use OffloadProject\Hoist\Attributes\Tags;
use OffloadProject\Hoist\Contracts\Feature;
#[Label('Billing Module')]
#[Description('Advanced billing features')]
#[Route('billing.index')]
#[Tags('subscription', 'pro')]
#[FeatureSet('premium')]
class BillingFeature implements Feature
{
public string $name = 'billing';
public function resolve(mixed $scope): mixed
{
return $scope->subscription?->isActive() ?? false;
}
public function metadata(): array
{
return [
'category' => 'premium',
'icon' => 'credit-card',
'version' => '2.0',
];
}
}
Note: The
Featureinterface is optional but recommended. Features are discovered based on having aresolve()method, but implementing the interface provides better IDE support and type safety.
Attributes
PHP attributes provide a clean, declarative way to define feature metadata directly on the class. All attributes are optional and target the class level.
| Attribute | Parameter | Description |
|---|---|---|
#[Label('...')] |
string $value |
Human-readable display name |
#[Description('...')] |
string $value |
Feature description |
#[Route('...')] |
string $value |
Named route for generating the feature's href |
#[Tags('...')] |
string ...$tags |
One or more tags for categorization |
#[FeatureSet('...')] |
string $name, ?string $label |
Group features into a named set |
When an attribute is present, it takes precedence over the equivalent class property. You can mix both approaches — for example, use attributes for static metadata and properties for values that need to be computed.
// Properties-only approach (still supported)
class MyFeature implements Feature
{
public string $name = 'my-feature';
public string $label = 'My Feature';
public ?string $description = 'A description';
public ?string $route = 'my-feature.index';
public array $tags = ['flag'];
public string $featureSet = 'core';
public function resolve(mixed $scope): mixed
{
return true;
}
}
Using Features
use OffloadProject\Hoist\Facades\Hoist;
// Get all features
$features = Hoist::all();
// Get features for a specific user with active status
$userFeatures = Hoist::forModel($user);
// Get array of all feature names
$featureNames = Hoist::names();
// Returns: ['billing', 'dashboard', 'reporting', ...]
// Access feature data
foreach ($userFeatures as $feature) {
echo $feature->name; // 'billing'
echo $feature->label; // 'Billing Module'
echo $feature->description; // 'Advanced billing features'
echo $feature->href; // route('billing.index')
echo $feature->active; // true/false
print_r($feature->metadata); // ['category' => 'premium', ...]
print_r($feature->tags); // ['subscription', 'pro']
}
Feature Discovery Service
The FeatureDiscovery service provides several methods for working with features:
discover()
Discovers all feature classes from configured directories.
use OffloadProject\Hoist\Services\FeatureDiscovery;
$discovery = app(FeatureDiscovery::class);
$featureClasses = $discovery->discover();
// Returns: Collection of feature class names
all()
Returns all features as FeatureData objects without checking active status.
$features = Hoist::all();
// Returns: Collection of FeatureData objects
forModel($model)
Returns all features with their active status for a specific model (typically a User).
$userFeatures = Hoist::forModel($user);
// Each FeatureData object includes 'active' property
names()
Returns an array of all feature names.
$names = Hoist::names();
// Returns: ['feature-one', 'feature-two', ...]
Feature Tags
Tags provide a flexible way to categorize features for filtering. Use tags to separate feature flags from subscription features, or to group features by plan tier.
Define Tags
use OffloadProject\Hoist\Attributes\Label;
use OffloadProject\Hoist\Attributes\Tags;
#[Label('Dark Mode')]
#[Tags('flag', 'ui')]
class DarkMode implements Feature
{
public string $name = 'dark-mode';
// ...
}
#[Label('Advanced Reporting')]
#[Tags('subscription', 'pro', 'enterprise')]
class AdvancedReporting implements Feature
{
public string $name = 'advanced-reporting';
// ...
}
Filter by Tags
// Get features with a specific tag
$flags = Hoist::tagged('flag');
$subscriptionFeatures = Hoist::tagged('subscription');
// Get features with ALL specified tags (AND logic)
$proSubscriptions = Hoist::withTags(['subscription', 'pro']);
// Get features with ANY of the specified tags (OR logic)
$paidFeatures = Hoist::withAnyTags(['pro', 'enterprise']);
Filter with Model Scope
Include active status when filtering by tags:
// Get subscription features for a user with active status
$features = Hoist::taggedFor('subscription', $user);
// Get pro features for a user
$proFeatures = Hoist::withTagsFor(['subscription', 'pro'], $user);
// Get any paid tier features for a user
$paidFeatures = Hoist::withAnyTagsFor(['pro', 'enterprise'], $user);
Feature Data Structure
The FeatureData class provides a structured way to access feature information:
class FeatureData
{
public string $name; // Feature identifier
public string $label; // Human-readable name
public ?string $description; // Feature description
public ?string $href; // Generated route URL
public ?bool $active; // Active status (when using forModel)
public array $metadata; // Custom metadata
public array $tags; // Feature tags for categorization
public ?string $featureSet; // Feature set grouping
}
Integration with Laravel Pennant
This package extends Laravel Pennant by providing:
- Automatic Discovery: No need to manually register features
- PHP Attributes: Declarative metadata using native PHP attributes
- Rich Metadata: Add custom metadata to features
- Route Integration: Link features to routes automatically
- Structured Data: Get features as structured data objects
- Bulk Operations: Get all features and their status in one call
Using with Pennant's Native Features
You can still use all of Laravel Pennant's native features:
use Laravel\Pennant\Feature;
// Standard Pennant usage
if (Feature::active('billing')) {
// Feature is active
}
// In Blade
@feature('billing')
<!-- Feature content -->
@endfeature
// Combined with Pennant Hoist
$features = Hoist::forModel($user);
foreach ($features as $feature) {
if ($feature->active) {
// Do something with active feature
}
}
Use Cases
Building a Feature Dashboard
public function featureDashboard(Request $request)
{
$features = Hoist::forModel($request->user());
return view('features.dashboard', [
'features' => $features,
]);
}
<!-- resources/views/features/dashboard.blade.php -->
<div class="features-grid">
@foreach($features as $feature)
<div class="feature-card {{ $feature->active ? 'active' : 'inactive' }}">
<h3>{{ $feature->label }}</h3>
<p>{{ $feature->description }}</p>
@if($feature->active && $feature->href)
<a href="{{ $feature->href }}" class="btn">
Go to {{ $feature->label }}
</a>
@endif
@if(!empty($feature->metadata['icon']))
<i class="icon-{{ $feature->metadata['icon'] }}"></i>
@endif
</div>
@endforeach
</div>
API Endpoint for Frontend
Route::get('/api/features', function (Request $request) {
return Hoist::forModel($request->user());
});
Returns:
[
{
"name": "billing",
"label": "Billing Module",
"description": "Advanced billing features",
"href": "https://app.example.com/billing",
"active": true,
"metadata": {
"category": "premium",
"icon": "credit-card"
},
"tags": [
"subscription",
"pro"
],
"featureSet": "premium"
}
]
Dynamic Navigation
public function navigation(Request $request)
{
$features = Hoist::forModel($request->user())
->filter(fn($f) => $f->active && $f->href)
->filter(fn($f) => $f->metadata['show_in_nav'] ?? true);
return view('layouts.navigation', [
'features' => $features,
]);
}
Advanced Usage
Custom Feature Directories
You can configure multiple feature directories, each mapped to its namespace:
// config/hoist.php
return [
'feature_directories' => [
app_path('Authorization/Features') => 'App\\Authorization\\Features',
app_path('Billing/Features') => 'App\\Billing\\Features',
app_path('Admin/Features') => 'App\\Admin\\Features',
],
];
Each directory is mapped to its corresponding namespace, allowing you to organize features across different modules or domains while maintaining proper class resolution.
Feature Organization
Organize features by category:
app/Features/
├── Admin/
│ ├── UserManagementFeature.php
│ └── SystemSettingsFeature.php
├── Premium/
│ ├── BillingFeature.php
│ └── AnalyticsFeature.php
└── Core/
├── DashboardFeature.php
└── ProfileFeature.php
Route Handling
The href property in FeatureData is generated from the feature's route value (via the #[Route] attribute or
$route property). The package safely handles routes:
- If no route is defined,
hrefwill benull - If the route name doesn't exist,
hrefwill benull(no exception thrown) - If the route name is valid,
hrefwill contain the generated URL
use OffloadProject\Hoist\Attributes\Route;
#[Route('dashboard.index')]
class MyFeature implements Feature
{
public string $name = 'my-feature';
// ...
}
The Feature Interface
The package provides an optional Feature interface for better type safety:
use OffloadProject\Hoist\Attributes\Description;
use OffloadProject\Hoist\Attributes\Label;
use OffloadProject\Hoist\Contracts\Feature;
#[Label('My Feature')]
#[Description('A description')]
class MyFeature implements Feature
{
public string $name = 'my-feature';
public function resolve(mixed $scope): mixed
{
return true;
}
public function metadata(): array
{
return [];
}
}
Features are discovered if they either:
- Implement the
Featureinterface, OR - Have a
resolve()method (for backward compatibility with plain Pennant features)
Metadata Best Practices
Use metadata for:
- Categorization: Group features by category
- UI Elements: Icons, colors, badges
- Permissions: Access levels, roles
- Versioning: Track feature versions
- Analytics: Track feature usage
public function metadata(): array
{
return [
'category' => 'premium',
'icon' => 'credit-card',
'color' => 'blue',
'version' => '2.0',
'requires_subscription' => true,
'min_plan' => 'pro',
];
}
Testing
./vendor/bin/pest
License
The MIT License (MIT). Please see License File for more information.