Skip to content

omnibase/forum API reference

Floor
Prepared structure · Omnibase
Version
main
Verified for real
Its README does not say
Licence
MIT
composer require omnibase/forum:dev-main

Read from the package's own PHP files at the commit f074f7706ccb: its namespaces, classes, interfaces and enums, their public constants, properties and methods with their PHPDoc, and the options its factories take. Nothing of it is run.

App\Controller

  • DemoController class

    The demonstrator's one page: it seeds a small forum, signs a visitor in, and reports on the wiring - then hands over to the forum itself.

    2 methods

App\Entity

  • User class

    The demo's member. base-bundle's User identifies people by email and has no username; the forum and the mailbox address members by username (an @mention, a recipient typed by hand), so the application's User carries one - as every base-bundle application's User does.

    3 methods

App\Repository

Base\Forum

  • ForumBundle class

    A discussion forum on top of base-bundle's Thread.

    3 methods

Base\Forum\Controller\Admin

  • OrderController class

    The BBS's display order on one screen, the way a settings page reads: the groups with their boards (a board can change group here too), and the tags of the tag cloud. CategoryCrudController edits one board at a time - ordering twenty of them by typing positions is what this spares.

    2 methods · 1 constant

Base\Forum\Controller\Admin\Crud

Base\Forum\Controller\Client

  • ForumController class

    The reading side of the forum: the board index, a board, a tag, the search. Writing lives in TopicController.

    7 methods · 5 constants

  • TopicController class

    A topic: reading it, opening one, replying, editing, and the moderation switches (pin, lock, delete).

    10 methods · 3 constants

Base\Forum\DependencyInjection

Base\Forum\Entity

  • Category class

    A board of the forum: "Agora", "Helpz", "Games"... Categories form a two-level tree, the way phpBB's categories held forums: a root category is a group (the old `phpbb_categories`), its children are the boards topics are posted in (the old `phpbb_forums`). Topics may only be posted in a leaf.

    36 methods

  • Poll class

    A topic's poll - phpBB's "sondage", which the 2004 BBS had above a topic's messages: one question, a few answers, and each member picks one of them - or, when the poll allows it (maxChoices), up to that many.

    15 methods · 2 constants

  • PollVote class

    One answer a member picked in a poll: one row per answer, so a poll that allows several (Poll::$maxChoices) holds a member's picks as several rows.

    6 methods

  • Post class

    One message in a topic. Written in Markdown (a "modern forum" writes Markdown, the way Discourse and the CERN ROOT forum do) and rendered through Base\Forum\Service\MarkdownRenderer, which escapes raw HTML.

    24 methods

  • Topic class

    A forum topic is a Thread: title, slug, tags, owner(s), followers, likes, mentions, publish state and soft delete come from base-bundle. The body of the topic is its first Post, so a topic and its replies are read, quoted, edited and moderated the same way.

    28 methods

Base\Forum\EventListener

  • SitemapListener class

    The forum's own pages in /sitemap.xml: base-bundle lists the routes it can generate by itself, and a board or a topic is behind a slug it cannot know.

    2 methods · 1 constant

Base\Forum\Form\Model

  • CategoryModel class

    What the "new board" form collects (ForumController::CategoryNew): the name, the group it goes in - none makes it a group of its own - and what it is for.

    5 properties

  • PostModel class

    What a reply (or an edit of one) collects. A DTO rather than the Post itself: base-bundle's form factory refuses an entity as form data, to keep a half-submitted form from ever reaching a flush.

    1 method · 2 properties

  • TopicModel class

    What the "new topic" form collects: the topic's title, board and tags, and the opening post's content - two entities, one form - and, when it is given a question, the topic's poll.

    3 methods · 9 properties

Base\Forum\Form\Type

  • CategoryType class

    A board opened from the BBS itself (ForumController::CategoryNew): its name, the group it goes in, a line to say what it is for. Left without a group, it is a new group. The back office's CRUD has the rest - icon, colour, the rank a board is reserved to, the lock.

    2 methods

  • PostType class

    A reply, or the edit of one - just the Markdown body (see PostModel).

    3 methods

  • TopicType class

    3 methods

Base\Forum\Repository

  • CategoryRepository class

    3 methods

  • PostRepository class

    5 methods

  • TopicRepository class

    Extends ThreadRepository (not ServiceEntityRepository) on purpose: Thread is #[Hierarchify], which requires HierarchifyTrait on the repository of every subclass, and that trait comes with the parent.

    15 methods

Base\Forum\Security

  • ForumVoter class

    Who may do what on the forum.

    2 methods · 7 constants

Base\Forum\Service

  • MarkdownRenderer class

    Posts are Markdown, rendered here with GitHub flavouring (tables, task lists, strikethrough, autolinks) and NO raw HTML: anything a member types as a tag is escaped, links are never allowed to be javascript:, and nesting is capped so a pathological post cannot stall the renderer.

    4 methods

Base\Forum\Twig

App\Controller

DemoController class

class DemoController extends AbstractController DemoController.php:26

The demonstrator's one page: it seeds a small forum, signs a visitor in, and reports on the wiring - then hands over to the forum itself.

Everything here is demo scaffolding. A real application seeds boards from a fixture or the admin, and signs members in through base-bundle's security controllers; nothing in this file is a pattern to copy.

DemoController::__construct()

public function __construct(readonly EntityManagerInterface $entityManager, readonly MarkdownRenderer $markdown, readonly Security $security)

DemoController::index()

public function index(): Response

App\Entity

User class

class User extends User User.php:15

The demo's member. base-bundle's User identifies people by email and has no username; the forum and the mailbox address members by username (an @mention, a recipient typed by hand), so the application's User carries one - as every base-bundle application's User does.

User::getUsername()

public function getUsername(): ?string

User::setUsername()

public function setUsername(string $username): self

User::__toString()

public function __toString(): string

App\Repository

UserRepository class

class UserRepository extends UserRepository UserRepository.php:5

Base\Forum

ForumBundle class

class ForumBundle extends AbstractBaseBundle ForumBundle.php:17

A discussion forum on top of base-bundle's Thread.

A Topic IS a Thread (JOINED subclass), so it inherits what base-bundle already gives every thread - tags, owners, followers, likes, mentions, a slug, publish states and soft delete - and the forum only adds what a forum needs on top: categories, posts, pins, locks and view counts.

ForumBundle::__construct()

public function __construct()

ForumBundle::getPath()

public function getPath(): string

Modern bundle layout: the class lives in src/, the bundle root is the package root - so TwigBundle picks up ./templates as @Forum and Symfony picks up ./translations. Doctrine's auto_mapping still finds the entities: for attribute mappings it looks next to the bundle CLASS, i.e. src/Entity, with the prefix Base\Forum\Entity.

ForumBundle::build()

public function build(ContainerBuilder $container): void

Base\Forum\Controller\Admin

OrderController class

class OrderController extends AbstractController OrderController.php:30

The BBS's display order on one screen, the way a settings page reads: the groups with their boards (a board can change group here too), and the tags of the tag cloud. CategoryCrudController edits one board at a time - ordering twenty of them by typing positions is what this spares.

Categories are written 10, 20, 30... so a board added later through the CRUD can still slot in between two without renumbering the rest. Tags go the other way, highest first, because Tag::$priority is a priority: the tag on top gets the largest value (TopicRepository::findTagUsage).

const CSRF_TOKEN_ID = 'forum_admin_order'

OrderController::__construct()

public function __construct(readonly EntityManagerInterface $entityManager, readonly CategoryRepository $categories, readonly TranslatorInterface $translator, readonly AdminContext $adminContext, readonly MenuBuilder $menuBuilder)

OrderController::Order()

public function Order(Request $request): Response

Base\Forum\Controller\Admin\Crud

CategoryCrudController class

class CategoryCrudController extends AbstractCrudController CategoryCrudController.php:22

Admin CRUD for the boards.

CategoryCrudController::getEntityFqcn()

public static function getEntityFqcn(): string

CategoryCrudController::getPreferredIcon()

public static function getPreferredIcon(): ?string

CategoryCrudController::configureFilters()

public function configureFilters(Filters $filters): Filters

CategoryCrudController::configureFields()

public function configureFields(string $pageName): iterable

TopicCrudController class

class TopicCrudController extends AbstractCrudController TopicCrudController.php:17

Admin CRUD for the topics. Posts are moderated on the site itself.

TopicCrudController::getEntityFqcn()

public static function getEntityFqcn(): string

TopicCrudController::getPreferredIcon()

public static function getPreferredIcon(): ?string

TopicCrudController::configureFilters()

public function configureFilters(Filters $filters): Filters

TopicCrudController::configureFields()

public function configureFields(string $pageName): iterable

Base\Forum\Controller\Client

ForumController class

class ForumController extends AbstractController ForumController.php:29

The reading side of the forum: the board index, a board, a tag, the search. Writing lives in TopicController.

Route names are forum_*; paths stay under /bbs, the name the site has always given its forum ("BBS (forums)" in the menu).

const VIEWS = ['classic', 'feed']

The index's views. "classic" is the phpBB board list, grouped; "feed" is the flat, Discourse-like feed with the announcements and the pinned topics as post-its on top. A view no longer offered - a cookie from before (the French names these once had, the "arbre" the BBS was once grown as) - falls back to the first.

const SORTS = ['recent', 'popular']

The feed's two orders: the latest activity, or what is busiest lately.

const DISPLAY = ['view' => [self::VIEW_COOKIE, self::VIEWS], 'sort' => [self::SORT_COOKIE, self::SORTS], 'style' => [TopicController::STYLE_COOKIE, TopicController::STYLES]]

How a reader likes the BBS drawn, each choice remembered per browser for a year - never carried in the address: the index's view, the feed's order, a topic's style. What the buttons send (display()), name => [cookie, allowed values].

ForumController::__construct()

public function __construct(readonly EntityManagerInterface $entityManager, readonly PaginatorInterface $paginator, readonly TranslatorInterface $translator, readonly int $topicsPerPage = 20, readonly int $hotThreshold = 25)

ForumController::Display()

public function Display(Request $request): Response

A display choice (DISPLAY) remembered, then back to the page it was made on. A small POST form per button rather than a query parameter: the address stays the page's own, and reloading it, sharing it or coming back to it later shows what the reader last chose. Any other name or value is ignored; back is only ever a path on this site.

ForumController::Index()

public function Index(Request $request): Response

ForumController::Category()

public function Category(Request $request, string $slug): Response

ForumController::CategoryNew()

public function CategoryNew(Request $request): Response

"Nouveau forum": a board opened from the BBS itself, for FORUM_ADMIN (forum.admin_role). ?dans=<group slug> chooses its group; left without one, it is a new group. It lands last in its group - the order page (/admin/bbs/ordre) moves it, and the back office's CRUD has the rest.

Declared with a priority: "/bbs/{slug}" would take "nouveau-forum" for a topic.

ForumController::Tag()

public function Tag(Request $request, string $slug): Response
public function Search(Request $request): Response

TopicController class

class TopicController extends AbstractController TopicController.php:38

A topic: reading it, opening one, replying, editing, and the moderation switches (pin, lock, delete).

const STYLES = ['retro', 'cards']

A topic's two looks: the messages as separate cards, or the 2005 board's one box with its rows (topic/show.html.twig). Chosen with the reader's choice (ForumController::Display()), remembered per browser in STYLE_COOKIE.

const STREAM_HEADER = 'X-Forum-Stream'

Asked by forum-thread.js for one more page of a topic being read as a single stream (Discourse's way): the page's posts alone, and the reading is not counted again.

TopicController::__construct()

public function __construct(readonly EntityManagerInterface $entityManager, readonly PaginatorInterface $paginator, readonly MarkdownRenderer $markdown, readonly TranslatorInterface $translator, readonly int $postsPerPage = 10, readonly int $floodInterval = 15)

TopicController::Create()

public function Create(Request $request, ?string $category = null): Response

Declared before Show(): "/bbs/{slug}" would otherwise swallow "nouveau" as a topic slug.

TopicController::Show()

public function Show(Request $request, string $slug): Response

TopicController::Reply()

public function Reply(Request $request, string $slug): Response

TopicController::Vote()

public function Vote(Request $request, string $slug): Response

A vote on the topic's poll: one answer, or on a poll that allows several (Poll::$maxChoices) up to that many, sent together. Once per member and final once cast, as the 2004 BBS had it; none on a locked topic.

TopicController::Edit()

public function Edit(Request $request, string $slug): Response

TopicController::Moderate()

public function Moderate(Request $request, string $slug, string $action): Response

Pin, lock, delete: one POST endpoint, one switch. CSRF-protected the plain way (a token in the form), no JavaScript needed.

TopicController::ShowPost()

public function ShowPost(int $id): Response

A message's own address: its place in its topic, on whichever page it is.

TopicController::EditPost()

public function EditPost(Request $request, int $id): Response

TopicController::DeletePost()

public function DeletePost(Request $request, int $id): Response

Base\Forum\DependencyInjection

ForumConfiguration class

class ForumConfiguration extends AbstractBaseConfiguration ForumConfiguration.php:8

ForumConfiguration::getConfigTreeBuilder()

public function getConfigTreeBuilder(): TreeBuilder

ForumExtension class

class ForumExtension extends AbstractBaseExtension ForumExtension.php:11

ForumExtension::getConfiguration()

public function getConfiguration(array $config, ContainerBuilder $container): ForumConfiguration

ForumExtension::load()

public function load(array $configs, ContainerBuilder $container): void

Base\Forum\Entity

Category class

class Category implements LinkableInterface, IconizeInterface Category.php:25

A board of the forum: "Agora", "Helpz", "Games"...

Categories form a two-level tree, the way phpBB's categories held forums: a root category is a group (the old phpbb_categories), its children are the boards topics are posted in (the old phpbb_forums). Topics may only be posted in a leaf.

Category::__construct()

public function __construct(?string $title = null, ?Category $parent = null)

Category::__toString()

public function __toString(): string

Category::__iconize()

public function __iconize(): ?array

Category::__iconizeStatic()

public static function __iconizeStatic(): ?array
public function __toLink(array $routeParameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): ?string

Category::getId()

public function getId(): ?int

Category::getTitle()

public function getTitle(): ?string

Category::setTitle()

public function setTitle(?string $title): self

Category::getSlug()

public function getSlug(): ?string

Category::setSlug()

public function setSlug(?string $slug): self

Category::getDescription()

public function getDescription(): ?string

Category::setDescription()

public function setDescription(?string $description): self

Category::getIcon()

public function getIcon(): ?string

Category::setIcon()

public function setIcon(?string $icon): self

Category::getColor()

public function getColor(): ?string

Category::setColor()

public function setColor(?string $color): self

Category::getPosition()

public function getPosition(): int

Category::setPosition()

public function setPosition(int $position): self

Category::isLocked()

public function isLocked(): bool

Category::setLocked()

public function setLocked(bool $locked): self

Category::isAnnouncement()

public function isAnnouncement(): bool

Category::setAnnouncement()

public function setAnnouncement(bool $announcement): self

Category::allowsPolls()

public function allowsPolls(): bool

Category::isPolls()

public function isPolls(): bool

Category::setPolls()

public function setPolls(bool $polls): self

Category::getRequiredRole()

public function getRequiredRole(): ?string

Category::setRequiredRole()

public function setRequiredRole(?string $requiredRole): self

Category::getParent()

public function getParent(): ?Category

Category::setParent()

public function setParent(?Category $parent): self

Category::getChildren()

public function getChildren(): Collection

Category::addChild()

public function addChild(Category $child): self

Category::isBoard()

public function isBoard(): bool

A board (leaf) takes topics; a group only holds boards.

Category::isGroup()

public function isGroup(): bool

Category::getTopics()

public function getTopics(): Collection

Category::getCreatedAt()

public function getCreatedAt(): ?\DateTimeInterface

Category::getUpdatedAt()

public function getUpdatedAt(): ?\DateTimeInterface

Poll class

class Poll Poll.php:20

A topic's poll - phpBB's "sondage", which the 2004 BBS had above a topic's messages: one question, a few answers, and each member picks one of them - or, when the poll allows it (maxChoices), up to that many.

The answers are a plain list kept on the poll (their order is their identity: a vote records the index it chose), because a poll's answers are written once, with the topic, and never edited - editing an answer after votes were cast would change what those votes meant.

const MIN_OPTIONS = 2
const MAX_OPTIONS = 10

Poll::__construct()

public function __construct(Topic $topic, string $question, array $options, int $maxChoices = 1)

Poll::getId()

public function getId(): ?int

Poll::getTopic()

public function getTopic(): ?Topic

Poll::getQuestion()

public function getQuestion(): string

Poll::getOptions()

public function getOptions(): array

Poll::getMaxChoices()

public function getMaxChoices(): int

Poll::isMultiple()

public function isMultiple(): bool

Poll::getCreatedAt()

public function getCreatedAt(): \DateTimeInterface

Poll::getVotes()

public function getVotes(): Collection

Poll::addVote()

public function addVote(PollVote $vote): self

Poll::isOpen()

public function isOpen(): bool

A poll closes with its topic: a locked topic takes no reply, and no vote either.

Poll::getCounts()

public function getCounts(): array
Returns

votes per answer, by the answer's index

Poll::getTotal()

public function getTotal(): int

How many members voted - not how many answers were picked: on a poll that allows several, one member's three answers are one voter, and an answer's share is the share of VOTERS who picked it (so the shares may add up past 100%, as they do on any "pick several" poll).

Poll::getChoicesOf()

public function getChoicesOf(?User $user): array
Returns

the answers a member picked, by index; empty when they have not voted

Poll::hasOption()

public function hasOption(int $choice): bool

PollVote class

class PollVote PollVote.php:15

One answer a member picked in a poll: one row per answer, so a poll that allows several (Poll::$maxChoices) holds a member's picks as several rows.

The same answer twice is what the unique index refuses - not a check in the controller - so two clicks racing each other cannot count one pick twice; how many answers a member may pick is the vote action's to hold.

PollVote::__construct()

public function __construct(Poll $poll, User $user, int $choice)

PollVote::getId()

public function getId(): ?int

PollVote::getPoll()

public function getPoll(): ?Poll

PollVote::getUser()

public function getUser(): ?User

PollVote::getChoice()

public function getChoice(): int

PollVote::getCreatedAt()

public function getCreatedAt(): \DateTimeInterface

Post class

class Post Post.php:20

One message in a topic. Written in Markdown (a "modern forum" writes Markdown, the way Discourse and the CERN ROOT forum do) and rendered through Base\Forum\Service\MarkdownRenderer, which escapes raw HTML.

Deleted posts are kept (deletedAt) so a topic never loses its numbering and moderators can see what was removed.

Post::__construct()

public function __construct(?User $author = null, ?string $content = null)

Post::__toString()

public function __toString(): string

Post::getId()

public function getId(): ?int

Post::getTopic()

public function getTopic(): ?Topic

Post::setTopic()

public function setTopic(?Topic $topic): self

Post::getAuthor()

public function getAuthor(): ?User

Post::setAuthor()

public function setAuthor(?User $author): self

Post::getContent()

public function getContent(): ?string

Post::setContent()

public function setContent(?string $content): self

Post::getCreatedAt()

public function getCreatedAt(): ?\DateTimeInterface

Post::getUpdatedAt()

public function getUpdatedAt(): ?\DateTimeInterface

Post::getShownAt()

public function getShownAt(): ?\DateTimeInterface

The date the post is shown with: when it was written - except a scheduled topic's opening post, which is dated the hour its topic came out (Topic::schedule()). createdAt itself cannot carry that hour: it is a #[Timestamp] and gets overwritten on persist.

Post::getEditedAt()

public function getEditedAt(): ?\DateTimeInterface

Post::markEdited()

public function markEdited(): self

Post::getEditCount()

public function getEditCount(): int

Post::isDeleted()

public function isDeleted(): bool

Post::getDeletedAt()

public function getDeletedAt(): ?\DateTimeInterface

Post::getDeletedBy()

public function getDeletedBy(): ?User

Post::delete()

public function delete(?User $by = null): self

Post::restore()

public function restore(): self

Post::getReplyTo()

public function getReplyTo(): ?Post

Post::setReplyTo()

public function setReplyTo(?Post $replyTo): self

Post::isFirst()

public function isFirst(): bool

Is this the topic's opening post?

Post::getNumber()

public function getNumber(): int

1-based position in the topic, for "#12" anchors and permalinks.

Topic class

class Topic extends Thread implements LinkableInterface Topic.php:27

A forum topic is a Thread: title, slug, tags, owner(s), followers, likes, mentions, publish state and soft delete come from base-bundle. The body of the topic is its first Post, so a topic and its replies are read, quoted, edited and moderated the same way.

A topic may be written ahead of its hour (schedule()): until publishedAt it is out of every list and closed to replies, and only its authors and the moderators of its board can open it (ForumVoter). The date alone decides - nothing has to run at that hour for it to appear.

Topic::__construct()

public function __construct(?User $owner = null, ?Category $category = null, ?string $title = null)

Topic::schedule()

public function schedule(?\DateTimeInterface $at): self

Out at $at when that is still to come; otherwise now.

The state stays PUBLISH either way. Base-bundle's FUTURE waits for its thread:publishable command to be run to become PUBLISH, and nothing runs it here: the lists go by the date (TopicRepository::whereOut()), so a scheduled topic comes out at its hour on its own.

A topic nobody has answered yet moves its last activity to that hour too: the lists sort by it, and a topic out at 18:00 must come in at 18:00, not at the hour it was written.

Topic::isUpcoming()

public function isUpcoming(): bool

Written, but not out yet (schedule()).

Topic::setAuthor()

public function setAuthor(User $author): self

Who the topic is signed by: its opening post and its owner, together.

An admin may publish in someone else's name (TopicController).

Topic::__toString()

public function __toString(): string
public function __toLink(array $routeParameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): ?string

Topic::getCategory()

public function getCategory(): ?Category

Topic::setCategory()

public function setCategory(?Category $category): self

Topic::getPosts()

public function getPosts(): Collection

Topic::addPost()

public function addPost(Post $post): self

Topic::removePost()

public function removePost(Post $post): self

Topic::getFirstPost()

public function getFirstPost(): ?Post

The opening post: the topic's own body.

Topic::getAuthor()

public function getAuthor(): ?User

Topic::isPinned()

public function isPinned(): bool

Topic::setPinned()

public function setPinned(bool $pinned): self

Topic::isLocked()

public function isLocked(): bool

Topic::setLocked()

public function setLocked(bool $locked): self

Topic::getViews()

public function getViews(): int

Topic::setViews()

public function setViews(int $views): self

Topic::getReplies()

public function getReplies(): int

Topic::setReplies()

public function setReplies(int $replies): self

Topic::getLastPostAt()

public function getLastPostAt(): ?\DateTimeInterface

Topic::setLastPostAt()

public function setLastPostAt(?\DateTimeInterface $lastPostAt): self

Topic::getPoll()

public function getPoll(): ?Poll

Topic::setPoll()

public function setPoll(?Poll $poll): self

Topic::getLastPoster()

public function getLastPoster(): ?User

Topic::setLastPoster()

public function setLastPoster(?User $lastPoster): self

Topic::getActivityAt()

public function getActivityAt(): ?\DateTimeInterface

Last activity, for sorting: the last reply, else the topic itself.

Base\Forum\EventListener

SitemapListener class

final class SitemapListener SitemapListener.php:19

The forum's own pages in /sitemap.xml: base-bundle lists the routes it can generate by itself, and a board or a topic is behind a slug it cannot know.

Only what a visitor may read goes in: a board reserved to a role is left out, and so is everything written in it. A topic carries the date of its last message, so a crawler comes back to the ones that move.

const MAX_TOPICS = 5000

Enough for a busy forum, short of a sitemap's 50 000 URL limit.

SitemapListener::__construct()

public function __construct(readonly CategoryRepository $categories, readonly TopicRepository $topics)

SitemapListener::__invoke()

public function __invoke(SitemapEvent $event): void

Base\Forum\Form\Model

CategoryModel class

class CategoryModel CategoryModel.php:12

What the "new board" form collects (ForumController::CategoryNew): the name, the group it goes in - none makes it a group of its own - and what it is for.

public ?string $title = null
public ?Category $parent = null
public ?string $description = null
public bool $announcement = false

A board of announcements: it and its topics wear the megaphone badge.

public bool $polls = true

Whether topics opened in it may carry a poll.

PostModel class

class PostModel PostModel.php:13

What a reply (or an edit of one) collects. A DTO rather than the Post itself: base-bundle's form factory refuses an entity as form data, to keep a half-submitted form from ever reaching a flush.

public ?string $content = null
public ?int $replyTo = null

PostModel::fromPost()

public static function fromPost(Post $post): self

TopicModel class

class TopicModel TopicModel.php:15

What the "new topic" form collects: the topic's title, board and tags, and the opening post's content - two entities, one form - and, when it is given a question, the topic's poll.

public ?string $title = null
public ?Category $category = null
public iterable $tags = []
public ?string $content = null
public ?string $pollQuestion = null

A poll is optional: no question, no poll.

public ?string $pollOptions = null

Its answers, one per line; blank lines are dropped.

public ?int $pollMax = 1

How many answers a member may pick: 1, a single one; more, up to that many (Poll::$maxChoices).

public ?\DateTimeInterface $publishedAt = null

When it comes out (Topic::schedule()): empty, at once. Only asked of those who may schedule (ForumVoter::SCHEDULE).

public ?\App\Entity\User $author = null

The member it is published under: the forum admin may sign a topic with another member's name (ForumVoter::ADMIN), picked from a search.

Empty, their own.

TopicModel::pollOptionList()

public function pollOptionList(): array

TopicModel::hasPoll()

public function hasPoll(): bool

TopicModel::validatePoll()

public function validatePoll(ExecutionContextInterface $context): void

Base\Forum\Form\Type

CategoryType class

class CategoryType extends AbstractType CategoryType.php:22

A board opened from the BBS itself (ForumController::CategoryNew): its name, the group it goes in, a line to say what it is for. Left without a group, it is a new group. The back office's CRUD has the rest - icon, colour, the rank a board is reserved to, the lock.

CategoryType::configureOptions()

public function configureOptions(OptionsResolver $resolver): void

CategoryType::buildForm()

public function buildForm(FormBuilderInterface $builder, array $options): void

PostType class

class PostType extends AbstractType PostType.php:14

A reply, or the edit of one - just the Markdown body (see PostModel).

PostType::__construct()

public function __construct(readonly int $contentMaxLength = 20000)

PostType::configureOptions()

public function configureOptions(OptionsResolver $resolver): void

PostType::buildForm()

public function buildForm(FormBuilderInterface $builder, array $options): void

TopicType class

class TopicType extends AbstractType TopicType.php:21

TopicType::__construct()

public function __construct(readonly int $titleMaxLength = 120, readonly int $contentMaxLength = 20000)

TopicType::configureOptions()

public function configureOptions(OptionsResolver $resolver): void

TopicType::buildForm()

public function buildForm(FormBuilderInterface $builder, array $options): void

Base\Forum\Repository

CategoryRepository class

class CategoryRepository extends ServiceEntityRepository CategoryRepository.php:14

CategoryRepository::findTree()

public function findTree(): array

The whole tree in display order: groups first, each followed by its boards - one query, then assembled in memory.

Returns

root groups, with their children loaded

CategoryRepository::findBoards()

public function findBoards(): array
Returns

the boards (leaves) topics can be posted in

CategoryRepository::findOneBySlug()

public function findOneBySlug(string $slug): ?Category

PostRepository class

class PostRepository extends ServiceEntityRepository PostRepository.php:17

PostRepository::createTopicQuery()

public function createTopicQuery(Topic $topic): Query

PostRepository::findOutline()

public function findOutline(Topic $topic): array

The whole topic, light: one scalar row per post, in reading order, for the timeline and the posts' own hierarchy (TopicController::outline()).

No content, no entities - a topic of a thousand posts stays cheap.

PostRepository::findLastByAuthor()

public function findLastByAuthor(User $author): ?Post

The member's most recent post, for the flood check.

PostRepository::findPageOf()

public function findPageOf(Post $post, int $perPage): int

Which page of a topic a post lands on, for permalinks.

PostRepository::countByAuthor()

public function countByAuthor(User $author): int

How many posts a member wrote - the profile's "Messages sur le forum".

TopicRepository class

class TopicRepository extends ThreadRepository TopicRepository.php:22

Extends ThreadRepository (not ServiceEntityRepository) on purpose: Thread is #[Hierarchify], which requires HierarchifyTrait on the repository of every subclass, and that trait comes with the parent.

TopicRepository::whereOut()

public function whereOut(QueryBuilder $qb, string $alias = 't'): QueryBuilder

Only the topics that are out: a scheduled one (Topic::schedule()) stays out of every list until its hour. Every query below that lists topics goes through here - a list that forgot it would give the topic away before its time, on the home page as much as on the BBS.

TopicRepository::createListQueryBuilder()

public function createListQueryBuilder(bool $withUpcoming = false): QueryBuilder

A listing query: pinned first, then by last activity. Soft-deleted topics are filtered by base-bundle's Trasheable filter, so nothing to do here about them. Scheduled topics are left out, unless asked for ($withUpcoming: a board's own list, read by those who may see them).

TopicRepository::createCategoryQuery()

public function createCategoryQuery(Category $category, bool $withUpcoming = false): Query

A board's topics. $withUpcoming for the moderators of that board: they see what is scheduled there, on top (its last activity is its hour to come) and marked as such (_topic_row.html.twig).

TopicRepository::createTagQuery()

public function createTagQuery(Tag $tag, ?array $readableCategoryIds = null): Query

A tag's topics, of the readable boards only (the caller passes them, as for createLatestQuery(); null: every board's).

TopicRepository::createLatestQuery()

public function createLatestQuery(?array $readableCategoryIds = null): Query

Latest activity across the whole forum, for the front page and the "unread" style feeds - never pinned first here, that only makes sense inside a board.

TopicRepository::createTrendingQuery()

public function createTrendingQuery(?array $readableCategoryIds = null, int $days = 30): Query

What people are talking about: among the topics that have moved in the last days, the busiest first - replies, then readers, then the most recently active. The feed's second order ("popular"). Readable boards only, the caller passing them as for createLatestQuery().

TopicRepository::findPinned()

public function findPinned(array $boardIds, int $limit = 6): array

The pinned topics of the given boards, most recently active first: the post-its of the flat view. Readable boards only - the caller passes them, as for createLatestQuery().

TopicRepository::findAnnouncements()

public function findAnnouncements(array $boardIds, int $limit = 2): array

The announcements of the flat view: the latest topics of the announcement boards (Category::$announcement - "Annonce", "Messages Officiels"), in the order they came out - a scheduled one takes its place at its hour, not at the hour it was written.

$boardIds

the readable announcement boards

TopicRepository::createSearchQuery()

public function createSearchQuery(string $term, ?array $readableCategoryIds = null): Query

Plain title/content search: a LIKE over the topic title and its posts.

Small forums do not need an index; when the site wants Typesense, the Topic entity is where a mapping goes. Readable boards only, as createTagQuery().

TopicRepository::findOneBySlug()

public function findOneBySlug(string $slug): ?Topic

TopicRepository::incrementViews()

public function incrementViews(Topic $topic): void

A view is not an edit: bump the counter with a direct UPDATE so the Thread's updatedAt (Timestamp on update) is not touched by readers.

TopicRepository::countPerCategory()

public function countPerCategory(): array

Per-board counters for the index page: topics and replies, in one query. Keyed by category id.

TopicRepository::findHotCategoryIds()

public function findHotCategoryIds(int $threshold): array

The boards holding at least one popular topic, for the folder badge of a board row. One grouped query for the whole index rather than a count per row, and the very threshold a topic row uses, so "populaire" means the same thing at both levels of the hierarchy.

Returns

keyed by category id, to be read with hot[board.id]|default(false) in Twig

TopicRepository::findLastPerCategory()

public function findLastPerCategory(): array

The most recently active topic of each board, for the classic index's "last message" column (who wrote last, when, in which topic), as phpBB's forum list had it. One query: a topic whose last activity is its board's latest. Keyed by category id.

TopicRepository::findTagUsage()

public function findTagUsage(int $limit = 30): array

Tags in use on the forum - the tag cloud. Rows of ['tag' => Tag, 'nb' => int]. Queried from the Tag side: DQL will not select a joined entity without its root alias, and the count is what the join is for.

Highest Tag::$priority first, as the admin set it on /admin/bbs/ordre; usage only decides between tags of equal priority - which, until an admin orders them, is all of them (priority 0).

Base\Forum\Security

ForumVoter class

final class ForumVoter extends Voter ForumVoter.php:39

Who may do what on the forum.

FORUM_READ a category (its required role, if any) FORUM_POST a category: signed in, board not locked, readable; an announcement board takes topics from the admin role only FORUM_REPLY a topic: signed in, topic and board not locked FORUM_EDIT a topic or post: its author, or its board's moderator FORUM_MODERATE a category, topic or post: whoever moderates its board (see moderates()); with no subject, whoever moderates any board at all FORUM_SCHEDULE a category: whoever may post there and moderates it - writing a topic to come out at a later hour (Topic::schedule()); with no subject, whoever moderates any board, to know whether to offer it at all

A scheduled topic that is not out yet (Topic::isUpcoming()) is its authors' and its board's moderators' alone: everyone else is refused everything on it and on its posts, READ included, and nobody replies to it before its hour. FORUM_ADMIN opening a board or a group from the site: the configured admin role (forum.admin_role)

const READ = 'FORUM_READ'
const POST = 'FORUM_POST'
const REPLY = 'FORUM_REPLY'
const EDIT = 'FORUM_EDIT'
const MODERATE = 'FORUM_MODERATE'
const ADMIN = 'FORUM_ADMIN'
const SCHEDULE = 'FORUM_SCHEDULE'

ForumVoter::__construct()

public function __construct(readonly Security $security, readonly RoleHierarchyInterface $roleHierarchy, readonly string $moderatorRole = 'ROLE_ADMIN', readonly string $adminRole = 'ROLE_SUPER_ADMIN')

ForumVoter::moderates()

public function moderates(array $roles, ?Category $category): bool

Whether holders of these roles moderate this board - or, with no board, any board at all.

The admin role moderates everything. Below it, the moderator role moderates the open boards and the boards kept for a role it stands ABOVE, never one kept for its own rank: the moderators read their own board but the admins keep order there, while they do keep it on the animators' and helpers'. A board kept for a role outside their branch they cannot even read. Announcement boards are the admins' alone.

Base\Forum\Service

MarkdownRenderer class

class MarkdownRenderer MarkdownRenderer.php:25

Posts are Markdown, rendered here with GitHub flavouring (tables, task lists, strikethrough, autolinks) and NO raw HTML: anything a member types as a tag is escaped, links are never allowed to be javascript:, and nesting is capped so a pathological post cannot stall the renderer.

Two forum habits are added on top of CommonMark:

MarkdownRenderer::__construct()

public function __construct(readonly ?RouterInterface $router = null, readonly ?TranslatorInterface $translator = null)

MarkdownRenderer::render()

public function render(?string $markdown): string

MarkdownRenderer::excerpt()

public function excerpt(?string $markdown, int $length = 160): string

A one-line, tag-free excerpt for lists and previews.

MarkdownRenderer::quote()

public function quote(?string $markdown, ?string $author = null): string

Quote a post the way a reply box pre-fills it.

Base\Forum\Twig

ForumTwigExtension class

final class ForumTwigExtension extends AbstractExtension ForumTwigExtension.php:13

ForumTwigExtension::__construct()

public function __construct(readonly MarkdownRenderer $markdown, readonly int $hotThreshold = 25, readonly int $activeHours = 72)

ForumTwigExtension::getFilters()

public function getFilters(): array

ForumTwigExtension::getFunctions()

public function getFunctions(): array

ForumTwigExtension::isHot()

public function isHot(Topic|int|null $subject): bool

phpBB's "popular" flag: a topic is hot from hot_threshold replies on.

The templates ask for it instead of carrying the number, so a topic row, a board row and the legend all mean the same thing.

ForumTwigExtension::getHotThreshold()

public function getHotThreshold(): int

ForumTwigExtension::isActive()

public function isActive(Topic|\DateTimeInterface|null $subject): bool

Awake or asleep: a message within forum.active_hours. The folder art says it - the "zzz" face sleeps, the one with a speech bubble talks.

Recent activity, not "unread": base-bundle keeps no per-member read marker, and a badge that meant "unread" could only ever be false.

ForumTwigExtension::topicFolder()

public function topicFolder(Topic $topic): array

A topic row's badge, phpBB's precedence top down: an announcement (a topic of an announcement board), a post-it, a locked topic, a popular one, then the plain folder - the last three awake or asleep.

Returns

title is a translation key, or ''

ForumTwigExtension::boardFolder()

public function boardFolder(Category $board, ?Topic $last = null, bool $hot = false): array

A board's badge: an announcement board, a locked one, one holding a popular topic, then the plain folder - the last three awake or asleep after the board's latest message. A board reserved to a rank wears the ordinary badge; the tooltip still says it is reserved.