Skip to content

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.


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']);
});
MethodDoes
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

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'); // 1

This 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.


Every name lives in the LifecycleEvent enum. LifecycleEvent::all() returns them as strings.

GroupEvents
Agentagent.before, agent.after, agent.iteration, agent.error, agent.max_iterations
Providerprovider.request, provider.response, provider.retry, provider.cache_hit, provider.error, provider.token
Tooltool.before, tool.after, tool.error, tool.not_found
Contextcontext.overflow, compaction.before, compaction.after
Guardguard.blocked, guard.rate_limit_exceeded, guard.tool_output_redacted, guard.output_php_tag_removed, guard.output_function_redacted
Conversationconversation.start, conversation.end
Streamstream.start, stream.end, stream.abort
Shellshell.exec, shell.denied
Memorymemory.read, memory.write, memory.forget
Jobjob.started, job.completed, job.failed
Skillskill.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.

Each event supplies its own keys. Events fired inside a run also carry these when they are set:

KeyPresent when
run_idthe event belongs to a run
parent_run_idthe event belongs to one iteration of a run
conversation_idthe run is part of a conversation
streamingthe run is streaming; the key is absent, not false, otherwise

Two examples:

EventKeys
tool.aftertool_name, tool_input, tool_result, iteration
agent.aftermessage, text, provider, model, iterations, duration_ms, tools_called, cache_read_tokens, cache_write_tokens

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.

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.

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" }
OptionDefaultEffect
webhookUrl''falls back to the PHPCLAW_SECURITY_WEBHOOK environment variable
timeout3seconds
events['guard.blocked']events the hook reacts to; others are ignored
payloadFormatternulla 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.


HookCatalogue lists hooks by key. It ships one entry:

KeyEventClassPriorityEnabled by default
security_alertguard.blockedSecurityAlertHook50yes
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:

Adaptersecurity_alert registered
WordPress, Joomla, Drupal, PrestaShop, OpenCart, Symfonyyes: settings are passed without hooks_enabled, so the defaults apply
Laravelno: it passes hooks_enabled => []
Magentono: 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.


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.

AdapterMechanismtool.after arrives as
WordPressdo_action()phpclaw_tool.after
Joomlaapplication dispatcher, currently delivers nothingonPhpClawToolAfter
Drupalevent dispatcherphpclaw.tool.after
Symfonyevent dispatcherphpclaw.tool.after
Laravelevent dispatcherphpclaw.tool.after
Magentoevent manager, context under dataphpclaw_tool_after
PrestaShopHook::exec(), as ['event' => ..., 'context' => ...]actionPhpClawToolAfter
OpenCartregistry eventphpclaw/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.

EventJoomlaPrestaShopMagento
guard.rate_limit_exceededonPhpClawGuardRateLimitExceededactionPhpClawGuardRate_limit_exceededphpclaw_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();
ArgumentDefaultEffect
dispatcherrequireda closure receiving the event name and context
eventsnulla list of event names to forward; null forwards all 40
priority50the 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.


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);

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.


  • Guards: what fires guard.blocked and the other guard.* events
  • Memory: the drivers that fire memory.*
  • Skills: when skill.matched and skill.not_matched fire