GitHub-style cross-references between discussions and posts for Flarum 2.
Type #42 in any post to link the current discussion to discussion #42 — the
target picks up a backlink, the target's author can be notified, and a sidebar
widget surfaces every inbound reference.
Built as a more robust Flarum 2 successor to
club-1/flarum-ext-cross-references:
rename-safe rendering, visibility-aware backlinks, batched queries, and a
references:N search filter — all wired through Flarum 2's first-party
patterns.
Type #42 in a post and it renders as a tappable chip showing the target
discussion's current title — pulled fresh from the database on every render,
so renames flow through automatically and you never read a stale title.
When a post references another discussion, a small "Referenced from #X" event-post appears in the target's stream — slot-in to your existing moderation history, search index, and reply pipeline.
A widget on the right-hand side of DiscussionPage lists the most recent
inbound refs, with the source author and a relative timestamp. Click an item
to jump to the referring discussion.
The recipient sees the alert in their Flarum notification dropdown, with the source discussion title resolved live. Self-references (when you reference your own discussion) are silently skipped to avoid noise.
So you always know which post number to use when typing #42/pN. Click the
chip and the canonical cross-reference (#discussionId/pN) is copied to
your clipboard, ready to paste into a reply.
composer require ernestdefoe/cross-references
php flarum migrate
php flarum cache:clearThen enable from the admin panel under Extensions → Cross References,
grant the Use cross-references in posts permission to whichever groups
should be allowed to type #42, and you're done — references start working
immediately on new and edited posts.
| Type this in your post | What renders | Where it points |
|---|---|---|
#42 |
#42 — <Discussion Title> |
discussion 42, top of the page |
#42/p7 |
#42 — <Discussion Title> (post #7) |
discussion 42, post number 7 |
Tip: every post header shows a clickable
#Nchip — click it to copy the canonical#discussionId/pNreference straight to your clipboard, so you never have to guess a post number.
| https://forum.example.com/d/42 | #42 — <Discussion Title> | discussion 42 (URL auto-shortened) |
| https://forum.example.com/d/42-some-slug/7 | #42 — <Discussion Title> (post #7) | discussion 42, post 7 |
| [click here](https://forum.example.com/d/42) | a regular markdown link "click here" | preserved as-is — Markdown wins |
The #N form is the canonical one. Pasted forum-discussion URLs get
rewritten to #N at parse time before storage, so the database only ever
sees a single representation. URLs you explicitly wrap in markdown brackets
([label](url)) are left alone — the extension assumes you meant the custom
label intentionally.
You're replying to a support discussion and want to link out to two related threads — a bug report and a specific post inside a roadmap discussion:
Write:
Thanks for the report! This looks like the same regression as #42, and the
fix is being planned in #58/p3. I'll close this as a duplicate once #58/p3
lands.Renders as:
Thanks for the report! This looks like the same regression as
#42 Login button stops responding on mobile, and the fix is being planned in#58 Q3 roadmap (post #3). I'll close this as a duplicate once#58 Q3 roadmap (post #3)lands.
The chips are clickable — they navigate to /d/42 and /d/58/3 respectively.
Meanwhile, in discussions #42 and #58:
- The bug report (#42) gets a new event-post that reads
you referenced this from #137(the support thread's ID). - The roadmap (#58) gets the same event-post pointing back to #137.
- The author of #42 and the author of #58 each get an in-app notification alerting them that their discussion was referenced.
- The sidebar of both discussions now lists
#137as an inbound reference.
References are visibility-scoped on every read path. If a viewer can't see the
target (e.g., it's in a restricted tag or a private group), the chip renders
as a muted #42 placeholder with no title leaked. Backlink event-posts
inherit the target's normal visibility, so a backlink in a private thread
doesn't leak the source thread's existence to non-members.
| Viewer can see target? | Chip renders as |
|---|---|
| Yes | #42 — <Title> — full chip, clickable |
| No (private, restricted tag) | #42 (muted, CrossReference--hidden class) |
The sidebar widget applies the same scope — inbound refs from sources you can't view simply don't appear.
Because titles are never persisted in the post content — only the
discussion ID is — renaming a referenced discussion flows through to every
chip on every render. No reindex, no chore:reparse, no cache invalidation
needed. The next request gets the new title.
Use the references:N filter on the discussion list to find every discussion
whose posts reference discussion #N:
filter[references]=42
Either as a URL parameter on /api/discussions or via the standard search
input if you've wired it (works alongside any other filter):
filter[references]=42&filter[tag]=support
Negation works too: -references:42 excludes them.
Under Admin → Extensions → Cross References, three toggles:
| Setting | Default | Effect |
|---|---|---|
| Show inline references in posts | on | Render #42 / pasted-URL refs as rich chips. Turn off to fall back to raw #42 text. |
| Create backlink event-posts in target discussions | on | Insert "Referenced from #X" event-posts in the target. Disable for forums that prefer quiet target threads. |
| Notify the target discussion's author | on | Send an in-app alert when someone references the recipient's discussion. The actor never notifies themselves. |
And one permission:
Use cross-references in posts— gate#42rendering at the group level. Useful for forums that want references to work only for trusted members.
A single companion table cross_references with a row per
(source_post, target_discussion, target_post?) tuple. Unique constraint on
that tuple gives storage-layer dedupe — re-saving a post that mentions #42
three times produces one row.
cross_references
id bigint PK
source_post_id FK posts.id ON DELETE CASCADE
source_discussion_id FK discussions.id
target_discussion_id FK discussions.id ON DELETE CASCADE
target_post_id FK posts.id (nullable — null = whole-discussion ref)
created_at timestamp
UNIQUE (source_post_id, target_discussion_id, target_post_id)
INDEX (target_discussion_id) -- inbound-refs lookup
INDEX (source_discussion_id) -- outbound-refs lookup
The table follows Flarum 2's companion-table convention —
no columns added to posts or discussions (CLAUDE.md §45).
- Parse-time: The s9e/TextFormatter
Pregplugin matches\B#(\d+)\band\B#(\d+)/p(\d+)\band emits<CROSSREF id="42" postnum="7"/>tags into the stored XML. Pasted forum URLs are pre-rewritten to the#Nform before the parser sees them. - Pre-render: a single batched
Discussion::whereIn('id', $ids) ->whereVisibleTo($actor)query resolves every referenced discussion's title in one round-trip. Titles + visibility flags get injected onto each<CROSSREF>tag as attributes. - Render: the XSL template uses the
titleattribute when set, falling back to theCrossReference--hiddenplaceholder when visibility denies the read.
No titles are persisted in post content. No per-post N+1 query. One DB call per page render, regardless of how many cross-references are in the visible posts.
The Posted / Revised event listener extracts CROSSREF tags from the
post's parsed XML, diffs against the existing cross_references rows for
that post, then:
- Inserts rows for new references; deletes rows for removed ones.
- For each new ref, creates a
CrossReferenceEventPost(extending Flarum'sAbstractEventPost) in the target discussion. Lives alongside core event-posts like "renamed" or "locked" — participates in moderation, search, and the standard reply pipeline. - Optionally dispatches a
DiscussionReferencedBlueprintnotification to the target's author via thealertchannel.
Self-references are silently dropped. The listener is wrapped in
try/catch with PSR-3 logging so a cross-ref bug can never block a
post save (CLAUDE.md §41).
GET /api/discussions/{id}/cross-references
Returns the inbound references for a discussion, with each row
visibility-scoped against the requesting actor and eager-loaded with the
source discussion title + first-post author. Capped at 50 rows — the
response includes a meta.capped50 boolean so a future "view all" page can
fall back to a paginated source.
{
"data": [
{
"id": 1,
"sourceDiscussionId": 6,
"sourcePostId": 4,
"targetPostId": null,
"createdAt": "2026-05-17T13:59:39+00:00",
"source": {
"discussionTitle": "Cross-ref smoke test",
"discussionSlug": "cross-ref-smoke-test",
"author": {
"id": 2,
"displayName": "tester",
"username": "tester",
"avatarUrl": null
}
}
}
],
"meta": { "count": 1, "capped50": false }
}Registered via Extend\SearchDriver(DatabaseSearchDriver) ->addFilter(DiscussionSearcher, ReferencesFilter). The value is cast to
(int) before reaching SQL — defeats the §10 wildcard / sort-allowlist
trap surface; the gambit accepts numeric ids only, never strings.
| Concern | club-1/cross-references |
ernestdefoe/cross-references |
|---|---|---|
| Flarum version | 1.x | 2.x |
| Discussion title in chip | Baked into post content at save | Resolved live from DB on every render |
| Behavior on target rename | Requires chore:reparse to refresh stored chips |
Automatic — chips read current title |
| Behavior on target delete | Plain link to nothing | Chip renders as muted #N placeholder |
| Visibility-aware | No (leaks restricted titles) | Yes — whereVisibleTo($actor) everywhere |
| Backlink mechanism | Event post | Event post (first-class, extends AbstractEventPost) |
| Notifications to target author | No | Yes — AlertableInterface blueprint |
| Inbound-refs sidebar widget | No | Yes |
Search filter (references:N) |
No | Yes |
| Per-post listener failures block post save | Possible | Caught + logged, never blocks save |
| Dedupe | None | Unique index at storage layer |
This extension was built against the Flarum 2 security/structure playbook — relevant sections:
- §5 —
whereVisibleTo($actor)applied at every read path (API endpoint, render-time enrichment, search filter sub-query). Inbound-refs sidebar filters source visibility per actor before payload assembly. - §19 —
getData()carries IDs only; titles re-resolved from the subject relation at render time so a target that turns private later doesn't leak its old title via a stale notification. - §26 — Migration uses
Flarum\Database\Migration::createTableIfNotExistswith explicitcascadeOnDeleteFKs and a composite unique index for storage-layer dedupe. - §38 — Single batched query per render to resolve all titles for a post; one batched visibility check for the sidebar. No N+1.
- §41 —
Psr\Log\LoggerInterfaceinjected in the listener + controller; try/catch wraps both so a ref bug can never block a post save. - §43 —
composer.jsonconstrained to"flarum/core": "^2.0"; no^1.0fallback, since v2-onlyEndpoint/Schemaclasses are imported. - §45 — Companion
cross_referencestable; no columns added to coreposts/discussions/users. - §46 —
DiscussionReferencedBlueprinthas a typed constructor +TYPEclass constant +getSubjectModel()returnsDiscussion::class, so the polymorphicsubjectrelationship resolves cleanly viatypeForModel().
Pull requests welcome. For non-trivial changes, please open an issue first so we can discuss the approach.
composer require ernestdefoe/cross-references:dev-main(or path repo) in your local Flarum to develop against.cd js && npm install && npm run devwatches and rebuilds the JS bundle on every change.php flarum cache:clearafter PHP changes.
MIT © Ernestdefoe




