linkrobins / support
Support ticket system for Flarum. Users open tickets, staff handle them in a private threaded view. Banned users can submit ban appeals with rate limits.
Package info
Type:flarum-extension
pkg:composer/linkrobins/support
Requires
- php: ^8.3
- flarum/core: ^2.0
Requires (Dev)
- flarum/phpstan: ^2.0
- flarum/realtime: ^2.0
- flarum/testing: ^2.0
Suggests
- flarum/realtime: For live updates: new replies and ticket changes appear without reloading the page.
- fof/upload: For file attachments on tickets and replies. The support extension automatically renders an 'Attach files' button when this is installed.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-13 21:07:05 UTC
README
A private support-desk extension for Flarum 2. Lets registered users open support tickets with staff, with an emphasis on workflows that keep forum-wide moderation actions (suspensions, bans) honest.
Features
- Private tickets. Tickets are NOT Flarum discussions. A user's ticket is visible only to that user and to staff. Other users never see it, even if they know the URL.
- Categories. Admin-configurable, each with name, slug, color, icon,
position, and a
ticket_typedriver (e.g.standard,appeal, or custom extensions). - Appeal flow. Categories using the appeal driver follow stricter rate limits and are filable by suspended users so they can plead their case. General categories are blocked for suspended users so a ban isn't trivially worked around.
- Internal notes. Staff can add replies marked as internal. These are filtered out at the database level for non-staff users -- the ticket owner doesn't see them in their list, can't fetch them directly, and the replyCount on the ticket reflects only what they can see. Visually, internal notes get a subtle background tint on the reply card for staff.
- Reply moderation. Staff can edit, soft-delete, restore, and
permanently delete any reply via a
⋯menu in the reply header. Edits stampedited_atandedited_by_user_idso other staff can see the audit info; permanent deletion requires the reply to be soft-deleted first (no accidental single-click destruction). - Ticket moderation. Staff can soft-delete tickets via a
⋯menu in the ticket title row. Soft-deleted tickets are hidden from the index for both the owner and staff but remain reachable via direct URL for staff to restore. Permanent deletion is for admins and groups withlr-support.force_delete_tickets, and cascades to all replies. - Rate limits. Per-user, configurable. Defaults:
- 3 appeals per 30 days
- 1 concurrent open appeal at a time
- 10 general tickets per 24 hours
- Permanent appeal-ban. A per-user flag (
support_appeal_banned) that blocks appeals while leaving general tickets available. Toggled from the admin's "Appeal bans" tab. - Status workflow. open → in_progress → awaiting_user → resolved → closed. Auto-advances based on who replies (staff to open ⇒ in_progress; user to awaiting_user ⇒ in_progress). Closed tickets reject replies.
- "Did this solve your problem?" When staff mark a ticket resolved, the person who opened it can confirm it and close it straight away, or say they still need help, which opens the reply box. Staff are told when an owner confirms a ticket solved.
- Assignment. Staff assign a ticket to anyone on the support team (themselves included) or unassign it, from "Assign..." in the ticket's menu. The assigned staff member shows in the ticket header and on each row of the staff ticket list, so nobody has to open a ticket to see who has it.
- Staff queues. "Assigned to me" and "Unassigned" views list the open tickets waiting on you and the ones nobody has picked up yet. "My tickets" is still the tickets you opened yourself.
- Ticket history. Every status and assignment change appears in the ticket between the replies, with who made it and when: "Karl changed the status from Open to Resolved". Staff see all of it; the person who opened the ticket sees status changes but not internal assignment.
- Automatic closing. Tickets left Resolved with no further activity
close themselves after 7 days (configurable, or off). A reply from
either side reopens a resolved ticket first, so this only closes
tickets nobody came back to. Nobody is notified, the history shows it
was closed automatically, and the owner can still reopen it. Needs the
Flarum scheduler (
php flarum schedule:runin cron). - Reminders. When a ticket has waited on its owner (Awaiting response) for 3 days with no activity, they get one reminder by notification and email (configurable, or off). Once per wait. Also needs the Flarum scheduler.
- Search. A search box beside the list title finds tickets by subject, by the text of a reply, or by ticket number, as you type. Members search only their own tickets and never match the text of a staff internal note.
- Unread markers. Tickets with a reply you have not seen yet show a dot and a bold subject, the way Flarum marks unread discussions. Opening the ticket clears it. Internal notes never mark a member's ticket unread.
- Sidebar counts. Staff see how much open work sits in each queue and status view ("Unassigned 7"); everyone sees how many of their own tickets have an unread reply.
- Priority. Staff can mark a ticket urgent or low priority from its menu. Urgent tickets carry a red chip and come first in every staff list. Members never see priority.
- Saved replies. Admins keep a library of common answers; staff insert one into a reply from the composer and edit it before sending. Members never see the list.
- Support stats. Staff can open a stats view from the support sidebar or their account menu: tickets opened and closed, typical first-response and resolution times with the slowest tenth, opened versus closed over time, the current backlog, how resolved tickets were closed, and a breakdown per staff member, over 7, 30 or 90 days.
- Live updates. With the bundled
flarum/realtimeextension enabled, new replies and status changes appear on an open ticket, and new tickets in the staff lists, without reloading. Each person only ever receives what they could already see: never another member's ticket, never an internal note. - Notifications. In-app and email. The ticket owner is notified when staff replies or changes the ticket's status; staff are notified when a new ticket is opened, when the owner replies, reopens a ticket or confirms it solved, and when a ticket is assigned to them. A category can route its new tickets to one staff member. Internal notes never produce notifications. Users can toggle each of these per driver in their notification preferences.
- Decisions on appeals. Resolved appeal tickets record a
decisionfield (approved / rejected / null). - File attachments. Optional integration with
fof/upload. When installed, the compose and reply forms surface an "Attach files" button that uploads throughfof/upload's normal pipeline. No configuration here; the button respects whateverfof/uploadpermissions you've set.
Requirements
- Flarum 2.0.0+
- PHP 8.3+
Installation
composer require linkrobins/support php flarum migrate php flarum cache:clear
Then enable the extension in admin → Extensions.
Permissions
The extension adds three permissions. They sit in the Moderate section of admin → Permissions, and none is granted to any group on install, so until you grant them only admins handle tickets:
lr-support.handle_tickets("Handle support tickets") -- makes a group support staff: see all tickets, reply on any ticket, post internal notes, change ticket status, set decisions, and claim tickets. Grant it to your moderators or support team.lr-support.manage_appeal_bans("Manage support appeal-bans") -- toggle a user's permanent appeal-ban from their profile controls.lr-support.force_delete_tickets("Permanently delete support tickets") -- permanently delete a ticket that is already soft-deleted.
Anyone in the admin group bypasses all three.
Filing tickets requires being authenticated; the policy doesn't add a separate permission for it.
Admin UI
Settings live at admin → Extensions → LR Support, in these sections:
- Categories. CRUD for ticket categories, including an optional staff member who receives each category's new tickets.
- Saved replies. The answers staff can insert into a reply: a title, the text, and an optional position.
- Navigation. Whether the Support link appears in the forum sidebar and the account menu, and whether support pages also show the forum's own navigation.
- Reminders and automatic closing. How many days a resolved ticket may sit with no activity before it closes, and how many days a ticket may wait on its owner before they are reminded. 0 turns either off.
- Rate limits. Configurable values for the appeal and general limits described above.
- Appeal bans. Search users and toggle their permanent appeal-ban flag.
Forum UI
Users see:
/support-- their tickets list, with a search box and unread markers./support/new-- compose form. Banned-from-appeals users see only general categories; suspended users see only appeal categories./support/:id-- the ticket page, with reply form and reply thread.
Staff additionally see:
- The "Assigned to me" and "Unassigned" queues, and the "All" filter with status views (open, in_progress, awaiting_user, resolved, closed).
- On each ticket: the status pill is a dropdown for changing status (and the appeal decision works the same way), the ticket's menu holds "Assign..." (the team picker) and the priority options, and the reply form has the "Internal note" toggle and the saved replies menu.
- "Support stats" in the sidebar and the account menu.
Data model
Seven tables:
linkrobins_support_categories-- name, slug, description, color, icon, position, ticket_type, default_assignee_id, allowed_group_ids.linkrobins_support_tickets-- category_id, user_id, assigned_staff_id, subject, status, decision, priority, last_reply_at, status_changed_at, reminded_at, deleted_at.linkrobins_support_replies-- ticket_id, user_id, content (parsed-source XML), is_internal_note, deleted_at, edited_at, edited_by_user_id.linkrobins_support_events-- the ticket history: ticket_id, user_id (who made the change; null for automatic changes), type (statusorassignment), from/to status, from/to assignee.linkrobins_support_reads-- when each person last opened each ticket, for the unread markers.linkrobins_support_saved_replies-- title, content (raw Markdown or BBCode), position.
One column added to the existing users table:
support_appeal_banned(boolean, default 0).
Replies use Flarum's content formatter via the HasFormattedContent
trait. The rendered HTML is computed at serialize time via
formatContent(), NOT cached in a content_html column -- this means
formatter extensions like mentions and emoji apply to older replies the
moment they're installed.
Category Access Control
Admins can configure Allowed User Groups on individual categories in the Admin Panel:
- No groups selected (default/empty): Open to all registered users eligible under the category's ticket type driver (e.g. Standard allows active users; Ban Appeal allows suspended users who are not appeal-banned).
- Specific groups selected: Only users belonging to at least one of the selected groups (or Administrators) can create tickets in the category. Categories the actor is not eligible for are automatically filtered out of the ticket creation picker.
- Full backward compatibility: All existing categories default to unrestricted. Users who already have appeal tickets can continue viewing, tracking, and replying to their tickets without disruption.
File attachments (fof/upload integration)
If fof/upload is installed
and enabled, the compose form and reply form get an "Attach files"
button. Uploaded files are stored, validated, and rendered by
fof/upload; this extension only inserts the resulting BBCode marker
into the message body. No additional configuration is needed -- if
the user has permission to upload via fof/upload, the button
appears.
Privacy caveat
fof/upload's download URLs act as capabilities: anyone who has the
URL to a file can download it. CSRF protection limits direct
hot-linking, but if a staff member copies a file URL out of a ticket
and shares it elsewhere, that link works for anyone who clicks it.
This is identical to how fof/upload behaves on regular discussions,
so it isn't unique to this extension. If you need a hard guarantee
that ticket attachments can be read only by ticket-eligible users,
fof/upload would need to be patched to gate downloads against
per-resource policies. That's out of scope for v1.
In practice, for the support-desk use case, the risk is small: attachments tend to be screenshots and logs from the ticket-opener themselves, who is also the only non-staff party with the URL.
Security notes
- The
creating()hooks on both tickets and replies overwriteuser_idwith the authenticated actor's id. Even if the client sendsrelationships.user, JSON:API rejects it because the field isn't declared writable, AND the hook would overwrite it anyway. - Visibility is enforced in two places that must stay in sync: the
resource's
scope()(for single-resource Show endpoints) and the Searcher'sgetQuery()(for list Index endpoints). Both use the same rules. - Internal notes are filtered at the database level (a WHERE clause
on
is_internal_note), not at render time. A non-staff user hitting the API directly cannot bypass the filter. - The "Update" endpoint is gated by
->can('update'), which routes toSupportTicketPolicy::update. Field setters add a second layer of defense: even if the gate ever loosened, status / decision / assignment changes wouldn't take effect for a non-staff actor. - Soft-deleted rows are hidden from non-staff at the DB query level
(Eloquent's default SoftDeletes scope). Staff see them via
withTrashed()in the resource scope, but Index/Searcher queries deliberately stay on the active set so the staff index isn't cluttered. Force-delete on a live (non-trashed) row is rejected by thedeleting()hook -- a soft-delete must come first.
API summary
| Endpoint | Method | Auth |
|---|---|---|
/api/linkrobins-support-categories |
GET | public |
/api/linkrobins-support-categories |
POST | admin |
/api/linkrobins-support-categories/:id |
PATCH/DELETE | admin |
/api/linkrobins-support-tickets |
GET | authenticated |
/api/linkrobins-support-tickets |
POST | authenticated |
/api/linkrobins-support-tickets/:id |
GET | per-policy |
/api/linkrobins-support-tickets/:id |
PATCH | staff (handle_tickets) |
/api/linkrobins-support-tickets/:id |
DELETE | admin or force_delete_tickets, soft-deleted only |
/api/linkrobins-support-replies |
GET | authenticated |
/api/linkrobins-support-replies |
POST | authenticated |
/api/linkrobins-support-replies/:id |
PATCH | staff (handle_tickets) |
/api/linkrobins-support-replies/:id |
DELETE | staff, soft-deleted only |
/api/linkrobins-support-events |
GET | authenticated (staff: all; owner: status changes on own tickets) |
/api/linkrobins-support-tickets/:id/read |
POST | anyone who can see the ticket |
/api/linkrobins-support-counts |
GET | authenticated (staff get the queue counts) |
/api/linkrobins-support-stats |
GET | staff (handle_tickets); ?days=7|30|90 |
/api/linkrobins-support-staff |
GET | staff (handle_tickets) |
/api/linkrobins-support-saved-replies |
GET | staff (handle_tickets) |
/api/linkrobins-support-saved-replies |
POST/PATCH/DELETE | admin |
Moderation patterns:
- PATCH a ticket or reply with
{ attributes: { content: "..." } }to edit (replies only). Stampsedited_at+edited_by_user_id. - PATCH with
{ attributes: { isDeleted: true } }to soft-delete. PATCH with{ isDeleted: false }to restore. - DELETE permanently removes -- but only if the row is already soft-deleted; otherwise returns 400.
Supported filters (use filter[name]=value shape; Flarum 2 rejects
unrecognized top-level params):
- On tickets:
filter[q]=...(search),filter[mine]=1,filter[status]=open,filter[categoryId]=N,filter[ticketType]=standard|appeal|<custom>,filter[assigned]=meorfilter[assigned]=none - On replies:
filter[ticketId]=N - On history:
filter[ticketId]=N
Events for other extensions
Other extensions can react to tickets (webhooks, bots, audit logs) by listening for these events with Flarum's Extend\Event extender. Every event is dispatched after the database transaction commits, so a listener never sees a change that is then rolled back. All live in the LinkRobins\Support\Event namespace.
| Event | When | Properties |
|---|---|---|
TicketCreated |
A ticket is opened, with its opening message saved | ticket, actor |
TicketStatusChanged |
The status changes: picked by staff or the owner, moved on by a reply, or by the scheduled auto-close (actor is null then) |
ticket, actor, oldStatus, newStatus |
TicketDecided |
The decision on an appeal ticket changes | ticket, actor, oldDecision, newDecision |
TicketAssigned |
The assignee changes: assigned, claimed by a staff reply, or unassigned (assignee is null) |
ticket, actor, assignee, oldAssignee |
ReplyCreated |
A follow-up reply or internal note is posted (not the opening message, which TicketCreated covers) |
reply, actor |
ReplyCreated fires for internal notes too, so check $reply->is_internal_note before showing a reply to anyone but staff.
use Flarum\Extend; use LinkRobins\Support\Event\TicketStatusChanged; return [ (new Extend\Event())->listen(TicketStatusChanged::class, function (TicketStatusChanged $event) { // $event->ticket, $event->oldStatus, $event->newStatus, $event->actor }), ];
The extension also dispatches TicketChanged and ReplyPosted, broader events that drive live updates; prefer the specific ones above.
Extending Ticket Types
Other extensions can register custom ticket types to introduce specialized workflows, custom fields, rate limits, and review actions.
Backend Driver (PHP)
Create a driver extending LinkRobins\Support\TicketType\AbstractTicketType:
namespace Vendor\Example\TicketType; use Flarum\User\User; use LinkRobins\Support\RateLimiter; use LinkRobins\Support\SupportCategory; use LinkRobins\Support\SupportTicket; use LinkRobins\Support\TicketType\AbstractTicketType; class VerificationTicketType extends AbstractTicketType { public const ID = 'verification'; public function id(): string { return self::ID; } public function name(): string { return 'Identity Verification'; } public function isAppeal(): bool { return false; } public function canCreate(User $actor, SupportCategory $category): bool { // Custom eligibility check (e.g. require email confirmation or user group) return $actor->is_email_confirmed; } public function checkRateLimit(User $actor, SupportCategory $category, RateLimiter $limiter): ?array { // Optional custom rate limiting (return null to use default general quotas) return null; } public function initialDecision(SupportTicket $ticket): ?string { return 'pending'; } public function allowedDecisions(): array { return ['pending', 'accepted', 'rejected']; } public function canReopen(SupportTicket $ticket, User $actor): bool { return false; } public function onCreating(SupportTicket $ticket, array $data, User $actor): void { // Server-side validation or custom attribute mutation before saving if (empty($data['attributes']['verificationCode'])) { throw new \Tobyz\JsonApiServer\Exception\BadRequestException('Verification code is required.'); } } public function onCreated(SupportTicket $ticket, array $data, User $actor): void { // Trigger post-creation integrations or external sync jobs } public function onDecided(SupportTicket $ticket, string $newDecision, ?string $oldDecision, ?User $actor): void { // Automated workflows upon approval/rejection (e.g. assign group or update external API) if ($newDecision === 'accepted') { $ticket->user->groups()->syncWithoutDetaching([/* verified group ID */]); } } public function onStatusChanged(SupportTicket $ticket, string $newStatus, ?string $oldStatus, ?User $actor): void { // Status transition workflows (e.g. sync with Jira, CRM, or external webhook) } }
Register your driver in extend.php:
use LinkRobins\Support\Extend\TicketType; use Vendor\Example\TicketType\VerificationTicketType; return [ (new TicketType()) ->add(VerificationTicketType::ID, VerificationTicketType::class), ];
Frontend Driver (TypeScript / Mithril)
Register a client-side driver in your forum and admin initializers:
app.initializers.add('vendor-example-support', () => { if (app.support?.ticketTypes) { app.support.ticketTypes.register({ id: 'verification', // Add custom fields to the composer fields(items, context) { items.add('id-code', m('input.FormControl', { placeholder: 'Verification Code', oninput: (e: any) => { context.verificationCode = e.target.value; }, })); }, // Client-side pre-submit validation (return an error message string to halt submission) validate(data, context) { if (!context.verificationCode) { return 'Please provide a verification code before submitting.'; } }, // Append custom fields to the ticket creation payload submitData(data, context) { data.verificationCode = context.verificationCode; }, // Add custom panels or cards above the ticket replies contentItems(items, ticket) { items.add('id-badge', m('div.VerificationBadge', 'ID Verification Pending')); }, // Display custom badges or chips in the ticket list/queue row badgeItems(items, ticket) { items.add('id-chip', m('span.LinkRobinsSupport-chip', 'Verified ID')); }, // Add custom category settings to the admin CategoryEditorModal categoryFields(items, modal) { items.add('target-group', m('.Form-group', [ m('label', 'Target User Group'), m('input.FormControl', { value: modal.targetGroup, oninput: (e: any) => { modal.targetGroup = e.target.value; }, }), ])); }, // Append custom category settings to the save payload categorySubmitData(data, modal) { data.targetGroup = modal.targetGroup; }, }); } });
License
MIT.