Middleware and router hooks
Add your own middleware to FeatherPanel plugin routes, use the onRouterReady hook, emit custom events and write event listeners that are safe in worker mode.
On this page
This page covers the lower-level hooks: middleware on your routes, the onRouterReady event, events you emit yourself,
and how plugin code behaves when the API runs in worker mode.
How a request reaches your controller
For every API request the panel:
- matches the path against the route table,
- copies the route’s parameters into
$request->attributes. Keys that start with_lose the underscore, so the_permissiondefault of an admin route is read aspermission, - builds the middleware stack from the route’s
_middlewaredefault, a list of class names, - calls each middleware, then your controller as
$controller($request, $parameters).
Each middleware class is created with new $class(), so it cannot take constructor arguments. The register*Route
helpers set _middleware for you, for example registerAdminRoute uses AuthMiddleware and AdminMiddleware, and
adds RateLimitMiddleware when the route has a rate limit.
An uncaught exception in a controller or middleware is logged as Exception in router: ... and the client gets a 500
with INTERNAL_SERVER_ERROR and a request_id.
Write a middleware
Implement App\Middleware\MiddlewareInterface. Return $next($request) to continue, or return your own response to
stop:
<?php
namespace App\Addons\myplugin\Middleware;
use App\Helpers\ApiResponse;
use App\Middleware\MiddlewareInterface;
use App\Plugins\PluginSettings;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class RequireEnabled implements MiddlewareInterface
{
public function handle(Request $request, callable $next): Response
{
if (PluginSettings::getSetting('myplugin', 'enabled') !== 'true') {
return ApiResponse::error('This feature is turned off', 'MYPLUGIN_DISABLED', 403);
}
return $next($request);
}
}
Put it in Middleware/ (or any subfolder). Composer maps App\Addons\ to storage/addons/, so the class above lives
in storage/addons/myplugin/Middleware/RequireEnabled.php.
Attach it to a route
Register the route with a helper, then add your class to the route’s _middleware list in the same route file:
<?php
use App\App;
use App\Helpers\ApiResponse;
use App\Addons\myplugin\Middleware\RequireEnabled;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\RouteCollection;
return function (RouteCollection $routes): void {
App::getInstance(true)->registerAuthRoute(
$routes,
'myplugin-report',
'/api/addons/myplugin/report',
fn (Request $request) => ApiResponse::success(['ok' => true]),
['GET']
);
$route = $routes->get('myplugin-report');
$route->setDefault('_middleware', array_merge(
$route->getDefault('_middleware') ?? [],
[RequireEnabled::class]
));
};
Keep the helper’s middleware in the list and append yours after it. AuthMiddleware is what puts the signed-in user in
$request->attributes->get('user'), so your middleware can read the user when it runs after it.
Doing this inside the route file works with the route cache: when the panel serves a request from the compiled cache, it still loads the route from your file and reads its defaults from there.
The onRouterReady hook
AppEvent::onRouterReady() is emitted once the route table is set up, with the payload ['router' => $routes]. The
listener gets the RouteCollection:
use App\Plugins\Events\Events\AppEvent;
use Symfony\Component\Routing\RouteCollection;
$event->on(AppEvent::onRouterReady(), function (RouteCollection $routes): void {
$route = $routes->get('myplugin-report');
// Inspect or change routes here.
});
Prefer route files for your own routes. Use this hook only when you really need the whole collection, because:
- With the route cache on (the default), the collection is built lazily. Calling any method on it in this listener builds every route and turns the compiled cache off for that PHP process.
- In worker mode the hook runs once per worker, not once per request.
FEATHERPANEL_ROUTE_CACHE=0 turns the route cache off completely, which can help while you debug routing.
Emit your own events
The panel’s event manager is the global $eventManager, the same PluginEvents instance that is passed to
processEvents(). Other plugins can listen to events you emit:
use App\Plugins\PluginEvents;
/** @var PluginEvents|null $eventManager */
global $eventManager;
if (isset($eventManager)) {
$eventManager->emit('myplugin:report:created', [
'reportId' => $reportId,
'userUuid' => $user['uuid'],
]);
}
Listeners receive the payload values in order, so a listener for this event takes (int $reportId, string $userUuid).
Prefix event names with your identifier. To give other developers a stable name, add a class with static methods that
return the names, the way the core classes in App\Plugins\Events\Events do.
on() returns the event manager, so calls can be chained. removeListener(), removeAllListeners() and
listeners() are also available.
Errors in processEvents and listeners
| Where | What happens |
|---|---|
An exception thrown inside processEvents() | Caught by the plugin loader and logged as Failed to process plugin event, without the message. Listeners registered before the throw stay registered. |
| An exception thrown inside a listener | Not caught by emit(). It travels up into the panel code that emitted the event and usually ends the request with a 500. |
Wrap listener bodies in try/catch and log with App::getInstance(true)->getLogger().
Worker mode
The Docker image serves /api/* with FrankenPHP workers. A worker boots the app once and then handles many requests.
For plugins that means:
processEvents()andonRouterReadyrun once per worker boot, not once per request. Register listeners there, and do not read request data or per-user state in it.- Static properties and globals in your classes keep their values between requests handled by the same worker. Do not cache user-specific data in them.
- A worker restarts after
FEATHERPANEL_WORKER_MAX_REQUESTSrequests (500 in the Docker Caddy config) or when its memory goes aboveFEATHERPANEL_WORKER_MAX_MEMORY(256M). - Successful non-GET requests under
/api/admin/plugins,/api/admin/plugin-manager,/api/admin/plugin-toolsand/api/admin/rate-limitswrite a restart stamp, and every worker restarts before its next request. Saving a plugin setting or clicking Resync therefore reloads all plugin code. - PHP files you edit by hand are not picked up until the workers restart.
Setting FRANKENPHP_WORKER=false on the backend container switches to classic mode, where every request boots the
app again and PHP edits apply on the next request. See
Architecture for how the backend runs.
Related
Frequently asked questions
No. conf.yml has no middleware key. Middleware is attached per route through the _middleware default of the route, usually in your route file.
Yes. PluginEvents::emit() does not catch exceptions, so an exception in your listener reaches the code that emitted the event. Catch and log errors inside your listeners.