Skip to content

Beispiel: Hello World Plugin

Ein komplettes Minimal-Plugin von null — nach Copy-Paste und Plugin-Manager-Aktivierung lauffaehig. Zeigt alle wichtigen Bausteine: Bootstrap, Migration, eigener API-Endpoint, Vue-Layout.

Das Plugin helloworld zeigt einen einfachen Admin-Bereich, der eine Gruss-Nachricht aus der Datenbank laedt und ueber ein eigenes API-Model aktualisiert.

Struktur

_public/extensions/core/backend/helloworld/
├── bootstrap.php                 # Plugin-Registrierung
├── migrations/
│   └── 001_create_table.sql      # DB-Tabelle
├── api/
│   └── backend/
│       └── helloworld/
│           └── model.php         # Plugin-eigener API-Endpoint
└── layout/
    └── index.vue                 # Admin-UI

1. bootstrap.php

php
<?php

class helloworld_BackendPlugin extends install_controller
{
    public static function getVersion(): string
    {
        return "1.0.0";
    }

    public static function getName(): string
    {
        return "Hello World";
    }

    public function install()
    {
        $this->url_rewrites_backend = ["helloworld"];
        $this->content_construct    = ["template"];
        $this->content_table        = [""];
        $this->backend_widget_base  = ["core/backend/helloworld"];
        $this->content_titles       = ["Hello World"];
        $this->content_columns      = [""];
        $this->content_button       = ['[]'];
        $this->action_button        = ['[]'];
        $this->content_replace      = ['{}'];
        $this->header_buttons       = ['[]'];
        $this->modal_edits          = ['{}'];

        $this->icon       = "fa-light fa-hand-wave";
        $this->menu_group = 5;

        $this->apiEndpoints = [
            '{"modelPath": "backend/helloworld/model.php", "endpoint": "backend/helloworld"}'
        ];
    }

    public function onLoad()
    {
        parent::onLoad();

        $q = query("SELECT message FROM helloworld_greetings ORDER BY id DESC LIMIT 1");
        $message = num_rows($q) > 0 ? fetch_assoc($q)['message'] : 'Hello World!';

        if ($GLOBALS['isApiCall']) {
            return json_encode(['message' => $message]);
        }
        return "[]";
    }

    public function uninstall() { }
    public static function update() { }

    public function getDirectory()
    {
        return $this->getDirectoryClass(__DIR__);
    }
}

Das ist ein Template-Construct: Kein DataTable-CRUD, stattdessen eigene layout/index.vue mit Daten aus onLoad().

2. Migration: migrations/001_create_table.sql

sql
-- 001_create_table.sql — helloworld-Plugin

CREATE TABLE IF NOT EXISTS helloworld_greetings (
  id         INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  message    VARCHAR(500) NOT NULL DEFAULT 'Hello World!',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- Initialer Datensatz
INSERT INTO helloworld_greetings (message) VALUES ('Hello from newmeta!');

Die Migration wird erst beim naechsten MigrationRunner-Durchlauf eingespielt — nicht automatisch beim Plugin-Aktivieren. Details: Migrations.

3. API-Model: api/backend/helloworld/model.php

php
<?php

class backend_helloworld extends apiBaseModel
{
    protected array $publicMethods = [];   // alle Methoden brauchen Auth

    protected function apiAction(): void
    {
        if (empty($_SESSION['backend_loggedin']) || !$_SESSION['backend_loggedin']) {
            $this->error('Unauthorized', 401);
            return;
        }

        $method = strtoupper($_SERVER['REQUEST_METHOD']);
        switch ($method) {
            case 'GET':  $this->getMessage(); break;
            case 'POST': $this->saveMessage(); break;
            default:     $this->notSupported();
        }
    }

    private function getMessage(): void
    {
        $q = query("SELECT message FROM helloworld_greetings ORDER BY id DESC LIMIT 1");
        if (num_rows($q) === 0) {
            $this->success(['message' => 'Hello World!']);
            return;
        }
        $this->success(fetch_assoc($q));
    }

    private function saveMessage(): void
    {
        $body = json_decode(file_get_contents('php://input'), true);
        if (!is_array($body) || empty($body['message'])) {
            $this->error('Message required', 400);
            return;
        }

        $message = real_escape_string(trim($body['message']));
        if (mb_strlen($message) > 500) {
            $this->error('Message too long (max 500 chars)', 422);
            return;
        }

        query("INSERT INTO helloworld_greetings (message) VALUES ('$message')");
        $this->success(['message' => $message, 'id' => insert_id()]);
    }
}

Wichtig:

  • Klasse heisst backend_helloworld (Pfad backend/helloworld mit /_)
  • apiAction() checkt $_SESSION['backend_loggedin'] — Backend-Endpoints brauchen das immer
  • real_escape_string() fuer String-Inputs, insert_id() statt last_insert_id()
  • json_decode(file_get_contents('php://input'), true) fuer den Request-Body

Details: API-Endpunkte.

4. Vue-Layout: layout/index.vue

vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'

const message = ref('')
const draft   = ref('')
const saving  = ref(false)
const error   = ref('')

async function load() {
  try {
    const res = await $fetch<{ message: string }>('/api/backend/helloworld', {
      method: 'GET',
      credentials: 'include',
    })
    message.value = res.message
    draft.value   = res.message
  } catch (e: any) {
    error.value = e?.statusMessage || 'Load failed'
  }
}

async function save() {
  if (!draft.value.trim()) return
  saving.value = true
  error.value  = ''
  try {
    const res = await $fetch<{ message: string }>('/api/backend/helloworld', {
      method: 'POST',
      credentials: 'include',
      body: { message: draft.value.trim() },
    })
    message.value = res.message
  } catch (e: any) {
    error.value = e?.statusMessage || 'Save failed'
  } finally {
    saving.value = false
  }
}

onMounted(load)
</script>

<template>
  <div class="helloworld-plugin">
    <h1>Hello World</h1>
    <p>Aktueller Gruss: <strong>{{ message || '—' }}</strong></p>

    <div class="form-group">
      <label>Neuer Gruss</label>
      <input v-model="draft" type="text" maxlength="500" class="form-control" />
    </div>

    <button class="btn btn-primary" :disabled="saving || !draft.trim()" @click="save">
      {{ saving ? 'Speichere …' : 'Speichern' }}
    </button>

    <p v-if="error" class="text-danger mt-2">{{ error }}</p>
  </div>
</template>

<style scoped>
.helloworld-plugin { padding: 1.5rem; max-width: 600px; }
.form-group { margin-bottom: 1rem; }
</style>

Weil das Plugin content_construct=template hat, laedt die Admin-Shell diese index.vue via import.meta.glob dynamisch und rendert sie anstelle der Standard-DataTable. Der Scan deckt alle Plugin-Layouts unter _public/extensions/core/backend/*/layout/ ab. Details zum Admin-Routing: Theme-Doku › Struktur.

5. Plugin aktivieren

  1. Ordner _public/extensions/core/backend/helloworld/ mit allen vier Dateien anlegen
  2. Backend-Login → /admin/plugin-manager
  3. "Hello World" in der Liste suchen → Aktivieren
  4. MigrationRunner triggern/admin/update oeffnen, "Migrations ausfuehren", oder CLI: php console/bin scheduled-tasks --time-limit=60
  5. /admin/helloworld aufrufen — Plugin laedt den initialen "Hello from newmeta!"-Text
  6. Eingabefeld benutzen, speichern, Seite reloaden — neuer Gruss wird angezeigt

Pruefung

Alles ok, wenn:

  • SELECT * FROM plugin_backend WHERE plugin_id = … liefert eine Zeile mit content_construct = 'template' und url_rewrite = 'helloworld'
  • SELECT * FROM pluginAPI WHERE plugin_id = … liefert endpoint = 'backend/helloworld', modelPath = 'backend/helloworld/model.php'
  • SELECT * FROM helloworld_greetings liefert mind. einen Datensatz (Initial-INSERT)
  • curl -b cookie.txt https://domain/api/backend/helloworld liefert {"message":"..."} (Backend-Session vorausgesetzt)

Erweiterungen

Was das Plugin jetzt noch nicht hat (Uebung fuers nachvollziehen):

  • Multi-Language: base_id + language_short in Tabelle, updateItem-Pattern fuer Sprach-Tabs
  • Webhook-Event: $this->webhookEvents = ['{"name":"helloworld.updated","type":"manual","description":"..."}'] + Dispatch in saveMessage(). Siehe Webhook-Events
  • Scheduled Task: $this->scheduledTasks + Script unter scheduledTasks/cleanup_old_greetings.php. Siehe Scheduled Tasks
  • Rechte-System: Derzeit darf jeder Backend-User bearbeiten. Mit content_button-JSON + Button-Rechten einschraenken. Siehe Buttons

Haeufige Fehler beim Nachbauen

404 nach Plugin-Aktivieren

Wenn /admin/helloworld 404 liefert: MigrationRunner noch nicht gelaufen → keine Tabelle, onLoad() wirft SQL-Fehler. Fix: /admin/update oder php console/bin scheduled-tasks ausfuehren.

401 beim API-Call

Fehlender Klassen-Name oder $_SESSION['backend_loggedin']-Check schlaegt fehl. Pfad backend/helloworld → Klasse backend_helloworld (mit Unterstrich).

"Class 'helloworld_BackendPlugin' not found" beim Aktivieren

Klassenname im bootstrap.php passt nicht zum Ordnernamen. Ordner heisst helloworld → Klasse muss helloworld_BackendPlugin heissen (exakt, Gross-/Kleinschreibung).

onLoad() gibt PHP-Array zurueck

Core echot den Rueckgabewert ungefiltert — PHP-Arrays werden als Array-String geliefert. Immer json_encode() nutzen.

Vue-Komponente laedt nicht

Bei content_construct=template muss layout/index.vue exakt an diesem Pfad liegen. Die Admin-Shell scannt alle Plugin-Layouts beim Build via import.meta.glob — Abweichungen von der Standard-Struktur ({plugin}/layout/*.vue) werden nicht gefunden. Der genaue Glob-Pfad liegt in den Admin-Komponenten unter _theme/vue-base/app/components/admin/ (z. B. NsTemplate.vue, NsDetail.vue).

Siehe auch