stave.dev

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.

Zero Configuration

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:

afterAutoloaders()
afterParseRequest()
afterConstants()
beforeRun()
afterRun()
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:

plugin/
MyPlugin/
MyPlugin.php

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]
Rule of Thumb

If it applies to every request, use a plugin. If it applies to specific endpoints, use an attribute.