Creating a Helpdesk with October CMS

Tutorial

On this page

In this tutorial, you will build a helpdesk ticket system where registered users can submit support requests and your team can respond from the backend. The system includes ticket types, file attachments, a conversation thread, and automated email notifications, all powered by the Responsiv Support plugin.

What You Will Build

You will create a helpdesk powered by two plugins: RainLab.User for authentication and Responsiv.Support for the ticketing system. The finished helpdesk includes:

  • A ticket list page where users see their submitted tickets and their current status
  • A submission form for creating new tickets with type selection, Markdown content, and file attachments
  • A ticket detail page with a conversation thread showing user and staff messages
  • A message editing page for updating posted tickets and replies
  • Backend management where support staff respond to tickets, add internal notes, and assign tickets

Premium Plugins

Responsiv.Support is a premium plugin available from the October CMS Marketplace. Premium plugins work exactly like free plugins: you install them the same way and use their components identically. The only difference is that premium plugins require a license to install.

Prerequisites

Before starting, you should have:

  • October CMS installed and running
  • A theme with a default layout that includes Tailwind CSS and the AJAX framework with {% framework extras %}
  • User authentication set up with RainLab.User, including the session component on your layout and login/registration pages

If you need to set up user authentication, work through Part 1 of the Forum tutorial first. It covers installing RainLab.User, configuring the session component, and building login, registration, and password reset pages. Its default layout already includes Tailwind CSS and {% framework extras %}.

Overview

  1. Installing the Plugin: install Responsiv.Support and configure ticket types
  2. Ticket List: create the ticket list page for browsing submitted tickets
  3. Submit Ticket: build the ticket submission form
  4. Ticket Page: display ticket details with a conversation thread and reply form
  5. Editing Messages: allow users to edit their submitted tickets and messages

Installing the Plugin

With user authentication in place, you can now add the helpdesk functionality. The Responsiv Support plugin provides a ticketing system with ticket types, conversation threads, file attachments, staff assignment, and email notifications.

Installing Responsiv.Support

Navigate to Settings > Updates & Plugins > Install Packages and search for "Responsiv.Support". Click Install to add it to your project.

Alternatively, install via the command line:

php artisan plugin:install Responsiv.Support

The Support plugin depends on RainLab.User, which you already installed when setting up authentication. After installation, you will see a new Support section in the backend navigation with links to manage tickets and ticket types.

Creating Ticket Types

Ticket types categorize incoming requests so your team can prioritize and route them. The plugin ships with some example types (General, Sales, Technical, Account, Press) that you can keep or remove. Navigate to Support > Ticket types and create a few of your own:

Type Code
General Inquiry general
Bug Report bug-report
Feature Request feature-request

Each type can optionally auto-assign tickets to a specific staff member. You can configure this later as your team grows.

Updating the Navigation

Add a "Support" link to your layout navigation so logged-in users can access the helpdesk. Update the nav section in your default layout:

<div class="flex items-center gap-4">
    <a href="{{ 'forum/index'|page }}" class="text-sm text-gray-600 hover:text-gray-900">
        Forum
    </a>
    {% if user %}
        <a href="{{ 'support/tickets'|page }}" class="text-sm text-gray-600 hover:text-gray-900">
            Support
        </a>
        <a href="{{ 'forum/member'|page }}" class="text-sm text-gray-600 hover:text-gray-900">
            Account
        </a>
        <a
            href="javascript:;"
            data-request="onLogout"
            data-request-data="redirect: '/'"
            class="text-sm text-gray-600 hover:text-gray-900"
        >
            Sign out
        </a>
    {% else %}
        <a href="{{ 'account/login'|page }}" class="text-sm text-gray-600 hover:text-gray-900">
            Sign in
        </a>
    {% endif %}
</div>

The "Support" link is inside the {% if user %} block because only logged-in users can submit and view tickets.

While you have the layout open, check that it loads the AJAX framework with the {% framework extras %} tag. The helpdesk forms are AJAX forms, and the extras show their errors next to each field, display flash messages and add loading indicators to buttons. With the plain {% framework %} tag, the forms still work, but errors appear as browser alerts. The layout from the Forum tutorial already includes the tag.

Ticket List

The ticket list page is the main dashboard for users to see all their submitted support tickets. It shows the ticket ID, subject, status, and creation date, with a link to submit new tickets.

Creating the Ticket List Page

Create a new page at pages/support/tickets.htm:

title = "Support Tickets"
url = "/support"
layout = "default"

[supportTickets]
submitPage = "support/submit"
ticketPage = "support/ticket"

[session]
security = "user"
redirect = "account/login"
==
<div class="flex items-center justify-between mb-6">
    <h1 class="text-2xl font-bold">
        My Tickets
    </h1>
    <a
        href="{{ 'support/submit'|page }}"
        class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
    >
        Submit a new ticket
    </a>
</div>

{% set tickets = supportTickets.tickets %}

{% if tickets|length %}
    <div class="bg-white rounded-lg border border-gray-200 divide-y divide-gray-200">
        {% for ticket in tickets %}
            <a href="{{ ticket.url }}" class="block p-4 hover:bg-gray-50">
                <div class="flex items-center justify-between">
                    <div class="flex items-center gap-3">
                        {% if ticket.has_new %}
                            <span class="w-2 h-2 bg-blue-600 rounded-full shrink-0">
                            </span>
                        {% endif %}
                        <div>
                            <span class="text-sm text-gray-400">
                                #{{ ticket.id }}
                            </span>
                            <span class="font-medium text-gray-900 ml-1">
                                {{ ticket.subject }}
                            </span>
                        </div>
                    </div>
                    <div class="flex items-center gap-4 text-sm text-gray-500 shrink-0 ml-4">
                        <span>
                            {{ ticket.status.name }}
                        </span>
                        <span>
                            {{ ticket.created_at|date('M j, Y') }}
                        </span>
                    </div>
                </div>
            </a>
        {% endfor %}
    </div>

    {% if tickets.lastPage > 1 %}
        <div class="flex items-center justify-center gap-2 mt-6">
            {% if tickets.currentPage > 1 %}
                <a href="{{ paginationUrl }}{{ tickets.currentPage - 1 }}" class="px-3 py-1.5 text-sm text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
                    Previous
                </a>
            {% endif %}
            <span class="text-sm text-gray-500">
                Page {{ tickets.currentPage }} of {{ tickets.lastPage }}
            </span>
            {% if tickets.currentPage < tickets.lastPage %}
                <a href="{{ paginationUrl }}{{ tickets.currentPage + 1 }}" class="px-3 py-1.5 text-sm text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
                    Next
                </a>
            {% endif %}
        </div>
    {% endif %}
{% else %}
    <div class="bg-white rounded-lg border border-gray-200 p-8 text-center">
        <p class="text-gray-500 mb-4">
            You have not submitted any support tickets yet.
        </p>
        <a
            href="{{ 'support/submit'|page }}"
            class="text-blue-600 hover:underline"
        >
            Submit your first ticket
        </a>
    </div>
{% endif %}

How This Page Works

The [session] component with security = "user" requires the visitor to be logged in. If they are not, they are redirected to the login page.

The supportTickets component provides the tickets variable, a paginated collection of the logged-in user's tickets sorted by newest first. Each ticket has a url property that links to the ticket detail page (set by the ticketPage property).

The has_new property is true when the last public message on the ticket was from support staff, indicating the ticket is waiting for the user to respond. The blue dot makes these tickets easy to spot.

Try It Out

  1. Navigate to /support while logged in
  2. The list should be empty since no tickets exist yet
  3. The "Submit a new ticket" button links to the submission page, which you will create next

Submit Ticket

The submission page lets users create new support tickets. They select a ticket type, write a subject and description (with Markdown support), and optionally attach files.

Creating the Submit Page

Create a new page at pages/support/submit.htm:

title = "Submit Ticket"
url = "/support/submit"
layout = "default"

[supportTicketSubmit]
redirect = "support/ticket"

[session]
security = "user"
redirect = "account/login"
==
<div class="mb-4">
    <a href="{{ 'support/tickets'|page }}" class="text-sm text-blue-600 hover:underline">
        &larr; Back to My Tickets
    </a>
</div>

<h1 class="text-2xl font-bold mb-6">
    Submit a Ticket
</h1>

{% set ticketTypes = supportTicketSubmit.ticketTypes %}

{{ form_ajax('onSubmitTicket', { files: true, flash: true, validate: true }) }}

    <div class="mb-4">
        <label for="ticketType" class="block text-sm font-medium text-gray-700 mb-1">
            Ticket type
        </label>
        <select
            name="type_id"
            id="ticketType"
            class="w-full px-3 py-2 border border-gray-300 rounded-md"
        >
            <option value="">
                Select a type...
            </option>
            {% for type in ticketTypes %}
                <option value="{{ type.id }}">
                    {{ type.name }}
                </option>
            {% endfor %}
        </select>
        <div data-validate-for="type_id" class="text-sm text-red-600 mt-1"></div>
    </div>

    <div class="mb-4">
        <label for="subject" class="block text-sm font-medium text-gray-700 mb-1">
            Subject
        </label>
        <input
            name="subject"
            type="text"
            id="subject"
            class="w-full px-3 py-2 border border-gray-300 rounded-md"
            maxlength="255"
        />
        <div data-validate-for="subject" class="text-sm text-red-600 mt-1"></div>
    </div>

    <div class="mb-4">
        <label for="content" class="block text-sm font-medium text-gray-700 mb-1">
            Description
        </label>
        <textarea
            name="content"
            id="content"
            rows="8"
            class="w-full px-3 py-2 border border-gray-300 rounded-md"
        ></textarea>
        <p class="text-xs text-gray-500 mt-1">
            You can use Markdown syntax for formatting.
        </p>
        <div data-validate-for="content" class="text-sm text-red-600 mt-1"></div>
    </div>

    <div class="mb-6">
        <label class="block text-sm font-medium text-gray-700 mb-1">
            Attachments
        </label>
        <input
            name="files[]"
            type="file"
            multiple
            class="text-sm text-gray-600"
        />
        <div data-validate-for="files" class="text-sm text-red-600 mt-1"></div>
    </div>

    <button
        type="submit"
        data-attach-loading
        class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
    >
        Submit ticket
    </button>

{{ form_close() }}

How File Attachments Work

The files: true option tells form_ajax() to add enctype="multipart/form-data" to the form and to send the selected files with the AJAX request. The files[] input name uses array syntax, and the multiple attribute lets users choose several files at once, so one input handles every attachment.

The plugin checks every file before it saves anything. It accepts jpg, jpeg, png, gif, webp, pdf, txt, doc, docx, xls and xlsx files, at most 5 files of 10 MB each. SVG files are always refused, because they can contain scripts. If a file breaks these rules, the ticket is not created and the reason appears under the file input. The plugin reports every attachment error under the files name, whichever file caused it, so one data-validate-for="files" element shows them all. Replies and message edits follow the same rules.

To change the rules, create a config override file at config/responsiv/support/config.php in your project and return only the keys you want to change:

<?php

return [
    'attachmentTypes' => ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'csv'],
    'attachmentMaxFiles' => 3,
    'attachmentMaxSize' => 5,
];

The attachmentTypes value replaces the whole default list, so include every extension you want to allow. SVG stays refused even if you add it. The attachmentMaxFiles value is the number of files allowed in one submission, and attachmentMaxSize is the limit for each file in megabytes.

How Ticket Types Work

The ticketTypes variable lists the ticket types that are not marked as hidden, sorted by name. Choosing a type is optional, so the form keeps an empty "Select a type..." option. Any other type_id must be one of the listed types. The handler refuses a hidden or unknown type, and the message "Please select a valid ticket type." appears under the select.

To build a page for one kind of request, set the component's ticketType property to a type code, for example ticketType = "bug-report", and leave the select out of the form. Every ticket from that page gets that type, even if the type is hidden, and any posted type_id is ignored. The component sets the ticketTypeCode page variable to the property value and ticketType to the matching type. If no type has that code, the handler refuses every submission and shows the flash message "This ticket type is not available." Show a notice in place of the form in that case, as the component's default markup does:

{% if ticketTypeCode and not ticketType %}
    <p class="text-gray-500">
        This ticket type is not available.
    </p>
{% else %}
    <!-- Submission form -->
{% endif %}

How the Redirect Works

The redirect = "support/ticket" property tells the component where to send the user after a successful submission. The onSubmitTicket handler creates the ticket and redirects to the ticket page, passing the new ticket's ID as the :id URL parameter. This takes the user directly to their newly created ticket.

How Flash Messages Work

The form_ajax() helper builds an AJAX form. Submitting it sends the form to the onSubmitTicket handler without reloading the page, and the validate and flash options control how the result is shown:

  • The validate option shows each validation error in the data-validate-for element with the same field name. If the subject is empty, for example, the error appears under the Subject field. The page does not reload, so the form keeps what the user typed.
  • The flash option also shows each error as a flash message, including errors that belong to no field, such as "This ticket type is not available."

When the ticket is created, the handler stores a success message and redirects to the ticket page. That page shows the message with its {% flash %} block, which you will add in the Ticket Page section.

Inline errors, flash messages and the loading indicator that data-attach-loading adds to the button all come from the AJAX framework extras, which your layout loads with {% framework extras %}. The extras also keep each data-validate-for element hidden until it holds an error.

Try It Out

  1. Navigate to /support/submit and fill in the form
  2. Select a ticket type, enter a subject and description
  3. Click "Submit ticket"
  4. You should be redirected to the new ticket's detail page
  5. Check Support > Tickets in the backend to see the ticket
  6. Submit the form again with an empty subject and check that the error appears under the Subject field

Ticket Page

The ticket page shows the full conversation between the user and support staff. It displays the original ticket message, all follow-up replies, and a form for posting new messages. Users can also close their tickets from this page.

Creating the Ticket Page

Create a new page at pages/support/ticket.htm:

title = "Support Ticket"
url = "/support/ticket/:id"
layout = "default"

[supportTicket]
updatePage = "support/update"
isPrimary = "1"

[session]
security = "user"
redirect = "account/login"
==
{% flash %}
    <p
        data-control="flash-message"
        data-type="{{ type }}"
        data-interval="5"
    >
        {{ message }}
    </p>
{% endflash %}

{% set ticket = supportTicket.ticket %}

{% if ticket %}

    {% set customerName = ticket.user ? ticket.user.full_name : __('Deleted user') %}

    <div class="mb-4">
        <a href="{{ 'support/tickets'|page }}" class="text-sm text-blue-600 hover:underline">
            &larr; Back to My Tickets
        </a>
    </div>

    <!-- Ticket header -->
    <div class="flex items-center justify-between mb-6">
        <div>
            <h1 class="text-2xl font-bold">
                Ticket #{{ ticket.id }}: {{ ticket.subject }}
            </h1>
            <div class="flex items-center gap-2 mt-1">
                <span class="text-xs font-medium px-2 py-0.5 rounded
                    {% if ticket.closed_at %}
                        text-gray-700 bg-gray-100
                    {% elseif ticket.status.code == 'new' %}
                        text-blue-700 bg-blue-100
                    {% else %}
                        text-green-700 bg-green-100
                    {% endif %}
                ">
                    {{ ticket.status.name }}
                </span>
                {% if ticket.type %}
                    <span class="text-xs text-gray-500">
                        {{ ticket.type.name }}
                    </span>
                {% endif %}
            </div>
        </div>
        {% if not ticket.closed_at %}
            <button
                data-request="onCloseTicket"
                data-request-confirm="Are you sure you want to close this ticket?"
                data-request-flash
                class="px-3 py-1.5 text-sm text-red-600 border border-red-300 rounded-md hover:bg-red-50"
            >
                Close ticket
            </button>
        {% endif %}
    </div>

    <!-- Original message -->
    <div class="bg-white rounded-lg border border-gray-200 mb-6">
        <div class="p-4">
            <div class="flex items-center gap-3 mb-3">
                <img
                    src="{{ ticket.user.avatarThumb(40) }}"
                    alt="{{ customerName }}"
                    class="w-10 h-10 rounded-full"
                />
                <div>
                    <span class="text-sm font-medium text-gray-900">
                        {{ customerName }}
                    </span>
                    <span class="text-xs text-gray-400 ml-2">
                        {{ ticket.created_at|date('M j, Y \\a\\t g:i a') }}
                    </span>
                </div>
            </div>
            <div class="prose prose-sm max-w-none text-gray-700">
                {{ ticket.content_html|raw }}
            </div>
            {% if ticket.attachments|length %}
                <div class="mt-3 pt-3 border-t border-gray-100">
                    <p class="text-xs font-medium text-gray-500 mb-1">
                        Attachments
                    </p>
                    {% for file in ticket.attachments %}
                        <a href="{{ file.path }}" class="text-xs text-blue-600 hover:underline block">
                            {{ file.file_name }} ({{ file.sizeToString }})
                        </a>
                    {% endfor %}
                </div>
            {% endif %}
            {% if not ticket.closed_at %}
                <div class="mt-3">
                    <a href="{{ ticket.url }}?mode=ticket" class="text-xs text-gray-400 hover:text-blue-600">
                        Edit
                    </a>
                </div>
            {% endif %}
        </div>
    </div>

    <!-- Messages -->
    {% for message in ticket.messages|filter(m => not m.is_internal) %}
        <div id="message-{{ message.id }}" class="bg-white rounded-lg border mb-4
            {{ message.is_user_message ? 'border-gray-200' : 'border-blue-200' }}
        ">
            <div class="p-4">
                <div class="flex items-center gap-3 mb-3">
                    {% if message.is_user_message %}
                        <img
                            src="{{ ticket.user.avatarThumb(40) }}"
                            alt="{{ customerName }}"
                            class="w-10 h-10 rounded-full"
                        />
                        <div>
                            <span class="text-sm font-medium text-gray-900">
                                {{ customerName }}
                            </span>
                            <span class="text-xs text-gray-400 ml-2">
                                {{ message.created_at|date('M j, Y \\a\\t g:i a') }}
                            </span>
                        </div>
                    {% else %}
                        {% set staffName = message.created_user ? message.created_user.full_name : __('Deleted user') %}
                        <img
                            src="{{ message.created_user.avatarThumb(40) }}"
                            alt="{{ staffName }}"
                            class="w-10 h-10 rounded-full"
                        />
                        <div>
                            <span class="text-sm font-medium text-gray-900">
                                {{ staffName }}
                            </span>
                            <span class="text-xs font-medium text-blue-600 ml-1">
                                Staff
                            </span>
                            <span class="text-xs text-gray-400 ml-2">
                                {{ message.created_at|date('M j, Y \\a\\t g:i a') }}
                            </span>
                        </div>
                    {% endif %}
                </div>
                <div class="prose prose-sm max-w-none text-gray-700">
                    {{ message.content_html|raw }}
                </div>
                {% if message.attachments|length %}
                    <div class="mt-3 pt-3 border-t border-gray-100">
                        <p class="text-xs font-medium text-gray-500 mb-1">
                            Attachments
                        </p>
                        {% for file in message.attachments %}
                            <a href="{{ file.path }}" class="text-xs text-blue-600 hover:underline block">
                                {{ file.file_name }} ({{ file.sizeToString }})
                            </a>
                        {% endfor %}
                    </div>
                {% endif %}
                {% if message.is_user_message and not ticket.closed_at %}
                    <div class="mt-3">
                        <a href="{{ message.url }}" class="text-xs text-gray-400 hover:text-blue-600">
                            Edit
                        </a>
                    </div>
                {% endif %}
            </div>
        </div>
    {% endfor %}

    <!-- Reply form -->
    {% if not ticket.closed_at %}
        <div class="mt-6">
            <h3 class="text-lg font-semibold mb-3">
                Post a Reply
            </h3>
            {{ form_ajax('onSubmitMessage', { files: true, flash: true, validate: true }) }}
                <div class="mb-4">
                    <textarea
                        name="content"
                        rows="5"
                        class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
                        placeholder="Write your reply... Markdown is supported."
                    ></textarea>
                    <div data-validate-for="content" class="text-sm text-red-600 mt-1"></div>
                </div>
                <div class="mb-4">
                    <label class="block text-sm font-medium text-gray-700 mb-1">
                        Attachments
                    </label>
                    <input
                        name="files[]"
                        type="file"
                        multiple
                        class="text-sm text-gray-600"
                    />
                    <div data-validate-for="files" class="text-sm text-red-600 mt-1"></div>
                </div>
                <button
                    type="submit"
                    data-attach-loading
                    class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
                >
                    Submit reply
                </button>
            {{ form_close() }}
        </div>
    {% else %}
        <div class="mt-6 p-4 bg-gray-50 rounded-lg text-center text-sm text-gray-500">
            This ticket has been closed.
        </div>
    {% endif %}

{% else %}

    <p class="text-gray-500">
        Ticket not found.
    </p>

{% endif %}

How the Conversation Thread Works

The supportTicket component provides the ticket variable, which includes ticket.messages (the follow-up messages in the conversation). Each message has:

  • is_user_message: true for messages sent by the user, false for staff responses
  • is_internal: true for internal staff notes, which the component never includes in ticket.messages
  • content_html: the message content rendered from Markdown to HTML, with any HTML the author typed shown as plain text
  • created_user: the backend user who sent the message (for staff messages)
  • attachments: any files attached to the message
  • url: a link to the editing page for this message

The component leaves internal staff notes out of ticket.messages, so they never reach the page. The filter(m => not m.is_internal) in the loop is only a safety net. Older examples wrote this loop as {% for message in ticket.messages if not message.is_internal %}, but Twig 3 removed that syntax and the page fails with a syntax error, so use the filter filter instead.

Names come from full_name, which combines the account's first and last name. The customerName and staffName variables fall back to "Deleted user" when the account no longer exists, and the __() function makes that text translatable. The template also styles user and staff messages differently using the border color.

Posting a Reply

The reply form is an AJAX form, built the same way as the submission form. It sends the reply to the onSubmitMessage handler, which saves the message and its attachments, notifies the support team and reloads the page. The {% flash %} block at the top of the page then shows the success message. The submission form and the editing page also return to this page after a successful save, so the same block shows their messages too.

Errors, such as an empty reply or a file type that is not allowed, appear under the fields and as flash messages without reloading the page. The data-control="flash-message" attribute in the {% flash %} block lets the AJAX framework extras show the success messages as notifications too. See How Flash Messages Work for details.

The isPrimary Property

Setting isPrimary = "1" marks this page as the canonical ticket page. When the plugin sends email notifications about ticket activity, it generates links that point to this page. Only one ticket page should be marked as primary. If more than one is marked, the links use the first one the plugin finds, and if none is marked, they use the first page that has the supportTicket component.

Responding from the Backend

Support staff respond to tickets through the backend:

  1. Navigate to Support > Tickets in the backend
  2. Click on a ticket to open it
  3. Click Reply in the toolbar, or click the reply panel below the conversation
  4. Type a response in the message area
  5. Click Submit reply to send it

The user will see the staff response when they visit the ticket page. To leave a note that only other staff members can see, use Post internal message instead, either from the toolbar or from the second tab of the reply panel. That tab lists the other support members under Notify Users, and only the members you tick receive an email about the note.

While a ticket is closed, the reply panel is hidden and staff cannot post to it. Click Reopen ticket in the toolbar to continue the conversation.

Closing a Ticket

The "Close ticket" button calls onCloseTicket, which marks the ticket as closed and refreshes the page. The data-request-flash attribute shows an error if the request fails, and the {% flash %} block shows the confirmation after the refresh. All participants receive a notification when a ticket is closed.

A ticket counts as closed when its closed_at date is set, which is why the template checks ticket.closed_at rather than the status. Once closed, the reply form is replaced with a notice, and the close button and the Edit links are hidden. The plugin enforces this on the server as well: it refuses replies, edits and attachment changes on a closed ticket until staff reopen it from the backend.

Try It Out

  1. Submit a ticket from the frontend
  2. Reply to it from the backend as support staff
  3. Visit the ticket page to see the conversation thread
  4. Post a reply from the frontend
  5. Close the ticket and verify the form is replaced with the closed notice and the Edit links are gone

Editing Messages

The update page lets users edit the content of their original ticket or any of their follow-up messages. It also supports managing file attachments.

Creating the Update Page

Create a new page at pages/support/update.htm:

title = "Update Message"
url = "/support/update/:id"
layout = "default"

[supportTicketUpdate]
ticketPage = "support/ticket"

[session]
security = "user"
redirect = "account/login"
==
{% set model = supportTicketUpdate.model %}
{% set updateMode = supportTicketUpdate.updateMode %}
{% set ticket = model and updateMode == 'message' ? model.ticket : model %}

{% if model and ticket.closed_at %}

    <div class="mb-4">
        <a href="{{ supportTicketUpdate.returnUrl }}" class="text-sm text-blue-600 hover:underline">
            &larr; Back to ticket
        </a>
    </div>

    <div class="p-4 bg-gray-50 rounded-lg text-center text-sm text-gray-500">
        This ticket has been closed and can no longer be edited.
    </div>

{% elseif model %}

    <div class="mb-4">
        <a href="{{ supportTicketUpdate.returnUrl }}" class="text-sm text-blue-600 hover:underline">
            &larr; Back to ticket
        </a>
    </div>

    <h1 class="text-2xl font-bold mb-6">
        Edit {{ updateMode == 'ticket' ? 'Ticket' : 'Message' }}
    </h1>

    {{ form_ajax('onUpdateMessage', { files: true, flash: true, validate: true }) }}

        <input type="hidden" name="mode" value="{{ updateMode }}" />

        {% if updateMode == 'ticket' %}
            <div class="mb-4">
                <label for="subject" class="block text-sm font-medium text-gray-700 mb-1">
                    Subject
                </label>
                <input
                    type="text"
                    name="subject"
                    id="subject"
                    value="{{ model.subject }}"
                    class="w-full px-3 py-2 border border-gray-300 rounded-md"
                    maxlength="255"
                />
                <div data-validate-for="subject" class="text-sm text-red-600 mt-1"></div>
            </div>
        {% endif %}

        <div class="mb-4">
            <label for="content" class="block text-sm font-medium text-gray-700 mb-1">
                Content
            </label>
            <textarea
                name="content"
                id="content"
                rows="8"
                class="w-full px-3 py-2 border border-gray-300 rounded-md"
            >{{ model.content }}</textarea>
            <div data-validate-for="content" class="text-sm text-red-600 mt-1"></div>
        </div>

        <div class="mb-4">
            <label class="flex items-center gap-2">
                <input
                    type="hidden"
                    name="minor_update"
                    value="0"
                />
                <input
                    type="checkbox"
                    name="minor_update"
                    value="1"
                    checked="checked"
                    class="rounded border-gray-300"
                />
                <span class="text-sm text-gray-700">
                    Minor update
                </span>
            </label>
            <p class="text-xs text-gray-500 mt-1 ml-6">
                Do not notify the support team about this update.
            </p>
        </div>

        {% if model.attachments|length %}
            <div class="mb-4">
                <p class="text-sm font-medium text-gray-700 mb-2">
                    Current attachments
                </p>
                <div class="space-y-1">
                    {% for file in model.attachments %}
                        <div class="flex items-center gap-2">
                            <a href="{{ file.path }}" class="text-sm text-blue-600 hover:underline">
                                {{ file.file_name }}
                            </a>
                            <span class="text-xs text-gray-400">
                                ({{ file.sizeToString }})
                            </span>
                        </div>
                    {% endfor %}
                </div>
            </div>
        {% endif %}

        <div class="mb-6">
            <label class="block text-sm font-medium text-gray-700 mb-1">
                Attach new files
            </label>
            <input
                name="files[]"
                type="file"
                multiple
                class="text-sm text-gray-600"
            />
            <div data-validate-for="files" class="text-sm text-red-600 mt-1"></div>
        </div>

        <div class="flex items-center gap-3">
            <button
                type="submit"
                data-attach-loading
                class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
            >
                Update {{ updateMode }}
            </button>
            <a href="{{ supportTicketUpdate.returnUrl }}" class="text-sm text-gray-600 hover:underline">
                Cancel
            </a>
        </div>

    {{ form_close() }}

{% else %}

    <p class="text-gray-500">
        Message not found.
    </p>

{% endif %}

How the Two Modes Work

This page handles both ticket editing and message editing using the mode query parameter:

  • Ticket mode (?mode=ticket): the :id is the ticket ID. The component loads the ticket and shows the subject field along with the content.
  • Message mode (default): the :id is the message ID. The component loads the message and shows only the content field.

The "Edit" links on the ticket page pass the appropriate mode and ID. For example, the original ticket links to /support/update/5?mode=ticket, while a message links to /support/update/12 (message mode is the default).

The form is an AJAX form, like the submission form. After a successful save, the component returns to the ticket page, where the {% flash %} block shows the confirmation. If the update is refused, for example because the content is empty, the error appears under the field and as a flash message, and the page stays as it is.

Editing a Closed Ticket

The ticket variable holds the ticket being edited in ticket mode, or the ticket the message belongs to in message mode. Once that ticket is closed, the page shows a notice instead of the form. The component also refuses edits and attachment changes on a closed ticket, so a form that was opened before the ticket closed cannot be saved afterwards, and the refusal appears as a flash message. Staff can reopen the ticket from the backend.

The Minor Update Checkbox

When "Minor update" is checked, the support team is not notified about the edit. This is useful for fixing typos or adding forgotten details. The checkbox is checked by default since most edits are minor corrections.

Summary

You now have a complete helpdesk system built with October CMS plugins:

  1. Plugin Setup: installed Responsiv.Support and configured ticket types for categorizing requests
  2. Ticket List: a dashboard where users browse their submitted tickets with status indicators
  3. Ticket Submission: a form with type selection, Markdown content, and file attachments
  4. Ticket Detail: a conversation thread showing user and staff messages with a reply form
  5. Message Editing: the ability to update ticket content and individual messages

How Notifications Work

The plugin sends email notifications automatically for all ticket activity:

  • When a ticket is created, support staff are notified
  • When a user posts a reply, the assigned staff member is notified
  • When staff respond, the user receives an email
  • When a ticket is closed, all participants are notified
  • Internal staff messages are never sent to users

Configure your mail settings in Settings > Mail Configuration. You can customize the notification templates under Settings > Mail Templates (search for "responsiv.support").

Auto-closing Tickets

Tickets close automatically after 7 days if the last message was from support staff and the user has not responded. This keeps the ticket queue clean without manual intervention. The plugin checks for expired tickets every 5 minutes using the system scheduler, so make sure your cron table is configured. The auto-close interval can be changed by creating a config override file at config/responsiv/support/config.php with an expireInterval value. The same file holds the attachment settings.

Restricting Support Staff

By default, all backend administrators are considered support staff and receive notifications for new tickets. To restrict this to specific team members:

  1. Navigate to Settings > Team Groups and click New Group
  2. Create a new group named Support members with the code support-members
  3. Add the administrators who should act as support staff on the group's Users tab

Only members of this group will receive ticket notifications and appear in the assignment dropdown.

Going Further

  • Auto-assignment: set a default assignee on each ticket type so tickets route to the right team member automatically
  • Status Filtering: use the statusFilter property on the supportTickets component to create separate pages for open and closed tickets
  • Embedded Support Link: add a "Need help?" link anywhere in your site that points to the submit page for quick access

Was this article helpful?

comments powered by Disqus
Still need help?
October Forum

Community members share experiences, ask questions and exchange tips.

Discord

Talk with other October CMS developers in real time.

Custom Development

Hire the October CMS team to work on your website.

Contact Us

For bug reports and help with this website. See the roadmap or submit an idea on the Portal.