Brazil's biggest MU Online portal — since 2003
Tutorial Intermediate Website

How to build a web support ticket system for MU Online

Build a support ticket system on your MU Online server's website, with tickets opened by players, staff replies, statuses, categories and an admin panel in PHP and SQL.

BR Bruno · Updated on May 20, 2024 · ⏱ 22 min read
Quick answer

Every MU Online server reaches a point where support over Discord and direct messages no longer scales: requests get lost, two GMs answer the same case and no one has a history of what was resolved. A ticket system on the website organizes that chaos. Each player opens a ticket tied to their account

Every MU Online server reaches a point where support over Discord and direct messages no longer scales: requests get lost, two GMs answer the same case and no one has a history of what was resolved. A ticket system on the website organizes that chaos. Each player opens a ticket tied to their account, picks a category, follows the status and talks with the team in a single place — and the administrators get a panel to view, filter and answer everything. This tutorial shows how to build that system in PHP and SQL, from the database to the admin panel, with attention to security and good support practices. The account table and column names appear as an example and vary by version of your MuServer. If you are still building the server's foundation, start with how to create a MU Online server.

Prerequisites

The ticket system is a web application on top of your server's same database (or an auxiliary one). You need:

  • A working PHP website with login sessions already implemented (the player signs in with the game account).
  • PHP 7.4 or higher with PDO enabled.
  • Database access — it can be the MuServer's own SQL Server or a separate MySQL/MariaDB just for the website.
  • A login system that identifies the player's account in the session.
  • Optional but recommended: email sending via SMTP, to notify replies.
  • Some notion of permissions to separate a regular player from a GM/administrator.
RoleWhat they can doWhere they access it
PlayerOpen a ticket, reply to their own, view historyAccount area
Agent / GMView the queue, reply, change statusAdmin panel
AdministratorAll of the above + manage categories and assign ticketsAdmin panel

Database modeling

The base of a good ticket system is two tables: one for the ticket itself and another for the conversation messages. Separating the two allows unlimited message exchange within each ticket.

-- Example (varies by version): ticket tables
CREATE TABLE SUPPORT_TICKET (
    id           INT IDENTITY(1,1) PRIMARY KEY,
    conta        VARCHAR(10)  NOT NULL,        -- game account
    personagem   VARCHAR(20)  NULL,            -- affected character
    categoria    VARCHAR(20)  NOT NULL,        -- account, bug, donation, report
    assunto      VARCHAR(120) NOT NULL,
    prioridade   TINYINT      NOT NULL DEFAULT 1, -- 1=low 2=medium 3=high
    status       VARCHAR(15)  NOT NULL DEFAULT 'aberto',
    atendente    VARCHAR(20)  NULL,            -- GM in charge
    criado_em    DATETIME     NOT NULL DEFAULT GETDATE(),
    atualizado_em DATETIME    NOT NULL DEFAULT GETDATE()
);

CREATE TABLE SUPPORT_MESSAGE (
    id           INT IDENTITY(1,1) PRIMARY KEY,
    ticket_id    INT          NOT NULL,
    autor        VARCHAR(20)  NOT NULL,        -- account or GM
    is_staff     BIT          NOT NULL DEFAULT 0,
    mensagem     NVARCHAR(MAX) NOT NULL,
    criado_em    DATETIME     NOT NULL DEFAULT GETDATE(),
    CONSTRAINT fk_ticket FOREIGN KEY (ticket_id) REFERENCES SUPPORT_TICKET(id)
);

The status field guides the entire support flow. A lean set of states avoids confusion:

StatusMeaningWho changes it
abertoJust created, awaiting the teamSystem
em_andamentoA GM took the caseAgent
aguardandoWaiting for the player's replyAgent
resolvidoSolution deliveredAgent
fechadoClosed, no new repliesAgent/System

Ticket opening by the player

The opening form must sit behind login, to tie the ticket to the account and reduce spam. Validate everything on the server: category within the allowed list, subject and message length, and a limit on open tickets per account.

<?php
// abrir-ticket.php
require 'src/db.php';
session_start();

if (!isset($_SESSION['conta'])) {
    header('Location: /login');
    exit;
}

const CATEGORIAS = ['conta', 'bug', 'doacao', 'denuncia', 'outro'];
const MAX_ABERTOS = 3; // simultaneous tickets per account

function abrirTicket(string $conta, array $dados): array
{
    $pdo = getPDO();

    $categoria  = in_array($dados['categoria'] ?? '', CATEGORIAS, true)
                ? $dados['categoria'] : null;
    $assunto    = trim($dados['assunto'] ?? '');
    $mensagem   = trim($dados['mensagem'] ?? '');
    $personagem = trim($dados['personagem'] ?? '') ?: null;

    if (!$categoria) {
        return ['ok' => false, 'msg' => 'Invalid category'];
    }
    if (mb_strlen($assunto) < 5 || mb_strlen($assunto) > 120) {
        return ['ok' => false, 'msg' => 'The subject must be between 5 and 120 characters'];
    }
    if (mb_strlen($mensagem) < 15) {
        return ['ok' => false, 'msg' => 'Describe the problem in more detail (min. 15 characters)'];
    }

    // Limit on open tickets per account
    $q = $pdo->prepare(
        "SELECT COUNT(*) FROM SUPPORT_TICKET
         WHERE conta = ? AND status IN ('aberto','em_andamento','aguardando')"
    );
    $q->execute([$conta]);
    if ($q->fetchColumn() >= MAX_ABERTOS) {
        return ['ok' => false, 'msg' => 'You already have too many open tickets. Please wait for a reply.'];
    }

    $pdo->beginTransaction();
    $ins = $pdo->prepare(
        "INSERT INTO SUPPORT_TICKET (conta, personagem, categoria, assunto)
         VALUES (?, ?, ?, ?)"
    );
    $ins->execute([$conta, $personagem, $categoria, $assunto]);
    $ticketId = $pdo->lastInsertId();

    // First message = initial description
    $pdo->prepare(
        "INSERT INTO SUPPORT_MESSAGE (ticket_id, autor, is_staff, mensagem)
         VALUES (?, ?, 0, ?)"
    )->execute([$ticketId, $conta, $mensagem]);
    $pdo->commit();

    return ['ok' => true, 'id' => $ticketId];
}

Note that the category is validated against an allowlist (in_array with the CATEGORIAS constant), never accepted directly from the form. That prevents anyone from injecting unexpected values. The whole insertion happens inside a transaction, so the ticket and the first message are born together.

Displaying the ticket to the player

The player needs to follow the conversation. An essential security rule: they can only see their own tickets. Always check that the ticket's conta matches the session's before displaying anything.

<?php
// ver-ticket.php
require 'src/db.php';
session_start();

$conta    = $_SESSION['conta'] ?? null;
$ticketId = (int) ($_GET['id'] ?? 0);
if (!$conta) { header('Location: /login'); exit; }

$pdo = getPDO();
$stmt = $pdo->prepare("SELECT * FROM SUPPORT_TICKET WHERE id = ? AND conta = ?");
$stmt->execute([$ticketId, $conta]);
$ticket = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$ticket) {
    http_response_code(404);
    exit('Ticket not found');
}

$msgs = $pdo->prepare(
    "SELECT autor, is_staff, mensagem, criado_em
     FROM SUPPORT_MESSAGE WHERE ticket_id = ? ORDER BY criado_em ASC"
);
$msgs->execute([$ticketId]);
?>
<h1><?= htmlspecialchars($ticket['assunto']) ?></h1>
<p>Status: <strong><?= htmlspecialchars($ticket['status']) ?></strong>
   — Category: <?= htmlspecialchars($ticket['categoria']) ?></p>

<div class="conversa">
<?php foreach ($msgs as $m): ?>
  <div class="msg <?= $m['is_staff'] ? 'staff' : 'jogador' ?>">
    <span class="autor"><?= htmlspecialchars($m['autor']) ?>
      <?= $m['is_staff'] ? '(Team)' : '' ?></span>
    <p><?= nl2br(htmlspecialchars($m['mensagem'])) ?></p>
    <time><?= $m['criado_em'] ?></time>
  </div>
<?php endforeach; ?>
</div>

Using htmlspecialchars on all output is non-negotiable. The player's message is untrusted content; without escaping, a <script> in the message would become an XSS that hits the GM who opens the ticket in the panel.

Replying to a ticket

Both the player and the team reply by writing a new row in SUPPORT_MESSAGE. The difference is in the is_staff field and in the permissions. When replying, also update the ticket's atualizado_em and adjust the status according to who spoke.

<?php
// responder-ticket.php
function responder(int $ticketId, string $autor, string $texto, bool $staff): array
{
    $pdo = getPDO();
    $texto = trim($texto);

    if (mb_strlen($texto) < 2) {
        return ['ok' => false, 'msg' => 'Empty message'];
    }

    // Confirm the ticket exists and (if a player) belongs to them
    $t = $pdo->prepare("SELECT conta, status FROM SUPPORT_TICKET WHERE id = ?");
    $t->execute([$ticketId]);
    $ticket = $t->fetch(PDO::FETCH_ASSOC);
    if (!$ticket) return ['ok' => false, 'msg' => 'Ticket does not exist'];
    if (!$staff && $ticket['conta'] !== $autor) {
        return ['ok' => false, 'msg' => 'No permission'];
    }

    $pdo->beginTransaction();
    $pdo->prepare(
        "INSERT INTO SUPPORT_MESSAGE (ticket_id, autor, is_staff, mensagem)
         VALUES (?, ?, ?, ?)"
    )->execute([$ticketId, $autor, $staff ? 1 : 0, $texto]);

    // Staff replied -> awaiting player; player replied -> back to open
    $novoStatus = $staff ? 'aguardando' : 'aberto';
    $pdo->prepare(
        "UPDATE SUPPORT_TICKET
         SET status = ?, atualizado_em = GETDATE() WHERE id = ?"
    )->execute([$novoStatus, $ticketId]);
    $pdo->commit();

    return ['ok' => true];
}

If you set up SMTP on the website, this is the ideal point to fire an email notification to the player when the team replies — the reply arrives faster and the ticket closes sooner.

Admin panel

The team's panel is where tickets become organized work. It lists the queue with filters by status, category and priority, and lets agents take and reply to cases. Access must be restricted to accounts with GM/admin permission — check this at the top of every panel file.

<?php
// admin/fila.php
require '../src/db.php';
session_start();

// Permission check (adapt to your roles table)
if (($_SESSION['nivel'] ?? 0) < 8) { // e.g. 8 = GM
    http_response_code(403);
    exit('Access denied');
}

$pdo    = getPDO();
$status = $_GET['status'] ?? 'aberto';

$stmt = $pdo->prepare(
    "SELECT id, conta, categoria, assunto, prioridade, status, atendente, atualizado_em
     FROM SUPPORT_TICKET
     WHERE status = ?
     ORDER BY prioridade DESC, atualizado_em ASC"
);
$stmt->execute([$status]);
$tickets = $stmt->fetchAll(PDO::FETCH_ASSOC);
?>
<table class="fila">
  <tr><th>#</th><th>Account</th><th>Category</th><th>Subject</th>
      <th>Priority</th><th>Agent</th><th>Updated</th></tr>
  <?php foreach ($tickets as $t): ?>
  <tr>
    <td><a href="ver.php?id=<?= $t['id'] ?>">#<?= $t['id'] ?></a></td>
    <td><?= htmlspecialchars($t['conta']) ?></td>
    <td><?= htmlspecialchars($t['categoria']) ?></td>
    <td><?= htmlspecialchars($t['assunto']) ?></td>
    <td><?= ['','Low','Medium','High'][$t['prioridade']] ?></td>
    <td><?= htmlspecialchars($t['atendente'] ?? '—') ?></td>
    <td><?= $t['atualizado_em'] ?></td>
  </tr>
  <?php endforeach; ?>
</table>

Sorting by priority and then by the oldest update ensures that critical cases come first and that no old ticket is forgotten at the bottom of the queue.

Assigning and changing status

Let the team "take" a ticket by marking themselves as the agent. This prevents two GMs from working on the same case. Status changes should be simple, recorded actions.

  1. The GM opens an aberto ticket in the queue.
  2. Clicks "Take", which writes their name into atendente and changes the status to em_andamento.
  3. Replies to the player; the status becomes aguardando.
  4. When the player confirms the solution, the GM marks it as resolvido.
  5. resolvido tickets with no reply for X days can automatically become fechado via a scheduled routine.
<?php
// admin/assumir.php
function assumirTicket(int $ticketId, string $gm): void
{
    $pdo = getPDO();
    $pdo->prepare(
        "UPDATE SUPPORT_TICKET
         SET atendente = ?, status = 'em_andamento', atualizado_em = GETDATE()
         WHERE id = ? AND status = 'aberto'"
    )->execute([$gm, $ticketId]);
}

The AND status = 'aberto' condition in the update is a lock against a race: if two GMs click "Take" almost at the same time, only the first one commits the change.

Security and best practices

A ticket system handles account data and receives free text from users, so take security seriously. Always use prepared statements — never concatenate user input into SQL. Escape all HTML output with htmlspecialchars to block XSS, especially because the GM will read the player's text in the panel. Check permissions in every panel file; do not rely only on hiding the link. Limit the size and frequency of tickets and messages to curb abuse. Log the team's actions (who took it, who closed it) for accountability. And protect the forms with a CSRF token, so a malicious page cannot open or reply to tickets on behalf of a logged-in user.

Common errors and fixes

ErrorLikely causeFix
Player sees another's ticketMissing conta check in the queryAlways filter by conta = session when loading a ticket
Script executed in the panel (XSS)Unescaped outputApply htmlspecialchars to every displayed message
Two GMs on the same ticketAssignment with no lockUse AND status = 'aberto' in the "Take" UPDATE
Ticket spamNo per-account limitEnforce MAX_ABERTOS and a CAPTCHA at opening
Invalid category storedAccepting the raw form valueValidate against an allowlist of categories
Player unaware of the replyNo notificationFire an email via SMTP on each team reply
Disorganized queueNo priority orderingSort by priority and then by oldest

Optional improvements

Once the basics are running, a few additions add a lot of value. Image attachments let the player send a screenshot of the bug — just validate the file type and size. A rating field at ticket closing ("was your problem solved?") generates support quality metrics. Pre-made quick replies (macros) speed up the team's work on repeated questions. And a small report with average response time and volume per category helps you understand where the server generates the most questions — often revealing a bug or a UI confusion worth fixing at the source.

Launch checklist

  • SUPPORT_TICKET and SUPPORT_MESSAGE tables created
  • Ticket opening requires login and ties to the account
  • Categories validated against an allowlist
  • Limit on open tickets per account applied
  • Player only sees their own tickets
  • All output escaped with htmlspecialchars
  • Admin panel protected by a permission check
  • "Take" with a lock against a race
  • Complete status flow (open → closed)
  • Email notification on replies (if SMTP is available)
  • CSRF token on the open and reply forms
  • Prepared statements in all queries

Frequently asked questions

Why use tickets instead of just Discord or WhatsApp?

Tickets leave an organized history, with a status and an owner, tied to the player's account. On Discord, messages get lost in the flow and no one knows what has already been resolved or who is handling it.

Does the player need to be logged in to open a ticket?

Ideally yes, to tie the ticket to the account and prevent anonymous spam. You can allow attaching the name of the affected character, but authenticating with the game account is what gives the support request its context.

How do I stop bots from flooding the ticket system?

Combine mandatory login, a limit on open tickets per account, a CAPTCHA at opening and a minimum/maximum message length check. That blocks most automated abuse.

Should I notify the player when I reply?

Yes, an email notice on each reply greatly improves the experience. The player does not have to keep refreshing the page, and the resolution rate rises because they respond faster.

Can I separate tickets by category and priority?

Yes, and it is recommended. Categories (account, bug, donation, report) let you route to the right team, and the priority helps you handle the critical ones first, such as payment problems.

BR
Events, maps & items editor

Bruno specializes in MU Online events, maps, bosses and item economy. He documents every detail based on real gameplay.

Keep reading

Related articles