Skip to content

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 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__);
    }
}

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

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
<?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 (path backend/helloworld with /_)
  • apiAction() checks $_SESSION['backend_loggedin'] — backend endpoints always need this
  • real_escape_string() for string inputs, insert_id() instead of last_insert_id()
  • json_decode(file_get_contents('php://input'), true) for the request body

Details: API Endpoints.

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>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

  1. Create _public/extensions/core/backend/helloworld/ with all four files
  2. Log into the backend → /admin/plugin-manager
  3. Find "Hello World" in the list → Activate
  4. Trigger the MigrationRunner — open /admin/update, click "Run migrations", or use the CLI: php console/bin scheduled-tasks --time-limit=60
  5. Open /admin/helloworld — the plugin loads the initial "Hello from newmeta!" text
  6. Edit the input, save, reload — the new greeting appears

Verification

Everything is fine if:

  • SELECT * FROM plugin_backend WHERE plugin_id = … returns one row with content_construct = 'template' and url_rewrite = 'helloworld'
  • SELECT * FROM pluginAPI WHERE plugin_id = … returns endpoint = 'backend/helloworld', modelPath = 'backend/helloworld/model.php'
  • SELECT * FROM helloworld_greetings returns at least one row (the initial INSERT)
  • curl -b cookie.txt https://domain/api/backend/helloworld returns {"message":"..."} (assuming a valid backend session)

Extensions

What the plugin still lacks (exercises to reinforce the concepts):

  • Multi-language: base_id + language_short on the table, updateItem pattern for language tabs
  • Webhook event: $this->webhookEvents = ['{"name":"helloworld.updated","type":"manual","description":"..."}'] + dispatch inside saveMessage(). See Webhook Events
  • Scheduled task: $this->scheduledTasks + script under scheduledTasks/cleanup_old_greetings.php. See Scheduled Tasks
  • Permissions: currently every backend user can edit. Restrict via content_button JSON + 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