Reference
Theme and plugin API reference
Full view payloads, hooks, permissions, lifecycle and provider contracts.
Includes the complete docs/EXTENSIONS.md reference from this build. Application paths refer to your own installation.
On this page
Theme and plugin development#
Extension API: 1 (current reference: VoltexaHub 1.8). Incompatible contract changes require a new API version and release notes. Extensions execute trusted server-side code and must be reviewed before installation. Never patch the core.
Create a theme#
Copy themes/aurora to themes/your-theme, set the manifest ID to the directory name, update its name, and edit its CSS. Directory names use lowercase letters, digits and hyphens.
{
"id": "your-theme",
"name": "Your Theme",
"version": "0.1.0",
"api": 1,
"parent": "default",
"description": "A short description.",
"styles": ["custom.css"],
"scripts": ["custom.js"],
"view_revisions": {"forums": 2}
}
Place custom.css and custom.js in assets/. Styles and scripts are loaded from parent to child after the compiled base assets; scripts use defer. Only declared .css/.js files with safe filenames are served by the asset endpoint. Copy just the Blade templates you want to change into views/, keeping their names. View lookup: active theme → parent chain → default. A cyclic or incompatible parent chain is rejected. Invalid manifests are omitted from Administration → Themes; runtime fallback uses the default theme when the selected manifest is incompatible. Syntactically broken templates require an admin or CLI switch.
Activate in Administration → Themes, or php artisan forum:theme your-theme. Restore with php artisan forum:theme default. No rebuild is needed for child CSS or templates. Changes to default assets require npm run build.
Tokens: --bg, --surface, --text, --muted, --line, --accent, --accent-hover, --button-text, --tint, --radius. Choose --button-text for readable contrast against both accent states (Default uses white in light mode and dark text in dark mode). Associate account-setting errors/help with their fields using aria-describedby and mark invalid fields with aria-invalid; keep named error bags separate. Supply dark overrides under :root[data-mode=dark]. A child theme can replace full page structure, layout, navigation, markup, CSS, and JS; it is not limited to colors. For example, themes/aurora/views/forums.blade.php moves the forum directory before the welcome heading while preserving the forum.top slot, controller-provided nodes, and escaped settings. A replacement layout can reuse the compiled base assets with @vite(['resources/css/app.css', 'resources/js/app.js']), $themeStyles links, and $themeScripts deferred tags, or replace the styling/interactions entirely while implementing equivalent required navigation, forms, CSRF, accessibility, and responsive behavior. Themes display data; core controllers and policies remain responsible for authorization. To add data or behavior, use a documented plugin extension point or core change. The theme asset endpoint currently supports only flat, declared CSS/JS files; images/fonts are not a theme asset API. Each $themeStyles/$themeScripts entry holds theme, file and a v content token, so rendering it with route('theme.asset', $asset) yields a versioned URL that browsers may cache for a year; a URL without a current token is always revalidated. The endpoint runs without a session, so it cannot depend on the signed-in member. Bundle them with the compiled app or use separately managed public assets where appropriate.
Supported view and component reference#
Every file in themes/default/views is a supported override. The reference revision for each is in config/theme_views.php. Copy an override into themes/<id>/views/<name>.blade.php and declare the revision it was reviewed against in view_revisions in theme.json. The initial reference revision is 1 for every listed view. When the default payload or required behavior changes, the affected reference revision increases. Administration → Themes compares existing overrides with these references and warns about missing, older, or future declarations. Warnings are advisory; they do not prevent activation. Review the current default view and the contract below, update the override, then set its revision to the current value. Remove an override to inherit the updated parent/default view. Extra theme-owned helper partials have no core revision and are not warned about.
The administrator-only component showcase (/admin/themes/showcase) renders synthetic examples for brand, button/status states, directory nodes, discussion rows, editor/attachment input, signature, and empty state. Select a theme, light/dark mode, and desktop/mobile width. Its preview is sandboxed, and example links/forms cannot act on real forum data. It does not replace checking a real site with each supported role and state. If a theme fails publicly, Administration → Themes remains a core view: activate Default there, or run php artisan forum:theme default.
| View | Payload or local variables | Required behavior when replacing |
|---|---|---|
layout (revision 6) |
$activeTheme, $themeStyles, $themeScripts, $canModerate, $unreadNotificationCount; current auth/request/settings |
Responsive navigation, search, notices, theme assets, CSRF and safe account links. Keep a keyboard-focusable main skip-link target (tabindex="-1"). Admin link remains permission-gated. Render app(\App\Extensions\Hooks::class)->navigationFor(request()) for authorized plugin links. Link to community.rules when app(\App\Support\RegistrationPolicy::class)->publishedTerms() returns a document. |
brand, nav-icon |
brand: community setting; nav-icon: $icon |
Preserve configured name, accessible control labels. |
forums (revision 2) |
$forum nullable, permission-filtered $nodes, $roots |
Directory and optional forum heading; render each category in $roots as a separate card and keep forum.top slot. |
forum-node (revision 3) |
$node with forum-only visibleChildren, own threads_count, latestThread, has_unread, and combined directory_threads_count, directoryLatestThread, directory_has_unread |
Show each main forum as one row with compact links to supplied visible children. Directory summaries include visible descendants. Categories are separate roots on the homepage. |
index (revision 2) |
$forum nullable, $nodes with permission-filtered visibleChildren, $threads paginator, $stats, $search |
Global latest/scoped lists, filters, pagination, forum.top. For parent forums, show a compact subforum section above one combined descendant discussion list, retaining source forum badges and a clear list scope label. |
thread-row |
$thread with nullable user, forum, posts_count, posts_max_id, last_read_post_id; optional $showForum |
Pin/lock/unread states and links to authorized routes. |
thread |
$thread, $posts paginator, $watching, $emailNotifications, $moveForums, $postAuthorLayout |
Posts, replies, moderation and watch controls; keep post.after for each post. |
thread-tools, post-dialogs |
Parent thread locals, including $post/$postNumber for dialogs |
CSRF-protected moderation/report forms and accessible dialogs. |
content-author |
$content, a Thread or Post |
Shows the author name from $content->author_name; link to the profile only when $content->user exists. |
post-signature (revision 2) |
$post with nullable user; user.signature when present |
Escape member signature text; render nothing when the post has no account. |
attachments |
$attachments collection |
Use authorized download/preview routes, not storage URLs. |
editor (revision 2), editor-controls, attachment-input |
Optional $body, $existingAttachments |
editor wraps the selected text controls and always includes attachment-input. Keep the body textarea, preview contract, and file input/status attributes. Full custom themes can override editor but should include the selected app(\App\Extensions\Hooks::class)->editorView() and theme::attachment-input. |
compose |
$forums allowed destinations |
Verified member create form, CSRF, file upload encoding. |
edit |
$post with attachments |
Authorized edit form, CSRF, retained/new files. |
search |
$search, $author, $sort, $forums, $forumId, $results nullable paginator |
Escape excerpts; preserve filters and pagination. |
profile |
$user, visible $stats, $tab, $threads, $posts nullable paginator |
Do not compute unrestricted activity counts. |
profile-edit, avatar-settings |
Authenticated $user in parent views |
Owner-only forms; preserve CSRF, validation feedback and avatar upload controls. |
account-settings (revision 3) |
Authenticated $user; separate password/email error bags |
Owner-only forms; preserve CSRF, named validation errors, field error/help associations and invalid-state attributes. |
auth (revision 3) |
$mode and reset $token when applicable; signup also receives $registrationPolicy, nullable $publishedTerms and $registrationClosed, plus nullable $signupCaptcha |
Preserve login/forgot/reset fields and CSRF. For signup, display escaped introduction and configured name bounds; render theme::registration-policy or equivalent required acceptance controls. A closed signup returns HTTP 403 with escaped configured explanation and validated contact link, without a registration form. Never render old passwords. Include theme::captcha or the equivalent widget when $signupCaptcha is non-null; an unavailable site key must not bypass server enforcement. |
registration-policy |
Signup $registrationPolicy and nullable current $publishedTerms |
When acceptance is required, render an explicit required terms_accepted=1 checkbox and hidden terms_version equal to the current published UUID; link to community.rules. After a stale-version error, require fresh acceptance. Core enforces these policies on direct POSTs regardless of theme markup. |
rules |
$terms: current published immutable document (id, title, body, published_at) |
Escape plain text, preserve line breaks and make the page readable on mobile. Do not query drafts or expose historical versions. Unpublished content returns HTTP 404. |
verify-email |
Authenticated user | Verification notice and resend route. |
email-unsubscribe |
$thread, optional $completed |
Signed unsubscribe confirmation flow. |
notifications |
$notifications paginator, $unreadNotificationCount |
Only supplied visible notifications; mark/read actions use CSRF. |
moderation |
$items paginator, $tab, permission-filtered $counts |
Moderation actions stay permission-gated and CSRF-protected. |
The payloads above are produced by core controllers and composers. They are presentation contracts, not permission grants. Do not perform unfiltered relationship queries inside overrides for search, counts, activity, or moderation. For the full route and state details, see the topical sections below and the current default views.
Discussion activity views#
The new notifications view receives a paginated $notifications collection with post.user and post.thread loaded. layout and notifications receive $unreadNotificationCount, calculated using current forum visibility. Child themes inherit the default view. The thread view now receives boolean $watching; discussion rows on index/profile have last_read_post_id and posts_max_id for signed-in viewers. Compare the latter to last_read_post_id ?? 0 to show an unread link.
Use threads.watch (CSRF-protected PUT with boolean watching) for explicit subscription, threads.unread (GET) to jump to reading progress, and home/forums.show with filter=unread or filter=watched for filtered lists. Notification routes are notifications.index (GET), notifications.open (CSRF-protected POST with notification ID), and notifications.read (CSRF-protected POST to mark visible notifications read). Keep links available in desktop and mobile navigation. Core controllers enforce ownership and visibility; templates only display the result. These contracts do not add plugin events or background jobs.
Moderation views and soft deletion#
The moderation view receives paginated $items, $tab (open, resolved, threads, or posts), and permission-filtered $counts for those tabs. Items are PostReport, Thread, or Post models respectively. Report posts and their threads may be soft-deleted; these relationships are explicitly loaded for authorized review. The thread view receives $moveForums, containing allowed destination forums. Layouts receive boolean $canModerate; expose moderation.index in desktop and mobile navigation when true.
CSRF-protected POST routes: posts.report accepts reason; moderation.resolve accepts resolution; moderation.thread accepts action=move|delete|restore plus forum_node_id for moves; moderation.post accepts action=delete|restore. Reporting requires a verified account. Core controllers enforce visibility and moderation permissions, including both forums for moves. Do not infer authorization from these UI flags.
An individual assignment at a forum or category grants thread.moderate there and in descendant spaces. Explicit group rules at the same scope take precedence when they deny; a nearer scoped rule or assignment takes precedence over an ancestor. Assignments do not grant forum.view or admin.access, and banned members receive no capabilities. Permissions::explain($user, $ability, $node) returns allowed, reason, and the winning scope for admin inspection; Permissions::allows() uses the same decision path. Public theme controls must still rely on core authorization for reads, counts, and writes. Administration → edit space manages individual assignments, and the Permissions inspector shows reasons for moderation decisions. This adds no plugin registration API.
Thread and Post now use Eloquent soft deletion. Default queries exclude deleted records; queries for public posts must also require a visible, non-deleted parent thread. Deleting a thread does not change its posts' deletion timestamps, so restoring it preserves separately deleted posts. Extensions must not use withTrashed() on public queries. No new plugin events are introduced.
Community content settings#
The verify-email public view is now available for theme overrides and falls back to the default theme. It uses the authenticated user and the named verification.notice, verification.send (CSRF-protected POST), and verification.verify routes. Replacement layouts should link unverified members to verification.notice. Core route middleware enforces verification before creating discussions, replying, or editing posts; themes must not bypass it. Laravel's built-in verification notification supplies the email template independently of public themes.
The default public views read these string keys through app(App\Support\Settings::class)->get($key, $default): community_name, community_description, welcome_title, welcome_description, and footer_text. Administrators edit them in Community settings. Child themes inherit this behavior; replacement brand, layout, and index views should read the corresponding keys to preserve customization. Escape settings as text using Blade's {{ }} syntax.
registration_open is "1" when signup is enabled and "0" when closed. Themes should hide signup invitations when closed. The authentication controller enforces this setting on both GET and POST registration routes; hiding a link is not authorization.
The administration layout and its Blade components live in resources/views/admin and resources/views/components/admin, with styles in resources/css/admin.css. They are core views, independent of the active public theme and plugin boot hooks, and are not a theme override contract.
Content-Security-Policy#
Pages are sent with a Content-Security-Policy (report-only until the site owner enforces it; see the operations guide). Themes should not depend on anything it blocks:
- No inline
<script>or<style>elements,style="…"attributes,on…=event handlers orjavascript:links in theme views. Put code and styles in files listed underscriptsandstylesintheme.json. - Load fonts, scripts and stylesheets from this site (ship them in the theme's assets). Images from HTTPS sites are allowed.
x-data,@clickand other Alpine attributes work, because the policy allows the Alpine library to evaluate expressions.- The site's own pages may not be embedded by other sites (
frame-ancestors 'self'), and<base>may only point at the site itself.
Create a plugin#
Run php artisan forum:make-plugin your-plugin for a working web plugin or php artisan forum:make-theme your-theme for a child theme. IDs must start with a lowercase letter, contain only lowercase letters/digits/single hyphens, and be at most 32 characters. Existing directories are never overwritten. See the plugin tutorial and theme tutorial for a clean-install walkthrough. Alternatively, copy plugins/announcements, changing its ID, PHP namespace/class, migration filename/table prefix and view namespace. Deploy code, install in Administration → Plugins, then enable. Migration filenames must be globally unique across core and plugins; do not edit an already released migration.
Implement App\Extensions\Plugin::boot(Hooks $hooks). Plugins can register:
$hooks->slot('forum.top', fn (array $context) => view('my-plugin::banner')->render());
$hooks->slot('post.after', fn (array $context) => view('my-plugin::footer', $context)->render(), 20);
$hooks->filter('my-plugin.items', fn (array $items) => [...$items, 'new item']);
Event::listen(\App\Events\ThreadCreated::class, function ($event) {
// Transaction has committed. Make side effects idempotent.
});
Callbacks at lower priorities run first; registration order breaks ties. Slot renderers return HTML, so escape user text in Blade. Filters are opt-in: the caller must invoke $hooks->apply(...). The two documented public slots are available on the index and discussion screens respectively. Boot hooks are request-scoped, on public web requests only. CLI and queued listeners do not boot plugins.
Editor adapters#
$hooks->editor('your-plugin::controls') selects a plugin-owned Blade view for the text controls. Register its namespace with View::addNamespace('your-plugin', __DIR__.'/views') first. The view receives optional $body; use old('body', $body ?? ''), a name="body" textarea with x-ref="body", and the existing markdownEditor preview contract when desired. plugins/plain-editor is a working replacement with a minimal textarea and core Markdown preview. The last successfully booted editor plugin in dependency order wins (IDs break independent ties). Disabling it restores theme::editor-controls on the next request. The parent theme::editor remains responsible for theme::attachment-input, preserving new and retained files across compose, reply, and edit. Server-side validation, Markdown rendering and attachment authorization remain in core. A custom theme may replace the wrapper while preserving these calls and controls.
Web routes, permissions and navigation#
Declare plugin-owned global ability labels in plugin.json, for example "permissions": {"your-plugin.view": "View example"}. Administration lists these declarations without executing plugin boot. New abilities receive no automatic group grants; an administrator must explicitly allow them. Existing announcement.manage remains a core ability for the shipped announcement settings.
Register a public endpoint with $hooks->webRoute('example', 'GET', 'your-plugin.view', fn () => view('your-plugin::example')). POST is also supported with a separate handler, e.g. function (Request $request) { $data = $request->validate([...]); ... }. The fixed route is route('extensions.dispatch', ['plugin' => 'your-plugin', 'endpoint' => 'example']), at /extensions/your-plugin/example; it works with Laravel route caching. Endpoint names use lowercase letters, digits and hyphens. The permission must be declared in this plugin's manifest under its own namespace. GET may be granted to guests; POST always requires authentication and normal web CSRF protection. Both methods check the current permission again, and plugin handlers must further authorize object-specific reads, writes, searches and counts. The dispatcher limits requests to 60 per minute per client; plugins can enforce stricter limits for expensive handlers. Other HTTP verbs return 405. Disabled, incompatible, or globally disabled plugins return 404 without dispatching.
After registering a GET endpoint, $hooks->navigation('example', 'Example') adds a link for viewers with its permission. The default layout renders these links in its responsive navigation. Replacement layouts should iterate app(\App\Extensions\Hooks::class)->navigationFor(request()), using escaped label and url. Core filters disabled plugins and permission-denied viewers. A hidden link is never a substitute for handler authorization. The shipped plain-editor plugin demonstrates GET and POST routes, validation, output escaping, a permission and navigation. Plugin route handlers are registered during public request boot and dispatched through the fixed core route because dynamic route registration during middleware runs after routing.
For a generic admin settings form implement ConfigurablePlugin: settings() returns a field-name/value array; saveSettings(array) validates and persists it. The plugin must perform its own capability checks. The shipped announcement settings require announcement.manage. These legacy admin-only settings remain available for installed plugins, including disabled ones, to support recovery; they do not register public routes or navigation. A normal admin request does not boot public plugins, allowing a faulty plugin to be disabled safely.
For account-owned preferences, register $hooks->personalRoute('dismiss', function (Request $request) { ... }). This registers only POST through the same dispatcher, CSRF protection, throttle, and enabled-plugin checks. The dispatcher requires an authenticated, non-banned account; the handler must derive the owner from the request user, ignore submitted user IDs, and separately authorize access to any referenced content. Personal routes do not grant read access or register navigation. Existing webRoute permission checks remain unchanged.
Plugins with richer settings may implement App\Extensions\CustomSettingsPlugin, extending ConfigurablePlugin, and return a plugin-namespaced view from settingsView() (for example, announcements::settings). Core registers plugins/<id>/views without booting public plugin code and supplies $id, $pluginName, and $fields from settings(). Extend admin.layout and submit CSRF-protected forms to admin.plugin.save; validate every action and authorize reads and writes within the plugin. Generic configurable plugins retain the generated settings form. The admin sidebar lists installed plugins with settings: true under Plugins, including disabled plugins for recovery.
The bundled Announcements plugin is installed and enabled by forum:install. Version 0.2.0 supports multiple notices with an enabled state and optional category/forum placement. Targets include descendants and respect viewer access; whole-community notices appear on directory, Latest, and forum listings. Account dismissals apply independently to each notice's current title, message, and placement. Guests close notices for the current page only. Upgrade existing installations with php artisan forum:plugin upgrade announcements; migrations preserve the original notice and existing account dismissals. Deleting a targeted space deletes its notices rather than exposing them globally. Railway web startup runs forum:plugins:upgrade-bundled after core migrations to upgrade an already installed Announcements plugin; it preserves enabled/disabled state and does not install missing plugins. Downloaded releases still use the explicit upgrade command above.
Dependencies and lifecycle#
The manifest version is a stable three-part numeric version such as 1.2.3; prereleases and build suffixes are rejected. requires accepts an ID list such as ["base-plugin"] for any installed version, or an ID-to-constraint object such as {"base-plugin": "^1.2.0"}. Supported constraint atoms are exact 1.2.3/=1.2.3, ^1.2.3, ~1.2.3, >=, >, <=, <, and *; comma-separated atoms mean AND. Other syntax, including OR and prereleases, is rejected. ^0.2.3 stops before 0.3.0; ^0.0.5 stops before 0.0.6; ~1.2.3 stops before 1.3.0. Dependencies install first and must be enabled before their dependent can be enabled. Cycles, missing packages, version conflicts, and incompatible APIs fail closed. Enabled plugins boot in deterministic dependency order; package code whose version differs from the installed database version cannot boot until upgraded.
The admin plugin page supports install, upgrade, enable, disable, and retained-data uninstall. The same actions are available through php artisan forum:plugin <action> <id>. Install/upgrade apply only pending plugin migrations. Upgrading an enabled plugin first disables it, runs migrations, and restores it only after success with enabled compatible dependencies; enabled dependents must be disabled beforehand. On failure the old installed version remains recorded and the plugin stays disabled. MySQL DDL is not transactional: a successful earlier migration may remain in Laravel's ledger, and a failing migration may have created a table without a ledger entry. Inspect the database, repair the migration idempotently (for example, check Schema::hasTable), then rerun upgrade/install. Never edit a migration already shipped to users; ship a corrective migration for released packages. forum:plugins:disable and FORUM_PLUGINS_ENABLED=false remain emergency recovery paths independent of plugin boot.
Disable blocks enabled dependents; uninstall blocks installed dependents and requires the target disabled first. Uninstall normally removes only the plugin registration; data, settings tables, and migration ledger remain so reinstall can reuse them. php artisan forum:plugin uninstall <id> --purge explicitly drops only tables declared in that plugin's owned_tables and then deletes exact migration names belonging to its package. Table names must begin plugin_<id_with_underscores> and cannot overlap another plugin declaration. Purge rejects migration name collisions with core/other plugins and refuses migration-backed plugins with no declared tables. It never runs migrate:rollback; unrelated core or plugin tables and ledger rows are preserved. Purge can be retried after partial DDL failure while the disabled plugin registration remains. Review backups and declared table ownership before destructive purge.
Compatibility checklist#
Check desktop and narrow mobile layouts, both appearances, keyboard focus, escaped user content, logged-out/member/moderator permissions, disabling/re-enabling, and running migrations on an existing database. Never rely on hidden controls for authorization.
Forum directory#
home now renders forums by default; discussions.latest (/latest) renders the discussion feed. Existing home filter URLs still render filtered discussions; nonempty search URLs render search as described below. Categories render a directory; individual forums retain their discussion list. forums receives $forum (nullable), permission-filtered $nodes, and $roots. On the homepage, $roots includes every visible category as an independent card, including categories saved below another space, plus visible top-level or orphaned forums. On a category page, $roots contains its direct visible children. Each node's visibleChildren contains only its directly nested visible forums, so category cards never appear inside another card. Nodes also have threads_count, latestThread, and (for members) has_unread. Counts and latest activity describe that forum's own non-deleted discussions. forum-node shows each main forum once with compact links to supplied visible child forums; do not reconstruct children from an unrestricted model relationship. Directory summaries include reachable visible descendants as described below. Preserve the forum.top hook in directory overrides. Category collapse preferences use browser-local storage and default to expanded.
Search#
search (GET /search) accepts q (up to 120 characters), forum (visible forum/category ID, including visible descendants), author (exact public display name; all matching names), and sort (relevance or newest). Nonempty q on /, /latest, and /forums/{forum} uses the same search view; legacy activity filters do not apply to search. Layout search forms should submit to route('search').
The search view receives $search, $author, $sort, permission-filtered $forums, $forumId, and $results (null before searching, otherwise a 15-post paginator). Results include thread.forum, user, and a plain-text search_excerpt; escape it with Blade {{ }}. Titles match only the first non-deleted post of a discussion. Counts, excerpts, and results exclude inaccessible forums and deleted posts/discussions. Use route('posts.show', $post) for links: it rechecks current visibility and resolves the post's current page and anchor. Child themes inherit the default search view.
Consolidated public navigation#
The default layout uses a single responsive header with Forums, Latest, search, a notification bell, and an account dropdown. Discussion filters (All, Unread, Watched, Unanswered) live in index; the sidebar and duplicate community-view tabs have been removed. The nav-icon partial accepts $icon for decorative SVG icons; controls retain accessible text labels. The notification bell uses the existing visibility-filtered $unreadNotificationCount. Moderation and administration links in the account menu retain their existing permission checks. No routes or permission contracts changed.
Activity feed and forum context#
index now presents the global Latest feed with catch-up filters, while scoped forum pages use breadcrumbs, a forum header, and a plain discussion list. Welcome settings remain on the directory homepage. Latest sorts by last_post_at then ID, without promoting pinned threads; scoped forums retain pinned-first sorting. Legacy scoped filter URLs still work and expose a clear-filter link. thread-row accepts optional boolean $showForum (defaults to true); the default index suppresses redundant forum tags only when a thread belongs to the displayed forum. Breadcrumbs and subforum links use the controller's permission-filtered $nodes. Existing view payloads and hooks remain available.
Discussion controls and author layout#
Community settings now accepts post_author_layout=compact|postbit, defaulting to compact. The thread view receives normalized $postAuthorLayout. In postbit mode, each paginated post's user has visible_posts_count, counting only non-deleted posts in visible, non-deleted discussions. Do not replace this with an unrestricted author post count. The classic author panel shows public name, initials, join month/year, and that filtered count; desktop displays details as label/value rows; mobile hides those details and the starter badge, showing only the avatar and name above the message.
The default thread uses thread-tools and post-dialogs partials, styled secondary-action menus, and native modal dialogs initialized by the discussionPage Alpine component. Existing CSRF-protected reporting and moderation routes remain unchanged. Reports include a UI-only report_post_id field to restore the appropriate dialog and input after validation; the route-bound post remains the authorization target. Keep post.after for every rendered post. Theme overrides may reuse the new partials or continue using the existing routes directly.
Attachments#
Post::attachments() exposes attachment metadata; thread pages eager-load this relation. Reuse theme::attachments with $attachments for image previews/file cards. Download URLs use route('attachments.show', $attachment); add preview=1 only for images. Never generate disk URLs: core checks current forum visibility, bans, and deleted parents for every request.
Composer forms must use enctype="multipart/form-data". attachments[] contains new files; edit forms optionally submit remove_attachments[] with IDs belonging to that post. The retained-plus-new count cannot exceed five. The shared editor includes attachment-input; edit views pass existingAttachments. Forms marked data-attachment-form use progress-aware submission and expect JSON {redirect: URL} on success; normal requests retain redirects. Keep the input/status/list data attributes if overriding this partial. The core authorizes creates/replies/edits before writing files and saves attachment metadata with the post transaction. Child themes inherit these defaults. uploads is a private shared disk; future upload features should use separate path namespaces and their own authorization rules.
Avatars and brand assets#
Use User::avatarUrl() or @include('partials.avatar', ['avatarUser' => $user]) within an existing .avatar wrapper. The shared partial accepts optional avatarPriority=true for eager, high-priority header avatars and otherwise lazy loads. Uploaded avatars use private, always-revalidated responses with an ETag after ban and storage checks; replacement/removal changes the validator. The shared partial emits a decorative image; keep an accessible name on profile links and account controls. Without a custom photo, the URL points to the bundled WebP; the avatar delivery route also falls back to it if a stored file is missing. The avatar URL includes a version derived from the current storage path; never expose avatar_path as a public disk URL. Avatars are intentionally public profile data, independent of forum visibility. Banned viewers cannot read uploaded avatar endpoints.
POST /account/avatar (avatars.update) accepts an avatar file; DELETE /account/avatar (avatars.destroy) removes it. Both operate exclusively on the authenticated, verified, non-banned account. Normal uploads redirect back; JSON uploads return {redirect: profile URL}. Preserve CSRF and data-avatar-* controls when overriding avatar-settings; default JavaScript draws an adjustable crop and submits an image that the server independently revalidates and re-encodes. config/avatars.php defines the private disk and server limits. avatar_disk and avatar_path are not mass assignable.
The default and Aurora brands use public/brand/mark.svg with live text; default/admin layouts reference brand/favicon.svg. Standalone logo-light.svg and logo-dark.svg are available for reuse. Theme overrides should preserve configured community names.
Community profiles#
profile receives $user, $stats (discussions and posts), $tab (discussions, posts, or about), $threads, and $posts (a paginator on the posts tab, otherwise null). All activity, totals, and pagination exclude deleted posts/discussions and forums inaccessible to the current viewer. Preserve this filtering; never substitute unrestricted relationship counts. Post links use posts.show to recheck access and resolve the current page/anchor.
GET /account/profile (profile.edit) renders profile-edit with the authenticated $user. PUT /account/profile accepts a unique display name, optional headline (100 characters), biography (1,000), location (100), and HTTP(S) website (255). Only the authenticated, non-banned account is updated; target IDs and privileged fields are ignored. Details are public; email and other account credentials are not included. Escape profile text. Avatar controls now live on the editing page and retain their separate verification requirement and existing endpoints. Child themes inherit both views unless overridden.
PUT /account/profile also accepts an optional plain-text signature (500 characters, at most three lines); empty input removes it. Current signature text is shown below every post through theme::post-signature, which receives $post and uses escaped Blade text with preserved line breaks. The default thread view includes this partial in compact and postbit layouts; child themes inherit it unless overridden. Replacement post views should include it below the post body. Signatures do not support Markdown, HTML, media, or automatic links.
GET /account/settings renders account-settings with the authenticated $user; child themes inherit the view. PUT /account/settings/password requires current_password, confirmed password of at least 12 characters; PUT /account/settings/email requires current_password and a new unique valid email. Both update only the signed-in non-banned account, rotate its session and remember token, revoke other sessions and reset links. Email changes clear verification and send a new link; posting stays restricted until verified. Keep credentials out of public profile views. The default database session driver deletes other stored sessions; the account generation check invalidates existing sessions on any supported session driver. Remembered logins issued before a change are invalidated by the rotated token.
Uploaded branding#
partials.site-brand renders light/dark variants and falls back independently to the bundled mark plus configured community name. Keep an accessible community-name label on the enclosing home link. partials.favicon emits the appropriate PNG/SVG icon link. The default theme, Aurora, and admin layout use these partials; the admin sidebar deliberately selects the dark variant because its background is always dark. Use .site-brand-variant, .site-brand-light, .site-brand-dark, and .site-custom-logo when styling overrides.
App\Support\Branding::current($slot) returns current disk/path metadata only if the file exists; url($slot, $asset) returns the controlled public route or bundled fallback. Supported slots are logo_light, logo_dark, and favicon. Never expose the underlying private storage path. GET /branding/{slot} serves only current normalized PNGs; missing assets redirect to bundled artwork without caching. These decorative site assets are intentionally public.
The existing admin settings endpoint accepts optional branding[slot] files and remove_branding[slot]=1 flags. Sending both for a slot is invalid. Core validates and re-encodes files, serializes metadata updates, commits branding with normal settings, and removes old files afterward. The admin.branding partial preserves file controls, preview/error data attributes, and native multipart fallback. No extra public upload endpoint is introduced.
Compact subforum navigation#
forum-node revision 3 displays each main forum once, with inline links to directly visible children instead of recursive full rows. directory_threads_count, directoryLatestThread, and directory_has_unread summarize the forum and all reachable visible descendants; the original metrics still describe its own discussions. This replaces the recursive directory rendering described above.
index revision 2 receives nodes with permission-filtered visibleChildren. Parent forum pages show a compact, distinct subforum section above one paginated discussion list combining the parent and all reachable visible descendants. Preserve descendant source forum badges and the combined list scope label. Leaf forums retain their own discussion list. Never reconstruct children using unrestricted relationships.
Discussion polls (1.1)#
compose revision 2 includes theme::poll-fields (revision 1), with optional=true. It submits poll_enabled, poll[question], poll[options][], and optional poll[closes_at] in Y-m-dTH:i UTC. Blank unused option fields are omitted; two to ten distinct choices are required. Preserve old input and field errors.
thread revision 2 receives nullable $poll, an authorized array prepared by core, and includes theme::poll (revision 1). It contains question, closes_at (nullable UTC string), closed, selection (current member's option ID only), results, options (id, label), and canVote, canManage, canEdit. Only eligible result viewers receive total and option count/percentage; never derive counts or load vote relationships in themes. Full compose/thread overrides should include these partials or reproduce the contract. Child themes inherit them. These additions retain API 1.
CSRF-protected polls.vote POST takes poll_option_id; polls.update PUT takes the poll fields; polls.close POST closes irreversibly. All mutations require verified, non-banned membership and current forum visibility. Voting additionally requires post.reply, an unlocked thread and open poll. Owner management requires post.edit.own; scoped moderators use thread.moderate. Only moderator edits are allowed while locked; eligible owners may still close. Core serializes all mutations using discussion then poll row locks and a unique poll/member vote constraint. ThreadCreated remains an after-commit event.
See Discussion polls for member and operator behavior. Theme showcase includes synthetic poll results. MySQL 8.4 and 9.7 are the supported locking verification targets; SQLite tests do not establish concurrent row-lock behavior.
Private messaging views (phase 7A)#
Core private messaging is described in MESSAGES.md. Security remains in core controllers and queries; themes must never implement participant authorization or query arbitrary inboxes. Plugins receive no message-content hook or inbox API.
messagesrevision 1 receivesconversations(20-item paginator, participant scoped, loadedlowUser,highUser,latestMessage),conversation(nullable authorized conversation),messages(nullable newest-first 30-item paginator withuserloaded),other(nullable partner),recipients(at most 20 eligible members for name search; at most one selected member forrecipient=<id>),blocked,blockedByMe, andunreadIdsscoped to the displayed conversation page. Render chat messages in chronological order within the page and escape all text. Routesmessages.store,messages.send,messages.read,messages.block, andmessages.reportrequire CSRF-protected forms and the documented bounded fields.message-reportsrevision 1 receivesreports(20-item paginator of stored report rows). Decodecontextas a bounded JSON list of{name, body, sent_at}and escape all values. Never replace snapshots with conversation queries. Resolution posts tomessages.reports.resolvewithresolution.layoutrevision 4 additionally receivesunreadMessageCount(unread conversations) andcanReviewMessages(global review permission). Add inbox navigation and a reports link where permitted.profilerevision 2 adds a message entry viamessages.index?recipient=<id>for another available member.- The default recipient picker uses
messages.index?new=1with name search and Back/Cancel links. Preserve this no-JavaScript fallback.[data-message-new]opens the picker in place from a clean inbox;[data-message-picker]and[data-message-welcome]control visibility. - Optional default enhancement:
[data-message-history]scrolls to the last rendered message;[data-message-read]posts itsmessage_idonly while visible, then updates the header badge. The read endpoint returns{unread: integer, conversationUnread: boolean}for JSON requests. Preserve a manual form fallback and do not mark unseen newer messages read. message.report.reviewis a global core ability for the dedicated report queue. It grants no participant or inbox access. Forum scoped rules and assignments do not authorize private message reports.
Messaging enhancements (1.3, phase 8)#
messages reference revision is now 2, retaining API 1. Direct conversations retain their existing pair fields. Groups have nonnull name, owner_id, nullable direct-pair IDs and an authorized members relation. other is null for a group. Each member pivot has joined_after_id and read_id; core filters the current history and suppresses previews before joining. groupBlockedIds contains only IDs blocked by the current viewer. Themes must use core-provided history and previews rather than query arbitrary messages. Render member names beside group messages.
messages.group creates a group from name, 2–19 distinct other member_ids[], and body. messages.manage accepts action=rename|add|remove|transfer|leave, optional name, and bounded member_id. Preserve CSRF, name-based member selectors, group history/privacy explanation and ownership controls. messages.block optionally accepts a current group member_id; only the signed-in member's own block is changed. Group reports remain bounded to the viewer's visible history. New/rejoining members see future history only; departing members immediately lose access. Database-deleted owners are succeeded by the lowest remaining member ID during the next group mutation.
The default data-message-inbox enhancement refreshes the authorized, paginated page while visible, preserves draft/focus/older-page position, pauses while hidden, backs off errors, and clears private content when access is revoked. data-message-history marks rendered messages read only at the bottom while visible. Keep no-JavaScript forms and manual refresh. Group choices use data-group-member and data-group-selection to retain selected names in tab-local session storage across searches. No content hook or plugin inbox API is added.
Messaging abuse controls and remote images#
messages reference revision is now 3. It additionally receives invitations, the viewer's pending group invitations (newest first, at most 20 rows with id, group_name and nullable inviter_name, never any group content), and groupInvitations, the members invited to the displayed group who have not joined (rows with id and name; empty unless the viewer owns the group). Render each invitation with CSRF-protected POST forms to messages.invitations.accept and messages.invitations.decline using the invitation id. messages.group now invites the chosen members and only the creator joins immediately. messages.manage with action=add sends an invitation, and the new action=cancel with member_id withdraws a pending one. recipients leaves out members who opted out of new conversations. Pending invitations count toward the 20-member limit.
account-settings reference revision is now 3. It includes a form with a dm_opt_out checkbox sent by PUT to account.messaging.
App\Support\Markdown::render() now takes the post's author as an optional second argument. Images that load from another website are kept only when the author is an administrator or an account older than FORUM_REMOTE_IMAGE_MIN_ACCOUNT_AGE_DAYS (default 7; 0 allows everyone). Otherwise each such image is shown as a plain link to it. Images served by this site and inline data:image/ images are always kept, and there is no image proxy. A call without an author is treated as untrusted. thread reference revision is now 4 and moderation revision 2: pass $post->user (or $item->user for moderation posts) as the second argument. Plugins that render member Markdown should pass the author the same way.
Post votes and reputation (1.4, phase 9)#
thread reference revision 3 receives nullable $reputation. Null means the community disabled the feature. Otherwise posts maps each authorized displayed post ID to {score, positive, negative, selection, canVote, canReset}; selection is only the current viewer's own -1/0/1 value. authors maps displayed author IDs to viewer-scoped net reputation. Include theme::post-votes (revision 1) below each post, passing $post, $postNumber and $reputation, or reproduce its accessible controls and confirmation. Compact author badges and classic postbit totals use the supplied author scores. Never query raw vote relationships or unrestricted author totals in themes.
profile revision 3 additionally receives nullable integer $reputation; zero is a valid enabled score, null is disabled. Explain that activity and reputation totals reflect posts visible to the viewer. Scores and counts exclude deleted posts/discussions and banned voters. No public voter identity payload is provided. These contracts retain extension API 1.
posts.vote POST accepts explicit value=-1|0|1; zero undoes, identical values are idempotent. Preserve CSRF and progressive no-JavaScript forms. posts.votes.reset requires a meaningful reason and confirmed=1, with current forum visibility and moderation permission. Display destructive reset confirmation. The core post.vote ability is forum/group configurable. Community setting reputation_enabled=0|1 controls presentation and mutations, retaining the ledger while disabled. Core service App\Support\Reputation applies all visibility and aggregate rules; plugins receive no raw vote content hook. See Reputation for lifecycle and abuse controls.
Billing provider contract (1.5)#
Core billing owns the ledger and paid entitlements. Manual group_user membership is never changed by billing. App\Billing\EffectiveGroups unions current paid grants into all core permission paths; expiry and staff-group safeguards apply at read time. Bans and explicit denies remain authoritative. Donations grant no membership. Refunded/disputed sources remain revoked even when old success events are replayed.
Implement App\Billing\Provider: configured, redirectHosts, checkout, webhook, reconcile, cancel. checkout receives a persisted server-owned source snapshot and returns {id,url}; never accept submitted user, amount, product or grant IDs. webhook verifies raw bytes/signature before returning {id,source_id,revoked} or null for ignored events. reconcile fetches canonical current paid state and returns {status,paid_at,paid_until,revoked} using UTC timestamps; recurring paid-through requires verified successful payment, never trial/status alone. cancel stops future renewals without removing already-paid time. Implement bounded network calls (aggregate under 50 seconds), fixed trusted API hosts, strict checkout redirect allowlists, no secret-bearing logs and no entitlements writes in providers. Core serializes canonical fetch/application under the source row lock. The interface supports crypto providers with the same confirmed-payment/reorg-revocation rules; no crypto provider ships by default.
Register a provider through an application service provider using app(App\Billing\ProviderRegistry::class)->register('your-id', YourProvider::class) or config/billing.php's providers map. A distributed forum plugin can declare "billing_provider": "Vendor\\Example\\BillingProvider" in plugin.json and ship plugins/<id>/BillingProvider.php, defining that class and requiring its own helper files. The class must implement the interface. The registry loads this dedicated file lazily in web, CLI and queue processes only when the plugin is installed, enabled and API/version compatible; it does not run public Plugin::boot in jobs. Provider ID is the plugin ID. Disabling/removing the plugin prevents new provider operations; retained entitlements expire normally, so finish reconciliation/cancellation before removing the integration. Restart workers after provider installation/configuration changes. Provider code is trusted server code, just like other plugins. Existing extension API 1 remains unchanged.
redirectHosts() returns exact trusted HTTPS hostnames for hosted checkout and management. Core rejects redirects outside those hosts or with credentials/nonstandard ports. Providers may implement App\Billing\ManagedProvider::manage($source): string for hosted payment-method management and ProviderCapabilities::supportedKinds(): array to restrict recurring|one_time|donation; providers without capability declarations support all three. The admin plan validator respects declared capabilities. Public views receive only authorized current-owner rows: billing revision 1 has $plans, paginated $sources, and boolean $billingEnabled; source snapshots contain immutable plan terms. layout revision 5 adds the account billing link. Keep CSRF, explicit cancellation confirmation and no-JavaScript forms. See billing operations.
A runnable disabled-by-default provider template is under docs/examples/billing-provider. Copy that directory to plugins/example-payments, install/enable through the documented plugin lifecycle, then supply real signature verification and canonical provider API code before returning configured=true. Its dedicated registration works in CLI/jobs without its public boot hook. Never treat its placeholder methods as a payment integration or add direct ledger writes.
Registration policy and terms#
Registration policies apply to new accounts only. Defaults retain open registration, 3-30 character names and unrestricted valid email domains. Administrators may choose name bounds within 3-30, up to 100 reserved names, and up to 100 exact domains per allow/block list. Reserved names use Unicode NFKC normalization, whitespace normalization and Unicode case folding; comparisons do not rename stored accounts. Email domains use IDNA ASCII normalization and case-insensitive exact matching, with block entries winning and no implicit subdomain match. Existing Composer polyfills support normalization when native intl is unavailable.
The Rules & terms editor publishes escaped plain text (up to 10,000 characters). Published content snapshots are immutable; changes create a new UUID and stale signup acceptance is rejected. New members receive terms_version_id and terms_accepted_at only when acceptance is required and successfully validated. Existing members are not backfilled, and these fields are hidden from model serialization and excluded from mass assignment. Disabling acceptance does not invent consent. Unpublishing requires disabling required acceptance. Signup and registration/terms policy saves share a database lock so publication and account creation cannot race. Themes must preserve the signup acceptance contract; they never enforce eligibility or consent themselves.
Signup CAPTCHA contract#
captcha revision 1 receives nullable $signupCaptcha: null means disabled; otherwise {site_key, action} uses the public site key and registration action. A null site key inside this payload means enabled protection requires administrator attention. Render the native Turnstile widget and official script only for signup, never login or recovery. The widget submits cf-turnstile-response; never persist or redisplay that token. Core checks the provider response, canonical APP_URL hostname, action, challenge age and single-use digest, then rechecks configuration under the registration policy lock. Theme code cannot grant a bypass.
Credentials use encrypted integration overrides, outside generic Settings. Enabled intent survives rotation while verification is invalidated, so registration fails closed until a current real-key test passes or an administrator explicitly disables protection. Audits contain actions only, never keys, tokens or IP addresses. This retains extension API 1.
Protected mail configuration#
Administrator SMTP/Resend overrides use the encrypted integration boundary and the existing Laravel mail pipeline. Core refreshes cached mailers on web requests and before queue jobs; extension mail should use Laravel Mail/notifications rather than cache transport instances. admin_smtp and admin_resend are internal protected drivers, not extension-facing configuration choices. Existing deployment transports remain inherited until an explicit override. Delivery test receipt URLs are signed, expiring and bound to the administrator address/current configuration; extensions must not treat provider acceptance as inbox delivery. Existing verification, password recovery and opted-in watched-thread notification contracts are unchanged.
Optional private software access and daily measurement#
The software-access reference view (revision 1) receives nullable $accessRequest with only owner-visible fields: id, community_name, community_type, idea, hosting_readiness, status, staff_response, revision and timestamps. Internal notes/user IDs are excluded. Preserve CSRF, optimistic revision and owner-scoped POST/DELETE actions; escape all text. Only new/needs_information requests are owner-editable. Approval does not grant external access. Layout revision 7 adds a conditional account-menu link only when marketing is enabled. Extension API remains 1.
The sessionless POST /api/marketing/events accepts text/plain JSON with exactly page/event and a maximum 2KB body. Origin must exactly match configured MARKETING_ORIGIN. Public events are page_view, get_started_click and access_request_click; request_submitted is server-only. Pages are home, tour, get_started, features, platform, use_cases, interest_communities, creator_memberships, product_support, guides, launch_guide, permissions_guide, discussions_guide and privacy. Valid, disabled or browser-privacy-suppressed calls return 204; rejected origin 403, malformed/unsupported payload 422, rate limit 429. No cookies, sessions or credentials are used. The endpoint provides no visitor identity, unique-visitor metric or cross-session attribution.
Launch-success views (1.8)#
Layout revision 8 adds optional access-inbox and story-submission account links when the official marketing module is enabled. software-access revision 2 adds fulfillment_status, delivered_at, installed_at and delivery_instructions to its nullable owner-visible accessRequest payload, plus unreadNotifications. Instructions are withheld until approval/manual delivery; internal notes remain excluded. Preserve CSRF and numeric revision fields; only the owner can confirm installed after delivery.
software-access-notifications revision 1 receives paginated owner-authorized notifications, unreadNotifications and emailEnabled. The default view supplies adminInbox=false to the shared inbox partial. Use the shared generic inbox behavior, owner-scoped read/preferences/retry routes, and safe email-state labels. Never interpret provider acceptance as inbox delivery or include private proposal/notes in email.
customer-stories revision 1 receives published stories and current-owner mine rows; customer-story receives a safe public story projection with has_screenshot instead of a storage path. customer-story-form receives nullable current-owner story. Preserve multipart upload, explicit publication_consent, revision, and separate withdrawal/deletion forms. Render escaped plain text and media through the authorized stories.media route. Core enforces consent/publication and upload access; themes cannot grant access or publish a story. Extension API remains 1.
Starter template applications use normal forum/thread models and the existing after-commit ThreadCreated event; no extension contract bypass or core patch is required. Existing permissions remain authoritative. Manual launch attestations are not automatic evidence of external hosting, backups or email receipt.
Content without an account#
threads.user_id and posts.user_id are nullable. A thread or post without an account (for example, imported history or a deleted member's content) carries a plain-text historical_author_name, which never grants ownership, permissions or a profile link. Always read the display name from $thread->author_name or $post->author_name (the label, then the account name, then Guest) and guard every ->user access; theme::content-author is the shared partial. The default views changed with this: thread is now revision 5, thread-row, search, notifications, post-dialogs and post-signature are revision 2, moderation is revision 3, and content-author is revision 1. Update overrides that print $thread->user->name, $post->user->name or link to route('profile', ...->user). Reputation does not score posts without an account. This is a pre-1.0 contract change.
Download the billing provider example#
These are the complete disabled-by-default template files referenced above. Save the PHP text downloads with their original .php names in your own plugin directory. Implement and verify a real provider before enabling payments.