Hooks
A hook is a listener that runs when phpClaw reaches a point in a run: a provider request going out, a tool finishing, memory being written, a guard blocking a message. You register a handler against an event name, and it is called with a context array when that event fires.
Hooks observe. A handler cannot alter the run’s data or stop it: the context is passed by value, the return value is ignored, and an exception is caught.
Registering a listener
Section titled “Registering a listener”use PhpClaw\Hooks\HookRegistry;use PhpClaw\Hooks\LifecycleEvent;
HookRegistry::on( LifecycleEvent::ToolAfter->value, function (array $context): void { error_log('tool finished: ' . $context['tool_name']); }, priority: 20,);on(string $event, callable|HookInterface $handler, int $priority = 10). Lower priority runs
first.
To receive every event with one handler, use onAny(). The fired event’s name is always in
$context['event']:
HookRegistry::onAny(function (array $context): void { error_log('event fired: ' . $context['event']);});| Method | Does |
|---|---|
on($event, $handler, $priority) | listen to one event |
onAny($handler, $priority) | listen to every event |
off($event, $handler) | remove one callable from one event |
fire($event, $context) | fire an event through the same chain |
count(?string $event) | handlers on one event, or on all events |
countAny() | wildcard handlers |
hasListener($event, $class) | whether a HookInterface class is bound to an event |
reset() | remove everything, mainly for tests |
Rules that affect you
Section titled “Rules that affect you”Wildcard handlers always run after typed handlers. Priority orders handlers within each group,
not across them. An onAny() handler at priority 1 still runs after an on() handler at priority 20.
A handler that throws does not stop the run. The exception is caught, logged as
[phpclaw] hook error on <event>: <message>, and the next handler runs.
A HookInterface class binds once per event, by class. Registering a second instance of the
same class on the same event is silently ignored, even when it is configured differently:
HookRegistry::on('agent.after', new DebugLogHook('/var/log/a.log'));HookRegistry::on('agent.after', new DebugLogHook('/var/log/b.log')); // ignored
HookRegistry::count('agent.after'); // 1This makes repeated adapter boots safe. When you really need two, register a closure that calls the second instance. Callables are never de-duplicated.
off() removes callables only. It compares the exact callable you passed to on(), so keep a
reference to it. Its parameter is typed callable, so passing a HookInterface instance raises a
TypeError, and it cannot remove a wildcard handler either.
fire() does nothing when nobody is listening, and adds the event key if your context lacks
one.
The 40 events
Section titled “The 40 events”Every name lives in the LifecycleEvent enum. LifecycleEvent::all() returns them as strings.
| Group | Events |
|---|---|
| Agent | agent.before, agent.after, agent.iteration, agent.error, agent.max_iterations |
| Provider | provider.request, provider.response, provider.retry, provider.cache_hit, provider.error, provider.token |
| Tool | tool.before, tool.after, tool.error, tool.not_found |
| Context | context.overflow, compaction.before, compaction.after |
| Guard | guard.blocked, guard.rate_limit_exceeded, guard.tool_output_redacted, guard.output_php_tag_removed, guard.output_function_redacted |
| Conversation | conversation.start, conversation.end |
| Stream | stream.start, stream.end, stream.abort |
| Shell | shell.exec, shell.denied |
| Memory | memory.read, memory.write, memory.forget |
| Job | job.started, job.completed, job.failed |
| Skill | skill.registered, skill.loaded, skill.matched, skill.not_matched |
Every event is fired somewhere in the shipped code. Two groups depend on where you run:
job.*fire only from queued runs. Of the eight released adapters, only Laravel and Symfony provide those.memory.*fire from the memory driver, so they depend on the driver you configured firing them.
What the context carries
Section titled “What the context carries”Each event supplies its own keys. Events fired inside a run also carry these when they are set:
| Key | Present when |
|---|---|
run_id | the event belongs to a run |
parent_run_id | the event belongs to one iteration of a run |
conversation_id | the run is part of a conversation |
streaming | the run is streaming; the key is absent, not false, otherwise |
Two examples:
| Event | Keys |
|---|---|
tool.after | tool_name, tool_input, tool_result, iteration |
agent.after | message, text, provider, model, iterations, duration_ms, tools_called, cache_read_tokens, cache_write_tokens |
Built-in hooks
Section titled “Built-in hooks”Core ships two hook classes. Only SecurityAlertHook carries a #[Hook] attribute, so it is the one
the catalogue knows. DebugLogHook needs a file path, so you always register it yourself.
DebugLogHook
Section titled “DebugLogHook”Appends one JSON line per event to a file, with FILE_APPEND | LOCK_EX, so concurrent workers do
not interleave lines.
use PhpClaw\Hooks\DebugLogHook;use PhpClaw\Hooks\HookRegistry;
$log = new DebugLogHook(logFile: '/var/log/phpclaw/events.log');
HookRegistry::on('agent.after', $log);Each line has three keys:
{ "ts": "2026-09-16T10:00:00+00:00", "event": "event", "context": { "event": "agent.after", "...": "..." } }The top-level event is a label, taken from the second constructor argument and defaulting to
"event". The name of the event that fired is inside context.event.
A line that cannot be JSON-encoded is dropped, and a failed write is suppressed. Neither interrupts the run. Given the caution above, point it at a file only you can read.
SecurityAlertHook
Section titled “SecurityAlertHook”POSTs a JSON alert to an HTTPS webhook when a guard blocks a message.
use PhpClaw\Hooks\HookRegistry;use PhpClaw\Hooks\SecurityAlertHook;
HookRegistry::on('guard.blocked', new SecurityAlertHook(webhookUrl: 'https://hooks.example.com/alerts'));The default body:
{ "alert": "phpClaw: Attack blocked", "guard": "InjectionGuard", "reason": "...", "at": "2026-09-16T10:00:00+00:00" }| Option | Default | Effect |
|---|---|---|
webhookUrl | '' | falls back to the PHPCLAW_SECURITY_WEBHOOK environment variable |
timeout | 3 | seconds |
events | ['guard.blocked'] | events the hook reacts to; others are ignored |
payloadFormatter | null | a closure fn (string $event, array $payload, string $iso): array that shapes the body |
With no URL configured, it sends nothing and logs nothing.
The URL must be HTTPS. Redirects are not followed, and a host that is loopback, private, link-local, written as a decimal or hex number, or unresolvable is refused with a logged warning.
The class is not final, and sendWebhook() and buildWebhookContext() are protected, so you can
subclass it to swap the HTTP transport.
The catalogue
Section titled “The catalogue”HookCatalogue lists hooks by key. It ships one entry:
| Key | Event | Class | Priority | Enabled by default |
|---|---|---|---|---|
security_alert | guard.blocked | SecurityAlertHook | 50 | yes |
use PhpClaw\Hooks\HookCatalogue;
HookCatalogue::keys(); // ['security_alert']HookCatalogue::defaultEnabledKeys(); // ['security_alert']HookCatalogue::activateEnabled(['security_alert']);HookCatalogue::activateDefaults();activateFromSettings($settings) reads $settings['hooks_enabled']. When the key is absent it
activates the defaults. When it is present, only the listed keys are activated, so an empty list
activates nothing.
A plain Claw::builder()->build() does not activate the catalogue. What the adapters do:
| Adapter | security_alert registered |
|---|---|
| WordPress, Joomla, Drupal, PrestaShop, OpenCart, Symfony | yes: settings are passed without hooks_enabled, so the defaults apply |
| Laravel | no: it passes hooks_enabled => [] |
| Magento | no: it does not activate the catalogue |
Where it is registered, it still sends nothing until PHPCLAW_SECURITY_WEBHOOK is set, because the
catalogue constructs it with no arguments.
Forwarding into your framework
Section titled “Forwarding into your framework”Every adapter forwards all 40 events into its host’s event system with HookEventBridge, so you can
listen the native way instead of calling HookRegistry. The event name is transformed per host.
Joomla is the exception: its bridge is registered, but no event reaches a Joomla listener. See
Joomla: lifecycle events.
| Adapter | Mechanism | tool.after arrives as |
|---|---|---|
| WordPress | do_action() | phpclaw_tool.after |
| Joomla | application dispatcher, currently delivers nothing | onPhpClawToolAfter |
| Drupal | event dispatcher | phpclaw.tool.after |
| Symfony | event dispatcher | phpclaw.tool.after |
| Laravel | event dispatcher | phpclaw.tool.after |
| Magento | event manager, context under data | phpclaw_tool_after |
| PrestaShop | Hook::exec(), as ['event' => ..., 'context' => ...] | actionPhpClawToolAfter |
| OpenCart | registry event | phpclaw/tool.after |
WordPress keeps the dots, so a listener is add_action('phpclaw_tool.after', ...).
Underscores inside an event name are handled differently, so check a multi-word event: Joomla removes them, PrestaShop keeps them, and Magento turns the dots into underscores too.
| Event | Joomla | PrestaShop | Magento |
|---|---|---|---|
guard.rate_limit_exceeded | onPhpClawGuardRateLimitExceeded | actionPhpClawGuardRate_limit_exceeded | phpclaw_guard_rate_limit_exceeded |
To build a bridge yourself:
use PhpClaw\Hooks\HookEventBridge;
(new HookEventBridge( dispatcher: function (string $event, array $context): void { my_framework_dispatch('phpclaw.' . $event, $context); },))->register();| Argument | Default | Effect |
|---|---|---|
dispatcher | required | a closure receiving the event name and context |
events | null | a list of event names to forward; null forwards all 40 |
priority | 50 | the priority the forwarding listeners are registered at |
Call register() once. Each call registers new closures, and closures are not de-duplicated, so a
second call forwards every event twice.
Writing a hook
Section titled “Writing a hook”A callable works anywhere: a closure, a function name such as 'audit_hook', or [$object, 'method'].
Implement HookInterface when you want a reusable, configurable class:
namespace PhpClaw\Hooks\Contracts;
interface HookInterface{ public function handle(array $context): void;}use PhpClaw\Hooks\Contracts\HookInterface;use PhpClaw\Hooks\HookRegistry;
final class SlowRunWarningHook implements HookInterface{ public function __construct(private readonly int $thresholdMs = 2000) {}
public function handle(array $context): void { if ($context['duration_ms'] >= $this->thresholdMs) { error_log(sprintf( 'slow run: %d iterations in %dms on %s', $context['iterations'], $context['duration_ms'], $context['provider'], )); } }}
HookRegistry::on('agent.after', new SlowRunWarningHook);Making it addressable by key
Section titled “Making it addressable by key”Register it in the catalogue. Unlike the guard catalogue, a hook entry names the event it binds to:
HookCatalogue::register( key: 'slow_run_warning', event: 'agent.after', class: SlowRunWarningHook::class, priority: 50, enabledByDefault: false, label: 'Slow Run Warning',);The catalogue constructs the class with no arguments, so give every constructor parameter a default.
Registering in the catalogue does not bind the listener, even with enabledByDefault: true. Activate
it:
HookCatalogue::activateEnabled(['slow_run_warning']);Core reads the #[Hook] attribute only on classes in the PhpClaw\ namespace, as on
SecurityAlertHook, so on your own class it has no effect.
A Composer package can declare hooks under extra.phpclaw.hooks in its composer.json.
HookCatalogue::boot(), which PhpClaw\AutoDiscovery\Bootstrap::boot() calls, adds them to the
catalogue; they are bound only once activated.