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:

  • A small adjustment. Two files and a few lines: hang a callback on a hook and change a headline, add a note to a form, supply a translation, hide a menu entry.
  • A card on the overview page. A widget with its own settings, like the birthday list or β€œwho is online” that ship with Admidio.
  • A page of its own. An ordinary Admidio page with a form, a list, and the usual permission checks, reachable from the menu.
  • A complete module. Several pages, your own classes and services, your own database tables with their own update scripts, entries in the change history, command line tasks β€” everything a built-in Admidio module has.

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.

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:

  • The directory name is the plugin. plugins/hello/ means the plugin is called hello. There is no ID anywhere else, and renaming the directory renames the plugin.
  • plugin.json and plugin.php are the only required files, and they always have those names.
  • plugin.php registers things and returns nothing. There is no plugin class to extend, no interface to implement and no lifecycle to fill in.

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.

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.

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.

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.

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 underscores β€” PLG_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.

  • admidio and php take a space separated list of terms that must all match, e.g. β€œ>=5.1 <6.0”. A term without an operator means >=.
  • extensions are PHP extensions.
  • plugins are other plugins, as ID β†’ version constraint.
  • modules are Admidio modules the plugin needs β€” events, photos, documents-files and so on. A switched-off module does not make the plugin faulty: the plugin manager says so beside the plugin, and it starts working again when the module is switched on.

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

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" }
}
  • Types are string, boolean, integer, enum and array.
  • An integer may bound its input with min, max and step.
  • An enum may just list its values ([β€œASC”, β€œDESC”]) or name them, as above. Names may be language keys.
  • A setting without a label is not shown in the generated form. Use that for a preference the plugin owns but nobody should type into β€” the position of an overview widget, for example.

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.

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

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.

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.

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

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.

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

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

  • install.sql runs when the plugin is installed,
  • uninstall.sql runs when it is removed,
  • update_x_y.xml upgrades an installed plugin, in the same format as Admidio's own update 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.

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.

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.

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.

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.

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.

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

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.

Split your tests the way the suite does:

  • Unit tests touch no database and no network. Everything about your manifest, your value objects and your pure logic belongs here β€” these run in a second and you will run them constantly.
  • Integration tests use a real database through Admidio's entities and services. Anything that saves, reads or deletes a row belongs here.

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.

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.

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.

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:

  • every label and description is a real key of the plugin's own languages/en.xml β€” a misspelled one reaches the settings form as #PLG_…# and only a person looking at the page would notice;
  • the setting names have not changed β€” renaming one costs every administrator that setting on update, because the stored row is keyed by the name and nothing migrates it.

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

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.

  • The directory name is the identity. Lowercase letters, digits and hyphens only.
  • Never write into the Admidio core. Everything you need belongs in your own directory.
  • Installing must not change anything. A freshly installed plugin should do nothing until it is switched on.
  • Removing must leave nothing behind. Your settings and your tables go with it.
  • Validate every request parameter with admFuncVariableIsValid(), use FormPresenter for forms and Admidio's CSRF handling, and use $gDbβ†’queryPrepared().
  • Guard files that are not pages. A file below plugins/ can be requested directly, so put this at the top of your entry file and of anything else that is not a page:
if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
    exit('This page may not be called directly!');
}
  • en/entwickler/plugin_development.txt
  • Last modified: 2026/08/31 00:09
  • by kainhofer