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

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.

BR Bruno · Updated on Jul 12, 2025 · ⏱ 24 min read
Quick answer

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.

RequirementDetail (example — varies by version)
PHP7.4 or higher (8.1+ recommended)
DatabaseMySQL/MariaDB or SQL Server already used by the site
SessionsSupport for session_start() for the admin login
Admin accountA user with a hashed password for the panel
EditorAccess 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:

EventTimes (example — varies by version)Category
Blood Castleevery 2h, at minute 00invasao
Devil Squareevery 2h, at minute 30invasao
Chaos Castle4x a daypvp
Golden Invasion20:00 and 22:00invasao
Castle SiegeSunday, weeklyguild
<?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

SymptomLikely causeSolution
News item disappears from the listingThe publicado field ended up 0Mark it as published or review the filter
Layout breaks when opening a news itemUnsanitized HTML in the bodyUse strip_tags with a whitelist or a purifier
Accents appear as éDifferent charset between PHP and the databaseUse utf8mb4 in the connection and the tables
Pagination shows too many pagesTotal counted without the publicado filterApply the same WHERE in the count
Active event doesn't appearDate stored as textMigrate to DATETIME and compare with NOW()
Panel accessible without loginMissing exigirAdmin() on the pageInclude the guard at the top of every admin page
Form always rejectedCSRF token not sent in the formAdd 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. htmlspecialchars on everything that comes from the database and is displayed, except the HTML body you sanitized on save.
  • Hashed passwords. password_hash on creation, password_verify on 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

  • noticias and eventos tables created with DATETIME fields
  • PDO connection with utf8mb4 and real prepared statements
  • Public listing filtering publicado = 1 and 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.

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