Example: Hello World Plugin
A complete minimal plugin from scratch — ready to run after copy-paste and activation in the Plugin Manager. It covers every key building block: bootstrap, migration, a plugin-local API endpoint, and a Vue layout.
The helloworld plugin adds a simple admin area that loads a greeting from the database and updates it through its own API model.
Structure
_public/extensions/core/backend/helloworld/
├── bootstrap.php # Plugin registration
├── migrations/
│ └── 001_create_table.sql # DB table
├── api/
│ └── backend/
│ └── helloworld/
│ └── model.php # Plugin-local 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__);
}
}This is a template construct: no DataTable CRUD — instead, a custom layout/index.vue rendered with data from 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;
-- Initial record
INSERT INTO helloworld_greetings (message) VALUES ('Hello from newmeta!');The migration is applied on the next MigrationRunner run — not automatically when the plugin is activated. Details: Migrations.
3. API model: api/backend/helloworld/model.php
<?php
class backend_helloworld extends apiBaseModel
{
protected array $publicMethods = []; // every method requires 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()]);
}
}Key points:
- Class name is
backend_helloworld(pathbackend/helloworldwith/→_) apiAction()checks$_SESSION['backend_loggedin']— backend endpoints always need thisreal_escape_string()for string inputs,insert_id()instead oflast_insert_id()json_decode(file_get_contents('php://input'), true)for the request body
Details: API Endpoints.
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>Current greeting: <strong>{{ message || '—' }}</strong></p>
<div class="form-group">
<label>New greeting</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 ? 'Saving …' : 'Save' }}
</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>Because the plugin uses content_construct=template, the admin shell loads this index.vue dynamically via import.meta.glob and renders it instead of the default DataTable. The glob covers every plugin layout under _public/extensions/core/backend/*/layout/. Details on admin routing: Themes › Structure.
5. Activate the plugin
- Create
_public/extensions/core/backend/helloworld/with all four files - Log into the backend →
/admin/plugin-manager - Find "Hello World" in the list → Activate
- Trigger the MigrationRunner — open
/admin/update, click "Run migrations", or use the CLI:php console/bin scheduled-tasks --time-limit=60 - Open
/admin/helloworld— the plugin loads the initial "Hello from newmeta!" text - Edit the input, save, reload — the new greeting appears
Verification
Everything is fine if:
SELECT * FROM plugin_backend WHERE plugin_id = …returns one row withcontent_construct = 'template'andurl_rewrite = 'helloworld'SELECT * FROM pluginAPI WHERE plugin_id = …returnsendpoint = 'backend/helloworld',modelPath = 'backend/helloworld/model.php'SELECT * FROM helloworld_greetingsreturns at least one row (the initial INSERT)curl -b cookie.txt https://domain/api/backend/helloworldreturns{"message":"..."}(assuming a valid backend session)
Extensions
What the plugin still lacks (exercises to reinforce the concepts):
- Multi-language:
base_id+language_shorton the table,updateItempattern for language tabs - Webhook event:
$this->webhookEvents = ['{"name":"helloworld.updated","type":"manual","description":"..."}']+ dispatch insidesaveMessage(). See Webhook Events - Scheduled task:
$this->scheduledTasks+ script underscheduledTasks/cleanup_old_greetings.php. See Scheduled Tasks - Permissions: currently every backend user can edit. Restrict via
content_buttonJSON + button rights. See Buttons
Common pitfalls when reproducing
404 after activating the plugin
If /admin/helloworld returns 404: the MigrationRunner has not run yet → no table, onLoad() throws a SQL error. Fix: run /admin/update or php console/bin scheduled-tasks.
401 on the API call
A missing class name or a failing $_SESSION['backend_loggedin'] check. Path backend/helloworld → class backend_helloworld (with underscore).
"Class 'helloworld_BackendPlugin' not found" on activation
The class name in bootstrap.php doesn't match the folder. Folder is helloworld → class must be helloworld_BackendPlugin (exact, case-sensitive).
onLoad() returns a PHP array
The core echoes the return value unfiltered — PHP arrays arrive as the literal string Array. Always use json_encode().
Vue component fails to load
For content_construct=template, the layout/index.vue must live at exactly that path. The admin shell scans every plugin layout at build time via import.meta.glob — deviations from the standard structure ({plugin}/layout/*.vue) are not picked up. The exact glob path lives in the admin components under _theme/vue-base/app/components/admin/ (e.g. NsTemplate.vue, NsDetail.vue).
See also
- Plugin Anatomy — every property and required method
- Content Constructs — difference between table/template/formonly
- API Endpoints — apiBaseModel, response helpers, class naming
- Migrations — how the migration is applied
- Widgets › Widget Anatomy — next step: add a Pagebuilder widget to the plugin