# Hook reference

A hook is a named point in a template where an extension can render HTML.

```php
public function boot(): void
{
    $this->template()->registerHook('post.header', function (array $context): string {
        $post = $context['post'] ?? null;

        return $post === null ? '' : '<span>#' . (int) $post['id'] . '</span>';
    });
}
```

The callback is handed the hook's context array and returns HTML. Register
hooks in `boot()`, not `register()` — `register()` runs before every module
has been registered.

## The contract

**Within a major version of Convoro, a hook name listed here is not removed
and its context keys are not renamed.** New hook points and new context keys
may be added. That is the promise; it is written down before there are any
extensions rather than after, because breaking it costs other people money.

What the contract does *not* promise:

- **That a key is always present.** A hook name says the point exists and what
  it is called. Whether a particular page filled in every key is a different
  question, so read every key as `$context['thing'] ?? null` and return `''`
  when what you need is not there.
- **The surrounding markup.** Hooks are insertion points, not a template API.
  The classes and structure around a hook can change.

## Ordering

Two extensions on the same point run in priority order, lowest first. Equal
priorities keep registration order, which is module boot order — not something
to rely on.

```php
$this->template()->registerHook('footer.end', $callback, -10);   // early
$this->template()->registerHook('footer.end', $callback, 10);    // late
```

## Escaping

**A hook returns HTML, so nothing escapes it for you.** Anything from the
database goes through `htmlspecialchars(…, ENT_QUOTES, 'UTF-8')`, or is cast
to `int`, before it goes into a string. This is the one thing that turns a
harmless extension into a stored-XSS hole.

## Failure

A callback that throws costs its own output and nothing else. The error is
logged, and in debug mode it appears as an HTML comment where the output would
have been. One broken extension does not blank the page — but it also does not
announce itself to visitors, so check the log.

## Page shell — `themes/default/templates/layouts/main.cvr`

| Hook | Context | Where |
|---|---|---|
| `head.after` | — | End of `<head>`. Stylesheets, meta tags. |
| `body.start` | — | First thing in `<body>`, above the header. |
| `header.nav` | `user` (`?array`) | In the header nav, before the member block. |
| `header.right` | — | End of the header bar. |
| `content.before` | — | Above the page content. |
| `content.after` | — | Below the page content. |
| `footer.start` | — | Start of the footer. |
| `footer.end` | — | End of the footer. |
| `body.end` | — | Last thing in `<body>`. Scripts. |

## Admin shell — `themes/default/templates/layouts/admin.cvr`

| Hook | Context | Where |
|---|---|---|
| `admin.head.after` | — | End of `<head>` in the admin panel. |
| `admin.nav` | — | End of the admin sidebar. **This is how an extension links to its own admin screen.** |
| `admin.header.actions` | — | Beside the page title's action buttons. |
| `admin.content.before` | — | Above the admin page content. |
| `admin.content.after` | — | Below the admin page content. |
| `admin.body.end` | — | Last thing in the admin `<body>`. |

## Forum index — `forum::front/index`

| Hook | Context | Where |
|---|---|---|
| `forum.index.top` | — | Under the page heading, above the categories. |
| `forum.index.bottom` | — | Under the forum list, above who's online. |

## A forum — `forum::front/show`

| Hook | Context | Where |
|---|---|---|
| `forum.show.top` | `forum` | Under the forum description. |
| `forum.topiclist.before` | `forum`, `topics` | Above the topic list. |
| `topic.row.badges` | `topic` | Beside Pinned/Locked on each row. **Inline elements only** — the row is an `<a>`, so block or interactive content is invalid there. |
| `forum.topiclist.after` | `forum`, `topics` | Below the topic list, above the pagination. |

## Starting a topic — `forum::front/topic-create`

| Hook | Context | Where |
|---|---|---|
| `topic.create.fields` | `forum`, `old` | Between the title and the body, for whatever this forum requires beyond the two — a price and a condition in a for-sale forum, say. Above the body on purpose: what a forum requires is part of what you are writing, not an afterthought once you have written it. `old` carries what was submitted on a refused attempt, so a field can repopulate itself. |

🚨 **A field rendered here with nothing checking it is decoration.** The server
half is the `topic_checks` service: register a check and the topic is refused
before anything is inserted, with your reason shown on the re-rendered form.

```php
$this->app->make('topic_checks')->register(
    'classifieds.price',
    function (array $input, array $forum, ?array $user): ?string {
        if (($forum['id'] ?? 0) !== $this->forSaleForumId) {
            return null;                 // not our forum, no opinion
        }

        return trim((string) ($input['price'] ?? '')) === ''
            ? 'Give your item a price.'
            : null;
    },
);

// And once it exists, with what was typed — `TopicCreated` carries the topic,
// not the form, and you cannot store a price you can no longer see.
$this->app->make('topic_checks')->registerAfter(
    'classifieds.price',
    fn (int $topicId, array $input, array $forum) => $this->storePrice($topicId, $input),
);
```

A check that throws is logged and skipped, never fatal: an extension with a bug
must not leave a forum unable to accept topics.

## A topic — `forum::front/topic-show`

| Hook | Context | Where |
|---|---|---|
| `topic.show.top` | `topic`, `forum` | Under the breadcrumb. |
| `topic.header.after` | `topic` | Under the title, tags and meta. |
| `topic.moderation.actions` | `topic` | End of the moderator toolbar. Only rendered for moderators. |
| `post.sidebar` | `post`, `author` (`?array`) | Under the author block on each post. |
| `post.header` | `post`, `topic` | Beside the timestamp and post number. `topic` is there so a listener can answer "which forum is this in" without a query per post. |
| `post.body.before` | `post` | Immediately above the post body. |
| `post.body.after` | `post` | Between the body and its attachments. |
| `post.actions` | `post` | End of the Quote/Report row. |
| `topic.posts.after` | `topic`, `posts` | Below the posts, above the pagination. |
| `topic.reply.before` | `topic` | Above the reply box. Signed-in, unlocked topics only. |
| `topic.reply.guest` | `topic` | Where the reply box would be, for a signed-out visitor. The counterpart to `topic.reply.before`, which guests never reach. Fires on locked topics too — somebody who cannot reply either way is still being invited to join, not to post. Core renders nothing here itself. |

## Members — `system::front/*`

| Hook | Context | Where |
|---|---|---|
| `member.profile.top` | `member` | Above the profile card. |
| `member.profile.stats` | `member` | Under posts/topics/reputation/joined. |
| `member.profile.actions` | `member` | Beside View activity and Send message. |
| `member.profile.bottom` | `member` | Below the profile card. |
| `member.register.fields` | — | Last field on the registration form. |
| `member.register.after` | — | Below the registration form. |
| `member.login.fields` | — | Last field on the login form. |
| `member.login.after` | — | Below the login form. Third-party sign-in buttons go here. |
| `member.security.after` | — | Below two-step verification on Settings → Security. Anything else that decides how somebody gets into their account. |
| `settings.profile.after` | `user` | Below the form on Settings → Profile, for anything about how somebody presents themselves that an extension owns. 🚨 **Outside core's form, deliberately** — that controller saves only the fields it knows, so a control rendered inside it would post to an endpoint that ignores it and look saved when it was not. Carry your own form, or link to your own page. Anything that belongs in core's save is a profile field, which is already a feature. |

## Chat and conversations — `chat::front/*`, `messenger::front/index`

| Hook | Context | Where |
|---|---|---|
| `chat.header.actions` | `channel` | In the channel header, before the search box — anything here is an action on the channel, and search is a filter on what is already shown. Rendered in **both** the full page and the dock, so you write it once and it appears wherever chat is being used. |
| `messenger.header.actions` | `conversation`, `participants` | In the conversation header. It carries the participants as well as the conversation, because anything acting on a conversation almost always needs to know who is in it, and re-querying that per render repeats a query core has already made. |

Before `chat.header.actions` existed, an extension wanting a button there had
no way to ask for one and resorted to finding `[data-chat][data-channel]` in
JavaScript and inserting into it. That works until the markup is touched, and
then the button stops appearing with nothing broken and nothing logged.

## Adding a hook to your own templates

`@hook` works in an extension's templates too, so one extension can be
extensible by another:

```
@hook('my_extension.list.after', ['items' => $items])
```

If you publish an extension other people build on, the contract above becomes
yours: document your hooks and do not rename their keys inside a major version.
