Replies and internal notes
A ticket’s conversation is a comments-for-laravel thread on the ticket. Tickets writes every message through comments and adds a namespaced meta block — your own meta keys are kept next to it, and a host key can never spoof the tickets namespace:
comment.meta.tickets = { internal: bool, role: 'agent'|'requester'|'participant'|'system', channel: string }Messages are root comments (no threaded replies): comments hides everything under an invisible comment from the public thread, so a public reply under a note would vanish.
Replies and notes
// A public reply — with an optional transition applied in the same transaction
$comment = Tickets::for($ticket)->by($agent)->reply('A technician is on the way.', transition: 'wait');
Tickets::for($ticket)->by($customer)->reply('Here is a photo.', attachments: [$request->file('photo')], channel: 'web');
// An internal note — agents only, never shown to the requester
Tickets::for($ticket)->by($agent)->note('Roller worn out, order a spare.');
// Model conveniences, through the manager
$ticket->reply('Thanks!', $customer);
$ticket->note('Escalated to vendor.', $agent);
// Edit and delete (scoped to the ticket; each left in the timeline)
Tickets::for($ticket)->by($agent)->editComment($comment, 'A technician is on the way (ETA 2 pm).');
Tickets::for($ticket)->by($agent)->deleteComment($comment);
// Read
Tickets::for($ticket)->thread(); // public: published replies, oldest first
Tickets::for($ticket)->thread(internal: true); // every message: notes and held replies tooreply() takes body, transition, attachments, channel, reason and payload (for the transition) and meta; note() takes body, attachments, channel and meta. Both return the comments Comment. The channel must be one of tickets.channels (default tickets.default_channel).
| Aspect | Public reply | Internal note |
|---|---|---|
| comments visible | true | false — comments’ own public reads never show it |
meta.tickets.internal | false | true — defence in depth: a comment marked internal is treated as internal even if a host made it visible |
| who | requester, agent, participant, system | agents and the system |
| activity | replied (public) | note_added (internal; the comment id, never the body) |
| event | TicketReplied (with the role) | TicketNoteAdded |
| notification | replied / requester_replied | note_added (agents only) |
Edits and deletes
editComment() and deleteComment() act as the handle’s actor and record a comment_edited / comment_deleted activity entry — the comment id and the role of the message’s author, never a body — as visible as the message: public for a published public reply, internal for a note or a held reply. TicketCommentUpdated / TicketCommentDeleted fire after the commit. Who may edit or delete is the API policy’s call (updateComment / deleteComment); the PHP API does not ask.
Author roles
Computed when the message is posted:
| Author | Role |
|---|---|
| the ticket’s requester | requester |
| an agent for this ticket (the agent ability, narrowed by the AgentScope) | agent |
| a watcher (with tickets.watchers.can_view) | participant |
| nobody (asSystem()) | system |
| anyone else | refused — TicketAccessDeniedException |
An agent the agent scope keeps off this ticket is no agent here: they reply as the watcher they are (a participant — no first-response stamp, and their reply resumes a waiting ticket) or are refused. A note by anyone but an agent for this ticket throws TicketAccessDeniedException. A Closed ticket takes no message at all — replies, notes, edits and deletes throw TicketClosedException (409 ticket_closed in the API); open a follow-up ticket instead.
What a reply sets off
- An explicit transition: is applied atomically — a denied transition leaves no reply — and replaces the reply automation. It applies only when the reply publishes; on a held reply its name is not even checked.
- Reply automations (requester or participant replies only): a Waiting ticket resumes (RequesterReplied); a Resolved ticket reopens (RequesterReopened) when tickets.comments.reply_reopens is on.
- First response: the first published public agent reply stamps first_responded_at once and fires TicketFirstResponded. Notes, requester replies and held replies never stamp it.
- Auto-watch: an agent who writes starts watching (tickets.watchers.auto.agent_repliers).
- last_activity_at moves on every reply, note, transition and assignment. A note moves it too, so it is agent-only in API resources and sorts, and the bump never touches updated_at.
Host moderation
| Host setting | Effect on a ticket reply |
|---|---|
| comments.require_approval on | Approved at once (tickets.comments.auto_approve, on by default). |
| comments.require_approval on and tickets.comments.auto_approve off | Held for a moderator. |
| blocklist action pending / hidden matched | Held — never auto-approved (that would bypass the host’s filter). |
| blocklist action reject matched | comments throws CommentRejectedException and the whole reply rolls back (API 422 comment_rejected). |
A held reply changes nothing but the thread. It stays agent-visible only, its replied activity is internal (held: true, plus the transition it asked for), and it applies no transition, runs no automation, stamps no first response, fires no TicketReplied and notifies nobody — so a requester never sees a status change caused by a message nobody can read. Approving it later through comments publishes the message only; apply the transition yourself if you still want it.
Manual moderation stalls the waiting → resume flow: with comments.require_approval on and tickets.comments.auto_approve off, every reply is held and approving one later does not catch up — a pending ticket stays pending, a resolved one is not reopened, nobody is notified and the replied activity stays internal. Comments still delivers the reply’s @mentions on approval. If you moderate by hand, listen to comments’ CommentApproved for ticket comments and run what you need (Tickets::for($ticket)->resume(), a notification to the assignee).
comments.authorization
With comments.authorization on (off by default), comments checks its own Gate abilities against the authenticated user on every post, approval, lock and unlock — not against the comment’s author. Guest replies, system notes, merge notes, the auto-approval and the scheduler’s auto-close lock then depend on your CommentPolicy, and tickets:doctor warns. Tickets’ own policy has already decided who may write on a ticket, so let ticket commentables through:
use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Comments\Models\Comment;
use RoundlyConsulting\Comments\Policies\CommentPolicy;
use RoundlyConsulting\Tickets\Models\Ticket;
final class AppCommentPolicy extends CommentPolicy
{
public function create(?Model $user, ?Model $commentable = null): bool
{
return $commentable instanceof Ticket || parent::create($user, $commentable);
}
public function moderate(?Model $user, Comment $comment): bool
{
return $comment->commentable instanceof Ticket || parent::moderate($user, $comment);
}
public function lock(?Model $user, Model $lockable): bool
{
return $lockable instanceof Ticket || parent::lock($user, $lockable);
}
public function unlock(?Model $user, Model $lockable): bool
{
return $lockable instanceof Ticket || parent::unlock($user, $lockable);
}
// update() / delete() the same way, if you edit ticket messages through comments
}Mentions
Comments parses @handle and fires CommentMentioned — only when the host sets comments.mention_resolver, only for an approved comment, once per person. Tickets turns a mention on a ticket comment into TicketMentioned only for someone who may read that comment: a note’s mention reaches agents the agent scope lets work on the ticket, a reply’s mention needs view access. The mentioned notification goes out only when the message’s own notification does not already reach that person; with tickets.watchers.auto.mentioned on, a mentioned reader starts watching. Your own CommentMentioned / CommentCreated listeners see internal notes too — filter on meta.tickets.internal there.
Attachments on messages
Reply and note attachments are comment attachments: comments’ private attachments bucket on comments.media.private_disk, through media-library. The API serves them with short-lived signed URLs, and a guest can download only the files of the ticket and of its public thread.
Querying the thread through comments
use RoundlyConsulting\Comments\Facades\Comments;
Comments::for($ticket)->whereMeta('tickets.role', 'requester')->get();
Comments::for($ticket)->whereMeta('tickets.internal', true)->get(); // the notesShow your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.