Skip to content

Add lifecycle hooks#37

Merged
samdark merged 3 commits into
masterfrom
feature/hooks-events
Jun 2, 2026
Merged

Add lifecycle hooks#37
samdark merged 3 commits into
masterfrom
feature/hooks-events

Conversation

@samdark
Copy link
Copy Markdown
Member

@samdark samdark commented Jun 2, 2026

Summary

  • Add lifecycle hooks using PSR-14 events through yiisoft/yii-event and yiisoft/event-dispatcher.
  • Wire build and render events into BuildCommand, ParallelEntryWriter, and EntryRenderer.
  • Document event configuration, add tests, and mark the roadmap item complete.

Events

  • BuildStartedEvent
  • BuildFinishedEvent
  • RenderStartedEvent
  • RenderFinishedEvent, with mutable HTML via setHtml()

Verification

  • make test
  • make build-docs
  • make psalm CLI_ARGS="src/Hook benchmarks/EventDispatcherBench.php"
  • make bench BENCH_FILTER=EventDispatcherBench CLI_ARGS="--iterations=5 --revs=100000 --warmup=1"
  • make bench-compare BENCH_FILTER=SmallSiteBuildBench CLI_ARGS="--iterations=3 --revs=1 --warmup=1"

Performance

EventDispatcherBench:

  • null dispatcher path: ~0.010us
  • Yii dispatcher with no listeners: ~0.402us
  • Yii dispatcher with one listener: ~0.466us

The previous custom dispatcher measured about ~0.058us for empty dispatch and ~0.112us for one listener, so Yii's dispatcher is slower at the micro level. Whole-build impact was not significant: SmallSiteBuildBench comparison against the pre-hook baseline showed all six measured scenarios between -0.95% and -3.76%.

Notes

  • make composer-dependency-analyser no longer reports the new event dependencies, but still fails on existing unrelated findings: YiiPress\Build\PharArchiveFilter, evenement/evenement, and react/stream.
  • A broad Psalm run still reports existing repo-wide issues; focused Psalm for the hook event payload and benchmark passes.

Copilot AI review requested due to automatic review settings June 2, 2026 18:44
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 2, 2026

Review Change Stack

Warning

Review limit reached

@samdark, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 36 minutes and 11 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 76d2d7c1-0e45-46a1-9c39-f8009655e45b

📥 Commits

Reviewing files that changed from the base of the PR and between 202dabc and 4a01924.

⛔ Files ignored due to path filters (1)
  • composer.lock is excluded by !**/*.lock
📒 Files selected for processing (16)
  • benchmarks/EventDispatcherBench.php
  • composer-dependency-analyser.php
  • composer.json
  • config/common/di/content-pipeline.php
  • docs/architecture.md
  • docs/engine.md
  • docs/plugins.md
  • src/Build/EntryRenderer.php
  • src/Build/ParallelEntryWriter.php
  • src/Console/BuildCommand.php
  • src/Hook/BuildFinishedEvent.php
  • src/Hook/BuildStartedEvent.php
  • src/Hook/RenderFinishedEvent.php
  • src/Hook/RenderStartedEvent.php
  • tests/Unit/Build/EntryRendererTest.php
  • tests/Unit/Console/BuildCommandTest.php
📝 Walkthrough

Walkthrough

This pull request implements a complete lifecycle hooks system enabling plugins to observe and react to build and render phases. The system comprises core dispatcher infrastructure, immutable event objects carrying build context and entry data, integration through the build command and entry renderer, and comprehensive tests demonstrating both the dispatcher's basic mechanics and end-to-end hook dispatch during actual builds.

Changes

Lifecycle Hooks System

Layer / File(s) Summary
Hook System Core Interfaces and Dispatcher
src/Hook/HookEventInterface.php, src/Hook/HookInterface.php, src/Hook/HookDispatcher.php
HookEventInterface declares the event contract, HookInterface declares the listener contract, and HookDispatcher registers listeners (accepting both closures and HookInterface implementations), checks listener presence via hasListeners(), and dispatches events to all registered handlers with early short-circuit when no listeners exist.
Event Classes and Build Context
src/Hook/BuildContext.php, src/Hook/BuildStartedEvent.php, src/Hook/BuildFinishedEvent.php, src/Hook/RenderStartedEvent.php, src/Hook/RenderFinishedEvent.php
BuildContext immutably carries build configuration (paths, worker count, flags). Build events expose context and site/navigation/collection data at start and finish. Render events expose site config, entry, and permalink; RenderFinishedEvent additionally allows listeners to mutate and retrieve the final html output via setHtml() and html().
Build Command and Writer Hook Integration
src/Console/BuildCommand.php, src/Build/ParallelEntryWriter.php, config/common/di/content-pipeline.php
BuildCommand creates BuildContext during execution and dispatches BuildStartedEvent after dry-run phase, before rendering. It passes the HookDispatcher instance to ParallelEntryWriter, which propagates it to EntryRenderer for both sequential and parallel rendering paths. Dependency injection wiring ensures BuildCommand receives a HookDispatcher instance.
Entry Renderer Render Event Dispatch
src/Build/EntryRenderer.php
EntryRenderer receives HookDispatcher and dispatches RenderStartedEvent before any cache lookup or render attempt. Render results (both cached and newly rendered) are routed through dispatchRenderFinished(), which dispatches RenderFinishedEvent to listeners, allowing HTML mutation, and returns the final HTML. When no listeners exist, the helper short-circuits and returns HTML unchanged.
Hook System Testing and Benchmarking
tests/Unit/Hook/HookDispatcherTest.php, tests/Unit/Build/EntryRendererTest.php, tests/Unit/Console/BuildCommandTest.php, benchmarks/HookDispatcherBench.php
Unit tests verify HookDispatcher closure/interface listener invocation order, empty listener path, and render hook observation/HTML mutation. Integration test exercises build hooks end-to-end with temporary fixture content. Benchmark suite measures hasListeners and dispatch performance across listener configurations.
Documentation and Roadmap Updates
docs/architecture.md, docs/engine.md, docs/plugins.md, roadmap.md
Architecture and engine docs reference lifecycle hooks as a distinct extension mechanism. Plugins guide includes a new "Lifecycle hooks" section describing event names, DI listener registration, HookInterface implementation, and what context/events expose. Roadmap item marked complete.

Sequence Diagram

sequenceDiagram
  participant BuildCommand
  participant HookDispatcher
  participant Listeners
  participant ParallelEntryWriter
  participant EntryRenderer

  BuildCommand->>BuildCommand: createBuildContext()
  BuildCommand->>HookDispatcher: dispatch(BuildStartedEvent)
  HookDispatcher->>Listeners: invoke listener callbacks
  Listeners-->>HookDispatcher: listeners observe build context
  BuildCommand->>ParallelEntryWriter: construct with HookDispatcher
  ParallelEntryWriter->>EntryRenderer: construct with HookDispatcher
  loop for each entry
    EntryRenderer->>HookDispatcher: dispatch(RenderStartedEvent)
    HookDispatcher->>Listeners: invoke listener callbacks
    EntryRenderer->>EntryRenderer: render entry
    EntryRenderer->>HookDispatcher: dispatch(RenderFinishedEvent)
    HookDispatcher->>Listeners: invoke listener callbacks
    Listeners->>HookDispatcher: setHtml() modifies final output
  end
  BuildCommand->>HookDispatcher: dispatch(BuildFinishedEvent)
  HookDispatcher->>Listeners: invoke listener callbacks
  Listeners-->>HookDispatcher: listeners observe final state
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 Hooks for the thoughtful rabbit, building upon the stone,
Listen to the phases when your plugin's roots have grown,
Start and finish, render's dance, HTML you may rewrite,
No more swapping commands about—just hooks that fit just right!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.63% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add lifecycle hooks' directly and concisely describes the main change in the changeset. It accurately reflects the primary objective of introducing the hook API for build and render events.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/hooks-events

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request introduces a lightweight lifecycle hooks API (HookDispatcher + event objects) to enable plugin integrations to observe build and render phases, and wires those hooks into the build command and entry rendering pipeline. It also documents the new extension point and adds unit/integration coverage plus a micro-benchmark for dispatch overhead.

Changes:

  • Add hook core types (HookDispatcher, HookEventInterface, HookInterface) and lifecycle event classes for build and render phases.
  • Dispatch lifecycle events from BuildCommand and EntryRenderer (including support for render HTML mutation via RenderFinishedEvent::setHtml()).
  • Add tests/benchmarks and update documentation/roadmap to reflect the new hook surface.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/Unit/Hook/HookDispatcherTest.php Adds unit coverage for listener ordering, interface listeners, and no-listener behavior.
tests/Unit/Console/BuildCommandTest.php Adds an integration-style test asserting build lifecycle events are dispatched.
tests/Unit/Build/EntryRendererTest.php Adds coverage for render hooks observing and mutating final HTML.
src/Hook/HookEventInterface.php Defines the base hook event contract.
src/Hook/HookInterface.php Defines a reusable listener interface for DI-friendly hooks.
src/Hook/HookDispatcher.php Implements listener registration and dispatch for closures and HookInterface listeners.
src/Hook/BuildContext.php Introduces a context object exposing build paths and build-mode flags.
src/Hook/BuildStartedEvent.php Adds the build-start event payload (context + parsed metadata).
src/Hook/BuildFinishedEvent.php Adds the build-finished event payload (context + site config).
src/Hook/RenderStartedEvent.php Adds a render-start event payload (site config + entry + permalink).
src/Hook/RenderFinishedEvent.php Adds a render-finished event payload with mutable HTML.
src/Console/BuildCommand.php Wires build started/finished dispatch and passes dispatcher into entry writing.
src/Build/ParallelEntryWriter.php Threads the dispatcher into EntryRenderer for both single and forked workers.
src/Build/EntryRenderer.php Dispatches render started/finished hooks and allows final HTML mutation.
config/common/di/content-pipeline.php Injects a shared HookDispatcher into BuildCommand via DI.
docs/plugins.md Documents lifecycle hooks, events, and DI configuration examples.
docs/engine.md Explains how hooks differ from processors and where they fit in the engine.
docs/architecture.md Adds hooks as an architectural extension point reference.
roadmap.md Marks the hooks/events roadmap item as complete.
benchmarks/HookDispatcherBench.php Adds benchmarks for empty and single-listener dispatch paths.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Console/BuildCommand.php Outdated
Comment on lines +351 to +352
$this->hookDispatcher->dispatch(new BuildStartedEvent($buildContext, $siteConfig, $navigation, $collections, $authors));

Comment thread src/Console/BuildCommand.php Outdated
Comment on lines +798 to +799
$this->hookDispatcher->dispatch(new BuildFinishedEvent($buildContext, $siteConfig));

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/Build/ParallelEntryWriter.php (1)

100-139: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Parallel build dispatches render hooks only in forked workers; in-memory listener state won’t reach the parent.

In src/Build/ParallelEntryWriter.php::writeParallel(), each pcntl_fork() child builds its own EntryRenderer and dispatches RenderStartedEvent/RenderFinishedEvent; the child then exit(0)s while the parent only pcntl_waitpid()s. Any listener that aggregates cross-entry data by mutating in-memory state (counters, collected permalinks, indexes, object mutations, etc.) will do so in the child process and that state is discarded. RenderFinishedEvent::setHtml()-based HTML mutation still works because EntryRenderer::dispatchRenderFinished() returns $event->html() and the child writes the rendered file.

writeEntries() (workers==1) dispatches hooks in-process, so plugin behavior silently diverges between single-worker and parallel builds.

Document this constraint for hook authors and/or enforce a “HTML-only mutation” contract (or move aggregation to a parent-side mechanism).

🧹 Nitpick comments (1)
src/Console/BuildCommand.php (1)

351-798: ⚖️ Poor tradeoff

BuildFinishedEvent is not guaranteed to fire if the build throws.

BuildStartedEvent is dispatched at Line 351, but BuildFinishedEvent at Line 798 is only reached on the success path. Any exception in between (e.g. the RuntimeException directory failures, writer failures) leaves listeners with an unpaired start event and no chance to release/finalize resources. Consider a try { ... } finally { dispatch(BuildFinishedEvent) } around the build body, or document that finish is best-effort.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Console/BuildCommand.php` around lines 351 - 798, The build dispatch
pattern currently sends BuildStartedEvent but only dispatches BuildFinishedEvent
on the success path; wrap the entire build body (the code between the
BuildStartedEvent dispatch and the BuildFinishedEvent dispatch) in a try/finally
so BuildFinishedEvent is always dispatched even if exceptions occur; locate the
dispatch calls for BuildStartedEvent and BuildFinishedEvent in BuildCommand
(around the parsing/writing logic) and move the bulk of the work into the try
block and put $this->hookDispatcher->dispatch(new BuildFinishedEvent(...)) in
the finally block, ensuring any needed variables (e.g. $buildContext,
$siteConfig) are available for the finally scope.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/Unit/Build/EntryRendererTest.php`:
- Around line 406-436: The test only exercises a cold render; add a second
render call to exercise the cached path and verify hooks still run and mutate
cached HTML. After the existing $html = $renderer->render(...), call $cachedHtml
= $renderer->render($this->createSiteConfig(), $entry, '/blog/hooked-post/');
then assertStringContainsString('<span id="hooked"></span></body>', $cachedHtml)
(and optionally assert events still contains 'render.started:Hooked Post' or
other expected hook side-effects). This ensures EntryRenderer::render and its
dispatchRenderFinished behavior (and the RenderFinishedEvent mutation) are
covered for cache hits.

In `@tests/Unit/Hook/HookDispatcherTest.php`:
- Around line 36-54: Add a unit test to HookDispatcherTest that verifies
non-Closure callables passed to HookDispatcher::listen are accepted and invoked:
create and register (a) an array-callable (e.g., [new SomeListener, 'handle'])
and (b) an invokable object, call HookDispatcher::dispatch with a TestHookEvent
for their event name, and assert they were invoked (check listener call records)
and that HookDispatcher::hasListeners returns true for that event; place the
test alongside testDispatchesHookInterfaceListeners and
testIgnoresEventsWithoutListeners and reference HookDispatcher::listen,
HookDispatcher::dispatch and HookDispatcher::hasListeners when locating code to
exercise.

---

Nitpick comments:
In `@src/Console/BuildCommand.php`:
- Around line 351-798: The build dispatch pattern currently sends
BuildStartedEvent but only dispatches BuildFinishedEvent on the success path;
wrap the entire build body (the code between the BuildStartedEvent dispatch and
the BuildFinishedEvent dispatch) in a try/finally so BuildFinishedEvent is
always dispatched even if exceptions occur; locate the dispatch calls for
BuildStartedEvent and BuildFinishedEvent in BuildCommand (around the
parsing/writing logic) and move the bulk of the work into the try block and put
$this->hookDispatcher->dispatch(new BuildFinishedEvent(...)) in the finally
block, ensuring any needed variables (e.g. $buildContext, $siteConfig) are
available for the finally scope.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e568e6a5-7622-4fd2-bcd7-187da210246b

📥 Commits

Reviewing files that changed from the base of the PR and between 66e82ed and 202dabc.

📒 Files selected for processing (20)
  • benchmarks/HookDispatcherBench.php
  • config/common/di/content-pipeline.php
  • docs/architecture.md
  • docs/engine.md
  • docs/plugins.md
  • roadmap.md
  • src/Build/EntryRenderer.php
  • src/Build/ParallelEntryWriter.php
  • src/Console/BuildCommand.php
  • src/Hook/BuildContext.php
  • src/Hook/BuildFinishedEvent.php
  • src/Hook/BuildStartedEvent.php
  • src/Hook/HookDispatcher.php
  • src/Hook/HookEventInterface.php
  • src/Hook/HookInterface.php
  • src/Hook/RenderFinishedEvent.php
  • src/Hook/RenderStartedEvent.php
  • tests/Unit/Build/EntryRendererTest.php
  • tests/Unit/Console/BuildCommandTest.php
  • tests/Unit/Hook/HookDispatcherTest.php

Comment thread tests/Unit/Build/EntryRendererTest.php
Comment thread tests/Unit/Hook/HookDispatcherTest.php Outdated
Comment on lines +36 to +54
public function testDispatchesHookInterfaceListeners(): void
{
$event = new TestHookEvent('test.event');
$listener = new TestHookListener();
$dispatcher = new HookDispatcher();
$dispatcher->listen('test.event', $listener);

$dispatcher->dispatch($event);

assertSame(['test.event'], $listener->calls);
}

public function testIgnoresEventsWithoutListeners(): void
{
$dispatcher = new HookDispatcher();

assertSame(false, $dispatcher->hasListeners('missing.event'));
$dispatcher->dispatch(new TestHookEvent('missing.event'));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a test for non-Closure callables passed to listen().

src/Hook/HookDispatcher.php:36-43 has a separate Closure::fromCallable() branch, but this suite only exercises raw closures and HookInterface instances. A regression in array-callable or object-callable registration would currently slip through.

Suggested test shape
+    public function testDispatchesCallableListeners(): void
+    {
+        $event = new TestHookEvent('test.event');
+        $listener = new class {
+            /** `@var` list<string> */
+            public array $calls = [];
+
+            public function onEvent(HookEventInterface $event): void
+            {
+                $this->calls[] = $event->name();
+            }
+        };
+
+        $dispatcher = new HookDispatcher();
+        $dispatcher->listen('test.event', [$listener, 'onEvent']);
+        $dispatcher->dispatch($event);
+
+        assertSame(['test.event'], $listener->calls);
+    }

As per coding guidelines, **/*Test.php: For each piece of code add a test using phpunit.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public function testDispatchesHookInterfaceListeners(): void
{
$event = new TestHookEvent('test.event');
$listener = new TestHookListener();
$dispatcher = new HookDispatcher();
$dispatcher->listen('test.event', $listener);
$dispatcher->dispatch($event);
assertSame(['test.event'], $listener->calls);
}
public function testIgnoresEventsWithoutListeners(): void
{
$dispatcher = new HookDispatcher();
assertSame(false, $dispatcher->hasListeners('missing.event'));
$dispatcher->dispatch(new TestHookEvent('missing.event'));
}
public function testDispatchesHookInterfaceListeners(): void
{
$event = new TestHookEvent('test.event');
$listener = new TestHookListener();
$dispatcher = new HookDispatcher();
$dispatcher->listen('test.event', $listener);
$dispatcher->dispatch($event);
assertSame(['test.event'], $listener->calls);
}
public function testIgnoresEventsWithoutListeners(): void
{
$dispatcher = new HookDispatcher();
assertSame(false, $dispatcher->hasListeners('missing.event'));
$dispatcher->dispatch(new TestHookEvent('missing.event'));
}
public function testDispatchesCallableListeners(): void
{
$event = new TestHookEvent('test.event');
$listener = new class {
/** `@var` list<string> */
public array $calls = [];
public function onEvent(HookEventInterface $event): void
{
$this->calls[] = $event->name();
}
};
$dispatcher = new HookDispatcher();
$dispatcher->listen('test.event', [$listener, 'onEvent']);
$dispatcher->dispatch($event);
assertSame(['test.event'], $listener->calls);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/Unit/Hook/HookDispatcherTest.php` around lines 36 - 54, Add a unit test
to HookDispatcherTest that verifies non-Closure callables passed to
HookDispatcher::listen are accepted and invoked: create and register (a) an
array-callable (e.g., [new SomeListener, 'handle']) and (b) an invokable object,
call HookDispatcher::dispatch with a TestHookEvent for their event name, and
assert they were invoked (check listener call records) and that
HookDispatcher::hasListeners returns true for that event; place the test
alongside testDispatchesHookInterfaceListeners and
testIgnoresEventsWithoutListeners and reference HookDispatcher::listen,
HookDispatcher::dispatch and HookDispatcher::hasListeners when locating code to
exercise.

@samdark samdark merged commit 6000a44 into master Jun 2, 2026
7 checks passed
@samdark samdark deleted the feature/hooks-events branch June 2, 2026 19:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants