Inhaltsverzeichnis

Writing a plugin for Admidio

Admidio can be extended with plugins. A plugin lives in its own directory under plugins/, is switched on and off per organization, and can be removed again without leaving anything behind. You never change Admidio itself.

Plugins cover a wide range:

Nothing forces you up that ladder. A plugin that only registers one hook stays two files forever.

Important: This describes the plugin system of Admidio 5.1. Plugins written for 5.0 and earlier use a different mechanism and do not work unchanged.

The smallest plugin

Two files. Create plugins/hello/:

plugin.json
{
    "name": "Hello",
    "version": "1.0.0"
}
plugin.php
<?php
use Admidio\Hooks\Hooks;
 
Hooks::addFilter('page_headline', function (string $headline): string {
    return $headline . ' 👋';
});

That is a working plugin. Open Preferences → Plugins, switch it on, and every page headline gets a wave.

Three things are worth taking from this:

Everything a plugin may contain. Only the first two entries are required — take what you need.

plugins/my-plugin/
├── plugin.json                     the manifest: name, version, requirements, settings
├── plugin.php                      the entry file, included once when the plugin is loaded
├── README.md                       for whoever finds your plugin
│
├── modules/                        pages, each an ordinary Admidio page
│   ├── index.php                     plugins/my-plugin/modules/index.php
│   └── edit.php
│
├── src/                            your classes, reached through the "autoload" mapping
│   ├── MyPlugin.php                  registers the widget and the preferences panel
│   ├── Entity/
│   │   └── Item.php                  extends Admidio\Infrastructure\Entity\Entity
│   ├── Service/
│   │   └── ItemService.php           the save and delete logic of a form
│   └── Presenter/
│       └── ItemPresenter.php         builds the pages and forms
│
├── languages/                      your own strings, one file per language
│   ├── en.xml
│   └── de.xml
│
├── templates/                      Smarty templates, overridable by a theme
│   ├── my-plugin.list.tpl
│   └── plugin.my-plugin.tpl          the overview widget
│
├── assets/                         css, js, images shipped with the plugin
│   └── my-plugin.css
│
├── tests/                          run together with Admidio's own suite
│   ├── Unit/
│   │   └── ManifestTest.php
│   └── Integration/
│       └── ItemServiceTest.php
│
└── db_scripts/                     your own database tables
    ├── install.sql                   runs when the plugin is installed
    ├── uninstall.sql                 runs when it is removed
    └── update_1_1.xml                upgrades an installed plugin to 1.1

The directory names are fixed — Admidio looks for modules, languages, templates, assets and db_scripts by name. Inside src/ you may arrange things however you like; the example follows Admidio's own split into entities, services and presenters.

How a plugin is found and loaded

Admidio reads plugins/*/plugin.json on every request. That is a directory scan and a json_decode, nothing more — discovery never runs plugin code. A plugin with a broken manifest is reported in the plugin manager and skipped; it can never break the rest of Admidio.

plugin.php is included only for a plugin that is installed and enabled for the current organization, once, after the database, the session and the current user are ready. So the entry file may call any Admidio API.

A plugin that throws while loading is logged and skipped. The rest of the request continues.

The states a plugin can be in

State Meaning
Available The files are there. The plugin does nothing.
Enabled Switched on for this organization.
Update available The files are newer than the database, or the plugin store publishes a newer version.
Faulty The manifest is missing or malformed, or a requirement is not met.
Files missing Admidio still has the plugin in its database, but the directory is gone.

Installing is not a separate step. The first organization that switches a plugin on is what prepares it; every other organization only flips its own switch.

The manifest

plugin.json
{
    "name": "PLG_MY_PLUGIN_NAME",
    "description": "PLG_MY_PLUGIN_DESCRIPTION",
    "version": "1.2.0",
    "author": "Your name",
    "url": "https://example.org/my-plugin",
    "icon": "bi-emoji-smile",
    "requires": {
        "admidio": ">=5.1 <6.0",
        "php": ">=8.2",
        "extensions": ["json"],
        "modules": ["events"],
        "plugins": { "calendar-tools": ">=1.2" }
    },
    "autoload": { "AdmidioPlugin\\MyPlugin\\": "src/" },
    "preferences": { "section": "overview_extensions", "sequence": 20 },
    "settings": {
        "my_plugin_greeting": {
            "type": "string",
            "default": "Hello",
            "label": "PLG_MY_PLUGIN_GREETING",
            "description": "PLG_MY_PLUGIN_GREETING_DESC"
        }
    }
}

Only name and version are required.

Language keys instead of text

Wherever this page says a value may be a language key, you can write either a piece of text or the name of a string from a language file. Admidio decides by looking at the value itself.

A value counts as a language key when it is three upper case letters, an underscore, and then upper case letters, digits and single underscoresPLG_MY_PLUGIN_NAME. Anything else is shown exactly as written:

Value What happens
“PLG_MY_PLUGIN_NAME” looked up in your languages/ files
“SYS_SAVE” looked up in Admidio's own files
“My plugin” shown as it is
“MY_PLUGIN_NAME” shown as it is — only two letters before the first underscore
“PLUGIN_NAME” shown as it is — six letters before the first underscore

So a plugin for one language can simply write its text into the manifest, and a plugin that wants to be translated puts a key there and defines it in languages/en.xml, languages/de.xml and so on. The same applies to the label and description of a setting and to the names of the values of an enum.

Important: The three-letter prefix is not a convention, it is the rule. A key such as MYPLUGIN_NAME is never looked up and appears in the interface as raw text. Use PLG_ followed by your plugin name.

requires

An unmet admidio, php, extensions or plugins requirement makes the plugin faulty and it cannot be used.

settings

Every setting becomes an ordinary Admidio preference: registered when the plugin loads, given a row in every organization when it is installed, removed when the plugin is removed. It is validated and written to the change history like any other preference.

"settings": {
    "my_count":  { "type": "integer", "default": 3, "min": 1, "max": 20, "step": 1,
                   "label": "PLG_MY_PLUGIN_COUNT" },
    "my_shout":  { "type": "boolean", "default": false, "label": "PLG_MY_PLUGIN_SHOUT" },
    "my_order":  { "type": "enum", "default": "ASC",
                   "values": { "ASC": "PLG_MY_PLUGIN_ASCENDING", "DESC": "PLG_MY_PLUGIN_DESCENDING" },
                   "label": "PLG_MY_PLUGIN_ORDER" },
    "my_names":  { "type": "array", "default": [], "label": "PLG_MY_PLUGIN_NAMES" }
}

Admidio builds the settings form from this. You only write a preferences panel of your own when you need something the manifest cannot express, such as a role selection.

Tip: Read settings through the plugin, not through $gSettingsManager:

$values = PluginRegistry::get('my-plugin')->getSettingValues();

A setting whose row does not exist yet — a new setting in a new version, before the update ran — answers the default from the manifest instead of throwing.

preferences

Where the settings of the plugin appear. section is one of system, login_security, user_management, communication, content_management, overview_extensions or extensions, and sequence places the panel inside that tab. A plugin that names nothing lands in Extensions.

Hooks

Hooks are how a plugin reaches into Admidio without changing it. An action is told that something happened; a filter is given a value and returns it, changed or not.

Hooks::addFilter('page_headline', array(Greeting::class, 'decorate'), 100, 2, 'my-plugin');
Hooks::addAction('user_created', array(MyPlugin::class, 'onUserCreated'), 100, 1, 'my-plugin');

The arguments are the hook name, your callback, a priority, how many arguments the callback accepts, and — for a plugin, always — your plugin ID, so that Admidio can attribute a failing callback to your plugin.

The hooks a plugin reaches for most often

Hook Kind What it is for
overview_widgets filter Put a card on the overview page.
preferences_panels filter Add a settings panel of your own.
page_headline filter Change the headline of a page.
page_title filter Change the title of a page.
page_before_render action The page is about to be rendered — add CSS or JavaScript.
form_built filter Change a form Admidio has just assembled.
component_visible filter Decide whether a module or plugin is visible.
component_administrable filter Decide whether the current user may administrate it.
translation_text filter Supply or override a translated string.
user_created action A user was created.
user_changes_cumulated action The changes to a user, collected into one event.
email_recipients filter Change the recipients of an email.
plugins_loaded action Every plugin has been loaded.

That is a selection, not the list.

A failing hook callback fails the operation it extends — that is deliberate, because an operation can be aborted. A failing plugin load is only reported and skipped, because a request that never reaches the plugin manager cannot be repaired.

Growing a plugin

Your own classes

Declare a namespace and a directory:

"autoload": { "AdmidioPlugin\\MyPlugin\\": "src/" }
src/Greeting.php
<?php
namespace AdmidioPlugin\MyPlugin;
 
final class Greeting
{
    public static function decorate(string $headline): string
    {
        return $headline . ' 👋';
    }
}

The prefix must not start with Admidio\ — that belongs to the core — and the directory has to stay inside the plugin. No composer dump-autoload is needed.

Language files

Put languages/en.xml in the plugin, in the same format as Admidio's own:

languages/en.xml
<?xml version="1.0" encoding="UTF-8"?>
<resources>
  <string name="PLG_MY_PLUGIN_NAME">My plugin</string>
  <string name="PLG_MY_PLUGIN_GREETING">Greeting</string>
</resources>

The directory is added to the language search path when the plugin loads, so $gL10n→get() finds your keys like any other. Prefix them with PLG_ and your plugin name so they cannot collide.

Note: Language strings carry no HTML. Write plain text; use \n\n for a paragraph break.

A page

A page is an ordinary Admidio page in modules/ inside your plugin:

modules/list.php
<?php
require_once(__DIR__ . '/../../../system/common.php');
 
$page = new PagePresenter('adm_my_plugin');
$page->setHeadline($gL10n->get('PLG_MY_PLUGIN_NAME'));
// ... admFuncVariableIsValid(), FormPresenter, services, entities as in any module
$page->show();

It is always reachable at plugins/my-plugin/modules/list.php. When the administrator switches on short web addresses for plugin pages, the same page is also reachable at the shorter modules/my-plugin/list.php.

Because that depends on the installation, never hardcode either address. Ask instead:

$url = PluginRegistry::get('my-plugin')->getUrl('list.php');

A page of a plugin is an Admidio page like any other, so everything the core pages do applies:

An overview widget

PluginWidget::register($plugin, array(MyPlugin::class, 'renderWidget'), array('sequence' => 20));

The widget is shown according to the preference my_plugin_plugin_enabled, which takes the usual values 0 (off), 1 (everybody) and 2 (only registered users). Declare it in your manifest.

Templates

Put Smarty templates in templates/ and render them through the plugin, so a theme can override them:

return $plugin->renderTemplate($page, 'plugin.my-plugin.tpl', array('greeting' => $greeting));

Database tables

A plugin that needs its own tables puts them in db_scripts/:

Use %PREFIX% for the table prefix, one statement per update step, and Admidio's generic SQL — the same rules as for the core. Give tables the usual xxx_usr_id_create, xxx_timestamp_create, xxx_usr_id_change and xxx_timestamp_change columns.

Read and write your rows through an entity that extends Admidio\Infrastructure\Entity\Entity, so that changes reach the change history.

Command line tasks

Register commands from plugin.php. A plugin owns the command namespace named after its directory, so my-plugin may register my-plugin:sync and nothing else.

Setting up a development environment

You need an Admidio to develop against and a database to test against. They are separate things and it is worth keeping them separate: the test database is dropped and recreated by the test suite.

An Admidio to develop in

Check out Admidio, install the dependencies, and let the command line install it:

composer install
./admidio install:check
./admidio install:run --db-type=mariadb --db-host=localhost --db-name=admidio_dev \
    --db-user=admidio --root-url=http://admidio.local ...

install:check reports whether the PHP version, the extensions and the directory permissions are right. install:run asks for anything you leave out, so you can also just run it bare and answer the questions. Your plugin then goes into plugins/ and appears in Preferences → Plugins.

Development-only settings in config.php

A few settings exist for developing and are not in the shipped config_example.php by default. Add them to adm_my_files/config.php by hand:

// Show errors and their backtraces instead of a friendly message, and unlock the settings below.
$gDebug = true;
 
// Point the plugin store at a catalogue of your own while you work on a plugin.
// A relative path is resolved from the Admidio directory, so this also works inside a container.
$gPluginStoreUrl = 'adm_my_files/plugin-store/plugins.json';

Important: $gPluginStoreUrl only applies while $gDebug is on. That is not a security boundary — anybody who can edit config.php can do anything — but it means a line left over from development cannot quietly repoint a production installation's plugin store at a host nobody is watching.

A local catalogue is an ordinary plugins.json whose download names a file inside the installation. It does not have to be reachable from a browser: Admidio reads it from disk.

adm_my_files/plugin-store/plugins.json
{
    "format": 1,
    "plugins": [
        {
            "id": "my-plugin",
            "name": "My plugin",
            "description": { "en": "What it does." },
            "releases": [
                {
                    "version": "1.0.0",
                    "requires": { "admidio": ">=5.1" },
                    "download": "adm_my_files/plugin-store/my-plugin-1.0.0.zip"
                }
            ]
        }
    ]
}

Build the archive with ./admidio plugin:archive my-plugin –output=adm_my_files/plugin-store, then Add plugin offers it from the store. That is the whole publishing chain, locally.

Databases in Docker

The repository ships a compose file with the databases the test suite uses:

docker-compose -f docker-compose.test.yml up -d

That gives you MariaDB on 3306 and PostgreSQL on 5432, both with the database admidio_test and the user admidio. Testing a plugin against both is the cheapest way to find SQL that only works on one of them.

Note: If Admidio itself runs in a container, remember that it sees the container network. A host name like localhost in config.php means the container, and a path from your host does not exist inside it — which is why $gPluginStoreUrl takes a path relative to the Admidio directory.

Writing tests

Admidio has a regression test suite and your plugin can use the same tools.

Running it

Copy .env.test.example to .env.test, start the databases, then:

composer test:setup          # install the schema into the test database
composer test:unit           # no database, fast
composer test:integration    # against a real database
composer test:cli            # the command line
composer test:all

The suite refuses to run against a database whose name does not contain test as a separate word, so it cannot destroy a development database by accident. It reinstalls the schema as it goes.

What to test, and how

Split your tests the way the suite does:

The base classes are in tests/Support/: AdmidioTestCase for a unit test, DatabaseTestCase for one that needs the database, and AdministratorTestCase when the code under test requires rights. Each integration test runs inside a transaction that is rolled back afterwards, so tests do not see each other's data.

A unit test for a plugin usually needs three things — where the plugins directory is, what the database says is installed, and a settings object — and all three can be set:

PluginRegistry::setPluginsPath($myFixtureDirectory);
PluginRegistry::setInstallations(array('my-plugin' => array('comId' => 1, 'version' => '1.0.0')));
$GLOBALS['gSettingsManager'] = new PluginSettingsDouble(array('my_plugin_greeting' => 'Moin'));

Read a plugin with Plugin::read($directory) and assert on the descriptor. Build a real ZIP with ZipArchive and hand it to PluginPackage::install() if you want to test the whole route.

Where your tests live

Put them beside your plugin, in the same three suites the core uses:

plugins/my-plugin/tests/Unit/
plugins/my-plugin/tests/Integration/
plugins/my-plugin/tests/Cli/

phpunit.xml picks these up: each suite reads plugins/*/tests/<suite> as well as its own directory. So composer test:unit runs your unit tests together with Admidio's, and a plugin that is merely present in plugins/ is tested — it does not have to be enabled, and no organization has to have switched it on.

That has a consequence worth knowing: a broken test in your plugin breaks the run for everybody working in that installation, exactly as a broken core test would. Keep the unit tests free of the database and the network so they stay fast and cannot fail for reasons outside your plugin.

Nothing stops you from shipping your own phpunit.xml as well, for developing the plugin outside an Admidio checkout. Point it at your own tests/ and reuse tests/bootstrap.php for the autoloader.

Reaching your own classes

Composer autoloads Admidio\ and Admidio\Tests\, so a test can extend the base classes straight away. It does not autoload your plugin: your namespace is mapped by plugin.json and set up by the plugin loader at runtime, which a unit test does not go through. Require the file you are testing, or let the test read the plugin and load it the way Admidio does.

A worked example

The example plugin ships its tests, in plugins/hello-world/tests/Unit/ManifestTest.php. It is short and it guards the two things that are wrong most often, neither of which produces an error anywhere:

tests/Unit/Plugins/BuiltInPluginsTest.php does the same for the plugins that ship with Admidio, across every one of them at once.

Packaging and publishing

A plugin is distributed as a ZIP archive containing exactly one top-level directory, named as the plugin:

my-plugin-1.2.0.zip
└── my-plugin/
    ├── plugin.json
    ├── plugin.php
    └── ...

Admidio builds it for you:

admidio plugin:archive my-plugin --output=/path/to/somewhere

That leaves out what belongs to working on a plugin rather than to the plugin — .git, node_modules, .DS_Store and the like — and checks the result with the very gate the installer uses, so an archive that builds cleanly will install.

An administrator installs it under Preferences → Plugins → Add plugin. Admidio checks the whole archive before unpacking anything: the single directory, a valid plugin name, the manifest, the entry file, no path that escapes the plugin directory, and sensible limits on size.

To have your plugin offered in the plugin store, publish it and ask the Admidio team to add it to the catalogue.

Rules that will save you time

if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
    exit('This page may not be called directly!');
}

Where to look next