View .md

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:

  1. matches the path against the route table,
  2. copies the route’s parameters into $request->attributes. Keys that start with _ lose the underscore, so the _permission default of an admin route is read as permission,
  3. builds the middleware stack from the route’s _middleware default, a list of class names,
  4. 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

WhereWhat 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 listenerNot 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() and onRouterReady run 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_REQUESTS requests (500 in the Docker Caddy config) or when its memory goes above FEATHERPANEL_WORKER_MAX_MEMORY (256M).
  • Successful non-GET requests under /api/admin/plugins, /api/admin/plugin-manager, /api/admin/plugin-tools and /api/admin/rate-limits write 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.

Frequently asked questions

Can I list middleware in conf.yml?

No. conf.yml has no middleware key. Middleware is attached per route through the _middleware default of the route, usually in your route file.

Does a listener that throws break the request?

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.