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:

GroupCasesWhat fires them
agent.*5The run itself: start, each iteration, completion, failure, iteration cap
provider.*6Every model call, retry, failure, streamed token
tool.*4Tool dispatch, result, failure
context / compaction3History summarisation before an oversized provider call
guard.*5Prompt blocks and tool-output redactions
conversation.*2Conversation-scoped bookends
stream.*3The streaming path specifically
shell.*2Shell command execution and refusal
memory.*3Memory store reads and writes
job.*3Queued runs — Laravel and Symfony only
skill.*4Skill resolution and injection
Total40

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

EventFires whenPayload
agent.beforeA run startsCarries the raw user message (the augmented one — memory plus skill text — is a different body on a different path)
agent.iterationTop of every loop passIteration marker; each iteration also gets the id {runId}_I{n}, used as parent_run_id, so events from one iteration nest under it
agent.afterRun completes with a text responsemessage, text, provider, model, iterations, duration_ms, tools_called, and cache token fields — no input/output token counts
agent.errorRun failsFailure event; this is the event that marks a run failed
agent.max_iterationsLoop hits the cap (default 20)Fires, then the loop throws — there is no partial answer
provider.requestBefore each model callOne per iteration
provider.responseAfter each model callThe only event carrying input and output token counts. One per iteration
provider.retryBetween retry attemptsRetry default is 0, so in an out-of-the-box adapter this never fires
provider.errorProvider call fails after retriesNote: this does not mark the run failed
provider.tokenPer streamed deltaFires without a run id, so it does not roll up into a run
tool.afterA tool returnsTool result
tool.errorA tool throws ToolExceptionThe message is also handed back to the model as JSON so the run can recover
compaction.before / compaction.afterHistory summarisationBoth thresholds default to 0 and no adapter exposes them, so compaction is effectively off unless you wire it through the library builder
guard.blockedA guard throwsCarries a reason; the blocked message text is not forwarded to cloud
guard.output_php_tag_removed, guard.output_function_redactedToolOutputGuard redacts tool outputRedaction never halts the run
memory.*Memory read/writeFires 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.

AdapterNative event nameToggleDefault
WordPressdo_action('phpclaw_{event}', $ctx)events_bridge configon
Joomla (5/6, one registration)onPhpClaw{PascalEvent} via GenericEventnonealways on
LaravelEvent::dispatch('phpclaw.{event}', [$ctx])PHPCLAW_EVENTS_BRIDGEon
Symfonydispatch(new PhpClawEvent(...), 'phpclaw.{event}')phpclaw.event_bridgeon
DrupalEventDispatcher, phpclaw.{event}nonealways on (boot-time)
Magentophpclaw_{event_with_underscores} via Manager::dispatch()nonealways on
OpenCartEvent::trigger('phpclaw/{event}', [$ctx])PHPCLAW_EVENTS_BRIDGE constanton
PrestaShop\Hook::exec('actionPhpClaw{PascalEvent}', ...)events_bridge configon

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.