omnibase/forum is a discussion forum for a Symfony site of the omnibase
family: groups holding boards, topics, Markdown posts, tags, polls, pins,
locks, scheduled topics and moderation. Its namespace is Base\Forum\; it is
built on omnisense/omnibase and omnibase/admin.
What it adds
- A
Topicthat is omnisense/omnibase'sThread(a JOINED subclass, tableforum_topic): it inherits the thread's tags, owners, followers, likes, mentions, slug, publication state, soft delete and translations, and adds its board, posts, pin, lock, views, replies, last activity and poll. - A
Category(forum_category), a two-level tree: a category without a parent is a group, a category with one is a board. - A
Post(forum_post), Markdown rendered with league/commonmark, raw HTML escaped; a soft delete that keeps the numbering; the message it answers. - A
Poll(forum_poll) and itsPollVotes (forum_poll_vote): one answer or several, once per member. - A voter,
ForumVoter, with seven attributes (FORUM_READ,FORUM_POST,FORUM_REPLY,FORUM_EDIT,FORUM_MODERATE,FORUM_SCHEDULE,FORUM_ADMIN) and the moderation rule of a role hierarchy. - Fifteen routes under
/bbsand the order page/admin/bbs/ordre; the board index in two views, a topic in two styles, all remembered per browser. - Two back office screens (categories, topics) and an order page for the groups, boards and tags.
- A sitemap listener that lists the public boards and their topics.
- Twig filters and functions for the Markdown, the excerpts, the quotes and the folder badges.
- Its stylesheets and scripts under
public/, English and French messages (domainforum).
Pages
Propose a change docs/index.md
Installation
Requirements
- PHP 8.2 or later;
- doctrine/orm 2.15 or 3;
- omnisense/omnibase 3.0 or 4.0, omnibridge/symfony 1.x, omnibase/admin;
- league/commonmark 2.4 or later;
- Symfony 6, 7 or 8 (config, dependency-injection, form, http-kernel, security-bundle);
- an
App\Entity\Userclass: authors, last posters, deleters and voters are that entity.
omnibase/admin is required, not optional: omnisense/omnibase walks every class
of a bundle's src/ when it builds its App\ aliases, so CRUD controllers
extending an admin class that is not installed would break the application.
The bundle
composer require omnibase/forum:dev-main
// config/bundles.php
Base\Forum\ForumBundle::class => ['all' => true],
The routes
# config/routes.yaml
forum_controller:
resource: "@ForumBundle/src/Controller/Client"
type: attribute
prefix: /
The order page of the back office, /admin/bbs/ordre, is in
src/Controller/Admin; a site that wants it imports that directory too (the
CRUD controllers beside it carry no route attribute and add nothing):
forum_admin_controller:
resource: "@ForumBundle/src/Controller/Admin"
type: attribute
prefix: /
The configuration
Every key is optional (Configuration reference):
# config/packages/forum.yaml
forum:
topics_per_page: 20
posts_per_page: 10
flood_interval: 15
hot_threshold: 25
active_hours: 72
moderator_role: ROLE_ADMIN
admin_role: ROLE_SUPER_ADMIN
The moderation rule reads the role hierarchy of security.yaml
(Who may do what).
The schema and the assets
bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate # forum_category, forum_topic, forum_post, forum_poll, forum_poll_vote
bin/console assets:install # public/bundles/forum/css, public/bundles/forum/js
The layout
Every forum page extends @Forum/client/layout.html.twig, which extends the
site's layout1.html.twig and fills its title, stylesheets and content
blocks (Templates and Twig).
Overriding the entities
The bundle maps Base\Forum\Entity\* to App\Entity\Forum\* and
Base\Forum\Repository\* to App\Repository\Forum\* (omnisense/omnibase's
App-wins aliasing): an App\Entity\Forum\Topic extending
Base\Forum\Entity\Topic takes over.
The demo
The repository holds a demo application under example/app/ (bundles,
routes, Doctrine, security, forum.yaml, a layout1.html.twig) and a
Dockerfile that builds it with SQLite; its first page seeds two members, a
group with a public and a staff-only board, three tags and a topic with a
reply.
Propose a change docs/installation.md
Concepts
Groups and boards
Base\Forum\Entity\Category is a two-level tree. A category without a parent
is a group (isGroup()): it holds boards and no topics. A category with a
parent is a board (isBoard()): it holds topics.
| Field | Column | Meaning |
|---|---|---|
title |
string(128) | required |
slug |
string(128), unique | from the title |
description |
text, nullable | under the title |
icon, color |
string(64), string(9), nullable | how the board is drawn |
position |
integer | its order among its siblings (the order page writes 10, 20, 30...) |
locked |
boolean | no new topic and no reply, except by its moderators |
announcement |
boolean | an announcement board: its topics are the admin role's to write |
polls |
boolean, default true | whether a topic opened there may carry a poll (allowsPolls()) |
requiredRole |
string(64), nullable | the role a reader needs; null, everyone reads |
parent, children |
self-reference, SET NULL | the tree |
CategoryRepository::findTree() returns the groups with their boards,
findBoards() the boards, findOneBySlug() one category.
Topics
Base\Forum\Entity\Topic extends omnisense/omnibase's Base\Entity\Thread
(discriminator forum_topic). From the thread it has its owners (the author),
title (translatable), slug, tags, followers, likes, state and publication
date. It adds:
| Field | Meaning |
|---|---|
category |
its board (required) |
posts |
its messages, the first one being the opening post |
pinned, locked |
a post-it on top of its board; no reply but its moderators' |
views |
readers, counted once per session, never its author, never a page loaded by the stream |
replies |
the posts after the first |
lastPostAt, lastPoster |
its last activity, by which the lists sort |
poll |
its poll, if it has one |
addPost() updates replies, lastPostAt and lastPoster. setAuthor()
changes the owner and the opening post's author together. Views are counted
with a direct UPDATE, so a reader does not touch the thread's updatedAt.
Scheduled topics
A topic is out the moment it is written. schedule($at) gives it an hour to
come out at: the publication date becomes that hour when it is in the future,
now otherwise, and a topic nobody has answered moves its last activity there
too, so it comes into the lists at its hour. isUpcoming() is true until
then.
No command has to run: every list goes through
TopicRepository::whereOut(), which keeps the topics whose publication date
is null or past. Until its hour, an upcoming topic is its authors' and its
board's moderators' only; nobody replies to it; anyone else gets a 404 on its
address (Who may do what).
Posts
Base\Forum\Entity\Post holds the Markdown content, its author, the
message it answers (replyTo), its edit count and the hour of its last edit
(markEdited(), when its author edits it), and a soft delete (deletedAt,
deletedBy): delete() keeps the post's place and number, restore() brings
it back. getNumber() is its 1-based position in the topic, isFirst() says
whether it is the opening post, getShownAt() is the hour shown on it (for
the opening post of a scheduled topic, the hour the topic came out).
Deleting the opening post deletes the topic.
Polls
Base\Forum\Entity\Poll belongs to one topic: a question (200 characters),
2 to 10 answers (MIN_OPTIONS, MAX_OPTIONS, each cut at 120 characters) and
maxChoices (1, a single answer; more, up to that many, never more than
there are answers). PollVote records one answer of one member; a unique
key on poll, member and answer keeps a member from counting twice. A poll is
open while its topic is not locked (isOpen()); getCounts() gives the votes
per answer, getTotal() the voters, getChoicesOf($user) what a member
chose.
Tags
The tags are omnisense/omnibase's Tag, shared with every thread.
TopicRepository::findTagUsage() returns the tag cloud: the tags in use on
topics that are out, highest priority first (the order page sets it), then
the most used.
Propose a change docs/concepts.md
Who may do what
The voter
Base\Forum\Security\ForumVoter answers seven attributes. Its subject is a
category, a topic or a post; a topic and a post are judged by their board.
| Attribute | Subject | Granted when |
|---|---|---|
FORUM_READ |
category, topic, post | the board has no requiredRole, or the reader has it |
FORUM_POST |
board | signed in, the board readable and a board (not a group); on an announcement board, the admin role only, locked or not; on a locked board, its moderators only |
FORUM_REPLY |
topic | signed in, readable, and neither the topic nor its board locked; its moderators always |
FORUM_EDIT |
topic, post | signed in and readable: its moderators, or its author while the topic is not locked |
FORUM_MODERATE |
category, topic, post | the reader moderates its board; with no subject, the reader moderates any board |
FORUM_SCHEDULE |
board | the reader moderates it and may post there; with no subject, moderates any board |
FORUM_ADMIN |
none | the reader has forum.admin_role |
An upcoming topic (scheduled, not out yet) changes three answers: nobody
replies to it, and anyone who is neither one of its owners nor a moderator of
its board is refused everything on it and on its posts, FORUM_READ
included.
Who moderates a board
ForumVoter::moderates($roles, $category) reads the role hierarchy of the
firewall (security.role_hierarchy):
- the admin role (
forum.admin_role,ROLE_SUPER_ADMINby default) moderates every board; - the moderator role (
forum.moderator_role,ROLE_ADMINby default) moderates the open boards, and the boards kept for a role it stands above; - a board kept for the moderator role's own rank is the admins' to moderate: its moderators read it, the admins keep order there;
- a board kept for a role outside the moderator's branch, the moderator cannot even read;
- announcement boards are the admin role's alone.
With this hierarchy:
# config/packages/security.yaml
security:
role_hierarchy:
ROLE_HELPER: ROLE_USER
ROLE_ADMIN: ROLE_HELPER
ROLE_SUPER_ADMIN: ROLE_ADMIN
a ROLE_ADMIN member moderates the open boards and a board kept for
ROLE_HELPER; a board kept for ROLE_ADMIN is moderated by
ROLE_SUPER_ADMIN only.
What the controllers check
| Action | Check |
|---|---|
| reading a board, a topic, a post's address | FORUM_READ (403; an upcoming topic one may not see: 404) |
| opening a topic | ROLE_USER, then FORUM_POST on the board chosen; the board list offers only those the writer may post in |
| replying | ROLE_USER, FORUM_REPLY |
| voting | ROLE_USER, FORUM_READ, a CSRF token |
| editing a topic or a post | ROLE_USER, FORUM_EDIT; moving a topic to another board needs FORUM_MODERATE on both |
| pin, unpin, lock, unlock | ROLE_USER, FORUM_MODERATE, a CSRF token |
| deleting a topic | ROLE_USER, FORUM_EDIT (an author deletes their own), a CSRF token |
| deleting a post | ROLE_USER, FORUM_EDIT, a CSRF token; restoring a deleted post, FORUM_MODERATE |
| scheduling, posting in another member's name | FORUM_SCHEDULE, FORUM_ADMIN |
| opening a board or a group from the site | FORUM_ADMIN |
| the order page | ROLE_ADMIN |
The moderators are exempt from the flood interval.
In templates
{% if is_granted('FORUM_POST', category) %}
<a href="{{ path('forum_topic_new', {category: category.slug}) }}">{{ '@forum.topic.new'|trans }}</a>
{% endif %}
Lists outside a board
The index's feed, the tag page (/bbs/t/{slug}), the search (/bbs/recherche) and
the sitemap list only the topics of the boards the reader may read (FORUM_READ):
a board kept for a role never shows its titles, its name or its last posters to
someone without it. TopicRepository::createLatestQuery(), createTrendingQuery(),
createTagQuery() and createSearchQuery() take the readable boards' ids (null:
every board), the controller passing those FORUM_READ grants.
Propose a change docs/permissions.md
The pages
Routes
| Name | Path | Methods | Access |
|---|---|---|---|
forum_index |
/bbs |
any | everyone; in the sitemap (0.7, daily) |
forum_category |
/bbs/c/{slug} |
any | FORUM_READ; in the sitemap (0.6, daily) |
forum_category_new |
/bbs/nouveau-forum |
any | FORUM_ADMIN; ?dans=<group slug> |
forum_tag |
/bbs/t/{slug} |
any | everyone |
forum_search |
/bbs/recherche |
any | everyone; ?q= |
forum_display |
/bbs/display |
POST | everyone |
forum_topic_new |
/bbs/nouveau/{category} |
any | ROLE_USER; {category} optional; ?depuis=<post id> |
forum_topic |
/bbs/{slug} |
any | FORUM_READ; in the sitemap (0.5, weekly); ?page, ?quote=<post id>, ?reply=<post id> |
forum_topic_reply |
/bbs/{slug}/repondre |
POST | ROLE_USER, FORUM_REPLY |
forum_topic_vote |
/bbs/{slug}/voter |
POST | ROLE_USER, FORUM_READ |
forum_topic_edit |
/bbs/{slug}/modifier |
any | ROLE_USER, FORUM_EDIT |
forum_topic_moderate |
/bbs/{slug}/moderer/{action} |
POST | action: pin, unpin, lock, unlock, delete |
forum_post |
/bbs/message/{id} |
GET | FORUM_READ; redirects to the post in its topic, on its page |
forum_post_edit |
/bbs/message/{id}/modifier |
any | ROLE_USER, FORUM_EDIT |
forum_post_delete |
/bbs/message/{id}/supprimer |
POST | ROLE_USER, FORUM_EDIT |
forum_admin_order |
/admin/bbs/ordre |
GET, POST | ROLE_ADMIN |
forum_category_new and forum_topic_new carry a route priority of 5, so
/bbs/{slug} does not take nouveau-forum or nouveau for a topic. Likes
and follows go through omnisense/omnibase's thread API
(/api/thread/{slug}/like, unlike, follow, unfollow).
The index
/bbs has two views:
classic, the board list grouped under its groups, with each board's topics and replies, its last message (who, when, in which topic), its folder badge, and a list of the latest topics below (@Forum/client/index.html.twig);feed, a flat list of the latest topics with the two latest announcements and up to six pinned topics on top (@Forum/client/index_fil.html.twig).
The feed has two orders: recent (the latest activity) and popular (among
the topics that moved in the last 30 days, the most replies, then the most
views). Both list only the boards the reader may read, and topics that are
out.
Display choices
The reader's choices are kept in a cookie for a year, never in the address:
| Choice | Cookie | Values |
|---|---|---|
view (the index) |
FORUM/VIEW |
classic, feed |
sort (the feed) |
FORUM/SORT |
recent, popular |
style (a topic) |
FORUM/TOPIC |
retro (one box of rows), cards (a card per message) |
Each button is a small POST form to forum_display with the choice and
back, the page to return to. An unknown name or value is ignored; back
must be a path on the site (it starts with /, not //), otherwise the
index.
A board, a group, a tag, the search
A group's page lists its boards. A board's page lists its topics, pinned
first, then by last activity; its moderators also see its upcoming topics,
marked. A tag's page lists the topics carrying the tag. The search needs at
least three characters and matches, without case, a topic's title or the
content of any of its posts (a LIKE; no index).
A topic
A topic's page shows its posts, posts_per_page at a time, the reply form for
those who may reply, the poll, the previous and next topics of its board, and
a "jump to" list of the boards the reader may read. An outline of the whole
topic is built from one query: each post's parent (the message it answers, or
the trunk), its branch, level and depth, its number and page, and how many
posts answer it; the timeline script and the "in reply to" lines read it.
Infinite lists
A board, a tag, the search, the feed and a topic load their next page as the
reader scrolls (forum-feed.js, forum-thread.js): the script asks the same
address with the header X-Forum-Stream and receives the rows alone. The
responses carry Vary: X-Forum-Stream, and a page loaded that way does not
count a view.
The sitemap
Base\Forum\EventListener\SitemapListener adds, on omnisense/omnibase's
SitemapEvent::BUILD, every board without a requiredRole (its update date)
and the latest topics of those boards that are out, up to 5,000
(MAX_TOPICS), each with the date of its last activity.
Propose a change docs/pages.md
Writing, replying, voting
Opening a topic
/bbs/nouveau/{category} opens a topic (Base\Forum\Form\Type\TopicType,
model Base\Forum\Form\Model\TopicModel):
| Field | Rule |
|---|---|
category |
required; left out when the page was opened from a board the writer may post in |
title |
3 to 120 characters |
tags |
omnisense/omnibase's tags, any number |
content |
the opening post, 2 to 20,000 characters, Markdown |
pollQuestion, pollOptions, pollMax |
an optional poll: a question of at most 200 characters, 2 to 10 answers (one per line), at most pollMax choices (1 to 10, not more than the answers); only on a board that takes polls |
publishedAt |
for FORUM_SCHEDULE only: an hour to come out at; an hour already gone means now |
author |
for FORUM_ADMIN only: the member it goes out under, searched by name |
When the page is not opened from a board, the board list offers only the
boards the writer may post in (FORUM_POST); with none, the page flashes
@forum.aside.cannot_post and returns to the index.
?depuis=<post id> opens a topic out of a message: the new topic starts on
the same board, its content prefilled with a link back to that message
(@forum.topic.linked_opening).
On submit, in order: a scheduling refused for that board puts an error on
publishedAt; a board the writer may not post in flashes
@forum.flash.board_locked; a post sent within the flood interval flashes
@forum.flash.flood with the seconds to wait. Otherwise the topic, its
opening post and its poll are saved, and the page redirects to the topic,
flashing @forum.flash.topic_created or, for an upcoming topic,
@forum.flash.topic_scheduled with its hour.
Replying
The reply form (Base\Forum\Form\Type\PostType, 2 to 20,000 characters) is
on the topic's page for those who may reply, and posts to
/bbs/{slug}/repondre. ?quote=<post id> prefills it with that post quoted;
?reply=<post id> links the reply to that post without quoting it. The link
is sent as reply_to and kept when the post belongs to the same topic. After
the reply, the page redirects to the new post, on its page; a refusal or an
invalid form flashes its message and returns to the topic.
The flood interval
A member waits forum.flood_interval seconds (15 by default) after their last
post before posting again, a topic or a reply. 0 turns it off; moderators are
never held.
Editing
/bbs/{slug}/modifier edits a topic's title and tags (the opening post is
edited as a post). A moderator of the topic's board can also move it to
another board they moderate; while the topic is upcoming, those who may
schedule there can move its hour; the forum admin can change whose name it is
under.
/bbs/message/{id}/modifier edits a post. When its author edits it, the post
records the hour and counts the edit; a moderator's edit does not.
Moderating and deleting
The moderation buttons post to /bbs/{slug}/moderer/{action} with a CSRF
token (forum_moderate_<topic id>): pin, unpin, lock, unlock, and
delete, which an author may also do on their own topic. Deleting a topic
returns to its board.
/bbs/message/{id}/supprimer (CSRF token forum_post_<post id>) deletes a
post softly: it keeps its place and number, shown as deleted. The same button
on a deleted post restores it, for a moderator. Deleting the opening post
deletes the topic.
Voting
A poll's form posts to /bbs/{slug}/voter with a CSRF token
(forum_vote_<poll id>) and choice (one answer) or choice[] (several).
The vote is refused, with a flash, when the topic is locked
(@forum.poll.flash.closed), when no valid answer is sent
(@forum.poll.flash.no_choice), when more answers than allowed are sent
(@forum.poll.flash.too_many), or when the member has voted
(@forum.poll.flash.already). A vote is final, and its answers are saved
together: a second click arriving with the first meets the unique key and is
answered as already voted.
Opening a board from the site
/bbs/nouveau-forum (FORUM_ADMIN) opens a board in the group named by
?dans=<group slug>, or a new group without it
(Base\Forum\Form\Type\CategoryType: title, group, description, announcement,
polls). It is placed last among its siblings (ten past the highest position),
and the page redirects to it, flashing @forum.flash.board_created or
@forum.flash.group_created. The order page moves it; the back office edits
the rest.
Propose a change docs/writing.md
Markdown
The renderer
Base\Forum\Service\MarkdownRenderer renders a post with league/commonmark:
CommonMark, GitHub flavouring (tables, task lists, strikethrough), autolinks.
Raw HTML is escaped (html_input: escape, and the disallowed raw HTML
extension), javascript: links are dropped (allow_unsafe_links: false),
nesting stops at 20 levels, and a single line break becomes <br>.
Two forum habits are added:
>>12, at a word boundary, becomes a link to#post-12, the anchor of post 12 of the same topic; inside code it stays as typed;@name(2 to 32 letters, digits,_or-) becomes a link to the member's profile when the site has a route nameduser_profileByUsername(with ausernameparameter); inside code, a link or a tag it stays as typed.
| Method | Returns |
|---|---|
render(?string $markdown) |
the HTML; an empty string for empty input |
excerpt(?string $markdown, int $length = 160) |
the rendered text without tags, on one line, cut at $length characters with an ellipsis |
quote(?string $markdown, ?string $author = null) |
the text with > before every line, under a line naming the author, for a reply box |
An example
use Base\Forum\Service\MarkdownRenderer;
$markdown = new MarkdownRenderer();
$post = "As >>2 said, see [the docs](javascript:alert(1)) and <script>x()</script>.\n\n- [x] done\n- ~~old~~";
echo $markdown->render($post);
echo "---\n", $markdown->excerpt($post, 40), "\n---\n";
echo $markdown->quote("First line\nSecond line", 'anna');
Its output, run in omnisense/omnibase-harness (no router, so no mention links):
<p>As <a href="#post-2">>>2</a> said, see <a>the docs</a> and <script>x()</script>.</p>
<ul>
<li><input checked="" disabled="" type="checkbox"> done</li>
<li><del>old</del></li>
</ul>
---
As >>2 said, see the docs and <script>x(…
---
> **@anna** a écrit :
> First line
> Second line
The excerpt decodes the entities after removing the tags, so the escaped
<script> comes back as text: it is plain text, to be printed escaped (the
forum_excerpt filter is not marked safe, so Twig escapes it).
quote() writes the author's line in the reader's language (reply.quote_wrote of the
forum domain: "@author wrote:", "@author a écrit :"); built without a translator,
in English.
In Twig
{{ post.content|forum_markdown }} {# safe HTML #}
{{ topic.firstPost.content|forum_excerpt(120) }}
{{ forum_quote(post.content, post.author) }}
Propose a change docs/markdown.md
Templates and Twig
The layout
@Forum/client/layout.html.twig extends the site's layout1.html.twig. It
fills title (the page's forum_title block, else the index's title),
stylesheets (bundles/forum/css/forum.css) and content, in which it
draws the forum's header, its pages and its side column. A page of the forum
fills its own blocks:
| Block | Holds |
|---|---|
forum_header |
the header: the banner, the breadcrumb, the view or style switch, the search |
forum_banner |
includes @Forum/client/_banner.html.twig |
forum_breadcrumb, forum_crumbs |
the trail; a site with a breadcrumb of its own empties forum_breadcrumb |
forum_flashes |
the flash messages (includes the site's flashbag.html.twig if it has one) |
forum_lead |
what a page wants read first, across both columns |
forum_body |
the page itself |
forum_side |
the side column (_aside.html.twig: new topic, new board, sign in, the tag cloud); a page adds to it with parent() |
forum_footer |
the foot, empty by default |
The host's layout needs the title, stylesheets and content blocks.
Override points
A site overrides any template in templates/bundles/ForumBundle/. The two
meant for it:
client/_banner.html.twig, the banner above the forum;client/_avatar.html.twig, how a member is pictured.
The other partials (_topic_row, _post, _member, _folder, _badge,
_legend, _pager, topic/_poll...) can be overridden the same way.
Scripts and stylesheets
After assets:install, under bundles/forum/:
| File | Loaded by | Does |
|---|---|---|
css/forum.css |
every page | the forum |
css/forum-thread.css |
a topic | the post header and its tools, the timeline |
js/forum.js |
a topic | the like and follow buttons, a confirmation on the destructive moderation buttons, the Markdown toolbar of every editor, the board jump, a post edited in place |
js/forum-thread.js |
a topic | the timeline and the stream of pages, from the topic's outline |
js/forum-feed.js |
the feed, a board, a tag, the search | the stream of rows |
js/forum-index.js |
the index | folding a group's boards, remembered per group in the browser |
js/forum-poll.js |
the new topic page, a poll allowing several answers | the poll builder; the several-answers vote |
Twig filters and functions
| Name | Kind | Returns |
|---|---|---|
forum_markdown |
filter | a post's HTML (Markdown), marked safe |
forum_excerpt(length = 160) |
filter | a one-line plain-text excerpt |
forum_quote(markdown, author) |
function | the text quoted for a reply box |
forum_is_hot(topic or replies) |
function | whether a topic has forum.hot_threshold replies or more |
forum_is_active(topic or date) |
function | whether its last activity is within forum.active_hours |
forum_active_hours() |
function | forum.active_hours |
forum_topic_folder(topic) |
function | the topic's badge, {state, title} |
forum_board_folder(board, last = null, hot = false) |
function | the board's badge, {state, title} |
A topic's badge follows this precedence: an announcement (a topic of an
announcement board, announce), a post-it (sticky), a locked topic
(lock), a popular one (hot), then the plain folder (default); the last
three take their awake art (new_lock, new_hot, new) when the topic has
moved within active_hours. A board's badge reads the same way after its
latest message: announce, lock, hot when it holds a popular topic,
default; a board kept for a role says so in its title. "Awake" means recent
activity, not unread: the forum keeps no read marker per member.
Translations
The domain forum, in English and French (ICU format), holds every text of
the pages, the forms, the flashes and the back office; the templates call
its keys as '@forum.<key>'|trans. A site overrides a message in its own
translations/forum+intl-icu.<locale>.yaml.
Propose a change docs/templates.md
The back office
The screens are loaded when omnibase/admin is installed (its
Base\Admin\Controller\AbstractCrudController exists).
Categories
Base\Forum\Controller\Admin\Crud\CategoryCrudController edits groups and
boards: title, slug (following the title), parent (none for a group),
position, locked, announcement, polls, icon, colour, the role a reader needs
(requiredRole, chosen among the site's roles; empty, everyone reads) and
description. Filters: parent, locked.
Topics
Base\Forum\Controller\Admin\Crud\TopicCrudController edits a topic's title
(a translation field), slug, board, tags, pin and lock; the index shows its
views and replies. Filters: board, pinned, locked. Posts are edited on the
site.
The two CRUD screens carry no #[OpenToAdmins]: their writes follow
omnibase/admin's default.
The order page
/admin/bbs/ordre (forum_admin_order, ROLE_ADMIN;
Base\Forum\Controller\Admin\OrderController) orders the whole forum on one
screen: the groups, the boards in each group (a board can change group
there), and the tags of the tag cloud. It posts order[groups][],
order[boards][<group id>][] and order[tags][] with a CSRF token
(forum_admin_order) and flashes admin.order.saved (domain forum).
Groups and boards are numbered 10, 20, 30..., so a board added later can be
slotted between two without renumbering. A board stays a board and a group a
group: only a board's parent changes. Tags go the other way: the first one
gets the highest priority, which the tag cloud reads first.
Its route lives beside the CRUD controllers, in src/Controller/Admin, which
a site imports (Installation).
The dashboard
yield MenuItem::linkToCrud(\Base\Forum\Entity\Category::class, 'Forums', 'fa-solid fa-comments');
yield MenuItem::linkToCrud(\Base\Forum\Entity\Topic::class, 'Topics', 'fa-solid fa-comment-dots');
yield MenuItem::linkToRoute('forum_admin_order', [], 'Order', 'fa-solid fa-sort');
The categories screen labels the polls field in the reader's language (form.board_polls).
Propose a change docs/back-office.md
Configuration reference
The forum tree
Dumped with Symfony's YamlReferenceDumper from
Base\Forum\DependencyInjection\ForumConfiguration:
forum:
# Topics listed per page in a category, a tag or the search.
topics_per_page: 20
# Posts shown per page inside a topic.
posts_per_page: 10
# Seconds a member must wait between two posts (phpBB called it flood_interval).
flood_interval: 15
# Replies from which a topic is flagged "hot".
hot_threshold: 25
# A board or topic with a message this recent is awake: its folder badge shows the "new" art instead of the sleeping "zzz".
active_hours: 72
title_max_length: 120
content_max_length: 20000
# Role allowed to pin, lock, move and delete any topic or post.
moderator_role: ROLE_ADMIN
# Role allowed to open new boards and groups from the site itself (the back office has its own CRUD).
admin_role: ROLE_SUPER_ADMIN
| Key | Type | Default | Read by |
|---|---|---|---|
topics_per_page |
integer, at least 1 | 20 | the index's lists, a board, a tag, the search |
posts_per_page |
integer, at least 1 | 10 | a topic, a post's page, the outline |
flood_interval |
integer, at least 0 | 15 | opening a topic, replying; 0 turns it off |
hot_threshold |
integer, at least 1 | 25 | forum_is_hot(), the badges, the boards holding a popular topic |
active_hours |
integer, at least 1 | 72 | forum_is_active(), the badges, the legend |
title_max_length |
integer, at least 10 | 120 | TopicType: the title's constraint and maxlength |
content_max_length |
integer, at least 100 | 20000 | TopicType and PostType: the post's constraint |
moderator_role |
string | ROLE_ADMIN |
ForumVoter |
admin_role |
string | ROLE_SUPER_ADMIN |
ForumVoter (FORUM_ADMIN, the moderation of everything, the announcement boards) |
Parameters
Each key is a container parameter: %forum.topics_per_page%,
%forum.posts_per_page%, %forum.flood_interval%, %forum.hot_threshold%,
%forum.active_hours%, %forum.title_max_length%,
%forum.content_max_length%, %forum.moderator_role%, %forum.admin_role%.
Services
config/services.php autowires and autoconfigures everything under
Base\Forum\ except the DI classes, the entities and the bundle class; the
client controllers are tagged as controllers; the admin controllers are
loaded when omnibase/admin is installed.
| Service | Role |
|---|---|
Base\Forum\Security\ForumVoter |
the seven FORUM_* attributes, moderates() |
Base\Forum\Service\MarkdownRenderer |
render(), excerpt(), quote() |
Base\Forum\Twig\ForumTwigExtension |
the filters and functions |
Base\Forum\EventListener\SitemapListener |
the boards and topics of /sitemap.xml |
Base\Forum\Repository\CategoryRepository |
findTree(), findBoards(), findOneBySlug() |
Base\Forum\Repository\TopicRepository |
the list queries, whereOut(), findTagUsage(), incrementViews(), the counters |
Base\Forum\Repository\PostRepository |
createTopicQuery(), findOutline(), findLastByAuthor(), findPageOf(), countByAuthor() |
TopicRepository extends omnisense/omnibase's ThreadRepository (a Thread
subclass needs its hierarchy trait), not ServiceEntityRepository.
Propose a change docs/configuration.md
Testing
Running the suite
From the package's checkout, with its dev dependencies:
vendor/bin/phpunit
phpunit.xml.dist boots tests/bootstrap.php, which registers the test
namespace itself, so the same suite runs from inside a host application:
php vendor/bin/phpunit -c vendor/omnibase/forum/phpunit.xml.dist
In omnisense/omnibase-harness, mounting the checkout in place of the installed package:
docker run --rm --network none \
-v "$PWD":/srv/demo/vendor/omnibase/forum:ro \
omnisense/omnibase-harness:latest test forum
On this checkout, in the harness:
OK (28 tests, 59 assertions)
What the tests check
| Test | Checks |
|---|---|
Controller\CrudRequiredFieldsTest |
omnibase/admin's CrudFieldsTestCase on the two screens: a field is required exactly when the record needs it, an optional field takes an empty value, one field per property, the slug follows a field |
Entity\PollTest |
a single-answer poll; maxChoices never more than the answers; three picks of one member are one voter |
Entity\TopicScheduleTest |
a new topic is out at once; an hour to come keeps it upcoming and moves its activity there; an hour gone, or none, means now; the opening post is dated the hour its topic comes out; a reply keeps its own date |
Service\MarkdownRendererTest |
Markdown renders; raw HTML is escaped; javascript: links are dropped; >>n becomes an anchor, not inside code; empty input renders nothing; the excerpt is plain text and cut; a quote prefixes every line and names the author |
Twig\FolderStateTest |
a topic sleeps or talks after its last message; popular and locked topics have their awake art; announcement, then post-it, come first; the boards' badges; a reserved board is not an announcement |
The voter, the controllers and the repositories have no test of their own.