Skip to content

Paid add-on. Service is licensed separately from the open-source packages. See pricing

Tickets and conversations

The Ticket and TicketMessage models, ticket statuses, and how to create, reply to, resolve, close, and reopen tickets.

A ticket is a support request from a customer. Its conversation is a list of TicketMessage records: customer messages, agent replies, system messages, and internal notes. This page covers the models and the actions that change them.

The ticket model

Odden\Service\Models\Ticket stores:

Attribute Type Notes
ticket_number string Generated on create, see Ticket numbers. Unique.
portal_token string 40-character random token for the customer's ticket page and chat session. Unique.
subject, description string description is optional.
status TicketStatus Defaults to new.
priority TicketPriority Defaults to medium.
source TicketSource Defaults to web_portal.
contact_id, company_id int, nullable Core Contact and Company.
owner_id int, nullable The assigned user.
sla_policy_id int, nullable See SLA policies.
team_id int, nullable Stored and indexed. Ticket::forTeam($teamId) scopes a query to a team; nothing else in the package filters by it.
first_response_due_at, resolution_due_at datetime SLA deadlines, set on create.
first_responded_at, resolved_at, closed_at datetime Lifecycle timestamps.
is_sla_response_breached, is_sla_resolution_breached bool SLA breach flags.
merged_into_ticket_id, merged_at Set when the ticket is merged into another, see Merging.
csat_rating, csat_comment int (1–5), text Customer satisfaction feedback.
properties array Custom data through Core's HasCustomProperties.

Relationships: contact(), company(), owner(), slaPolicy(), mergedInto(), mergedTickets(), and messages(). messages() is always ordered by created_at ascending.

The service provider also adds tickets() to Core's Contact and Company models:

php
$contact->tickets()->where('status', 'open')->count();
$company->tickets;

Ticket numbers and portal tokens

When a ticket is created without a ticket_number, one is generated as {prefix}-{year}-{5 random uppercase letters or digits}, for example TICK-2026-7WBPJ. The prefix comes from odden-service.defaults.prefix (default TICK). A random 40-character portal_token is generated the same way.

getPortalUrl() returns the customer's ticket page (route('odden.support.show', $token)), and getCsatUrl() returns the satisfaction survey (route('odden.support.rate', $token)). See Support portal and CSAT.

SLA deadlines on create

When a ticket is created without an sla_policy_id, the active default policy is attached if there is one. If the ticket then has a policy, the deadlines you didn't set are calculated from the policy's targets for the ticket's priority. Changing the priority or the policy later recalculates the unmet deadlines from the clock base and pause credit, and removing the policy clears them. See The SLA clock.

Statuses

Status Set when
New The ticket is created.
Open A routing rule assigns a New ticket, or you call reopen().
WaitingOnCustomer A public agent reply is posted (unless the ticket is Resolved or Closed). The SLA clock is paused while the ticket waits.
WaitingOnAgent A customer posts a public message on the ticket (portal, email or chat), including to a Resolved or Closed ticket while reopen_on_customer_reply is on. It means the ball is with your agents.
Resolved You call resolve() or ResolveTicketAction.
Closed You call close(), the ticket is merged into another, or service:run-automations closes it.

These transitions happen inside Ticket::addMessage(), which every action and public endpoint uses to post messages:

  • Every public Agent message moves the status to WaitingOnCustomer unless the ticket is resolved or closed, so the ball is back with the customer and the SLA clock pauses. The first one also sets first_responded_at to now and sets is_sla_response_breached to whether the response was late.
  • A public Customer message moves the ticket to WaitingOnAgent, whatever its open status was. On a Resolved or Closed ticket it reopens the ticket (status WaitingOnAgent, resolved_at and closed_at cleared, the SLA clock restarted) when odden-service.reopen_on_customer_reply is true, the default (ODDEN_SERVICE_REOPEN_ON_CUSTOMER_REPLY). Set it to false to keep resolved and closed tickets as they are; the message is still added. A ticket that was merged into another is never reopened. Customer replies through ReplyTicketAction go to its primary instead, see Replying.
  • Internal notes and System messages never change the status.

These status updates are saved quietly (without model events). Status changes made any other way ($ticket->update(['status' => ...]), the panel's edit form and board) get the same bookkeeping from the model: resolved_at on Resolved, closed_at on Closed, the clock paused, resumed or restarted. See The SLA clock.

Creating tickets

Use CreateTicketAction for tickets from your own code, such as phone calls logged by an agent or an integration:

php
use Odden\Core\Models\Contact;
use Odden\Service\Actions\CreateTicketAction;
use Odden\Service\Enums\TicketPriority;
use Odden\Service\Enums\TicketSource;

$contact = Contact::where('email', 'dana@example.com')->firstOrFail();

$ticket = app(CreateTicketAction::class)->execute(
    subject: 'CSV export times out',
    description: 'The export runs for a minute and then fails.',
    priority: TicketPriority::High,
    source: TicketSource::Api,
    contact: $contact,
    properties: ['plan' => 'enterprise'],
);

The full signature:

php
public function execute(
    string $subject,
    ?string $description = null,
    ?TicketPriority $priority = null, // odden-service.defaults.priority
    ?TicketSource $source = null,     // odden-service.defaults.source
    ?Contact $contact = null,
    ?Company $company = null,
    ?Model $owner = null,
    ?SlaPolicy $slaPolicy = null,
    array $properties = [],
    bool $notifyContact = true
): Ticket

The action does more than insert a row:

  1. If no $company is given and the contact is associated with a company, the contact's first company is used.
  2. The ticket is created with status New. SLA deadlines are set as described above.
  3. If $description is not empty, it is also added as the first Customer message in the thread.
  4. If no $owner is given, RouteTicketAction runs. A matching rule assigns an owner and moves the ticket to Open.
  5. If there is a contact, a pending task titled Support Ticket #{number}: {subject} is logged on the contact's timeline, due at the first response deadline.
  6. If the contact has an email address and $notifyContact is true, TicketCreatedNotification is queued for them, at most odden-service.confirmations.per_address_per_hour times an hour per address (default 3; the ticket is created either way). See Notifications.

The panel's New Ticket form creates tickets through this action too, so the description becomes the first message the customer sees in the portal, routing rules run when no agent is picked, and the contact gets the confirmation email.

Ticket::create() also works, and still generates the number, token, and SLA deadlines, but skips steps 1 and 3 to 6.

Replying and internal notes

ReplyTicketAction posts a message to the thread:

php
use Odden\Service\Actions\ReplyTicketAction;

$reply = app(ReplyTicketAction::class);

$reply->execute(
    ticket: $ticket,
    body: 'Thanks, Dana. We can reproduce this and are working on a fix.',
    user: $agent,
);

$reply->execute(
    ticket: $ticket,
    body: 'Slow query in the export job, see the linked issue.',
    user: $agent,
    isInternalNote: true,
);

The full signature:

php
public function execute(
    Ticket $ticket,
    string $body,
    MessageSenderType $senderType = MessageSenderType::Agent,
    ?Model $user = null,
    ?Contact $contact = null,
    bool $isInternalNote = false,
    ?array $attachments = null
): TicketMessage

For a public Agent reply on a ticket with a contact, the action also logs a note on the contact's timeline (the first 150 characters of the reply) and, if the contact has an email address, sends TicketRepliedNotification. Internal notes and customer messages send nothing.

A public Customer message on a ticket that was merged into another is posted on the primary ticket instead, following the merge chain to its end ($ticket->mergeTarget()), as the merge note promises. If that primary is resolved or closed, the reply reopens it (see Statuses). The returned message's ticket_id tells you where it landed. Agent messages and internal notes are always posted on the ticket you pass. Decide who may reply (for example, the sender check in the email webhook) against the ticket the customer referenced before calling the action. Don't pass a merged ticket on behalf of someone whose identity you haven't verified: the portal and chat widget refuse replies to merged WebPortal and Chat tickets instead ($ticket->portalTokenFollowsMerge()), see Replies to merged tickets.

$attachments is stored as a JSON array on the message as given. The package doesn't upload or serve files; store them yourself and save whatever references you need.

To record a customer message from your own code, pass senderType: MessageSenderType::Customer and contact:.

Drafting a reply

In the panel, Draft a reply above the reply box fills it with a starting point for the agent to edit. DraftTicketReplyAction::execute(Ticket $ticket, ?Model $agent = null) greets the contact by first name, uses the canned response that best matches the subject and the customer's latest message (only ones the agent may use, with its {{ tags }} filled in), links up to two matching published help articles, and signs off with the agent's name. With no match it writes a general acknowledgement. It returns body, sources (what it drew from) and a rationale. It never posts anything and calls no outside service.

The panel asks the container for Odden\Service\Contracts\DraftsTicketReply, so an add-on can bind a different drafter that returns the same shape.

Ticket::addMessage()

Both the action and the public endpoints call Ticket::addMessage(), which you can also call directly when you don't want the timeline note or email:

php
public function addMessage(
    string $body,
    MessageSenderType $senderType = MessageSenderType::Agent,
    ?int $userId = null,
    ?int $contactId = null,
    bool $isInternalNote = false,
    ?array $attachments = null
): TicketMessage

Messages

TicketMessage has ticket_id, sender_type (MessageSenderType), user_id, contact_id, body, is_internal_note, and attachments, with ticket(), user(), and contact() relationships. senderName() returns the user's name, else the contact's full name, else the sender type label (for example "System Automation").

Message bodies are plain text. The bundled portal pages escape them and convert line breaks.

Resolving, closing, and reopening

php
use Odden\Service\Actions\ResolveTicketAction;

app(ResolveTicketAction::class)->execute(
    $ticket,
    resolutionNote: 'Fixed in release 4.2.1.',
);

ResolveTicketAction::execute(Ticket $ticket, ?string $resolutionNote = null, ?int $csatRating = null, ?string $csatComment = null): Ticket:

  • Calls $ticket->resolve($resolutionNote). A non-empty note is posted as a public Agent message attributed to the authenticated user (if any). The ticket moves to Resolved, resolved_at is set, and is_sla_resolution_breached is set if the clock had run out (time spent waiting on the customer doesn't count).

Resolving from the panel's edit form or board goes through this action as well.

  • If $csatRating is given, saves it with $csatComment. The value is not validated here.
  • If there is a contact, logs a note on their timeline, and if they have an email address, sends TicketResolvedCsatNotification, which links to the CSAT survey.

The model methods on their own send no email:

Method Effect
resolve(?string $resolutionNote = null) As above, without the timeline note or email.
close() Status Closed, closed_at set to now.
reopen() Status Open, resolved_at and closed_at cleared, the resolution clock restarted and its breach flag reset.
isFirstResponseBreached() true if flagged, or if there is no response yet and the deadline has passed (while waiting on the customer: had passed when the wait began).
isResolutionBreached() true if flagged, or if the ticket is unresolved and the deadline has passed (while waiting on the customer: had passed when the wait began).

Notifications

The package sends these notifications on the mail channel. All four implement ShouldQueue, so they are pushed to the queue and sent by a queue worker, and a slow mail provider doesn't slow down the request or command that triggered them. They use the connection and queue in odden-service.notifications.connection and odden-service.notifications.queue (ODDEN_SERVICE_NOTIFICATIONS_CONNECTION, ODDEN_SERVICE_NOTIFICATIONS_QUEUE); both default to null, which means your default queue connection and its default queue. Run a worker that listens on that queue, for example php artisan queue:work --queue=support-mail,default. With the sync connection they are sent immediately, as before. Customer notifications go to the Core Contact, which uses Laravel's Notifiable trait and its email attribute. They are not sent to an address known to be undeliverable; see Deliverability.

The ticket (and message) is serialized by ID and reloaded when the job runs, so the email reflects the ticket at sending time. The Message-ID is generated when the email is built in the worker, so threading works the same whether the notification is queued or sent synchronously.

Notification Sent to Sent by Main link
TicketCreatedNotification Contact CreateTicketAction getPortalUrl()
TicketRepliedNotification Contact ReplyTicketAction (public agent replies) getPortalUrl()
TicketResolvedCsatNotification Contact ResolveTicketAction getCsatUrl()
SlaBreachAlertNotification Ticket owner CheckSlaBreachesAction odden-service.admin_ticket_url (default /admin/tickets/{id}/edit)

Customer email subjects start with [#{ticket_number}] for the customer's reference. The three customer emails also set a Message-ID that contains the ticket's portal token, <ticket.{portal_token}.{unique}@{host}>, which the email webhook uses, along with the portal link, to thread replies from the ticket's contact back into the ticket. The ticket number alone doesn't thread a reply.

Tickets created by the chat widget and the support portal go through CreateTicketAction but only send the confirmation email when odden-service.chat.confirmation_email or odden-service.portal.confirmation_email is true (both are false by default): nobody verified the address typed into those forms.

Ticket subjects, contact names, agent names, the reply preview and the resolution note in these emails are escaped for Markdown (Odden\Service\Support\MailMarkdown::escape()), and HTML is escaped by the mail template, so a subject like [Reset](https://evil.example) is shown as typed rather than as a link. A reply can carry customer text (canned responses and drafts fill in the contact's name and the subject), so Markdown an agent types in a reply is shown as typed in the email too. Use MailMarkdown::escape() for customer-supplied values in your own MailMessage lines too.

Automatic closing

service:run-automations runs RunServiceAutomationsAction, which:

  1. Closes tickets that have been in WaitingOnCustomer for 7 or more days, counted from when the wait began (sla_paused_at; tickets from before that column fall back to updated_at). Each close is a conditional update, so a customer reply that arrives meanwhile wins. Then it posts a public message, "Ticket automatically closed after 7 days without customer response.", with sender type Agent and no user. No email is sent.
  2. Closes Resolved tickets whose resolved_at is 48 hours or more ago, again only if they are still resolved.

Both periods are fixed in code. The command prints a table of how many tickets each step closed. The package doesn't schedule it; see Installation.

bash
php artisan service:run-automations