# Event reference

Where hooks put something on a page, events tell you something happened.

```php
use Convoro\Modules\System\Events\TopicCreated;

public function boot(): void
{
    $this->events()->listen(TopicCreated::class, function (TopicCreated $event): void {
        $this->app->make('logger')->info('New topic: ' . $event->topicId);
    });
}
```

Events are typed classes rather than strings, because the class *is* the
documentation of what a listener receives, and a mistyped class name is an
error where a mistyped string is silence.

## Three rules

**Events are named for what has already happened.** They are dispatched after
the write has committed. A listener observes; it never decides.

**Listeners cannot veto.** There is no cancellable event and there will not
be: a broken extension quietly swallowing posts is the worst possible bug to
debug. If you need to prevent something, that is a permission question, not an
event one.

**Catch your own errors.** A listener that throws takes the request with it —
which for `TopicDeleted` means a moderator who cannot delete a topic. Wrap
anything that can fail and log it. Your bookkeeping being out of date is a much
smaller problem than the forum not working.

```php
$this->events()->listen(TopicDeleted::class, function (TopicDeleted $event): void {
    try {
        $this->cleanUp($event->topicId);
    } catch (\Throwable $e) {
        $this->app->make('logger')->error('My extension: ' . $e->getMessage());
    }
});
```

Listeners run inside the request, so keep them quick. Anything slow — an
outbound HTTP call in particular — makes every post slower for the member who
made it.

## Ordering

`listen()` takes a priority as its third argument; higher runs first. Calling
`$event->stopPropagation()` stops later listeners, so use it sparingly — it
silently disables somebody else's extension.

## Forum — `Convoro\Modules\System\Events\`

🚨 **`System`, not `Forum`.** This section said `Forum\Events\` until 2026-08-13
and every class in it was wrong. A listener registered against a class name
that does not exist never fires and never errors — the extension installs, the
admin screen works, and the thing it was written to do simply never happens.
Two ports were written against the wrong namespace before this was caught.

The events live under `System` because the forum's tables are core's; the name
is historical rather than meaningful. Moving them would break every listener
already written against the real name, so the documentation is what changed.

| Event | Properties | When |
|---|---|---|
| `TopicCreated` | `topicId`, `forumId`, `userId`, `firstPostId`, `title`, `slug` | A topic and its first post are stored. |
| `PostCreated` | `postId`, `topicId`, `forumId`, `userId`, `position` | A reply is stored. **Not** fired for a topic's first post — listen for `TopicCreated` for those. |
| `PostEdited` | `postId`, `topicId`, `authorId`, `editorId`, `titleChanged` | A post's content has been changed. `authorId` and `editorId` differ when a moderator edited somebody else's post. |
| `TopicDeleted` | `topicId`, `forumId`, `moderatorId`, `title` | A moderator soft-deletes a topic. |
| `PostDeleted` | `postId`, `topicId`, `moderatorId` | A moderator soft-deletes a post. |
| `TopicModerated` | `topicId`, `action`, `moderatorId` | `action` is one of `lock`, `unlock`, `pin`, `unpin`, `hide`, `unhide`. |
| `PostModerated` | `postId`, `topicId`, `action`, `moderatorId` | `action` is `hide` or `unhide`. |

## Members — `Convoro\Modules\System\Events\`

| Event | Properties | When |
|---|---|---|
| `UserRegistered` | `userId`, `username`, `email` | Registration completed and the member was signed in. |
| `UserLoggedIn` | `userId`, `remembered` | Credentials accepted and a session started. |
| `UserLoggedOut` | `userId` | A member signed out. The session is already gone. |

## Reactions — `Convoro\Modules\Reaction\Events\`

| Event | Properties | When |
|---|---|---|
| `ReactionChanged` | `postId`, `userId`, `reactionTypeId` (`?int`) | A reaction was added, switched or withdrawn. `null` means withdrawn. |

## Moderation — `Convoro\Modules\Moderation\Events\`

| Event | Properties | When |
|---|---|---|
| `ContentReported` | `contentType`, `contentId`, `reporterId`, `reason` | A report was stored. A duplicate of the reporter's own open report does not fire again. |

## Notifications — `Convoro\Modules\Notification\Events\`

| Event | Properties | When |
|---|---|---|
| `NotificationCreated` | `notificationId`, `userId`, `actorId`, `type`, `topicId` (`?int`), `postId` (`?int`) | A member has been notified of something. |

🚨 **Fired once per person, inside the request of whoever caused it.** One
reply notifying twenty followers is twenty of these, in the page load of the
member who pressed Post. Anything listening must do as close to nothing as it
can — queue, and get out.

It carries ids and not text, deliberately: resolving a topic title here would
be a query per follower on every reply, for the benefit of listeners that may
not exist. Whoever needs the words can fetch them on their own time.

🚨 **A listener that throws is swallowed.** The row in the bell is the product;
anything hanging off this event is a courtesy on top of it, and a failure in
that courtesy must not cost somebody their notification.

## Your own events

Extend `Convoro\Engine\Event\Event` and dispatch through the same dispatcher:

```php
namespace Convoro\Extensions\MyThing\Events;

use Convoro\Engine\Event\Event;

final class ThingHappened extends Event
{
    public function __construct(
        public readonly int $thingId,
    ) {
    }
}
```

```php
$this->app->make('events')->dispatch(new ThingHappened($id));
```

Controllers have a `dispatch()` helper for this. Dispatch after the write has
committed, and name it in the past tense — an event that fires before the fact
is a request for permission wearing the wrong hat.

## Registries — when you need an answer back

An event tells you something happened and returns nothing. When core needs to
*ask* you something, it uses a registry instead: something that returns an
answer, in a defined order, and says who gave it. Events here are past tense
and not cancellable, so "may this happen" and "how are you" both need this
shape rather than that one.

| Registry | Ask | Register |
|---|---|---|
| `registration_checks` | May this address create an account? | `register(string $key, callable(string $username, string $email): ?string)` — return a problem to show, or null to allow |
| `health_checks` | How are you? | `register(string $key, string $group, callable(): array)` — return `['label', 'value', 'tone', 'note']` |
| `widget_types` | What blocks can be placed? | see the Trending and Community Stats ports |

```php
public function boot(): void
{
    $this->app->make('health_checks')->register('typesense', 'running', function (): array {
        return [
            'label' => 'Typesense',
            'value' => 'Answering',
            'tone' => 'ok',       // ok | warn | bad
            'note' => '4,182 documents indexed at the last check.',
        ];
    });
}
```

Groups are `software`, `running` and `site`; anything else lands in `site`
rather than being dropped.

🚨 **A health check must not reach off the machine.** Read what a scheduled
probe left behind instead. A health screen that makes an outbound call is one
that hangs on the day the thing it calls is down, which is the day somebody
opens it. A check that throws is reported as unknown and never takes the page
with it.

🚨 **Guard the registry with `bound()`.** An extension must keep working on a
site where the module that owns a registry is switched off.

## What has no events yet

Worth naming so nobody goes hunting: there are no events for attachments,
private messages, themes or pages, and none for a member being **deleted** —
which an extension holding per-member rows would want, and currently works
around by joining `users` and pruning on a schedule. If you need one, it is a
small addition to core rather than something to work around.
