Skip to content

Plugins overview

Quiote is built as an unopinionated kernel plus opinionated drop-ins. The core gives you the request pipeline, container, config system, routing, validation, and rendering — and almost nothing else. Everything with a strong opinion or a heavy dependency lives outside the kernel, in a separately-installable package you add when you want it.

This is the design philosophy taken to its conclusion: a minimal app pulls only the kernel, and you opt into the rest with a composer require and a line of config.

Three words that get used together here, kept straight:

  • The core (kernel)quioteframework/quiote. The skeleton. It ships the seams a plugin plugs into (config, DI, middleware, events, routes, commands, database drivers, HTTP clients) and stays neutral about persistence, auth, front-end, observability, and so on.
  • A plugin — a class implementing Quiote\Plugin\PluginInterface. Its one register() call contributes to the core’s seams. See Plugins and extensibility for the mechanism and how to write your own.
  • A package — a Composer package (quioteframework/* for the official ones) that contains a plugin (and its dependency). Installing the package puts the code on disk; enabling its plugin wires it in.

The framework itself dogfoods this: a whole family of subsystems that used to live in the core have been extracted into their own packages. None of them is part of the kernel any more; each is a drop-in you choose (all MIT-licensed, atop the LGPL kernel). They fall into a few groups:

  • Security & web — CSRF, rate limiting, and the auth packages.
  • Developer experience — the Whoops error page and the MCP server.
  • Observability — OpenTelemetry export and the telemetry dashboard.
  • Persistence — the Eloquent / Doctrine / Cycle / Propulsion database adapters and the PDO / Azure / S3 / GCS session backends.
  • Rendering — the PHPTAL / XSLT / Twig template renderers.

Each one is catalogued, with its install and enable steps, in Official packages.

These two get confused because a plugin so often adds middleware — but they are different kinds of thing, operating at different times.

  • A plugin is a boot-time wiring mechanism. Its register() method runs once, when the framework boots, and contributes things to the core’s seams — config defaults, DI services, event listeners, routes, console commands, database drivers, HTTP clients, and middleware. Think of it as the setup step that installs capabilities.
  • A middleware is a per-request pipeline stage. It’s a PSR-15 object that runs on every request, getting a turn to act on the request on the way in and the response on the way out. Think of it as a worker on the assembly line each request passes down.
PluginMiddleware
When it runsOnce, at bootOn every request
What it isA registration hook (PluginInterface::register())A request/response processing stage (PSR-15)
Its jobWire capabilities into the framework’s seamsDo one thing to each request as it flows through
How you activate itList its class in Config/plugins.*It’s in the pipeline via a #[Middleware] attribute or a Config/middleware.* entry
Runs your code per request?No — only its contributions doYes

The relationship is one-directional: a plugin can register middleware (among many other contributions), but middleware is not a plugin. You can add middleware with no plugin at all — a Config/middleware.* entry or the #[Middleware] attribute is enough (see Writing custom middleware). And a plugin usually does much more than add middleware — the db-doctrine plugin, for instance, registers a database driver and no middleware at all.

So reach for a plugin when you’re packaging a capability to drop into an app (often a whole subsystem); reach for middleware when you specifically need to see or change every request as it passes through the pipeline. The middleware pipeline covers the request side; Plugins and extensibility covers the boot-time side.

Two payoffs, both downstream of the philosophy:

  1. A slim default install. Code that runs on zero requests unless you opted in shouldn’t be forced onto every deployment. Moving Whoops, CSRF, the rate limiter, OpenTelemetry, and the ORM adapters out of the kernel’s dependencies keeps a bare app small — you pull doctrine/orm only if you install quioteframework/db-doctrine, and so on.
  2. Honest opt-in. When a feature is a package you install, “is this app using an ORM / telemetry / the MCP server?” is answered by looking at composer.json and the plugins config key — not by reading kernel internals. Nothing important happens because it happened to ship in core.

The pattern is always the same two steps — install, then enable:

Terminal window
composer require quioteframework/db-doctrine

Then register its plugin in Config/plugins.php (or .xml/.yaml/.yml):

Config/plugins.php
return [
['class' => \Quiote\Database\Adapter\Doctrine\DoctrinePlugin::class, 'enabled' => true],
];

register() runs once at boot, after your settings load and before contexts are created, and it can only add — your settings.* and container bindings always win. Two packages skip the plugins step: rate limiting is a plain library you call from your own code, and the telemetry dashboard contributes a standalone console command that’s available as soon as the package is installed. Official packages spells out the exact “enable” step for each one.

Every official package’s plugin class already carries the required #[Quiote\Plugin\Attribute\Plugin] attribute — see Plugins and extensibility: Writing a plugin for why that attribute exists and what it means for your own plugins.

One package is not opt-in: quioteframework/csrf. It’s a required dependency of the kernel and registers itself automatically at boot, so every app is CSRF-protected out of the box without a plugins entry. It’s a security default, not a packaging convenience — so you turn it off consciously (set core.csrf.enabled => false, which logs a warning), rather than turning it on. See Official packages: csrf.

Enabling a plugin doesn’t run any request code by itself — there are two distinct moments. First the framework boots and lets each plugin contribute to the core’s seams; then, per request, those contributions are simply there in the pipeline the kernel already assembled. The path from a plugins entry to a live request is:

  1. Boot & discover. Quiote::bootstrap() loads your settings.*, then PluginManager reads the plugins config key, instantiates each enabled plugin (refusing any class-string that lacks the #[Plugin] attribute), and calls its register(PluginRegistrar $r) once, in declared order.
  2. Contribute. Inside register(), each PluginRegistrar method routes a contribution to an existing seam — config defaults to Config (set-if-absent), services to the DI Container, middleware to MiddlewareCatalog, listeners to Events, and routes / commands / HTTP clients to their registries.
  3. Assemble. The kernel builds the pipeline once: a scanner reads the #[Middleware] attributes, a topological resolver orders them by phase → before/after → priority, and any middleware added via MiddlewareCatalog::register() is spliced in at its requested position.
  4. Serve. Every request then flows through that assembled pipeline:

Kernel boots and builds the pipeline → request enters ErrorHandlingMiddlewareSessionMiddlewareRoutingMiddleware resolves the action → SecurityMiddleware checks access → DispatchMiddleware runs the action and renders the view → response is emitted.

A plugin that contributes middleware shows up as a station in that chain. A plugin that only contributes a config default or a service doesn’t add a station — it shows up as a value that an action or an existing middleware reads while the request runs. For the complete picture see The request lifecycle and The middleware pipeline.