Core
Core Plugins
Wire Core includes a plugin API for application-level extensions and companion packages. A plugin groups reusable setup in one place: macros, type registries, query pipes, hook callbacks, default configuration, and package integration.
Wire Core includes a plugin API for application-level extensions and companion packages. A plugin groups reusable setup in one place: macros, type registries, query pipes, hook callbacks, default configuration, and package integration.
For a single table, form, or action, prefer the public fluent API first. Use a plugin when the same behavior should be installed once and reused across multiple components, projects, or packages.
When To Use A Plugin
| Need | Prefer |
|---|---|
| Change one table query | Table::modifyQueryUsing() |
| Add one form save callback | Form lifecycle callbacks |
| Add one action behavior | Action fluent API |
| Reuse a table/action macro everywhere | Plugin boot() |
| Add the same table button to many tables | Plugin table macro that merges actions |
| Add a query rule to many tables | Plugin query pipe or table.querying hook |
| Share a custom column/filter/action class by name | Plugin type registry |
| Build a companion package | Plugin plus package service provider |
| Add audit, telemetry, tenant scope, or policy integration | Plugin hooks |
What A Plugin Can Do
| Capability | API |
|---|---|
| Register a plugin instance | PluginManager::register() |
| Run startup code after all plugins are registered | Plugin::boot() |
| Add table/action macros | Laravel Macroable classes such as Table and Action |
| Register query pipes | PluginManager::addQueryPipe() |
| Register column classes by name | PluginManager::addColumnType() |
| Register filter classes by name | PluginManager::addFilterType() |
| Register action classes by name | PluginManager::addActionType() |
| Register hook callbacks | PluginManager::hook() |
| Run array payload hooks | PluginManager::runHook() |
| Run object payload hooks | PluginManager::runTypedHook() |
| Read merged plugin config | PluginManager::getPluginConfig() |
Quick Start
Create a plugin class:
<?php namespace App\Wire\Plugins; use Illuminate\Database\Eloquent\Builder;use NyonCode\WireCore\Core\Plugin\Contracts\Plugin;use NyonCode\WireCore\Core\Plugin\PluginManager;use NyonCode\WireTable\Table; final class TenantPlugin implements Plugin{ public function getId(): string { return 'tenant'; } public function register(PluginManager $manager): void { // } public function boot(PluginManager $manager): void { Table::macro('tenantScoped', function (?int $tenantId = null): static { $tenantId ??= auth()->user()?->tenant_id; return $this->modifyQueryUsing( fn (Builder $query) => $query->where('tenant_id', $tenantId) ); }); }}
Register it in config/wire-core.php:
'plugins' => [ App\Wire\Plugins\TenantPlugin::class,],
Use the macro from any table:
public function table(Table $table): Table{ return $table ->model(Order::class) ->tenantScoped() ->columns([ // ... ]);}
Plugin Contract
Every plugin implements NyonCode\WireCore\Core\Plugin\Contracts\Plugin.
<?php namespace App\Wire\Plugins; use NyonCode\WireCore\Core\Plugin\Contracts\Plugin;use NyonCode\WireCore\Core\Plugin\PluginManager; final class ExamplePlugin implements Plugin{ public function getId(): string { return 'example'; } public function register(PluginManager $manager): void { // Register hooks, query pipes, type aliases, or lightweight metadata. } public function boot(PluginManager $manager): void { // Register macros or resolve services after all plugins are registered. }}
The getId() value must be unique. Registering two plugins with the same ID throws a RuntimeException.
Lifecycle
| Step | Method | Use for |
|---|---|---|
| Registration | register(PluginManager $manager) |
Hooks, query pipes, column/filter/action types, lightweight metadata |
| Boot | boot(PluginManager $manager) |
Macros, resolved services, views, package setup that depends on the Laravel container |
PluginManager::register() calls the plugin's register() method immediately. PluginManager::boot() runs each plugin's boot() method once.
Keep register() lightweight. Do not resolve request-scoped services or assume every Laravel service has already booted. Use boot() for work that needs the container, views, macros, or other registered plugins.
Register Plugins In Config
Publish the core config:
php artisan vendor:publish --tag=wire-core-config
Add plugin classes to config/wire-core.php:
'plugins' => [ App\Wire\Plugins\TenantPlugin::class, App\Wire\Plugins\AuditExportPlugin::class,],
Wire resolves config-registered plugins through Laravel's container when the plugin manager is resolved. Invalid entries are ignored, so only class names implementing Plugin are registered.
Register Plugins From A Package
If you are building a companion package, register your plugin from the package service provider.
use Illuminate\Support\ServiceProvider;use NyonCode\WireCore\Core\Plugin\PluginManager; final class AcmeWireServiceProvider extends ServiceProvider{ public function register(): void { $this->app->resolving(PluginManager::class, function (PluginManager $manager) { if (! $manager->has('acme')) { $manager->register($this->app->make(AcmePlugin::class)); } }); }}
The has() guard prevents duplicate registration if the application also lists the plugin in config.
Plugin Configuration
Plugins that accept user options can implement HasConfiguration.
<?php namespace App\Wire\Plugins; use NyonCode\WireCore\Core\Plugin\Contracts\HasConfiguration;use NyonCode\WireCore\Core\Plugin\Contracts\Plugin;use NyonCode\WireCore\Core\Plugin\PluginManager; final class ExportPlugin implements HasConfiguration, Plugin{ public function getId(): string { return 'export'; } public function defaultConfig(): array { return [ 'format' => 'csv', 'chunk_size' => 500, ]; } public function register(PluginManager $manager): void { // } public function boot(PluginManager $manager): void { $config = $manager->getPluginConfig($this->getId()); // $config is the merged default and user configuration. }}
User overrides live under wire-core.plugins.config.{pluginId}:
'plugins' => [ App\Wire\Plugins\ExportPlugin::class, 'config' => [ 'export' => [ 'format' => 'xlsx', ], ],],
The manager merges the plugin defaults with user config using array_merge(). Top-level keys from user config replace default keys.
Plugin Dependencies
Plugins that require other plugins can implement HasDependencies.
<?php namespace App\Wire\Plugins; use NyonCode\WireCore\Core\Plugin\Contracts\HasDependencies;use NyonCode\WireCore\Core\Plugin\Contracts\Plugin;use NyonCode\WireCore\Core\Plugin\PluginManager; final class BillingExportPlugin implements HasDependencies, Plugin{ public function getId(): string { return 'billing-export'; } public function dependencies(): array { return ['export']; } public function register(PluginManager $manager): void { // } public function boot(PluginManager $manager): void { // }}
Dependencies must already be registered. If a dependency is missing, PluginManager::register() throws a RuntimeException.
Register dependent plugins after their dependencies:
'plugins' => [ App\Wire\Plugins\ExportPlugin::class, App\Wire\Plugins\BillingExportPlugin::class,],
Hook System
Hooks let plugins and application code communicate through named callbacks.
public function register(PluginManager $manager): void{ $manager->hook('orders.exporting', function (array $payload): array { $payload['query']->where('tenant_id', auth()->user()->tenant_id); return $payload; });}
Run the hook from your own service or component:
use NyonCode\WireCore\Core\Plugin\PluginManager; $payload = app(PluginManager::class)->runHook('orders.exporting', [ 'query' => Order::query(),]); $query = $payload['query'];
A hook only affects runtime behavior when some code calls runHook() or runTypedHook() for that hook name. Registering a hook stores the callback; it does not automatically patch table, form, or action behavior.
Hook Return Values
Array hooks receive the current payload array.
| Callback return | Result |
|---|---|
array |
Replaces the payload for the next callback |
null or another non-array value |
Keeps the current payload unchanged |
| exception | Bubbles up to the caller |
Hook Priority
Callbacks run by ascending priority. Lower numbers run earlier.
public function register(PluginManager $manager): void{ $manager->hook('table.querying', fn (array $payload) => $payload, priority: -100); $manager->hook('table.querying', fn (array $payload) => $payload); $manager->hook('table.querying', fn (array $payload) => $payload, priority: 100);}
Suggested ranges:
| Priority | Use for |
|---|---|
-100 |
Security, tenancy, scoping |
0 |
Normal feature behavior |
100 |
Audit, logging, telemetry |
Callbacks with the same priority keep their registration order.
Runtime Hooks
These hooks are emitted by the current packages:
| Hook | Package | When | Payload | Consumes returned payload |
|---|---|---|---|---|
table.querying |
Table | Before the table query is planned | table, columns, filters, sort_column, sort_direction, search |
Yes, reads force_sort_column and force_sort_direction |
form.saving |
Forms | After mutation and before persistence | config, data |
Yes, reads modified data |
form.saved |
Forms | After persistence and relationship save | config, record |
No |
action.executing |
Table | Before the action pipeline runs | action, actionName, actionType, recordIds, data, component |
No |
action.executed |
Table | After the action pipeline runs | action, actionName, actionType, recordIds, result, component |
No |
The plugin manager does not enforce hook names. For application hooks, use names that describe your boundary, such as orders.exporting, orders.exported, billing.invoice.saving, or crm.customer.synced.
Example: Force Table Sort In A Hook
The sortable package uses table.querying to force a sort while a table is in reorder mode. The same pattern works for application-specific query rules.
public function register(PluginManager $manager): void{ $manager->hook('table.querying', function (array $payload): array { $table = $payload['table'] ?? null; if (! $table instanceof OrdersTable) { return $payload; } $payload['force_sort_column'] = 'position'; $payload['force_sort_direction'] = 'asc'; return $payload; }, priority: -100);}
Use modifyQueryUsing() when you only need to change one table. Use table.querying when the rule belongs to a reusable integration.
Typed Hooks
runTypedHook() is available for extension points that prefer object payloads instead of arrays.
final class ExportingOrders{ public function __construct( public Builder $query, public string $format, ) {}} $payload = app(PluginManager::class)->runTypedHook( 'orders.exporting', new ExportingOrders(Order::query(), 'csv'));
Callbacks receive the payload object. Returning an object replaces the payload for the next callback; returning null or another non-object keeps the current payload.
$manager->hook('orders.exporting', function (ExportingOrders $payload): ExportingOrders { $payload->query->where('tenant_id', auth()->user()->tenant_id); return $payload;});
Core also ships typed payload DTOs under NyonCode\WireCore\Core\Plugin\Hooks for common table, form, and action hook shapes. The current runtime hooks use array payloads, so these DTOs are most useful when building your own typed extension points or plugin-aware services.
Column, Filter, And Action Type Registries
Plugins can register class aliases for plugin-aware builders, admin tooling, schema importers, or package integrations.
public function register(PluginManager $manager): void{ $manager->addColumnType('money', \App\Tables\Columns\MoneyColumn::class); $manager->addFilterType('date-range', \App\Tables\Filters\DateRangeFilter::class); $manager->addActionType('workflow', \App\Tables\Actions\WorkflowAction::class);}
Read the registries from the manager:
$columns = app(PluginManager::class)->getColumnTypes();$filters = app(PluginManager::class)->getFilterTypes();$actions = app(PluginManager::class)->getActionTypes();
Wire Table components still accept normal instances directly:
return $table ->columns([ MoneyColumn::make('total'), ]) ->filters([ DateRangeFilter::make('created_at'), ]);
Type registries are metadata registries. They do not automatically render a column, filter, or action by alias unless your own builder or package consumes the registry.
Adding Buttons And Actions
Most buttons in Wire tables are actions:
| UI placement | Class/API |
|---|---|
| Row button | Action in Table::actions() |
| Bulk toolbar button | BulkAction in Table::bulkActions() |
| Header toolbar button | HeaderAction in Table::headerActions() |
| Button inside a table cell | ButtonColumn in Table::columns() |
| Plain Blade button | <x-wire::button> |
Plugins do not automatically inject buttons into every table. The usual pattern is to register a table macro in boot() and let each table opt in. The macro should merge with existing actions instead of replacing them.
Header Button Macro
use App\Services\InvoiceExportService;use NyonCode\WireCore\Actions\HeaderAction;use NyonCode\WireCore\Core\Plugin\Contracts\Plugin;use NyonCode\WireCore\Core\Plugin\PluginManager;use NyonCode\WireTable\Table; final class BillingPlugin implements Plugin{ public function getId(): string { return 'billing'; } public function register(PluginManager $manager): void { // } public function boot(PluginManager $manager): void { Table::macro('withInvoiceExportButton', function (): static { return $this->headerActions([ ...$this->getHeaderActions(), HeaderAction::make('export-invoices') ->label('Export invoices') ->icon('download') ->action(fn () => app(InvoiceExportService::class)->queue()), ]); }); }}
Use the button on the tables that need it:
public function table(Table $table): Table{ return $table ->model(Invoice::class) ->withInvoiceExportButton() ->columns([ // ... ]);}
Row Button Macro
use NyonCode\WireCore\Actions\Action;use NyonCode\WireTable\Table; Table::macro('withAuditTrailButton', function (): static { return $this->actions([ ...$this->getActions(), Action::make('audit-trail') ->label('Audit') ->icon('history') ->url(fn ($record) => route('audit.show', [ 'type' => get_class($record), 'id' => $record->getKey(), ])), ]);});
Bulk Button Macro
use Illuminate\Support\Collection;use NyonCode\WireCore\Actions\BulkAction;use NyonCode\WireTable\Table; Table::macro('withBulkArchiveButton', function (): static { return $this->bulkActions([ ...$this->getBulkActions(), BulkAction::make('archive-selected') ->label('Archive selected') ->icon('archive') ->requiresConfirmation() ->action(fn (Collection $records) => $records->each->archive()), ]);});
Cell Button Column Macro
Use ButtonColumn when the button is part of each row's visible columns rather than the row action area.
use NyonCode\WireTable\Columns\ButtonColumn;use NyonCode\WireTable\Table; Table::macro('withPreviewButtonColumn', function (): static { return $this->columns([ ...$this->getColumns(), ButtonColumn::make('preview') ->buttonIcon('eye') ->buttonLabel('Preview') ->actionUrl(fn ($record) => route('records.preview', $record)), ]);});
Prefer actions for commands. Use ButtonColumn when the button needs to sit among other columns or when its state is naturally column-like. For cell links, use actionUrl(). For cell Livewire calls, use livewireAction() and implement that method on the Livewire table component.
Query Pipes
Plugins can register query pipe instances with the manager. Table query execution appends plugin pipes after the default query pipeline.
use Closure;use Illuminate\Database\Eloquent\Builder;use NyonCode\WireCore\Core\Query\Contracts\QueryPipe;use NyonCode\WireCore\Core\Query\QueryPlan; final class ApplyTenantScope implements QueryPipe{ public function handle(Builder $builder, QueryPlan $plan, Closure $next): Builder { $builder->where('tenant_id', auth()->user()->tenant_id); return $next($builder, $plan); }}
Register it from the plugin:
public function register(PluginManager $manager): void{ $manager->addQueryPipe('tenant', new ApplyTenantScope());}
Retrieve registered pipes for a custom query executor:
$pipes = app(PluginManager::class)->getQueryPipes();
Default table query pipe order:
| Order | Pipe |
|---|---|
| 1 | ApplyScopes |
| 2 | ApplySoftDeletes |
| 3 | ApplyRelations |
| 4 | ApplySearch |
| 5 | ApplyFilters |
| 6 | ApplySorting |
| 7 | ApplyAggregates |
| 8 | ApplyEagerLoads |
| 9+ | Plugin pipes |
Use table modifyQueryUsing() when the change belongs to one table. Use a query pipe when you are building reusable query behavior that should run as part of the shared query planner/executor pipeline.
Practical Example: Action Preset
Actions are macroable through their base action class. This plugin adds a reusable admin-only preset.
use NyonCode\WireCore\Actions\Action;use NyonCode\WireCore\Core\Plugin\Contracts\Plugin;use NyonCode\WireCore\Core\Plugin\PluginManager; final class AdminActionPlugin implements Plugin{ public function getId(): string { return 'admin-actions'; } public function register(PluginManager $manager): void { // } public function boot(PluginManager $manager): void { Action::macro('adminOnly', function (): static { return $this->authorizeUsing( fn ($user) => method_exists($user, 'isAdmin') && $user->isAdmin() ); }); }}
Use it on an action:
Action::make('impersonate') ->label('Impersonate') ->adminOnly() ->requiresConfirmation() ->action(fn (User $record) => auth()->user()->impersonate($record));
Practical Example: Form Audit
This plugin adds a small audit hook around form persistence.
use NyonCode\WireCore\Core\Plugin\Contracts\Plugin;use NyonCode\WireCore\Core\Plugin\PluginManager; final class FormAuditPlugin implements Plugin{ public function getId(): string { return 'form-audit'; } public function register(PluginManager $manager): void { $manager->hook('form.saving', function (array $payload): array { $payload['data']['updated_by'] ??= auth()->id(); return $payload; }); $manager->hook('form.saved', function (array $payload): void { logger()->info('Form saved', [ 'record' => $payload['record'] ?? null, ]); }, priority: 100); } public function boot(PluginManager $manager): void { // }}
form.saving can modify the data that will be persisted. form.saved is observational in the current runtime because the save handler does not consume its returned payload.
PluginManager API
| Method | Description |
|---|---|
register(Plugin $plugin): void |
Register a plugin and call its register() method |
boot(): void |
Boot every registered plugin once |
has(string $id): bool |
Check whether a plugin ID is registered |
get(string $id): ?Plugin |
Return a plugin by ID |
all(): array |
Return all registered plugins keyed by ID |
getPluginConfig(string $pluginId): array |
Return merged config for a configurable plugin |
addQueryPipe(string $name, QueryPipe $pipe): void |
Register a query pipe |
getQueryPipes(): array |
Return registered query pipes |
addColumnType(string $name, string $columnClass): void |
Register a column class alias |
getColumnTypes(): array |
Return column aliases |
addFilterType(string $name, string $filterClass): void |
Register a filter class alias |
getFilterTypes(): array |
Return filter aliases |
addActionType(string $name, string $actionClass): void |
Register an action class alias |
getActionTypes(): array |
Return action aliases |
hook(string $name, callable $callback, int $priority = 0): void |
Register a hook callback |
runHook(string $name, array $payload = []): array |
Run array hook callbacks and return the final payload |
runTypedHook(string $name, object $payload): object |
Run object hook callbacks and return the final payload |
hasHook(string $name): bool |
Check whether a hook has callbacks |
Testing Plugins
Test plugin behavior by instantiating PluginManager directly.
use NyonCode\WireCore\Core\Plugin\PluginManager; it('registers tenant plugin', function () { $manager = new PluginManager(); $plugin = new TenantPlugin(); $manager->register($plugin); expect($manager->has('tenant'))->toBeTrue();});
For macros, boot the plugin first:
it('adds tenant table macro', function () { $manager = new PluginManager(); $plugin = new TenantPlugin(); $manager->register($plugin); $manager->boot(); expect(\NyonCode\WireTable\Table::hasMacro('tenantScoped'))->toBeTrue();});
For hook behavior, run the hook with the payload your runtime code emits:
it('adds updated_by before form save', function () { $manager = new PluginManager(); $plugin = new FormAuditPlugin(); $manager->register($plugin); $payload = $manager->runHook('form.saving', [ 'data' => ['name' => 'Jane'], ]); expect($payload['data'])->toHaveKey('updated_by');});
Best Practices
- Use stable, lowercase plugin IDs such as
tenant,audit-export, oracme-billing. - Keep
register()lightweight; do not resolve request-scoped services there. - Put Laravel macros and service-dependent setup in
boot(). - Prefer table/form/action fluent APIs for one-off behavior.
- Return a payload array from array hook callbacks when you want to modify hook data.
- Use hook priorities sparingly and document why a callback must run early or late.
- Guard package registration with
PluginManager::has()to avoid duplicate IDs. - Treat type registries as metadata unless your package explicitly consumes them.