====== 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/'': { "name": "Hello", "version": "1.0.0" } 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. ===== 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 ===== { "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 ==== * ''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. ==== 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'', ''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. ==== 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. * [[en:entwickler:hooks:catalogue|**The full hooks catalogue**]] — every hook, with its arguments and when it fires. * [[en:entwickler:hooks|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/" } 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: My plugin Greeting 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. * [[en:entwickler:how_to_make_your_plugin_translatable|How to make your plugin translatable]] * [[en:entwickler:mehrsprachigkeit|Multilingualism in Admidio]] ==== A page ==== A page is an ordinary Admidio page in ''modules/'' inside your plugin: 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: * [[en:entwickler:index:forms|Forms — create, validate and save]] * [[en:entwickler:uebergabevariablen_pruefen|Checking of passed variables]] * [[en:entwickler:url_call_with_csrf_token|URL call with CSRF token]] * [[en:entwickler:navigation_ueber_mehrere_seiten|Navigation across multiple pages]] * [[en:entwickler:message-klasse_fuer_hinweistexte|The message class for note texts]] ==== 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.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. * [[en:entwickler:aenderungen_in_der_datenbank_einbauen|Installing changes to the database]] * [[en:entwickler:tabellenzugriffsklassen|Table access classes and their methods]] * [[en:entwickler:datenbankzugriffsklasse|The database access class]] * [[en:entwickler:sql-injektion|Avoiding SQL injection]] * [[en:entwickler:changelog_implementation|Implementing a change log]] ==== 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. * [[en:entwickler:cli|Adding CLI commands to Admidio modules]] — how a command is registered * [[en:2.0:cli:usage|The Admidio command line — usage]] * [[en:2.0:cli:commands|The command reference]] ===== 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. { "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. * [[en:entwickler:testumgebung_einrichten|Setting up a test environment]] * [[en:entwickler:php.ini_einstellungen|PHP settings for a development environment]] ===== Writing tests ===== Admidio has a regression test suite and your plugin can use the same tools. * [[en:entwickler:regression_test_suite|The Admidio regression test suite]] ==== 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/'' 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 ''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. ===== 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()'', 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!'); } ===== Where to look next ===== * [[en:entwickler:hooks:catalogue|The hooks catalogue]] and the [[en:entwickler:hooks|hooks developer guide]]. * [[en:entwickler:cli|Adding CLI commands]], [[en:2.0:cli:usage|command line usage]] and the [[en:2.0:cli:commands|command reference]]. * [[en:entwickler:programmierrichtlinien|The Admidio coding guidelines]] — a plugin is held to the same standard as the core. * [[en:plugins:index|The plugins published for Admidio]]. * 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.