Skip to content

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

Support portal and CSAT

The public ticket form, token-based ticket pages where customers follow and reply to tickets, and the CSAT satisfaction survey.

The support portal lets customers submit tickets, follow the conversation, reply, and rate their experience without an account. Each ticket's pages are addressed by its 40-character portal_token; whoever has the link can read the public thread and reply.

Routes

These routes are in the web route group (odden-service.routes.web), with no prefix by default. See Configuration reference.

Method URI Route name Rate limit
GET /support odden.support.create
POST /support odden.support.store odden-public
GET /support/tickets/{token} odden.support.show
POST /support/tickets/{token}/reply odden.support.reply odden-public
GET /support/rate/{token} odden.support.rate
POST /support/rate/{token} odden.support.submitRating odden-public

The POST routes use the web group's normal CSRF protection; the bundled forms include @csrf. An unknown token returns 404.

Link to the form from your site with the route name, so a changed prefix or domain is picked up:

blade
<a href="{{ route('odden.support.create') }}">Contact support</a>

Submitting a ticket

GET /support shows a form. POST /support validates:

Field Rules
name required, string, max 255
email required, email, max 255
subject required, string, max 255
priority required, one of low, medium, high, urgent
description required, string, max 20,000 characters

The customer chooses the priority, and it sets the ticket's SLA targets. Nobody verifies the email address typed into the form, and anyone can type a customer's, so the controller then:

  1. Looks for a Core Contact with that email, ignoring case and surrounding whitespace (so Dana@Example.com finds dana@example.com).
    • If there is none, it creates one with the email lowercased and trimmed, splitting name into first and last name at the first space, and the ticket belongs to that new contact.
    • If there is one, the ticket is not linked to it: an unverified submitter must not be treated as that customer, or read replies about their account. The ticket gets no contact, the typed name and email and the matching contact's ID are stored in the ticket's unverified_submitter property, and an internal note tells agents to confirm who they are talking to before linking the ticket (in the ticket's edit form) or discussing the account.
  2. Creates the ticket with CreateTicketAction and source WebPortal. The ticket is routed and, when it has a contact, a task is logged on the contact's timeline. The "request received" email (TicketCreatedNotification) is only sent when odden-service.portal.confirmation_email is true (ODDEN_SERVICE_PORTAL_CONFIRMATION_EMAIL, default false), and then at most odden-service.confirmations.per_address_per_hour times an hour per address: otherwise the form would let anyone make your app email any address.
  3. Redirects to the ticket's page with the flash message "Your support ticket has been received. Our team will review it shortly." under the status session key.

While the customer types a subject, the form calls the knowledge suggestion endpoint and offers matching articles. Article titles, categories, and excerpts are inserted as text, so HTML in an article is shown as typed. If the customer clicks "This solved it", the form records a deflection and disables the ticket form.

The ticket page

GET /support/tickets/{token} shows the ticket number, status, priority, contact name, owner name, creation date, channel, and every message that is not an internal note. getPortalUrl() on the ticket returns this URL, and every customer email links to it.

POST /support/tickets/{token}/reply takes a required body (at most 20,000 characters) and adds it as a Customer message from the ticket's contact through ReplyTicketAction, so the status changes as described in Statuses: a reply reopens a Resolved or Closed ticket unless odden-service.reopen_on_customer_reply is false. It redirects back with the flash message "Your reply has been posted to the ticket." If the ticket was merged into another, what happens depends on where the ticket came from, see Replies to merged tickets: for a ticket created from an email, by phone, or through the API, the reply is posted on the primary ticket and the flash message says so instead. For a ticket created on the portal or in the chat widget, the reply is refused with a body validation error asking the customer to reply to the latest email from your team. The page itself always shows the token's own ticket, never the primary. The bundled page shows the reply form whenever a reply would be accepted and acted on: on an open or resolved ticket, on a Closed ticket while reopen_on_customer_reply is on (the reply reopens it), and on a merged ticket whose token follows the merge. It hides it on a closed ticket when reopening is off and on a merged portal or chat ticket.

CSAT surveys

When a ticket is resolved with ResolveTicketAction, the customer receives TicketResolvedCsatNotification with a "Rate Your Support Experience" button that links to getCsatUrl(). The ticket page also shows a rating button once the ticket is Resolved or Closed and has no rating yet.

GET /support/rate/{token} shows a 1 to 5 star form with an optional comment. POST /support/rate/{token} validates:

Field Rules
rating required, integer, 1 to 5
comment optional, string, max 1000

It saves csat_rating and csat_comment on the ticket and redirects to the ticket page with "Thank you! Your feedback has been recorded." The survey accepts a rating at any status, and a new submission replaces the previous one.

A rating of 1 or 2 also triggers service recovery:

  • An internal System note is added to the ticket with the rating, the comment, and "Supervisor review recommended."
  • If the ticket has a contact, a pending task titled CSAT Service Recovery: Ticket #{number} ({rating}/5 stars) is logged on the contact's timeline, due in 24 hours.

You can also record a rating from your own code with ResolveTicketAction's $csatRating and $csatComment arguments. That path doesn't trigger service recovery.

Customizing the pages

The pages are Blade views in the odden-service namespace: odden-service::portal.create, odden-service::portal.show, and odden-service::portal.rate (the help center uses odden-service::help.index and odden-service::help.show). They are standalone HTML pages styled with Tailwind CSS that the package compiles ahead of time and inlines in a <style> tag (the odden-service::partials.styles partial), so they run no third-party script and need no extra asset route or vendor:publish step. They load the Plus Jakarta Sans font from Google Fonts. The ticket and rating pages carry the ticket's secret token in their URL, so they set <meta name="referrer" content="no-referrer"> to keep it out of the font requests.

The package has no publish tag for its views. To change one, copy it from vendor/getodden/crm-service/resources/views into resources/views/vendor/odden-service in your app, keeping the same relative path (for example resources/views/vendor/odden-service/portal/show.blade.php). Laravel uses your copy instead of the package's.

To replace the routes and controllers entirely, see Using your own routes.

If your copy of a view uses Tailwind classes the package's views don't, they have no CSS rule in the inlined stylesheet. Either add your own stylesheet to the view, or leave out @include('odden-service::partials.styles') and load your app's compiled CSS instead.

Regenerating the stylesheet

The stylesheet is resources/dist/service.css, built with the Tailwind CSS 3 CLI from the classes in resources/views. After changing classes in the package's views, rebuild it from packages/service and commit the result (nothing is installed in the repository; npx fetches the CLI):

bash
npx tailwindcss@3.4.17 -c resources/build/tailwind.config.js -i resources/build/service.css -o resources/dist/service.css --minify

The config safelists the status and priority badge classes, which the ticket page builds from TicketStatus::color() and TicketPriority::color(). PageStylesTest fails if a class in a view has no rule.