How to build a news and events system for your MU server website
Build a complete news and event calendar system for your MU Online website, with an admin panel, database, paginated listing, and highlighting of active events.
News and an event calendar are what keep the server's website alive between one season and the next. That's where you announce maintenance, promote the next invasion, communicate weekend bonuses, and share the patch news. In this tutorial you'll build, from scratch and in pure PHP, a system with a p
News and an event calendar are what keep the server's website alive between one season and the next. That's where you announce maintenance, promote the next invasion, communicate weekend bonuses, and share the patch news. In this tutorial you'll build, from scratch and in pure PHP, a system with a protected admin panel, a well-modeled database, a paginated public listing, and an events block that automatically highlights what's active right now. All the concrete values (times, categories, formats) appear as examples, since they vary by version and by server.
Prerequisites
This module assumes you already have the server's website live. If you're still building the foundation, start with how to create a MU Online server and then integrate this system into the existing structure.
| Requirement | Detail (example — varies by version) |
|---|---|
| PHP | 7.4 or higher (8.1+ recommended) |
| Database | MySQL/MariaDB or SQL Server already used by the site |
| Sessions | Support for session_start() for the admin login |
| Admin account | A user with a hashed password for the panel |
| Editor | Access to create the noticias/ and admin/ folders |
It's worth separating the site database from the game database. News and events don't need to live in the MU database. Many admins create a site_cms database just for content, avoiding mixing management tables with the server tables. In this tutorial, the examples use a standalone site database.
Step 1 — Model the tables
Two main entities: news and events. News items are posts with a title, body, and date. Events have a start, an end, and a category. Model them with native date fields — never store a date as text.
-- Site database (example in MySQL/MariaDB)
CREATE TABLE noticias (
id INT AUTO_INCREMENT PRIMARY KEY,
titulo VARCHAR(160) NOT NULL,
slug VARCHAR(180) NOT NULL UNIQUE,
resumo VARCHAR(300) NULL,
corpo MEDIUMTEXT NOT NULL,
categoria VARCHAR(40) NOT NULL DEFAULT 'geral',
autor VARCHAR(60) NOT NULL,
publicado TINYINT(1) NOT NULL DEFAULT 0,
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
atualizado DATETIME NULL,
INDEX idx_pub (publicado, criado_em)
);
CREATE TABLE eventos (
id INT AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(120) NOT NULL,
descricao VARCHAR(400) NULL,
categoria VARCHAR(40) NOT NULL DEFAULT 'invasao',
inicio DATETIME NOT NULL,
fim DATETIME NOT NULL,
recorrente TINYINT(1) NOT NULL DEFAULT 0,
ativo TINYINT(1) NOT NULL DEFAULT 1,
INDEX idx_periodo (inicio, fim)
);
The publicado field lets you write drafts without displaying them. The slug is the friendly URL fragment (/noticias/manutencao-quarta). The idx_pub index speeds up the public listing, which always filters by publicado = 1 and orders by date.
Step 2 — Database access layer
Centralize the connection in a reusable file, using PDO with prepared statements. This protects against SQL injection by default:
<?php
// lib/db.php
function getSiteDB(): PDO {
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$dsn = 'mysql:host=127.0.0.1;dbname=site_cms;charset=utf8mb4';
$pdo = new PDO($dsn, 'usuario_site', 'senha_forte_aqui', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
Keeping ATTR_EMULATE_PREPARES => false ensures real prepared statements on the server, closing the door on most injection attacks.
Step 3 — Public news listing with pagination
The page the player sees. It filters only published news, orders by most recent, and paginates 10 at a time:
<?php
// noticias/index.php
require_once __DIR__ . '/../lib/db.php';
$pdo = getSiteDB();
// Safe pagination: force integer and minimum of 1
$pagina = max(1, (int)($_GET['p'] ?? 1));
$porPag = 10;
$offset = ($pagina - 1) * $porPag;
// Total to compute the number of pages
$total = (int) $pdo->query(
"SELECT COUNT(*) FROM noticias WHERE publicado = 1"
)->fetchColumn();
$totalPaginas = (int) ceil($total / $porPag);
// LIMIT/OFFSET with values already validated as integers
$stmt = $pdo->prepare(
"SELECT id, titulo, slug, resumo, categoria, autor, criado_em
FROM noticias
WHERE publicado = 1
ORDER BY criado_em DESC
LIMIT :lim OFFSET :off"
);
$stmt->bindValue(':lim', $porPag, PDO::PARAM_INT);
$stmt->bindValue(':off', $offset, PDO::PARAM_INT);
$stmt->execute();
$noticias = $stmt->fetchAll();
?>
<section class="noticias">
<h1>Server News</h1>
<?php foreach ($noticias as $n): ?>
<article class="noticia-card cat-<?= htmlspecialchars($n['categoria']) ?>">
<span class="tag"><?= htmlspecialchars($n['categoria']) ?></span>
<h2>
<a href="/noticias/<?= htmlspecialchars($n['slug']) ?>">
<?= htmlspecialchars($n['titulo']) ?>
</a>
</h2>
<p class="meta">
<?= htmlspecialchars($n['autor']) ?> ·
<?= date('d/m/Y', strtotime($n['criado_em'])) ?>
</p>
<p><?= htmlspecialchars($n['resumo'] ?? '') ?></p>
</article>
<?php endforeach; ?>
<nav class="paginacao">
<?php for ($i = 1; $i <= $totalPaginas; $i++): ?>
<a href="?p=<?= $i ?>" class="<?= $i === $pagina ? 'atual' : '' ?>"><?= $i ?></a>
<?php endfor; ?>
</nav>
</section>
Notice that every printed value passes through htmlspecialchars. That's what prevents a news item with malicious HTML from injecting a script into the page (XSS).
Step 4 — Individual news page
The friendly URL /noticias/<slug> needs a simple router. Configure the server to route those URLs to a ler.php that looks up by slug:
<?php
// noticias/ler.php
require_once __DIR__ . '/../lib/db.php';
$slug = $_GET['slug'] ?? '';
$pdo = getSiteDB();
$stmt = $pdo->prepare(
"SELECT * FROM noticias WHERE slug = :slug AND publicado = 1 LIMIT 1"
);
$stmt->execute([':slug' => $slug]);
$noticia = $stmt->fetch();
if (!$noticia) {
http_response_code(404);
echo '<h1>News item not found</h1>';
exit;
}
?>
<article class="noticia-full">
<span class="tag"><?= htmlspecialchars($noticia['categoria']) ?></span>
<h1><?= htmlspecialchars($noticia['titulo']) ?></h1>
<p class="meta">
By <?= htmlspecialchars($noticia['autor']) ?> on
<?= date('d/m/Y H:i', strtotime($noticia['criado_em'])) ?>
</p>
<div class="corpo">
<!-- body already sanitized on save; see Step 6 -->
<?= $noticia['corpo'] ?>
</div>
</article>
Step 5 — Active and upcoming events block
Here's the part that brings the home page to life. We want to show what's happening now and what comes next. A single query solves both, comparing the current time with the start and end fields:
<?php
// eventos/bloco.php
require_once __DIR__ . '/../lib/db.php';
$pdo = getSiteDB();
// Events happening now
$ativos = $pdo->query(
"SELECT nome, categoria, inicio, fim
FROM eventos
WHERE ativo = 1 AND NOW() BETWEEN inicio AND fim
ORDER BY fim ASC"
)->fetchAll();
// Upcoming events (start in the future)
$proximos = $pdo->query(
"SELECT nome, categoria, inicio, fim
FROM eventos
WHERE ativo = 1 AND inicio > NOW()
ORDER BY inicio ASC
LIMIT 5"
)->fetchAll();
?>
<div class="eventos-bloco">
<?php if ($ativos): ?>
<h3>Happening now</h3>
<ul class="ev-ativos">
<?php foreach ($ativos as $e): ?>
<li>
<span class="pulse"></span>
<strong><?= htmlspecialchars($e['nome']) ?></strong>
— ends at <?= date('H:i', strtotime($e['fim'])) ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<h3>Upcoming events</h3>
<ul class="ev-proximos">
<?php foreach ($proximos as $e): ?>
<li>
<span class="tag"><?= htmlspecialchars($e['categoria']) ?></span>
<?= htmlspecialchars($e['nome']) ?>
<em><?= date('d/m H:i', strtotime($e['inicio'])) ?></em>
</li>
<?php endforeach; ?>
</ul>
</div>
For recurring game events — such as Blood Castle, Devil Square, or Chaos Castle, which usually repeat at fixed times — you have two strategies. The simple one is to register each future occurrence. The elegant one is to store only the pattern and compute the next occurrence in PHP. The times below are examples and vary by version:
| Event | Times (example — varies by version) | Category |
|---|---|---|
| Blood Castle | every 2h, at minute 00 | invasao |
| Devil Square | every 2h, at minute 30 | invasao |
| Chaos Castle | 4x a day | pvp |
| Golden Invasion | 20:00 and 22:00 | invasao |
| Castle Siege | Sunday, weekly | guild |
<?php
// Compute the next occurrence of an event every N hours at minute M.
// ADJUST to your server's real schedule.
function proximaOcorrencia(int $intervaloHoras, int $minuto): DateTime {
$agora = new DateTime();
$prox = clone $agora;
$prox->setTime((int)$agora->format('H'), $minuto, 0);
// If it already passed in this block, advance to the next interval
if ($prox <= $agora) {
$prox->modify("+{$intervaloHoras} hours");
}
return $prox;
}
$bc = proximaOcorrencia(2, 0); // Blood Castle every 2h at minute 0 (example)
echo 'Next Blood Castle: ' . $bc->format('H:i');
Step 6 — Protected admin panel
No news system stays exposed without authentication. The panel requires login, protects against CSRF, and sanitizes input. First, the session guard:
<?php
// admin/guardiao.php
session_start();
function exigirAdmin(): void {
if (empty($_SESSION['admin_id'])) {
header('Location: /admin/login.php');
exit;
}
}
// CSRF token per session
function csrfToken(): string {
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
function validarCsrf(?string $token): bool {
return is_string($token)
&& !empty($_SESSION['csrf'])
&& hash_equals($_SESSION['csrf'], $token);
}
The login validates the password against a hash generated with password_hash. Never store a password in plain text:
<?php
// admin/login.php
require_once __DIR__ . '/../lib/db.php';
session_start();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$pdo = getSiteDB();
$user = $_POST['usuario'] ?? '';
$senha = $_POST['senha'] ?? '';
$stmt = $pdo->prepare("SELECT id, senha_hash FROM admins WHERE usuario = :u LIMIT 1");
$stmt->execute([':u' => $user]);
$admin = $stmt->fetch();
// password_verify compares against the hash; hash_equals avoids timing attacks in the flow
if ($admin && password_verify($senha, $admin['senha_hash'])) {
session_regenerate_id(true); // prevents session fixation
$_SESSION['admin_id'] = $admin['id'];
header('Location: /admin/noticias.php');
exit;
}
$erro = 'Invalid credentials.';
}
?>
<form method="post" class="login-form">
<h1>Admin Panel</h1>
<?php if (!empty($erro)): ?><p class="erro"><?= htmlspecialchars($erro) ?></p><?php endif; ?>
<input name="usuario" placeholder="Username" required>
<input name="senha" type="password" placeholder="Password" required>
<button type="submit">Sign in</button>
</form>
Step 7 — Create and edit news in the panel
The creation form validates CSRF, generates the slug, and sanitizes the content before saving:
<?php
// admin/noticia-salvar.php
require_once __DIR__ . '/../lib/db.php';
require_once __DIR__ . '/guardiao.php';
exigirAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !validarCsrf($_POST['csrf'] ?? null)) {
http_response_code(400);
exit('Invalid request.');
}
// Generate slug from the title (lowercase, no accents, hyphens)
function gerarSlug(string $texto): string {
$texto = iconv('UTF-8', 'ASCII//TRANSLIT', $texto);
$texto = strtolower(preg_replace('/[^a-zA-Z0-9]+/', '-', $texto));
return trim($texto, '-');
}
$titulo = trim($_POST['titulo'] ?? '');
$corpo = $_POST['corpo'] ?? '';
$categoria = $_POST['categoria'] ?? 'geral';
$publicado = isset($_POST['publicado']) ? 1 : 0;
// Basic body sanitization: allow only safe tags
$corpoLimpo = strip_tags($corpo, '<p><br><strong><em><ul><li><a><h2><h3>');
$pdo = getSiteDB();
$stmt = $pdo->prepare(
"INSERT INTO noticias (titulo, slug, resumo, corpo, categoria, autor, publicado, criado_em)
VALUES (:t, :s, :r, :c, :cat, :a, :pub, NOW())"
);
$stmt->execute([
':t' => $titulo,
':s' => gerarSlug($titulo) . '-' . substr(md5($titulo . time()), 0, 5),
':r' => mb_substr(strip_tags($corpo), 0, 280),
':c' => $corpoLimpo,
':cat' => $categoria,
':a' => 'Admin',
':pub' => $publicado,
]);
header('Location: /admin/noticias.php?ok=1');
For more robust rich-HTML sanitization (allowing formatting without opening up XSS), consider a dedicated HTML purification library instead of just strip_tags. strip_tags removes tags, but it doesn't neutralize dangerous attributes like onclick inside the allowed ones.
Step 8 — Lightweight home caching
The home page queries news and events on every visit. On busy servers, cache the rendered HTML of the block for a few minutes:
<?php
// lib/cache.php
function render_cache(string $chave, callable $render, int $ttl = 180): string {
$arq = sys_get_temp_dir() . '/cache_' . md5($chave) . '.html';
if (is_file($arq) && (time() - filemtime($arq)) < $ttl) {
return file_get_contents($arq);
}
ob_start();
$render();
$html = ob_get_clean();
file_put_contents($arq, $html);
return $html;
}
// Usage: echo render_cache('home_noticias', function () {
// require __DIR__ . '/../noticias/index.php';
// }, 180);
Remember to invalidate (delete) the cache when the admin publishes a new news item, so you don't leave stale content up for three minutes.
Common errors and fixes
| Symptom | Likely cause | Solution |
|---|---|---|
| News item disappears from the listing | The publicado field ended up 0 | Mark it as published or review the filter |
| Layout breaks when opening a news item | Unsanitized HTML in the body | Use strip_tags with a whitelist or a purifier |
Accents appear as é | Different charset between PHP and the database | Use utf8mb4 in the connection and the tables |
| Pagination shows too many pages | Total counted without the publicado filter | Apply the same WHERE in the count |
| Active event doesn't appear | Date stored as text | Migrate to DATETIME and compare with NOW() |
| Panel accessible without login | Missing exigirAdmin() on the page | Include the guard at the top of every admin page |
| Form always rejected | CSRF token not sent in the form | Add the hidden field with csrfToken() |
Security and best practices
- Always prepared statements. Every query with user data uses bound parameters, never string concatenation.
- Escape on output.
htmlspecialcharson everything that comes from the database and is displayed, except the HTML body you sanitized on save. - Hashed passwords.
password_hashon creation,password_verifyon login. Never plain text. - CSRF on every panel POST. Without a valid token, the request is refused.
- Drafts by default. Create news items as unpublished and review them before making them public.
- Separate the databases. Site content doesn't need to live in the game database.
Launch checklist
noticiasandeventostables created withDATETIMEfields- PDO connection with
utf8mb4and real prepared statements - Public listing filtering
publicado = 1and paginated - Individual page returning 404 for a nonexistent slug
- Events block separating "now" and "upcoming"
- Recurring event times checked against the GameServer
- Admin panel requiring login on every page
- Admin passwords hashed with
password_hash - CSRF token validated on every form
- Content sanitized on save and escaped on display
- Home cache with invalidation on publish
- Real test: publish a news item and see it on the home page
- Real test: create an active event and confirm the "now" highlight
With this system, the website stops being a static page and becomes a living communication channel with the community. Maintenance announcements, weekend bonuses, and the invasion calendar can be published in minutes, straight from the panel, with the security of code that treats every input as potentially hostile.
Frequently asked questions
Do I need a ready-made CMS like WordPress to have news?
No. A CMS works, but it tends to be heavy and hard to integrate with the game database. For a MU server, a custom PHP module with a news table is lighter, easier to match with the site's look, and doesn't open up a huge surface of vulnerable plugins.
How do I make automatic events like Blood Castle show up in the calendar?
You register the fixed schedules in a recurring-events table and compute the next occurrence in PHP from the current time. The exact times vary by version and by your event configuration in the GameServer, so treat the values as examples and adjust them.
Is it safe to leave the news panel accessible over the internet?
Yes, as long as it requires an admin login, uses hashed passwords, protects against CSRF, and escapes all HTML output. Never leave the panel without authentication, and preferably restrict access by IP or via a protected folder.
How do I stop the article's HTML from breaking the layout or injecting a script?
Escape the content with htmlspecialchars on display, or use a tag whitelist with a sanitization library. Never print what came from the form straight into the HTML without processing it — that opens up XSS.
Should I store the event date as text or as a date field?
Always as a native date/time field (DATETIME). Storing it as text breaks sorting, period filters, and the active-events calculation. Format it for display only at the moment you show it to the user.