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-UI1. bootstrap.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
-- 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
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(Pfadbackend/helloworldmit/→_) apiAction()checkt$_SESSION['backend_loggedin']— Backend-Endpoints brauchen das immerreal_escape_string()fuer String-Inputs,insert_id()stattlast_insert_id()json_decode(file_get_contents('php://input'), true)fuer den Request-Body
Details: API-Endpunkte.
4. Vue-Layout: layout/index.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
- Ordner
_public/extensions/core/backend/helloworld/mit allen vier Dateien anlegen - Backend-Login →
/admin/plugin-manager - "Hello World" in der Liste suchen → Aktivieren
- MigrationRunner triggern —
/admin/updateoeffnen, "Migrations ausfuehren", oder CLI:php console/bin scheduled-tasks --time-limit=60 /admin/helloworldaufrufen — Plugin laedt den initialen "Hello from newmeta!"-Text- Eingabefeld benutzen, speichern, Seite reloaden — neuer Gruss wird angezeigt
Pruefung
Alles ok, wenn:
SELECT * FROM plugin_backend WHERE plugin_id = …liefert eine Zeile mitcontent_construct = 'template'undurl_rewrite = 'helloworld'SELECT * FROM pluginAPI WHERE plugin_id = …liefertendpoint = 'backend/helloworld',modelPath = 'backend/helloworld/model.php'SELECT * FROM helloworld_greetingsliefert mind. einen Datensatz (Initial-INSERT)curl -b cookie.txt https://domain/api/backend/helloworldliefert{"message":"..."}(Backend-Session vorausgesetzt)
Erweiterungen
Was das Plugin jetzt noch nicht hat (Uebung fuers nachvollziehen):
- Multi-Language:
base_id+language_shortin Tabelle,updateItem-Pattern fuer Sprach-Tabs - Webhook-Event:
$this->webhookEvents = ['{"name":"helloworld.updated","type":"manual","description":"..."}']+ Dispatch insaveMessage(). Siehe Webhook-Events - Scheduled Task:
$this->scheduledTasks+ Script unterscheduledTasks/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
- Plugin-Anatomie — alle Properties und Pflicht-Methoden
- Content Constructs — Unterschied table/template/formonly
- API-Endpunkte — apiBaseModel, Response-Helper, Klassen-Naming
- Migrations — wie die Migration eingespielt wird
- Widgets › Widget-Anatomie — naechster Schritt: Pagebuilder-Widget zum Plugin