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.
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:
- The directory name is the plugin.
plugins/hello/means the plugin is calledhello. There is no ID anywhere else, and renaming the directory renames the plugin. plugin.jsonandplugin.phpare the only required files, and they always have those names.plugin.phpregisters things and returns nothing. There is no plugin class to extend, no interface to implement and no lifecycle to fill in.
A full-featured plugin
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 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.
requires
admidioandphptake a space separated list of terms that must all match, e.g.β>=5.1 <6.0β. A term without an operator means>=.extensionsare PHP extensions.pluginsare other plugins, as ID β version constraint.modulesare Admidio modules the plugin needs βevents,photos,documents-filesand 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.
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" } }
- Types are
string,boolean,integer,enumandarray. - An
integermay bound its input withmin,maxandstep. - An
enummay just list its values ([βASCβ, βDESCβ]) or name them, as above. Names may be language keys. - A setting without a
labelis 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.
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.
- **The full hooks catalogue** β every hook, with its arguments and when it fires.
- Hooks β the developer guide β the three primitives, priorities, vetoing an operation, and the engine API.
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/:
install.sqlruns when the plugin is installed,uninstall.sqlruns when it is removed,update_x_y.xmlupgrades 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.
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.
- Adding CLI commands to Admidio modules β how a command is registered
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:
- 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.
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:
- every
labelanddescriptionis a real key of the plugin's ownlanguages/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.
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
- 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(), useFormPresenterfor 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!'); }
Where to look next
- The hooks catalogue and the hooks developer guide.
- The Admidio coding guidelines β a plugin is held to the same standard as the core.
- The example plugin
plugins/hello-world/ships with Admidio and shows every convention on this page in one working plugin. Copy the directory, rename it, and start deleting what you do not need.