Skip to content

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.

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.

  1. 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
  2. Enable the plugin in Config/plugins.php (or .xml/.yaml/.yml):

    Config/plugins.php
    return [
    ['class' => \Quiote\Database\Adapter\Propulsion\PropulsionPlugin::class, 'enabled' => true],
    ];
  3. Reference the propulsion driver alias in Config/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 (required) — path to a PHP file that returns Propulsion’s runtime config array.
    • datasource (optional) — defaults to the config’s own datasources.default key if omitted.
    • overrides / init_queries / enable_instance_pooling (all optional) — see Quiote\Database\Adapter\Propulsion\PropulsionDatabase for exact shapes.

    Without step 2, class="propulsion" fails to resolve — core only ships the pdo alias by default.

See Databases: Propulsion for the full reference.

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.

  1. Install both packages:

    Terminal window
    composer require quioteframework/queue quioteframework/queue-db
  2. 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],
    ];
  3. Point queue.default_driver at db (or pass --driver=db to queue:work explicitly):

    Config/settings.php
    'queue.default_driver' => 'db',
  4. Create the backing tables — neither is created automatically; run the DDL Quiote\Queue\Db\DbQueueDriver::schema() and DbFailedJobStore::schema() return as a migration (portable across PostgreSQL and SQLite).

  5. 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.

  1. Install:

    Terminal window
    composer require quioteframework/whoops
  2. Enable the plugin in Config/plugins.php:

    Config/plugins.php
    return [
    ['class' => \Quiote\Exception\Rendering\Whoops\WhoopsPlugin::class, 'enabled' => true],
    ];
  3. Turn on developer exceptions — a separate, deliberate switch (default false, and unrelated to core.debug) in settings.*, typically only in a dev/local environment 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.

  1. Implement the interface and mark it discoverable:

    <?php
    namespace 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 (a plugins.* file, or PluginManager::add('App\Plugin\HealthzPlugin')) — a class without it is silently refused (logged) even if it’s correctly named somewhere. It’s skipped only for PluginManager::add(new HealthzPlugin()) — passing an already-built instance is itself the trust boundary, since your own code named the class directly.

    PluginInterface has no name() method — the attribute’s name argument is the name PluginManager actually reads. Don’t add a name(): string method on top of it; nothing calls it, and you’d just be repeating the same string twice. Reach for Quiote\Plugin\NamedPlugin (adds name(): string back) only when the name genuinely can’t be a compile-time constant — see Plugins and extensibility: Writing a plugin.

  2. Pick contribution methods on PluginRegistrar inside register() — all fluent, mix and match as needed:

    MethodEffect
    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] scanner
    command(fqcn)Console command contribution
    databaseDriver(alias, adapterClass)DatabaseDriverRegistry alias
    httpClient(name, configurator)Named HttpClientFactory client
    developerExceptionRenderer(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.

  3. Activate it in Config/plugins.php:

    Config/plugins.php
    return [
    ['class' => \App\Plugin\HealthzPlugin::class, 'enabled' => true],
    ];

    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.

  1. Implement PSR-15 and describe its position with #[Middleware] (the attribute is optional — see step 2 for the config-only alternative):

    <?php
    namespace 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);
    }
    }

    phase is the primary ordering key, one of (in order): bootstrap, pre_routing, pre, routing, before_action, action, after_action, finalize. before/after accept a short class name or FQCN; priority (higher runs earlier) breaks ties within a phase.

  2. Register it — the preferred way is Config/middleware.php (or .xml/.yaml/.yml):

    Config/middleware.php
    return [
    ['class' => \App\Middleware\HealthzMiddleware::class],
    ];

    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) and MiddlewareCatalog::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).

  3. Never touch a shipped framework middleware without both switches. Naming one of Quiote’s own classes (ErrorHandlingMiddleware, SessionMiddleware, RoutingMiddleware, SecurityMiddleware, etc. — see MiddlewarePipeline::coreMiddlewareClasses()) to change its placement or enabled state requires both, on purpose. First, the entry itself:

    Config/middleware.php
    ['class' => \Quiote\Middleware\TimingMiddleware::class, 'enabled' => false, 'override_framework' => true],

    Second, the global opt-in in settings.*:

    Config/settings.php
    'core.middleware.allow_framework_overrides' => true,

    Either alone throws a ConfigurationException at 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.