Plugins
Extend the framework at five lifecycle hooks without touching core. Drop in a file and it works.
Overview
The plugin system lets you hook into Zero's request lifecycle at key points. Plugins are automatically discovered from the plugin/ directory — no registration, no configuration. Drop a properly structured file in the right place and it runs on every request.
Plugins follow Zero's philosophy: convention over configuration. Create the file, extend the base class, override the hooks you need. The framework handles the rest.
Lifecycle Hooks
Plugins can hook into five points during the request lifecycle. Each hook fires at a specific stage, with progressively more framework state available:
| Hook | When It Fires | Available State | Common Uses |
|---|---|---|---|
afterAutoloaders() |
After SPL + Composer autoloaders registered | Autoloaders only | Early initialization, timing |
afterParseRequest() |
After URL parsed into Request object | Request::$Module, $endpoint, $args |
Request logging, route manipulation |
afterConstants() |
After all .ini constants defined | All constants, DEVMODE, MODULE_PATH |
Config-dependent setup, discovery |
beforeRun($module, $endpoint, $args) |
After module resolved, before instantiation | Module name (string), endpoint, args | Pre-execution hooks, caching |
afterRun($module, $endpoint, $args) |
After endpoint executed, before output flush | Everything + response generated | Output modification, logging |
Full Lifecycle Sequence
Application::__construct() │ Application::registerAutoloaders() ├─ SPL autoloaders registered ├─ Composer vendor/autoload.php loaded ├─ PluginLoader::load() discovers all plugins └─ afterAutoloaders() hook fires │ Application::parseRequest() ├─ Request object populated with module/endpoint/args └─ afterParseRequest() hook fires │ Application::defineConstants() ├─ All .ini files processed, DEVMODE set └─ afterConstants() hook fires │ Application::run() ├─ Module class resolved ├─ beforeRun() hook fires ├─ Attributes checked ├─ Module instantiated, endpoint called └─ afterRun() hook fires │ Application::__destruct() └─ Output buffer flushed to browser
Creating a Plugin
Create a directory and PHP file in plugin/ that matches the plugin name:
Extend the \Zero\Plugin base class and override only the hooks you need:
<?php
namespace Zero\Plugin;
use \Zero\Plugin as BasePlugin;
use \Zero\Core\Console;
class MyPlugin extends BasePlugin {
/**
* Priority: lower runs first (0-100, default 50)
*/
protected int $priority = 50;
public function afterConstants(): void {
Console::debug("MyPlugin loaded, DEVMODE = " . (DEVMODE ? 'true' : 'false'));
}
public function beforeRun(string $module, string $endpoint, mixed $args): void {
// Runs before the endpoint method is called
}
public function afterRun(string $module, string $endpoint, mixed $args): void {
// Runs after the endpoint completes
}
}
That's a complete plugin. Drop it in the directory and it runs automatically on every request.
Example: Request Logger
<?php
namespace Zero\Plugin;
use \Zero\Plugin as BasePlugin;
class RequestLogger extends BasePlugin {
protected int $priority = 20;
private float $startTime;
public function afterAutoloaders(): void {
$this->startTime = microtime(true);
}
public function beforeRun(string $module, string $endpoint, mixed $args): void {
$entry = sprintf(
"[%s] START %s::%s() from %s\n",
date('Y-m-d H:i:s'), $module, $endpoint, $_SERVER['REMOTE_ADDR']
);
file_put_contents(ZERO_ROOT . 'storage/logs/requests.log', $entry, FILE_APPEND);
}
public function afterRun(string $module, string $endpoint, mixed $args): void {
$elapsed = round((microtime(true) - $this->startTime) * 1000, 2);
$entry = sprintf(
"[%s] END %s::%s() [%sms]\n",
date('Y-m-d H:i:s'), $module, $endpoint, $elapsed
);
file_put_contents(ZERO_ROOT . 'storage/logs/requests.log', $entry, FILE_APPEND);
}
}
Plugin API Reference
Base Class Properties
| Property | Type | Description |
|---|---|---|
$priority |
int |
Execution priority, 0–100. Lower runs first. Default: 50. |
$cacheDir |
string |
Auto-set by PluginLoader. Points to storage/cache/plugin/{Name}/. |
Lifecycle Methods
All default to no-op. Override only the hooks your plugin needs:
public function afterAutoloaders(): void {}
public function afterParseRequest(): void {}
public function afterConstants(): void {}
public function beforeRun(string $module, string $endpoint, mixed $args): void {}
public function afterRun(string $module, string $endpoint, mixed $args): void {}
Cache Helper Methods
Plugins have built-in cache helpers for expensive operations. Cache files are stored at storage/cache/plugin/{PluginName}/{key}.json with automatic TTL expiration.
| Method | Description |
|---|---|
cacheGet(string $key, int $ttl = 3600): mixed |
Read from cache. Returns null if missing or expired. |
cacheSet(string $key, mixed $data): void |
Write to cache. |
cacheClear(string $key): void |
Delete a cache entry. |
getPriority(): int |
Get this plugin's priority value. |
public function afterConstants(): void {
// Try cache first (1 hour TTL)
$data = $this->cacheGet('mydata', 3600);
if ($data === null) {
// Cache miss - do expensive operation
$data = $this->expensiveOperation();
$this->cacheSet('mydata', $data);
}
// Use $data
}
Priority and Ordering
Plugins run in priority order: lower numbers run first. Ties are broken alphabetically by class name. The 0–100 scale is divided into conventional ranges:
| Range | Purpose | Examples |
|---|---|---|
0–20 |
Early initialization | Timers, logging setup |
20–40 |
Request processing | Routing, validation |
40–60 |
Normal operations | Default plugins (priority 50) |
60–80 |
Late processing | Navigation, UI assembly |
80–100 |
Output modification | HTML injection, compression |
// DevToolbar: priority 10 (runs early to capture start time) // MyPlugin: priority 50 (default) // SideNav: priority 70 (navigation not needed until render)
Built-In Plugins
| Plugin | Purpose | Hooks Used |
|---|---|---|
| SideNav | Auto-discovers modules, views, and submodules to build navigation | afterConstants() |
| DevToolbar | Debug toolbar showing performance, request, and session data | afterAutoloaders(), beforeRun(), afterRun() |
Both are included with the framework and can be removed by deleting their directory from plugin/.
Error Handling in Plugins
Plugins are wrapped in try/catch by the PluginLoader. A broken plugin logs an error but never crashes the site:
try {
$plugin->$hook(...$args);
} catch (\Throwable $e) {
Console::error("Plugin {$name}::{$hook}() threw: " . $e->getMessage());
// Framework continues running
}
Disabling Plugins
Since plugins are auto-discovered by directory, disabling them is simple:
# Rename the file mv plugin/MyPlugin/MyPlugin.php plugin/MyPlugin/MyPlugin.php.disabled # Or rename the directory mv plugin/MyPlugin plugin/_MyPlugin # Or delete it entirely rm -rf plugin/MyPlugin
No other changes are needed. The plugin system will skip it automatically.
Plugin vs. Attribute
Both plugins and attributes add behavior to the request cycle, but they serve different purposes:
| Concern | Plugin | Attribute |
|---|---|---|
| Scope | Framework-wide, every request | Per-endpoint or per-class |
| Purpose | Lifecycle concerns (timing, logging, discovery) | Endpoint concerns (auth, validation, sanitization) |
| Can block requests? | Not designed to | Yes — returns error to halt execution |
| Configuration | Zero — drop in a file | Declarative — add attribute to method |
| Examples | Request logging, navigation discovery, debug toolbar | #[RequireLogin], #[AllowedMethods('POST')], #[Sanitize] |
If it applies to every request, use a plugin. If it applies to specific endpoints, use an attribute.