A support ticket says the agent “did something weird on Tuesday.” You have the reply text and nothing else — not which tools ran, not how many loop iterations it burned, not what the model call cost.
phpClaw’s core fires 40 named lifecycle events during a run, and every one of the 8 adapters re-fires each of them as a native event on the host platform: a WordPress do_action, a Laravel Event::dispatch, a Symfony EventDispatcher dispatch. You subscribe with the API you already use, in the framework you already know.
What you cannot do is change the run from inside a listener. phpClaw hooks observe. They do not intervene in the loop itself.
What a hook actually is here
Two facts about HookRegistry decide almost everything else about how you should use this subsystem.
A hook cannot mutate its payload. The contract is HookInterface::handle(array $context): void, and the docblock on the parameter says it outright: “Event context data (read-only — mutations are ignored).” You receive a copy of the context. Whatever you do to that array dies with your function. There is no filter chain, no returned value, no “modified payload” that flows onward.
A hook cannot abort execution. HookRegistry::dispatch() wraps every handler call in try/catch. A handler that throws is caught, logged, and the next handler runs. The agent loop is completely unaffected. Throwing from a hook is not an emergency brake; it is a no-op with a log line.
Put those together and phpClaw’s hooks are pure observers, not interceptors. No hook can block a tool call, veto an agent turn, or change what the model sees. That is a real limitation and it is worth stating plainly rather than dressing up: if you need to stop something, you are looking for Guard, not Hooks.
Three mechanical details that follow:
- Dispatch is synchronous and inline. There is no queue. Your listener runs on the request that is running the agent, and its wall-clock time is part of the run’s wall-clock time.
- Listeners register per event or as a wildcard, with an integer priority, in a static in-process registry.
fire()short-circuits when an event has no listener and no wildcard listener exists — it returns before the payload is even built. Events nobody is listening to are close to free.
The 40 events
The LifecycleEvent enum in packages/core/src/Hooks/LifecycleEvent.php has 40 cases, in 11 groups:
| Group | Cases | What fires them |
|---|---|---|
agent.* | 5 | The run itself: start, each iteration, completion, failure, iteration cap |
provider.* | 6 | Every model call, retry, failure, streamed token |
tool.* | 4 | Tool dispatch, result, failure |
| context / compaction | 3 | History summarisation before an oversized provider call |
guard.* | 5 | Prompt blocks and tool-output redactions |
conversation.* | 2 | Conversation-scoped bookends |
stream.* | 3 | The streaming path specifically |
shell.* | 2 | Shell command execution and refusal |
memory.* | 3 | Memory store reads and writes |
job.* | 3 | Queued runs — Laravel and Symfony only |
skill.* | 4 | Skill resolution and injection |
| Total | 40 |
A note on the number, because it matters
This repo’s own internal audit document (rainboo-audit.md, point P18e) instructs that any README mentioning the event count “must say 34.” That figure is stale. The count used throughout this article comes from counting the enum cases in LifecycleEvent.php directly, cross-confirmed by a second, independent full enumeration that also summed to 40. A couple of secondary sources restate the count as “30” or “36” without recounting; treat those as mis-restatements, not contradicting evidence.
We are using 40, because the enum is the source and the docs are a claim about the source. That distinction is worth being precise about: internal documentation drifts, source doesn’t.
One more piece of honesty about the list: below, we name the events that have a direct file-and-line citation behind them. The remaining cases exist in the enum and are counted in the 40, but their exact spelling was not individually confirmed for this article — we’re not going to guess at it in a code example you’d then paste into production.
The payloads that are documented
| Event | Fires when | Payload |
|---|---|---|
agent.before | A run starts | Carries the raw user message (the augmented one — memory plus skill text — is a different body on a different path) |
agent.iteration | Top of every loop pass | Iteration marker; each iteration also gets the id {runId}_I{n}, used as parent_run_id, so events from one iteration nest under it |
agent.after | Run completes with a text response | message, text, provider, model, iterations, duration_ms, tools_called, and cache token fields — no input/output token counts |
agent.error | Run fails | Failure event; this is the event that marks a run failed |
agent.max_iterations | Loop hits the cap (default 20) | Fires, then the loop throws — there is no partial answer |
provider.request | Before each model call | One per iteration |
provider.response | After each model call | The only event carrying input and output token counts. One per iteration |
provider.retry | Between retry attempts | Retry default is 0, so in an out-of-the-box adapter this never fires |
provider.error | Provider call fails after retries | Note: this does not mark the run failed |
provider.token | Per streamed delta | Fires without a run id, so it does not roll up into a run |
tool.after | A tool returns | Tool result |
tool.error | A tool throws ToolException | The message is also handed back to the model as JSON so the run can recover |
compaction.before / compaction.after | History summarisation | Both thresholds default to 0 and no adapter exposes them, so compaction is effectively off unless you wire it through the library builder |
guard.blocked | A guard throws | Carries a reason; the blocked message text is not forwarded to cloud |
guard.output_php_tag_removed, guard.output_function_redacted | ToolOutputGuard redacts tool output | Redaction never halts the run |
memory.* | Memory read/write | Fires without a run id |
Two structural facts you should know before building on this:
There are no spans. Every event is flat key-value. There is no span id, no parent depth, no waterfall — correlation is by run id, plus the per-iteration {runId}_I{n} id.
Duration is measured in exactly three places — the agent run, the stream, and the conversation. There is no per-tool or per-provider-call latency. A duration_ms key exists in the cloud wire format for provider and tool events, but no dispatcher populates it; it is always null.
Two mechanisms people conflate
HookEventBridge wraps every core HookRegistry event and re-fires it as a real native event on the host platform. This flows one way: phpClaw → platform. Nothing your platform dispatches comes back into the agent through this bridge. Do not read “bridge” as “bidirectional.”
Separately, each platform offers a boot-time extension point where third parties register new tools, guards, hooks and skills before the registries finalise: phpclaw.booting on Laravel and Symfony, onPhpClawExtra* events on Joomla, observer events on Magento, OpenCart and PrestaShop, phpclaw.*-tagged services on Drupal.
Those are two different things. The bridge broadcasts what happened. The extension point lets you add participants — and it only runs at boot. Adding a listener via the extension point still gets you an observer; it does not upgrade you to an interceptor.
Per-adapter: names and toggles
All 40 events are defined in core and bridged the same way everywhere. What changes per adapter is
the native event name, whether you can switch the bridge off, and how many of the 40 are actually
reachable: the three job.* events fire from the queue workers that only Laravel and Symfony ship,
so a CMS adapter reaches 37 and a framework adapter reaches all 40.
| Adapter | Native event name | Toggle | Default |
|---|---|---|---|
| WordPress | do_action('phpclaw_{event}', $ctx) | events_bridge config | on |
| Joomla (5/6, one registration) | onPhpClaw{PascalEvent} via GenericEvent | none | always on |
| Laravel | Event::dispatch('phpclaw.{event}', [$ctx]) | PHPCLAW_EVENTS_BRIDGE | on |
| Symfony | dispatch(new PhpClawEvent(...), 'phpclaw.{event}') | phpclaw.event_bridge | on |
| Drupal | EventDispatcher, phpclaw.{event} | none | always on (boot-time) |
| Magento | phpclaw_{event_with_underscores} via Manager::dispatch() | none | always on |
| OpenCart | Event::trigger('phpclaw/{event}', [$ctx]) | PHPCLAW_EVENTS_BRIDGE constant | on |
| PrestaShop | \Hook::exec('actionPhpClaw{PascalEvent}', ...) | events_bridge config | on |
Two adapters give you more than the raw bridge. Symfony ships a Web Profiler panel that collects runs, tool calls and guard blocks. Laravel ships a Telescope watcher that records guard blocks as TYPE_GUARD_BLOCKED entries when Telescope is installed and PHPCLAW_TELESCOPE=true (default true — it no-ops silently if Telescope is not installed). Joomla, OpenCart and PrestaShop have an admin debug panel, but it is per-request: it shows the tool calls from the message you just sent, not a stored history.
Three things you can actually build
1. Per-call token accounting
agent.after looks like the obvious place to total up a run’s tokens. It is not: it carries iterations, duration_ms and tools_called, but no input/output token counts. provider.response is the only event that carries them, and it fires once per iteration — so a run’s total is a sum you compute yourself. Core also has no notion of cost, price or spend anywhere; it reports tokens and pricing them is your job.
WordPress, in an mu-plugin or your theme’s functions.php:
<?php
add_action( 'phpclaw_provider.response', static function ( array $ctx ): void {
$line = wp_json_encode( $ctx ) . "\n";
file_put_contents(
WP_CONTENT_DIR . '/phpclaw-provider-calls.jsonl',
$line,
FILE_APPEND | LOCK_EX
);
}, 10, 1 );
Run one agent turn, then open that file. That is the step to not skip: the exact token key names come out of ProviderEventDispatcher, and you should read them off your own install rather than take them from an article — including this one. Once you know the keys, replace the raw dump with the two or three columns you care about.
2. Out-of-band alerting on a guard block
guard.blocked fires when a guard throws. The exception is then re-thrown to your application, so the block is already enforced by the time your listener runs — which is exactly why a listener is the right place for the alert and the wrong place for the decision.
Laravel, in a service provider’s boot():
<?php
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
Event::listen('phpclaw.guard.blocked', static function (array $ctx): void {
$reason = $ctx['reason'] ?? 'unknown';
Log::channel('security')->warning('phpClaw guard block', ['reason' => $reason]);
Http::timeout(2)->post(config('services.slack.webhook'), [
'text' => "phpClaw guard block: {$reason}",
]);
});
reason is the field to key on — it is the only field the cloud payload for guard.blocked carries, deliberately: the message text that triggered the block is not forwarded.
Before you write this, check whether you need to. Core ships SecurityAlertHook, the only default-enabled hook in the product, and it fires on guard.blocked only. If a webhook POST is all you wanted, it is already there.
3. A durable run log
agent.after has the one fully-enumerated payload in the evidence, which makes it the best first listener to write. Laravel:
<?php
// database/migrations/2026_08_22_000000_create_agent_runs_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('agent_runs', function (Blueprint $table): void {
$table->id();
$table->string('provider')->nullable();
$table->string('model')->nullable();
$table->unsignedInteger('iterations')->nullable();
$table->unsignedInteger('duration_ms')->nullable();
$table->json('tools_called')->nullable();
$table->timestamp('created_at');
});
}
public function down(): void
{
Schema::dropIfExists('agent_runs');
}
};
<?php
// In a service provider's boot()
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
Event::listen('phpclaw.agent.after', static function (array $ctx): void {
DB::table('agent_runs')->insert([
'provider' => $ctx['provider'] ?? null,
'model' => $ctx['model'] ?? null,
'iterations' => $ctx['iterations'] ?? null,
'duration_ms' => $ctx['duration_ms'] ?? null,
'tools_called' => json_encode($ctx['tools_called'] ?? []),
'created_at' => now(),
]);
});
Pair it with agent.error and you have the two events that decide run status: a run is failed by agent.error and succeeded by agent.after. provider.error and tool.error do not mark a run failed.
The example that does not work: blocking a tool by policy
“Listen on tool.before, check the arguments against my policy, and refuse if it violates” is the first thing most developers want to build here, and it cannot be built with a hook. Throwing from your listener gets caught by HookRegistry::dispatch(), logged, and ignored; the tool runs anyway.
What a hook can do at tool.before is take an out-of-band action — log it, alert on it, increment a counter, page someone. What it cannot do is prevent the call. Blocking is Guard’s job: GuardInterface::scan() throws a GuardException and that exception genuinely halts the turn. If your policy is about tool arguments specifically, the other option is inside the tool’s own execute() — see Tools, since there is no argument validation in the core loop and each tool validates its own input anyway.
Limitations
Hooks cannot mutate or abort. Full stop. If you need to block something, that is Guard’s job, not Hooks’. Everything else in this list is downstream of that one design decision.
Hook handler exceptions are swallowed. A handler that throws is caught and logged — not surfaced, not retried, not reported to the caller. A listener that has been broken since your last deploy looks exactly like a listener that is working: the agent runs fine either way. Assert on your own side effects; do not assume silence means success.
There is no built-in persistent audit log of hook execution. Open-source phpClaw gives you HookRegistry, LifecycleEvent, EventPayload, HookEventBridge, DebugLogHook, SecurityAlertHook, HookCatalogue and Log — and no viewer, no storage, no aggregation. DebugLogHook writes JSON-lines but carries no #[Hook] attribute and no adapter references it, so it is opt-in and one line to register. When it is on, it appends the entire raw event context — including message and response text — with no sanitisation, no size cap and no rotation. Treat it as a debugging tool, not a log.
Diagnostics default to error_log(). Logging is a 3-method non-PSR-3 contract and no adapter installs a logger. That is where your swallowed hook exceptions go until you inject one.
No spans, no per-step latency. Flat events correlated by run id, duration at three places only. If you were hoping to drop this into an existing tracing stack: there is no OpenTelemetry, OTLP, Prometheus, StatsD, Jaeger or Datadog exporter in any package. Bridging to your stack means writing a listener.
Some events have no run id. Memory events and provider.token fire unattached, so they will not roll up into a run timeline.
What to read next
- phpClaw Guard: What Stops a Bad Tool Call — the subsystem that actually enforces. Read it directly after this one if you arrived wanting to block something.
- phpClaw Tools: How the Agent Calls Code — what the
tool.*events are describing, and where per-tool argument policy belongs. - phpClaw Providers: Choosing and Configuring the Model Backend — the retry behaviour behind
provider.retryandprovider.error. - phpClaw Memory: What the Agent Remembers, and What It Costs — why
memory.*events fire without a run id. - phpClaw Skills: Injecting Domain Knowledge Without Fine-Tuning — the four injection mechanisms behind the
skill.*group.