Plugins & middleware quickstart
This is the copy-paste cookbook for the most common plugin and middleware jobs. A plugin is a package that contributes to the framework at boot; middleware is a stage in the per-request pipeline. Most tasks here follow the same shape — composer require the code, then activate it in a config file under your app’s Config/ directory. Each recipe below is self-contained; work through the one you need.
For the full mechanism and design behind these steps, see Plugins and extensibility and The middleware pipeline / Writing custom middleware.
Use the db-propulsion plugin
Section titled “Use the db-propulsion plugin”db-propulsion adds a propulsion database driver alias backed by Propulsion (the maintained Propel 1 fork). It contributes exactly one thing: a DatabaseDriverRegistry alias — no config defaults, no services, no middleware.
-
Install both packages (the plugin package pulls in the ORM itself as a dependency, but call it out explicitly since the error message you’d get without it references installing it separately):
Terminal window composer require quioteframework/db-propulsion -
Enable the plugin in
Config/plugins.php(or.xml/.yaml/.yml):Config/plugins.php return [['class' => \Quiote\Database\Adapter\Propulsion\PropulsionPlugin::class, 'enabled' => true],];Config/plugins.yaml - class: Quiote\Database\Adapter\Propulsion\PropulsionPluginenabled: trueConfig/plugins.xml <ae:configurations xmlns:ae="http://quiote.dev/quiote/config/global/envelope/1.1"xmlns="http://quiote.dev/quiote/config/parts/plugins/1.1"><ae:configuration><plugin class="Quiote\Database\Adapter\Propulsion\PropulsionPlugin" /></ae:configuration></ae:configurations> -
Reference the
propulsiondriver alias inConfig/databases.xml(or.php/.yaml), the same alias pattern as the Eloquent/Doctrine/Cycle adapters:Config/databases.php return ['default' => 'main','databases' => ['default' => ['class' => 'propulsion','parameters' => ['config' => '%core.config_dir%/propulsion-runtime-config.php','datasource' => 'default', // optional],],],];Config/databases.yaml default: defaultdatabases:default:class: propulsionparameters:config: '%core.config_dir%/propulsion-runtime-config.php'datasource: default # optionalConfig/databases.xml <database name="default" class="propulsion"><parameter name="config">%core.config_dir%/propulsion-runtime-config.php</parameter><!-- optional: --><parameter name="datasource">default</parameter></database>config(required) — path to a PHP file thatreturns Propulsion’s runtime config array.datasource(optional) — defaults to the config’s owndatasources.defaultkey if omitted.overrides/init_queries/enable_instance_pooling(all optional) — seeQuiote\Database\Adapter\Propulsion\PropulsionDatabasefor exact shapes.
Without step 2,
class="propulsion"fails to resolve — core only ships thepdoalias by default.
See Databases: Propulsion for the full reference.
Use the queue-db plugin
Section titled “Use the queue-db plugin”quioteframework/queue alone runs jobs in-process via the sync driver — fine for dev/test, but it blocks the request that pushed the job. queue-db adds a persistent db driver, backed by the app’s own database, so jobs are processed later by a separate queue:work process instead.
-
Install both packages:
Terminal window composer require quioteframework/queue quioteframework/queue-db -
Enable both plugins in
Config/plugins.php(or.xml/.yaml/.yml):Config/plugins.php return [['class' => \Quiote\Queue\QueuePlugin::class, 'enabled' => true],['class' => \Quiote\Queue\Db\QueueDbPlugin::class, 'enabled' => true],];Config/plugins.yaml - class: Quiote\Queue\QueuePluginenabled: true- class: Quiote\Queue\Db\QueueDbPluginenabled: trueConfig/plugins.xml <ae:configurations xmlns:ae="http://quiote.dev/quiote/config/global/envelope/1.1"xmlns="http://quiote.dev/quiote/config/parts/plugins/1.1"><ae:configuration><plugin class="Quiote\Queue\QueuePlugin" /><plugin class="Quiote\Queue\Db\QueueDbPlugin" /></ae:configuration></ae:configurations> -
Point
queue.default_driveratdb(or pass--driver=dbtoqueue:workexplicitly):Config/settings.php 'queue.default_driver' => 'db', -
Create the backing tables — neither is created automatically; run the DDL
Quiote\Queue\Db\DbQueueDriver::schema()andDbFailedJobStore::schema()return as a migration (portable across PostgreSQL and SQLite). -
Run a worker to process the backlog:
Terminal window php bin/quiote queue:work
Without step 2, queue.default_driver = 'db' fails to resolve — core only ships the sync alias by default. See Background jobs & queues for the full reference, including retry/backoff, dead-letter inspection (queue:failed:*), and config keys.
Use the Whoops (developer exception) plugin
Section titled “Use the Whoops (developer exception) plugin”This is not middleware — it’s an exception-renderer plugin that plugs into the existing, always-on ErrorHandlingMiddleware. Two switches must both be on for it to actually render anything; either one alone leaves you on the safe, no-detail renderer.
-
Install:
Terminal window composer require quioteframework/whoops -
Enable the plugin in
Config/plugins.php:Config/plugins.php return [['class' => \Quiote\Exception\Rendering\Whoops\WhoopsPlugin::class, 'enabled' => true],];Config/plugins.yaml - class: Quiote\Exception\Rendering\Whoops\WhoopsPluginenabled: trueConfig/plugins.xml <ae:configurations xmlns:ae="http://quiote.dev/quiote/config/global/envelope/1.1"xmlns="http://quiote.dev/quiote/config/parts/plugins/1.1"><ae:configuration><plugin class="Quiote\Exception\Rendering\Whoops\WhoopsPlugin" /></ae:configuration></ae:configurations> -
Turn on developer exceptions — a separate, deliberate switch (default
false, and unrelated tocore.debug) insettings.*, typically only in adev/localenvironment file:'core.developer_exceptions' => true,
Both are required: the plugin registers a candidate renderer (ExceptionRendererRegistry::setDeveloperRenderer(), set-if-absent — first one registered wins), but ErrorHandlingMiddleware only reaches for it when core.developer_exceptions is true; without that setting it always uses SafeRenderer regardless of what’s registered. Never enable core.developer_exceptions in production — it renders full stack traces.
See Error handling: Developer vs safe rendering for how the renderer registry works.
Write your own plugin
Section titled “Write your own plugin”-
Implement the interface and mark it discoverable:
<?phpnamespace App\Plugin;use Quiote\Plugin\Attribute\Plugin;use Quiote\Plugin\PluginInterface;use Quiote\Plugin\PluginRegistrar;#[Plugin(name: 'app/healthz')]final class HealthzPlugin implements PluginInterface{public function register(PluginRegistrar $registrar): void{$registrar->configDefault('healthz.path', '/healthz')->attributedMiddleware(\App\Middleware\HealthzMiddleware::class);}}#[Plugin]is mandatory for any plugin activated via a class-string (aplugins.*file, orPluginManager::add('App\Plugin\HealthzPlugin')) — a class without it is silently refused (logged) even if it’s correctly named somewhere. It’s skipped only forPluginManager::add(new HealthzPlugin())— passing an already-built instance is itself the trust boundary, since your own code named the class directly.PluginInterfacehas noname()method — the attribute’snameargument is the namePluginManageractually reads. Don’t add aname(): stringmethod on top of it; nothing calls it, and you’d just be repeating the same string twice. Reach forQuiote\Plugin\NamedPlugin(addsname(): stringback) only when the name genuinely can’t be a compile-time constant — see Plugins and extensibility: Writing a plugin. -
Pick contribution methods on
PluginRegistrarinsideregister()— all fluent, mix and match as needed:Method Effect configDefault(key, value)Set-if-absent config default service(id, concrete, scope, ...aliases)Register-if-absent DI binding middleware(fqcn, factory, after:, before:, priority:)MiddlewareCatalog::register()attributedMiddleware(fqcn, factory?)MiddlewareCatalog::registerAttributed()listen(eventClass, listener, priority)Events::listen()moduleDirectory(dir)Extra dir for the #[Route]scannercommand(fqcn)Console command contribution databaseDriver(alias, adapterClass)DatabaseDriverRegistryaliashttpClient(name, configurator)Named HttpClientFactoryclientdeveloperExceptionRenderer(factory)Set-if-absent developer renderer Config defaults and services are set-if-absent: app
settings.*(loaded before plugins) always wins, and among plugins the first to contribute a given key wins. -
Activate it in
Config/plugins.php:Config/plugins.php return [['class' => \App\Plugin\HealthzPlugin::class, 'enabled' => true],];Config/plugins.yaml - class: App\Plugin\HealthzPluginenabled: trueConfig/plugins.xml <ae:configurations xmlns:ae="http://quiote.dev/quiote/config/global/envelope/1.1"xmlns="http://quiote.dev/quiote/config/parts/plugins/1.1"><ae:configuration><plugin class="App\Plugin\HealthzPlugin" /></ae:configuration></ae:configurations>or programmatically before
Quiote::bootstrap()runs:\Quiote\Plugin\PluginManager::add(new \App\Plugin\HealthzPlugin());
A module can also ship its own Modules/<Name>/Config/plugins.php — it’s discovered automatically (no app wiring needed); app-declared plugins are compiled first, so the app always wins on a same-class conflict.
See Plugins and extensibility for the full contribution table and ordering rules.
Write your own middleware
Section titled “Write your own middleware”-
Implement PSR-15 and describe its position with
#[Middleware](the attribute is optional — see step 2 for the config-only alternative):<?phpnamespace App\Middleware;use Psr\Http\Message\ResponseInterface;use Psr\Http\Message\ServerRequestInterface;use Psr\Http\Server\MiddlewareInterface;use Psr\Http\Server\RequestHandlerInterface;use Quiote\Middleware\Attribute\Middleware;#[Middleware(phase: 'pre_routing', before: 'SessionMiddleware')]final class HealthzMiddleware implements MiddlewareInterface{public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface{if ($request->getUri()->getPath() === '/healthz') {return new \Nyholm\Psr7\Response(200, [], 'ok');}return $handler->handle($request);}}phaseis the primary ordering key, one of (in order):bootstrap,pre_routing,pre,routing,before_action,action,after_action,finalize.before/afteraccept a short class name or FQCN;priority(higher runs earlier) breaks ties within a phase. -
Register it — the preferred way is
Config/middleware.php(or.xml/.yaml/.yml):Config/middleware.php return [['class' => \App\Middleware\HealthzMiddleware::class],];Config/middleware.yaml - class: App\Middleware\HealthzMiddlewareConfig/middleware.xml <ae:configurations xmlns:ae="http://quiote.dev/quiote/config/global/envelope/1.1"xmlns="http://quiote.dev/quiote/config/parts/middleware/1.1"><ae:configuration><use class="App\Middleware\HealthzMiddleware" /></ae:configuration></ae:configurations>A class with no
#[Middleware]attribute at all can be fully specified this way too — any field left unset in the config entry falls back to the attribute’s value (or the framework default:phase: 'pre',priority: 0) rather than requiring the attribute to exist:['class' => \App\Middleware\HealthzMiddleware::class, 'phase' => 'pre_routing', 'before' => 'SessionMiddleware']Same as plugins, a module’s own
Modules/<Name>/Config/middleware.*is discovered automatically, no app wiring needed.Two code-based alternatives also exist (see Writing custom middleware for full detail):
MiddlewareCatalog::registerAttributed($fqcn)(attribute required, DI-resolved) andMiddlewareCatalog::register($fqcn, $factory, after:, before:, priority:)(positional, explicit factory — the direct low-level path for middleware that needs constructor args the container can’t autowire). -
Never touch a shipped framework middleware without both switches. Naming one of Quiote’s own classes (
ErrorHandlingMiddleware,SessionMiddleware,RoutingMiddleware,SecurityMiddleware, etc. — seeMiddlewarePipeline::coreMiddlewareClasses()) to change its placement orenabledstate requires both, on purpose. First, the entry itself:Config/middleware.php ['class' => \Quiote\Middleware\TimingMiddleware::class, 'enabled' => false, 'override_framework' => true],Config/middleware.yaml - class: Quiote\Middleware\TimingMiddlewareenabled: falseoverride_framework: trueConfig/middleware.xml <use class="Quiote\Middleware\TimingMiddleware" enabled="false" override-framework="true" />Second, the global opt-in in
settings.*:Config/settings.php 'core.middleware.allow_framework_overrides' => true,Config/settings.yaml core.middleware.allow_framework_overrides: trueConfig/settings.xml <settings><setting name="middleware.allow_framework_overrides">true</setting></settings>Either alone throws a
ConfigurationExceptionat config-load time (not deferred to the first request) — a config file, especially one dropped in by a module, should never be able to silently disable error handling or CSRF just by naming the class.