Skip to content
View in the app

A better way to browse. Learn more.

ernestdefoe.online

A full-screen app on your home screen with push notifications, badges and more.

To install this app on iOS and iPadOS
  1. Tap the Share icon in Safari
  2. Scroll the menu and tap Add to Home Screen.
  3. Tap Add in the top-right corner.
To install this app on Android
  1. Tap the 3-dot menu (⋮) in the top-right corner of the browser.
  2. Tap Add to Home screen or Install app.
  3. Confirm by tapping Install.
ernestdefoe.online

Extensions, themes & support for Flarum and Invision Community

Vibe coding for the community web. Report a bug, request a feature, or dig into the source — this is where the tools you use get built, in the open.

We do custom Bespoke Invision Community apps. If you have an idea for something you want then use the contact form to get in touch with us.

Cross References for Flarum 2

Featured Replies

  • Administrators
  • Community Expert

Cross References

Flarum 2.0

License: MIT

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.


What it does, at a glance

1. Inline references render as rich chips with the live discussion title

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.

Inline cross-reference chips rendered inside a post showing #1 Mosaic and #1 Mosaic (post #3)

2. Target discussions get a backlink as a first-class event-post

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.

Backlink event-post saying "tester referenced this from #6"

3. Every discussion gets an "inbound references" sidebar widget

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.

Sidebar widget titled "Referenced from 2 discussions" listing Notif test and Cross-ref smoke test

4. The target's author gets an in-app notification

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.

Notification dropdown showing "Referenced your discussion from Notif test"

5. Every post header shows a copy-to-clipboard #N chip

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.

Post header showing the # 1 chip between the author name and the timestamp


Quick start


php flarum migrate
php flarum cache:clear```

Then **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.

---

## Usage — how to reference a discussion or a specific post

### Syntax cheat-sheet

| 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 `#N` chip — click it to copy

the canonical `#discussionId/pN` reference 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](https://github.com/ernestdefoe/cross-references/blob/main/url)`) are left alone — the extension assumes you meant the custom

label intentionally.

### A complete example

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 `#137` as an inbound reference.

### Visibility — references respect who can see what

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.

### Renames are automatic

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.

### Searching for references

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.

---

## Admin settings

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 `#42` rendering at the group level.

  Useful for forums that want references to work only for trusted members.

---

## Architecture

### What gets stored

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).

### How rendering works

1. **Parse-time**: The s9e/TextFormatter `Preg` plugin matches `\B#(\d+)\b`

   and `\B#(\d+)/p(\d+)\b` and emits `<CROSSREF id="42" postnum="7"/>` tags

   into the stored XML. Pasted forum URLs are pre-rewritten to the `#N`

   form before the parser sees them.
2. **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.
3. **Render**: the XSL template uses the `title` attribute when set,

   falling back to the `CrossReference--hidden` placeholder 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.

### Bidirectional backlinks + notifications

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's

  `AbstractEventPost`) 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 `DiscussionReferencedBlueprint` notification to

  the target's author via the `alert` channel.

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).

### API endpoint

```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 }
}```

### Search filter

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.

---

## Comparison vs. `club-1/flarum-ext-cross-references`

| 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               |

---

## Architecture notes (for extension authors reading this for reference)

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::createTableIfNotExists`

  with explicit `cascadeOnDelete` FKs 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\LoggerInterface` injected in the listener + controller;

  try/catch wraps both so a ref bug can never block a post save.
* **§43** — `composer.json` constrained to `"flarum/core": "^2.0"`; no

  `^1.0` fallback, since v2-only `Endpoint`/`Schema` classes are imported.
* **§45** — Companion `cross_references` table; **no** columns added to

  core `posts`/`discussions`/`users`.
* **§46** — `DiscussionReferencedBlueprint` has a typed constructor +

  `TYPE` class constant + `getSubjectModel()` returns `Discussion::class`,

  so the polymorphic `subject` relationship resolves cleanly via

  `typeForModel()`.

---

## Contributing

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 dev` watches and rebuilds the JS bundle

  on every change.
* `php flarum cache:clear` after PHP changes.

## License

[MIT](https://github.com/ernestdefoe/cross-references/blob/main/LICENSE.md) © Ernestdefoe

<https://ernestdefoe.online/extension/ernestdefoe/cross-references>
  • Replies 2
  • Views 26
  • Created
  • Last Reply

Top Posters In This Topic

  • Author
  • Administrators
  • Community Expert

cross-references 2.0.1

What's Changed

  • docs(migration): note why FK columns are unsignedInteger, not unsignedBigInteger
  • Settings: drop dead serializeToForum + showInline; clean orphan locale key
  • Listener: delete dead subscribe(), batch lookups, narrow catch
  • EventPost: drop pass-through initAttrs override

Full Changelog: https://github.com/ernestdefoe/cross-references/compare/2.0.0...2.0.1

View release on GitHub

  • Author
  • Administrators
  • Community Expert

cross-references 3.0.0

What's Changed

  • ci: auto-post releases to Flarum support site + draft release notes
  • ci: drop publish-to-flarum workflow
  • ci: restore publish-to-flarum workflow
  • Build changelog from commits when release body is a placeholder
  • Replace release-drafter with commit-based draft-release workflow
  • Add workflow_dispatch trigger to publish-to-flarum.yml
  • Convert to TypeScript (audit fix)
  • docs: add Floxum marketplace badges to README

Full Changelog: https://github.com/ernestdefoe/cross-references/compare/2.0.1...3.0.0

View release on GitHub

Guest
Reply to this topic...

Want to reply?

Ask a question about any of these applications and it will be answered by the person who wrote them. It takes about thirty seconds, and you keep what you have already typed.

Account

Navigation

Search

Search

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.