Häufige Fragen

Fragen zum Betrieb, und weiter unten der Aufbau eigener Module.

Betrieb

Die Fragen, die beim Aufsetzen und danach am häufigsten kommen.

Warum kein Composer?

  • Weil ein Forum auf jedem Webspace laufen soll, auch ohne Shell-Zugang. Alles Fremde liegt im Paket: Schriften, Code-Editor, QR-Erzeugung. Entpacken und hochladen genügt.

Wie aktualisiere ich?

  • Update-Paket herunterladen, im Adminbereich unter Wartung → Updates hochladen. Es enthält nur die geänderten Dateien.
  • Kein FTP nötig, und config.php sowie alles unter uploads/ bleiben unberührt.

Meine .htaccess wirkt nicht

  • Dann läuft nginx. Der liest .htaccess nicht — ohne Fehlermeldung. Die Regeln gehören in den Server-Block, siehe unten.

Wo liegen die Einstellungen?

  • In der Datenbank, nicht in Dateien. In der config.php stehen nur Zugangsdaten und ein paar Schalter, die vor der Datenbank greifen müssen.

E-Mail kommt nicht an

  • Adminbereich → E-Mail: Zugangsdaten eintragen, speichern, dann die Test-Mail darunter benutzen.
  • Ohne SMTP-Server fällt das Forum auf mail() zurück — das landet oft im Spam.

Hinter einem Reverse Proxy

  • Sonst sieht das Forum die IP des Proxys statt die des Besuchers. In die config.php: define('TRUSTED_PROXIES', ['127.0.0.1']); — nur dann werden Weiterleitungs-Kopfzeilen überhaupt gelesen.

Etwas geht kaputt, ich sehe nichts

  • Fehler gehen ins Protokoll, nie auf den Bildschirm. Zum Entwickeln: define('FORUM_DEBUG', true);
  • Es gilt eine Content-Security-Policy. Bricht ein eingebundenes Skript, schaltet define('FORUM_CSP', false); sie ab — als Notausgang, nicht als Dauerzustand.

Design ändern

  • Adminbereich → Design: Farben, Schrift, Abstände, Kopfzeile.
  • Ohne eigenes Logo wird eines aus dem Forennamen erzeugt. Ein eigenes: assets/logo.svg ablegen — lokale SVGs werden eingebettet, ein currentColor darin geht dann in hell und dunkel mit.
nginx statt Apache? Dann brauchst du die Regeln aus den mitgelieferten .htaccess-Dateien als Direktiven im Server-Block — vor allem: kein PHP unter uploads/, und lib/, views/, views_new/, cache/, lang/, backup/ dichtmachen. Frag im Forum nach dem fertigen Block, statt ihn nachzubauen.

Module

Forumix hat eine Modul-Schnittstelle. Ein Modul ist ein Ordner unter modules/ — mehr braucht es nicht, damit der Adminbereich es findet. Es kann eigene Seiten und Ansichten mitbringen, eigene Tabellen anlegen, sich an definierten Stellen ins Layout hängen und eigene Einstellungen speichern. Ein Modul ist kein Fremdkörper: es benutzt dieselben Funktionen wie der Kern.

Eigene Seiten

  • Eine PHP-Datei im Modulordner, direkt aufrufbar. Sie startet das Forum selbst und hat danach Anmeldung, Rechte und Layout zur Verfügung.

Eigene Ansichten

  • render('@code/name') holt views/name.php aus dem Modul und setzt es in das Layout des Forums — mit Kopf, Fuß und Theme.

Einhängepunkte

  • Eine Datei unter hooks/ wird an der passenden Stelle eingebunden, solange das Modul aktiv ist. Ohne Eingriff im Kern.

Eigene Tabellen

  • install.sql läuft beim ersten Aktivieren. Die Daten bleiben, wenn das Modul später wieder ausgeschaltet wird.

Eigene Einstellungen

  • Ein JSON-Feld je Modul, über zwei Funktionen erreichbar. Keine eigene Tabelle nötig, nur um sich drei Werte zu merken.

Eintrag in der Navigation

  • Ein Feld im Manifest genügt, und der Punkt steht in der Kopfzeile und im Fußbereich — nur solange das Modul aktiv ist.

Ein- und Ausschalten

  • Zwei optionale Funktionen im Manifest laufen beim Aktivieren und beim Deaktivieren — für Aufräumarbeiten oder eine erste Befüllung.

Vom Ordner zum laufenden Modul

  1. Ordner ablegen

    modules/meinmodul/ mit einer module.php darin. Der Adminbereich listet ab sofort alles auf, was eine module.php hat.

  2. Aktivieren

    Beim ersten Einschalten läuft install.sql — genau einmal. Danach wird das Modul in der Tabelle modules vermerkt.

  3. Deaktivieren

    Nur der Schalter geht auf 0. Tabellen und Inhalte bleiben unangetastet, ein erneutes Einschalten holt alles zurück.

Aufbau

modules/meinmodul/
  module.php          Pflicht — gibt ein Array zurück (das Manifest)
  install.sql         optional — läuft EINMAL beim ersten Aktivieren
  meinmodul.php       optional — eigene Seite, direkt aufrufbar
  views/liste.php     optional — render('@meinmodul/liste')
  hooks/home_top.php  optional — Einhängepunkt im Layout
Der Ordnername ist der Code des Moduls und muss zum Feld code im Manifest passen. Erlaubt sind nur a–z 0–9 _ - — der Name landet in einem Dateipfad, deshalb die enge Regel.

Das Manifest

<?php
// modules/meinmodul/module.php
if (!defined('FORUM_RUNNING')) return [];

return [
    'code'        => 'meinmodul',        // Pflicht, = Ordnername
    'name'        => 'Mein Modul',
    'description' => 'Was es tut, in einem Satz.',
    'icon'        => '🧩',
    'version'     => '1.0',
    'author'      => 'Dein Name',
    // Punkt in Navigation und Fußbereich. Weglassen = kein Eintrag.
    // 'admin' => true blendet ihn für alle außer der Verwaltung aus — das ist
    // Bequemlichkeit, die Seite dahinter muss ihre Rechte selbst prüfen.
    'menu'        => ['label' => 'Mein Modul', 'url' => 'modules/meinmodul/meinmodul.php'],
    'on_enable'   => function () { /* nach dem Einschalten */ },
    'on_disable'  => function () { /* vor dem Ausschalten */ },
];

Ein vollständiges Beispiel

„Zitat des Tages": zeigt auf der Startseite ein zufälliges Zitat und hat eine eigene Seite, auf der Admins Zitate pflegen. Fünf Dateien, alles dabei — Tabelle, Seite, Ansicht, Einhängepunkt.

1. modules/zitate/module.php

<?php
if (!defined('FORUM_RUNNING')) return [];

return [
    'code'        => 'zitate',
    'name'        => 'Zitat des Tages',
    'description' => 'Zeigt oben auf der Startseite ein zufälliges Zitat.',
    'icon'        => '💭',
    'version'     => '1.0',
    'author'      => 'Dein Name',
    'menu'        => ['label' => 'Zitate', 'url' => 'modules/zitate/zitate.php'],
];

2. modules/zitate/install.sql

CREATE TABLE IF NOT EXISTS `zitate` (
  `zitat_id`   INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  `text`       VARCHAR(500) NOT NULL,
  `quelle`     VARCHAR(120) NOT NULL DEFAULT '',
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- IGNORE, damit ein zweiter Durchlauf nicht an einem doppelten Schlüssel stirbt
INSERT IGNORE INTO `zitate` (`zitat_id`, `text`, `quelle`) VALUES
(1, 'Erst wenn es kaputt ist, weiß man, wie es funktioniert.', 'Werkstattweisheit');

3. modules/zitate/hooks/home_top.php

<?php
// Wird auf der Startseite eingebunden, solange das Modul aktiv ist.
// $currentUser und $csrfToken stehen hier bereit.
$z = db_fetch('SELECT text, quelle FROM zitate ORDER BY RAND() LIMIT 1');
if ($z):
?>
<div class="card" style="margin-bottom:16px">
  💭 <em><?= e($z['text']) ?></em>
  <?php if ($z['quelle'] !== ''): ?>
    <span style="color:var(--text-muted)">— <?= e($z['quelle']) ?></span>
  <?php endif; ?>
</div>
<?php endif; ?>

4. modules/zitate/zitate.php — die eigene Seite

<?php
if (!defined('FORUM_RUNNING')) define('FORUM_RUNNING', true);
require_once __DIR__ . '/../../bootstrap.php';

// Aus heißt aus — sonst bliebe die Seite auch im abgeschalteten Zustand erreichbar.
if (!module_enabled('zitate')) {
    error_page(404, 'Nicht gefunden', 'Dieses Modul ist nicht aktiv.');
}

$cu       = current_user();
$canAdmin = $cu && ($cu['is_admin'] || !empty($cu['is_owner']));

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // Beides prüfen: Token gegen fremde Formulare, Rechte gegen neugierige Besucher.
    if (!verify_csrf($_POST['csrf'] ?? '')) {
        error_page(400, 'Sicherheits-Token ungültig', 'Bitte lade die Seite neu.');
    }
    if (!$canAdmin) {
        error_page(403, 'Kein Zugriff', 'Zitate darf nur die Verwaltung pflegen.');
    }

    if (($_POST['tun'] ?? '') === 'anlegen') {
        $text   = trim($_POST['text'] ?? '');
        $quelle = trim($_POST['quelle'] ?? '');
        if ($text !== '') {
            // Werte gehören in die Platzhalter, nie in den SQL-Text.
            db_execute('INSERT INTO zitate (text, quelle) VALUES (?, ?)',
                       [mb_substr($text, 0, 500), mb_substr($quelle, 0, 120)]);
            flash('Zitat gespeichert.', 'success');
        } else {
            flash('Ohne Text geht es nicht.', 'error');
        }
    }

    if (($_POST['tun'] ?? '') === 'loeschen') {
        db_execute('DELETE FROM zitate WHERE zitat_id = ?', [(int) ($_POST['id'] ?? 0)]);
        flash('Zitat gelöscht.', 'success');
    }

    // Nach dem Schreiben umleiten, sonst legt ein Neuladen alles noch einmal an.
    redirect(FORUM_URL . '/modules/zitate/zitate.php');
}

render('@zitate/liste', [
    'zitate'   => db_fetchAll('SELECT * FROM zitate ORDER BY zitat_id DESC'),
    'canAdmin' => $canAdmin,
]);

5. modules/zitate/views/liste.php

<?php if (!defined('FORUM_RUNNING')) { http_response_code(403); die('Forbidden'); } ?>
<h1>Zitate</h1>

<?php if ($canAdmin): ?>
  <form method="post" class="form-card">
    <!-- csrfToken kommt von render() und ist in jeder Ansicht da -->
    <input type="hidden" name="csrf" value="<?= e($csrfToken) ?>">
    <input type="hidden" name="tun" value="anlegen">
    <div class="form-row">
      <label>Zitat</label>
      <input type="text" name="text" maxlength="500" required>
    </div>
    <div class="form-row">
      <label>Quelle</label>
      <input type="text" name="quelle" maxlength="120">
    </div>
    <button class="btn btn-primary">Speichern</button>
  </form>
<?php endif; ?>

<?php foreach ($zitate as $z): ?>
  <div class="card" style="margin-top:10px">
    <!-- Alles, was aus der Datenbank kommt, durch e() -->
    <em><?= e($z['text']) ?></em>
    <?php if ($z['quelle'] !== ''): ?> — <?= e($z['quelle']) ?><?php endif; ?>

    <?php if ($canAdmin): ?>
      <form method="post" style="display:inline">
        <input type="hidden" name="csrf" value="<?= e($csrfToken) ?>">
        <input type="hidden" name="tun" value="loeschen">
        <input type="hidden" name="id" value="<?= (int) $z['zitat_id'] ?>">
        <button class="btn btn-ghost">Löschen</button>
      </form>
    <?php endif; ?>
  </div>
<?php endforeach; ?>
Ordner nach modules/ hochladen, im Adminbereich unter Module einschalten — fertig. Das Zitat steht auf der Startseite, die Pflegeseite liegt unter /modules/zitate/zitate.php.

Einhängepunkte

Eine Datei hooks/<name>.php wird an der passenden Stelle eingebunden, sofern das Modul aktiv ist. Im Geltungsbereich stehen $currentUser und $csrfToken bereit.

NameWoZusätzlich im Geltungsbereich
layout_topÜber allem, auf jeder Seite–
home_topStartseite, über der Forenübersicht–
thread_topThema, über dem ersten Beitrag$thread
post_footerUnter jedem Beitrag im Thema$post, $thread
Eigene Einhängepunkte setzt du selbst: ein render_module_hook('name') an der gewünschten Stelle im Layout genügt. Das Einbinden aller aktiven Module übernimmt der Kern von da an allein.

Einstellungen je Modul

$cfg = module_config('zitate');            // Array, leer wenn nichts gesetzt
$cfg['anzahl'] = 3;
set_module_config('zitate', $cfg);         // als JSON in modules.config

Was der Kern bereitstellt

Nach dem require der bootstrap.php stehen diese Funktionen zur Verfügung. Es gibt keine eigene Modul-API daneben — was der Kern benutzt, benutzt dein Modul auch.

AufrufTut
db_fetchAll($sql, $params)Liste von Zeilen
db_fetch($sql, $params)Eine Zeile oder null
db_count($sql, $params)Eine Zahl
db_execute($sql, $params)Schreiben, liefert betroffene Zeilen
current_user()Angemeldetes Konto oder null
module_enabled($code)Ist das Modul an?
module_config($code) · set_module_config($code, $cfg)Einstellungen des Moduls
render($view, $daten)Ansicht ins Layout setzen
e($text)Ausgabe maskieren
verify_csrf($token)Token prüfen
flash($text, $art) · redirect($url)Meldung merken, umleiten
error_page($code, $titel, $text)Fehlerseite im Layout

Was du einhalten solltest

Nichts direkt aufrufbar

  • Jede Datei, die kein Einstiegspunkt ist, beginnt mit dem Wächter if (!defined('FORUM_RUNNING')) { http_response_code(403); die('Forbidden'); }

Immer vorbereitete Anweisungen

  • db_fetchAll('… WHERE id = ?', [$id]) — nie Werte in den SQL-Text kleben, auch nicht „nur diese eine Zahl".

Ausgabe maskieren

  • Alles, was aus der Datenbank oder vom Besucher kommt, durch e(). Für Beitragstext gibt es die BBCode-Ausgabe des Kerns.

Nur mit gültigem Konto

  • Rechte über current_user() prüfen, nicht über das, was das Formular mitschickt. Für Admin-Aktionen zusätzlich is_admin oder is_owner.

install.sql wiederholbar

  • CREATE TABLE IF NOT EXISTS und INSERT IGNORE. Bricht die Aktivierung mittendrin ab, läuft die Datei beim nächsten Versuch erneut.

Pflichtspalten mitgeben

  • Spalten mit NOT NULL ohne Vorgabewert gehören in jedes INSERT. Mit STRICT_TRANS_TABLES bricht es sonst mit Fehler 1364 ab.