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.
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.
| Role | What they can do | Where they access it |
|---|---|---|
| Player | Open a ticket, reply to their own, view history | Account area |
| Agent / GM | View the queue, reply, change status | Admin panel |
| Administrator | All of the above + manage categories and assign tickets | Admin 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:
| Status | Meaning | Who changes it |
|---|---|---|
aberto | Just created, awaiting the team | System |
em_andamento | A GM took the case | Agent |
aguardando | Waiting for the player's reply | Agent |
resolvido | Solution delivered | Agent |
fechado | Closed, no new replies | Agent/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.
- The GM opens an
abertoticket in the queue. - Clicks "Take", which writes their name into
atendenteand changes the status toem_andamento. - Replies to the player; the status becomes
aguardando. - When the player confirms the solution, the GM marks it as
resolvido. resolvidotickets with no reply for X days can automatically becomefechadovia 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
| Error | Likely cause | Fix |
|---|---|---|
| Player sees another's ticket | Missing conta check in the query | Always filter by conta = session when loading a ticket |
| Script executed in the panel (XSS) | Unescaped output | Apply htmlspecialchars to every displayed message |
| Two GMs on the same ticket | Assignment with no lock | Use AND status = 'aberto' in the "Take" UPDATE |
| Ticket spam | No per-account limit | Enforce MAX_ABERTOS and a CAPTCHA at opening |
| Invalid category stored | Accepting the raw form value | Validate against an allowlist of categories |
| Player unaware of the reply | No notification | Fire an email via SMTP on each team reply |
| Disorganized queue | No priority ordering | Sort 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_TICKETandSUPPORT_MESSAGEtables 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.