jonquihote / pluggable
This package allows developers to plug their custom code into Action/Filter hooks, similar to WordPress
Requires
- php: ^8.3
- crell/tukio: ^2.0
- illuminate/support: ^12.0||^13.0
Requires (Dev)
- larastan/larastan: ^3.9
- laravel/pao: ^1.0
- laravel/pint: ^1.29
- orchestra/testbench: ^10.0||^11.0
- pestphp/pest: ^4.6||^5.0
- pestphp/pest-plugin-laravel: ^4.1||^5.0
- pestphp/pest-plugin-type-coverage: ^4.0||^5.0
- phpstan/extension-installer: ^1.4
README
Pluggable
Typed, synchronous WordPress-style actions and filters for Laravel. Pluggable lets packages and application modules react to events or contribute values without depending directly on each other. Dispatch uses the PSR-14 standard through Tukio.
Installation
You can install the package via Composer:
composer require jonquihote/pluggable
Laravel discovers the package service provider and facade automatically. The package has no configuration, migrations, routes, views, or assets to publish.
Laravel Boost
Pluggable ships a Boost skill that teaches supported AI coding agents how to define, register, dispatch, and inspect actions and filters. After installing this package in an application that uses Laravel Boost, run:
php artisan boost:update
Select the pluggable-development skill when prompted. For a new Boost setup,
php artisan boost:install --skills discovers the skill during installation.
Actions
Actions announce that something happened. Listeners perform side effects while the action object can carry results back to its caller.
<?php namespace App\Pluggables; use Pluggable\Pluggable\PluggableAction; final class OrderPlaced extends PluggableAction { public function __construct(public readonly int $orderId) {} }
Dispatching is synchronous. Every listener finishes before dispatch() returns:
$action = OrderPlaced::dispatch($order->id);
Filters
Filters pass a typed value through listeners. Each listener can replace that value, making filters useful for transformations and cross-module aggregation.
<?php namespace App\Pluggables; use Illuminate\Support\Collection; use Pluggable\Pluggable\PluggableFilter; /** @extends PluggableFilter<Collection<int, string>> */ final class NavigationItems extends PluggableFilter { /** @param Collection<int, string> $items */ public function __construct(Collection $items = new Collection) { parent::__construct($items); } } $items = NavigationItems::dispatch()->getValue();
Registering pluggables and listeners
Create one provider for each logical package or application module. $module is
introspection metadata only; it does not affect dispatch.
<?php namespace App\Providers; use App\Listeners\AddAccountNavigation; use App\Listeners\SendOrderReceipt; use App\Pluggables\NavigationItems; use App\Pluggables\OrderPlaced; use Pluggable\Pluggable\Providers\PluggableServiceProvider; final class AppPluggableServiceProvider extends PluggableServiceProvider { protected string $module = 'app'; protected array $actions = [ OrderPlaced::class, ]; protected array $filters = [ NavigationItems::class, ]; protected array $listen = [ OrderPlaced::class => [ SendOrderReceipt::class, ], NavigationItems::class => [ [AddAccountNavigation::class, 'handle'], ], ]; }
Register this consumer provider in bootstrap/providers.php:
return [ App\Providers\AppServiceProvider::class, App\Providers\AppPluggableServiceProvider::class, ];
Listener classes are resolved lazily from Laravel's container, so constructor
injection works normally. A class entry invokes __invoke(); an array entry calls
the named method.
final readonly class SendOrderReceipt { public function __construct(private ReceiptSender $receipts) {} public function __invoke(OrderPlaced $action): void { $this->receipts->sendForOrder($action->orderId); } }
Override bootModuleEvents() when a module needs to bridge native Laravel events
into a pluggable action or filter. Keep framework-owned event subscriptions next
to module's pluggable declarations:
protected function bootModuleEvents(): void { Order::created( fn (Order $order) => OrderPlaced::dispatch($order->getKey()), ); }
For ad-hoc closure listeners or tests, use the manager directly. Higher priority listeners run first:
pluggable()->listen( NavigationItems::class, fn (NavigationItems $filter) => $filter->getValue()->push('Account'), priority: 20, );
Call $action->stopPropagation() or $filter->stopPropagation() to prevent later
listeners from running.
Introspection
List registered actions, filters, and listener counts:
php artisan pluggable:list php artisan pluggable:list --type=filter --module=app
Inspect one pluggable and its listeners:
php artisan pluggable:inspect 'App\Pluggables\OrderPlaced'
Registry data is also available through pluggable()->registry() or the
Pluggable facade.
When to use Pluggable
Use actions when one module owns a signal and another owns the reaction. Use filters when several modules contribute to or transform one value. Prefer direct calls or normal Laravel events inside one module. Dispatch is synchronous and in-process; listeners should enqueue jobs when work belongs in the background.
Contributing
Thank you for considering contributing to Pluggable! Please review our contributing guide to get started.
Credits
License
Pluggable is open-sourced software licensed under the MIT license.