Plugins and extensibility
Quiote’s core is deliberately unopinionated. The plugin system is how opinionated, batteries-included behaviour drops in on top of it: a self-contained package contributes to the framework through one register() call, and the app opts in by listing it.
A plugin adds no new low-level mechanism. Every contribution routes to a seam that already exists — config defaults, DI services, middleware, events, routes/modules, commands, and HTTP clients. The plugin system just wraps those seams in one coherent, discoverable API.
Writing a plugin
Section titled “Writing a plugin”Implement Quiote\Plugin\PluginInterface — just a register() that receives a PluginRegistrar — and mark the class with #[Quiote\Plugin\Attribute\Plugin], passing a name for diagnostics/logging:
<?phpnamespace App\Plugin;
use Quiote\Plugin\{PluginInterface, PluginRegistrar};use Quiote\Plugin\Attribute\Plugin;use Quiote\DI\Container;
#[Plugin(name: 'health')]final class HealthPlugin implements PluginInterface{ public function register(PluginRegistrar $r): void { $r->configDefault('health.path', '/healthz') ->attributedMiddleware(\App\Plugin\Health\HealthMiddleware::class) ->service(HealthChecker::class, HealthChecker::class, Container::SCOPE_SINGLETON) ->listen(\Quiote\Event\Lifecycle\KernelBootEvent::class, fn($e) => /* ... */) ->command(\App\Plugin\Health\HealthCommand::class); }}PluginInterface itself declares no name() method — a plugin only has to implement register(). The #[Plugin(name: '...')] attribute’s name argument is what PluginManager::resolveName() actually reads for diagnostics/logging; don’t also add your own name(): string method to the class, since nothing would call it and you’d just be maintaining the same string twice.
If a plugin’s name genuinely can’t be a compile-time constant — computed from config, an environment value, or an instance the plugin was built with — implement Quiote\Plugin\NamedPlugin (extends PluginInterface, adds back name(): string) instead of passing name to the attribute. resolveName() prefers NamedPlugin::name() over the attribute when a plugin implements both. A plugin with neither a NamedPlugin implementation nor an attribute name fails fast at boot with a QuioteException naming both routes — it’s never silently unnamed.
register() is called once at boot. Each PluginRegistrar method is fluent (returns $this) and maps to an existing seam:
| Method | Contributes | Routes to |
|---|---|---|
configDefault($key, $value) | A config default (set-if-absent) | Config::set(…, overwrite: false) |
service($id, $concrete, $scope, ...$aliases) | A DI service (per container, if not already bound) | Container::set() |
middleware($fqcn, $factory, $after, $before, $priority) | Middleware at a position | MiddlewareCatalog::register() |
attributedMiddleware($fqcn, $factory?) | An attribute-ordered middleware | MiddlewareCatalog::registerAttributed() |
listen($eventClass, $listener, $priority) | An event listener | Events::listen() |
moduleDirectory($dir) | A module dir for #[Route] scanning | route scanner default set |
command($fqcn) | A console command | console registry |
httpClient($name, $configurator) | A named HTTP client | HttpClientFactory::configure() |
databaseDriver($alias, $adapterClass) | A database adapter alias | DatabaseDriverRegistry (see Databases) |
developerExceptionRenderer($factory) | The developer-mode exception renderer (set-if-absent) | ExceptionRendererRegistry::setDeveloperRenderer() |
Say what scope your services have
Section titled “Say what scope your services have”service()’s $scope is nullable, and leaving it out asks the binding: a class name keeps the lifetime its own #[Service] declares (request scope if it declares none), a factory or closure is request-scoped, and an already-built instance or a bound value is a singleton. See what an omitted scope means.
Write it out anyway. A plugin’s services are wired once at boot and then live in every application that enables it, under whatever runtime that application deploys — and the plugin author is the only person in a position to know whether the service holds per-request state. Container::SCOPE_SINGLETON on an object you have confirmed is stateless, SCOPE_REQUEST on anything that isn’t, states that judgement where a reader of the plugin can see it:
// Stateless: one HTTP client for the life of the worker.$r->service(HealthChecker::class, HealthChecker::class, Container::SCOPE_SINGLETON);
// Holds the current request's findings: dropped at the request boundary.$r->service(HealthReport::class, fn() => new HealthReport(), Container::SCOPE_REQUEST);The one case for omitting it is a registration made purely to add an alias, where the point is to not disturb the class’s declared lifetime.
Clearing your own state at the end of a request
Section titled “Clearing your own state at the end of a request”A plugin holding request-scoped state of its own — a per-request cache, a memo keyed on the current user — needs that state gone before the process serves the next request. Register a clear:
use Quiote\Plugin\PluginManager;
PluginManager::addRequestEndClear('my per-request cache', function (): void { MyCache::forgetRequestState();});Quiote\ContextLifecycle runs these at the end of every request, after the framework’s own clears, so a plugin cannot displace the identity clears (session bag, user, request) that go first. Each clear is independently guarded: one that throws is logged and stepped over, every other clear still runs, and so does the re-arm afterwards — a broken clear cannot cost the next request its state-flush claim.
Clears are keyed by label, so registering the same label twice replaces rather than clearing twice.
Under classic per-request PHP this is harmless but unnecessary; under a persistent worker it’s the difference between per-request state and a leak. See the request lifecycle.
Clearing your own static state
Section titled “Clearing your own static state”Request-scoped state and process-scoped state are two different problems. A driver registry a plugin populates at boot must survive every request — but it must not survive PluginManager::reset(), which is what a test suite calls to get a clean process between cases. Register that clear from register():
$registrar->stateReset('my-driver-registry', static fn() => MyDriverRegistry::reset());PluginManager::addStateReset('label', $closure) is the same seam from outside a plugin. Callbacks are keyed by label, so two plugins touching the same registry collapse into one call.
This replaced (in 4.2) a hard-coded call in PluginManager::reset() that cleared the filesystem driver registry by name — core reaching into one optional subsystem it happened to know about. A plugin that keeps static state and registers no reset leaks it between tests in the same process.
How a plugin fits in a request
Section titled “How a plugin fits in a request”A plugin does not run on every request — it runs once at boot, and its job is to wire contributions into seams the framework already uses. After that, requests flow through those contributions as if the framework had shipped them itself:
-
How the framework finds and runs it.
PluginManager::bootFromConfig()(called fromQuiote::bootstrap(), after settings load) instantiates each activated plugin and calls itsregister(PluginRegistrar)exactly once. EachPluginRegistrarmethod hands its contribution to the matching registry — middleware toMiddlewareCatalog, services to the container, listeners toEvents, routes to the module scanner, and so on. -
The path a request then takes. Nothing plugin-specific happens at request time:
Quiote::bootstrap()calls each plugin’sregister()→ contributions land in their registries → the middleware pipeline builds from the merged set → a request flows through that pipeline (ErrorHandling→ … →Routing→Security→Dispatch) with the plugin’s middleware, routes, and services participating like any other.
So a plugin’s middleware is ordered by the same attribute/catalog rules as everyone else’s, its routes are matched by the normal scanner, and its services resolve from the same container. There is no separate plugin runtime to reason about.
Registering a plugin
Section titled “Registering a plugin”Two ways. The correct, documented way is a dedicated Config/plugins.{xml,php,yaml,yml} file (Quiote\Config\PluginConfigHandler) — a flat, ordered enable/disable list, resolved like any other config type (.php > .yaml/.yml > .xml):
return [ ['class' => \App\Plugin\HealthPlugin::class, 'enabled' => true],];- class: App\Plugin\HealthPlugin enabled: true<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\HealthPlugin" /> </ae:configuration></ae:configurations>enabled (PHP/YAML) / the enabled="…" attribute (XML) defaults to true when omitted, so a bare ['class' => ...] entry is enough to turn a plugin on; set it to false to declare a class without activating it.
enabled can also be an %env(NAME)% placeholder instead of a literal bool, so a container image ships one compiled config and a deployment turns the plugin on or off by setting a variable and restarting — no recompile:
['class' => \App\Plugin\DebugToolbarPlugin::class, 'enabled' => '%env(DEBUG_TOOLBAR, false)%'],This is a drop-in, the same way declarative middleware.xml is: any module’s own Config/ directory can carry its own Config/plugins.* (discovered by Quiote::bootstrap() globbing core.module_dir, no app wiring required), so a module registers its own plugins just by containing the file. Per-plugin options still live in settings.* (contributed by the plugin via configDefault()) — plugins.* only controls which plugins run and in what order. App-declared plugins are compiled first, so if the same class is declared by both the app and a module, the app’s declaration wins.
Or programmatically, before the kernel runs:
// pub/index.php — before Kernel::run()use Quiote\Plugin\PluginManager;
PluginManager::add(App\Plugin\HealthPlugin::class);
Quiote\Runtime\Kernel::create([/* ... */])->run();Either way, register() runs during Quiote::bootstrap() — after settings load, before contexts are created — in deterministic order: programmatic add() calls first (they happen before bootstrap), then config order. The list is de-duplicated by class, and the first occurrence wins (PluginManager stores each class with ??=, so a later duplicate is ignored). A class-string reaching either path must carry #[Plugin] (see above) or it’s silently refused.
Ordering and override rules
Section titled “Ordering and override rules”The rules are predictable and exist so a plugin can never silently override your application:
- App settings load before plugins, so
configDefault()is set-if-absent: yoursettings.*always win, and a plugin only fills a key you left unset. - Among plugins, first writer wins for both config defaults and DI services (also set-if-absent). Declared order decides who’s first.
- DI services are applied per container when it’s built, and only if the id isn’t already bound — app and core bindings win.
- Middleware uses the normal attribute / catalog ordering — a plugin’s middleware is placed by the same rules as anyone else’s, no special precedence.
So the precedence for anything contestable is, in order: app, first plugin, later plugins, core defaults.
Current boundaries
Section titled “Current boundaries”A few contribution kinds work with documented limits (the mechanism is real and tested; these are scoped follow-ups):
- Module routes — a plugin’s
moduleDirectory()is scanned for#[Route]actions, but action FQCNs are derived fromcore.namespace_prefix, so a plugin’s modules are discovered for routing only when they follow that namespace convention. Full multi-root module resolution in the controller (independent per-plugin namespaces) is future work — plugin modules are discovered for routing, not yet resolved from arbitrary roots. - Commands —
bin/quiotebuilds its console app before bootstrap, so plugin-contributed commands appear once a bootstrap has populated the registry in the same process (e.g. a programmatically constructedApplicationafterQuiote::bootstrap()). Wiringbin/quioteto bootstrap first is a deliberate non-goal for now.
The enablers
Section titled “The enablers”Two of the framework features the plugin system leans on are documented in their own right:
- Events — the lifecycle hooks a plugin listens to (via
register(), calling$r->listen(...)). - HTTP client — named clients a plugin can pre-configure (
$r->httpClient(...)).
Together these are the backbone of the “unopinionated core, opinionated drop-ins” design: the core stays lean, and anything with an opinion — an auth provider, health checks, a mailer — ships as a plugin you choose to add.
For copy-pasteable steps through the most common tasks — enabling a database adapter, turning on the Whoops error page, writing your own plugin or middleware — see the Plugins & middleware quickstart.