offload-project/laravel-hoist

Feature discovery and util extension for Laravel Pennant

Maintainers

Package info

github.com/offload-project/laravel-hoist

pkg:composer/offload-project/laravel-hoist

Statistics

Installs: 535

Dependents: 0

Suggesters: 0

Stars: 4

Open Issues: 0

v1.3.0 2026-03-31 02:58 UTC

README

Latest Version on Packagist GitHub Tests Action Status Total Downloads

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 Feature interface is optional but recommended. Features are discovered based on having a resolve() 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:

  1. Automatic Discovery: No need to manually register features
  2. PHP Attributes: Declarative metadata using native PHP attributes
  3. Rich Metadata: Add custom metadata to features
  4. Route Integration: Link features to routes automatically
  5. Structured Data: Get features as structured data objects
  6. 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, href will be null
  • If the route name doesn't exist, href will be null (no exception thrown)
  • If the route name is valid, href will 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:

  1. Implement the Feature interface, OR
  2. 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.