Search by

edalzell / laravel-features

edalzell

Self-contained feature modules for Laravel with auto-registration of routes, migrations, views, events, and seeders

Package info

github.com/edalzell/laravel-features

pkg:composer/edalzell/laravel-features

Statistics

Installs: 1 244

Dependents: 0

Suggesters: 0

Stars: 20

Open Issues: 0

v0.10.0 2026-09-15 03:54 UTC

README

Latest Version on Packagist GitHub Tests Action Status GitHub Code Style Action Status Total Downloads

Add self-contained features to your Laravel app or package, including all resources/routes/etc.

.
└── app/
...
└── features/
  │   └── MyGreatFeature/
  │       ├── config/
  │       │   └── my-great-feature.php
  │       ├── database/
  │       │   ├── factories
  │       │   ├── migrations
  │       │   └── seeders
  │       ├── resources
  │       ├── routes
  │       └── src/
  │           ├── Models
  │           ├── ...
  │           └── ServiceProvider.php

Each feature behaves like a mini Laravel app. The following are auto-registered and booted:

Phase What
Register Config, Migrations, Seeders, Views
Boot Config publishing, Listeners, Livewire components, Policies, Routes, Seeders

Route groups

A feature's route files are put in a route group, chosen by filename, the same way the framework does it for an application's own route files:

File Group
routes/web.php web middleware
routes/api.php api middleware, api prefix
anything else no middleware group, no prefix

Without this, loadRoutesFrom() is a bare require — a feature's routes/web.php would get no session or CSRF, and routes/api.php no throttling and no prefix.

Routes are loaded on boot, not register. A route file may reach for a macro that another package defines — Route::livewire() is the common one — and packages register in discovery order, so this one can run well before the package supplying the macro. Booting happens once every provider has registered, which is also where the framework loads its own route files.

Publish the config to change a group for all features at once — to add an API version, for example:

php artisan vendor:publish --tag=features-config
// config/features.php
'route_groups' => [
    'web' => ['middleware' => 'web'],
    'api' => ['middleware' => 'api', 'prefix' => 'api/v1', 'as' => 'api.v1.'],
],

Or override routeGroups() on one feature's service provider:

protected function routeGroups(): array
{
    return ['api' => ['middleware' => ['api', 'auth:sanctum'], 'prefix' => 'api/internal']];
}

Set an entry to null, or remove it, and that file gets no middleware group and no prefix — only what it declares itself.

Livewire components

Livewire only looks for components in the app's own locations, so it never sees a feature's. Put class components in src/Livewire, and single- or multi-file components under resources/viewslivewire/ for ordinary components and pages/ for full pages, the same split Livewire uses for the app. The views root is registered as the feature's Livewire namespace, so both directories answer under one name with the directory as a dotted prefix:

MyGreatFeature/
├── resources/
│   └── views/
│       ├── livewire/
│       │   ├── greeting.blade.php    -> <livewire:my-great-feature::livewire.greeting />
│       │   └── checklist/            -> <livewire:my-great-feature::livewire.checklist />
│       │       ├── checklist.php
│       │       └── checklist.blade.php
│       └── pages/
│           └── show.blade.php        -> Route::livewire('show', 'my-great-feature::pages.show')
└── src/
    └── Livewire/
        ├── PostList.php              -> <livewire:my-great-feature::post-list />
        └── Posts/
            ├── Index.php             -> <livewire:my-great-feature::posts />
            └── ShowPost.php          -> <livewire:my-great-feature::posts.show-post />

Single- and multi-file components follow Livewire's own rules — a single-file component is a .blade.php holding a new class extends Component block, a multi-file component a directory holding name.php and name.blade.php alongside any name.js or name.css. Nothing is scanned: Livewire resolves a name the first time it is used, exactly as it does for the app's own components.

The feature's slug goes in front so two features can each have a PostList. Change it, or drop it, on one feature's service provider:

protected function livewireNamespace(): string
{
    return '';
}

An empty string registers the feature's directories as plain locations instead, so its components answer to their bare names.

Full-page components

A feature's namespace is a peer of Livewire's own pages:: and layouts:: — those are just default entries in the app's component_namespaces, not a separate mechanism — so a feature routes one of its components as a full page from its own routes/web.php, with no extra registration:

Route::livewire('posts/create', 'my-great-feature::create-post');

The page renders into livewire.component_layout, the app's layout. To give a feature its own, point a component at a view from the feature's resources/views:

#[Layout('my-great-feature::layout')]

This needs Livewire 4, which introduced both the namespace registration and view-based components. Livewire is not a dependency of this package: when it isn't installed, nothing is registered and nothing breaks.

Seeders

Every feature's database/seeders are collected and run together, in the order their features were registered — alphabetical, for an app's own features/ directory. That order breaks down once one feature's data depends on another's.

#[SeedAfter] names the seeders that must run first:

use Edalzell\Features\Attributes\SeedAfter;
use Illuminate\Database\Seeder;

#[SeedAfter(ListingSeeder::class)]
class AvailabilitySeeder extends Seeder
{
    public function run(): void
    {
        // ...
    }
}

A seeder with no #[SeedAfter], or whose dependencies are already satisfied, keeps its registration position — that's the tiebreak, not alphabetical or random order. Naming a seeder outside the collected set is fine and is simply ignored for ordering; that one is the app's own database/seeders to order. A cycle between #[SeedAfter] declarations throws a LogicException naming the seeders involved.

Installation

You can install the package via composer:

composer require edalzell/laravel-features

Usage

To add a new feature in your app:

php artisan make:feature MyGreatFeature

To add feature to a package:

php artisan make:feature MyGreatFeature the-dev/my-package

This creates a ServiceProvider that extends FeatureServiceProvider — everything is auto-registered with no further code required.

Option 1: Extend FeatureServiceProvider

The zero-friction path. Your provider gets boot() and register() for free:

class MyGreatFeatureServiceProvider extends FeatureServiceProvider
{
    // nothing needed — everything is auto-registered
}

Override any of these protected methods to customise behaviour:

protected function configFileName(): string      // default: kebab-cased feature name
protected function configGroup(): string         // default: host Composer package short name when registered via package `features/` (e.g. transformstudios/prime → prime); merge key `{group}.{file}`, publish to `config/{group}/{file}.php`
protected function configPublishHandle(): string // default: kebab-cased feature name
protected function featuresPath(): string        // default: derived from the provider's own location
protected function livewireNamespace(): string   // default: kebab-cased feature name
protected function routeGroups(): array          // default: config('features.route_groups')

Option 2: Standalone Features object

When your provider already extends another class, wire up Features directly:

use Edalzell\Features\Features;

class MyServiceProvider extends SomeOtherProvider
{
    private Features $features;

    public function __construct(Application $app)
    {
        parent::__construct($app);

        $this->features = (new Features($this))
            ->path($this->featuresPath())
            ->name($this->name())
            ->configFileName($this->configFileName())
            ->configGroup($this->configGroup())
            ->configPublishHandle($this->configPublishHandle());
    }

    public function boot(): void
    {
        $this->features->bootFeature();
    }

    public function register(): void
    {
        $this->features->registerFeature();
    }
}

Features derives the path, namespace, and app from your provider via reflection. You only need to call the fluent setters when overriding the defaults.

Auto-discovering features

Use the HasFeatures trait in any service provider to automatically register all features from a directory. In your app, add it to AppServiceProvider:

use Edalzell\Features\Concerns\HasFeatures;

class AppServiceProvider extends ServiceProvider
{
    use HasFeatures;

    public function register(): void
    {
        $this->registerFeatures(app_path('../features'), 'App\\Features');
    }
}

For a package, add it to your package's main service provider:

use Edalzell\Features\Concerns\HasFeatures;

class MyPackageServiceProvider extends ServiceProvider
{
    use HasFeatures;

    public function register(): void
    {
        $this->registerFeatures();
    }
}

In a package, registerFeatures() defaults to looking in <package-root>/features/ and registering providers under YourPackage\Features\FeatureName\ServiceProvider. Pass explicit arguments to override either default:

$this->registerFeatures('/path/to/features', 'My\\Namespace\\Features');

Looking up registered features

registerFeatures() also records each discovered feature in FeatureRegistry. Use that when something outside the feature needs its path or namespace — Setup runners, for example — without re-scanning disk or hardcoding conventions:

use Edalzell\Features\Feature;
use Edalzell\Features\FeatureRegistry;

app(FeatureRegistry::class)
    ->all()
    ->filter(fn (Feature $feature) => $feature->has('src/Setup'))
    ->each(function (Feature $feature) {
        $feature->path('src/Setup');      // absolute path
        $feature->namespace('Setup');     // App\Features\Mail\Setup
    });

registerFeatures() records each feature via FeatureRegistry::register(), which also registers that feature's ServiceProvider. Feature::path() / namespace() with no argument return the feature root. has() checks that a relative path exists on disk. Use FeatureRegistry::add() when you only need the descriptor without loading the provider (e.g. tests).

Features outside the app

A feature works from anywhere — the app, a package, or a directory outside the app entirely, such as a monorepo where two apps share one set of features:

gym/
├── apps/
│   ├── server/
│   └── mobile/
└── shared/
    └── features/
        └── Scheduling/

Two things need wiring, in each app that uses them.

Autoloading — declare the directory and its namespace in composer.json, and the plugin generates PSR-4 entries for every feature it finds:

"extra": {
    "laravel-features": {
        "paths": {
            "../../shared/features": "Shared\\Features"
        }
    }
}

Registration — point registerFeatures() at the same directory:

$this->registerFeatures(base_path('../../shared/features'), 'Shared\\Features');

The app's own features/ directory is still scanned, so app-local and shared features can coexist.

Testing

composer test

Changelog

Please see CHANGELOG for more information on what has changed recently.

License

The MIT License (MIT). Please see License File for more information.