Addons
How addons are discovered, loaded, and wired into the app: services, hooks, views, and the routing constraint that keeps every URL traceable to a page.
What an addon is
An addon is a self-contained folder of PHP that can register services, listen to lifecycle events, and offer fields, views, routers, and controllers, without changing framework code. The built-in admin addon (login, page/media/user management) is the largest example; clockwork (the profiler integration) is the simplest.
Where addons live
Three locations, each given an id namespace:
| Location | Id | Notes |
|---|---|---|
phlat/addons/{name}/ |
phlat/{name} |
Framework built-ins |
site/addons/{name}/ |
site/{name} |
Site-specific |
addons/{creator}/{name}/ |
{creator}/{name} |
Each its own git repo, gitignored at the root |
Enabling an addon
AddonManager::load(), called during App::boot(), walks all three locations and requires a matching addons.{id} entry in config, with enabled not false:
{
"addons": {
"phlat/clockwork": { "enabled": true },
"site/newsletter": { "enabled": true, "alias": "@news" }
}
}
Local addons (phlat/, site/) get an automatic root alias from their folder name unless alias overrides it; external {creator}/{name} addons only get one if explicitly configured.
The index.php entry point
If an addon folder has an index.php, it's included once per boot, $app, the addon's own $path, and a NamespacedSession scoped to the addon's id are injected:
<?php
// phlat/addons/example/index.php
$app->bind('example', new ExampleService($app));
$app->on('app.route:after', function (\Phlat\Page $page) {
// react to the resolved page
});
Other files in the addon folder are not auto-loaded, they exist for the addon's own require/require_once calls.
Service binding
Addons register services with $app->bind('name', $instance), retrieved elsewhere with $app->make('name') or the shorthand $app('name'). Binding the same name twice throws, in worker mode $app persists across requests, so anything bound in index.php is only bound once per worker, not once per request.
Event hooks
$app->on(string $event, callable $handler) is the primary way an addon observes the request lifecycle: page.update:after, field.update:after, file.update:after, file.delete:after, item.update:after, item.delete:before, user.login:before/:after, user.logout:before/:after, plus the render-pipeline events (app.route:after, page.render:after, etc., see the Event Hooks doc for the full list). Hooks are observational only, they receive arguments but their return values are ignored; a hook that wants to stop a request redirects or throws like any other guard.
Views and routing mounts
An addon offers views the same way the site does: views/{name}/ with its own router.php/controller.php. But an addon cannot inject pages into the site on its own, every routable URL still has to correspond to a page the site author created, referencing the addon's @id/view namespace (e.g. a page with view: "@admin/login"). admin is the one framework-shipped exception, since it ships its own pre-wired pages/ tree.
Performance: don't do I/O at the top level of index.php
index.php runs inside the addon-loading phase of boot, which is itself inside the request's critical path (and, in worker mode, only runs once per worker, so whatever it does has to be safe to run with stale request context). Anything that calls $app->session, $app->registry(), or similar I/O-triggering methods at the top level of an addon's index.php should be moved inside a $app->on(...) callback instead, so it runs per-request, with the right request context, rather than once at boot with whatever happened to be the first request's data.
A concrete instance of this: PhlatPage's own Clockwork addon used to read $app->session->isLoggedIn() at the top level of its index.php to populate a debug panel. That call triggers session_start(), which takes a file lock, done at boot time, this serialized concurrent requests against that lock and showed up as a ~20ms spike in the profiler's own "Addons" timing. The fix was to move the session read into an app.route:after listener, where it belongs.
// Wrong, runs once at boot, with stale or no request context in worker mode
$loggedIn = $app->session->isLoggedIn();
// Right, runs per request, with the current request's session
$app->on('app.route:after', function () use ($app) {
$loggedIn = $app->session->isLoggedIn();
});
<?php
// site/addons/newsletter/index.php
use Phlat\App;
use Phlat\Page;
// Bind once per worker, safe, no I/O
$app->bind('newsletter', new NewsletterService($app));
// Defer anything session/registry/request-specific to a hook
$app->on('app.route:after', function (Page $page) use ($app) {
if ($app->session->isLoggedIn())
return;
$app('newsletter')->trackVisit($page);
});