NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Tickets for Laravel

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 too

reply() 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).

AspectPublic replyInternal note
comments visibletruefalse — comments’ own public reads never show it
meta.tickets.internalfalsetrue — defence in depth: a comment marked internal is treated as internal even if a host made it visible
whorequester, agent, participant, systemagents and the system
activityreplied (public)note_added (internal; the comment id, never the body)
eventTicketReplied (with the role)TicketNoteAdded
notificationreplied / requester_repliednote_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:

AuthorRole
the ticket’s requesterrequester
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 elserefused — 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 settingEffect on a ticket reply
comments.require_approval onApproved at once (tickets.comments.auto_approve, on by default).
comments.require_approval on and tickets.comments.auto_approve offHeld for a moderator.
blocklist action pending / hidden matchedHeld — never auto-approved (that would bypass the host’s filter).
blocklist action reject matchedcomments 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 notes

Show 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 crypto

By 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.