Differences

This shows you the differences between two versions of the page.

Link to this comparison view

Both sides previous revision Previous revision
Next revision
Previous revision
en:entwickler:plugin_manager [2026/02/08 12:10] – mightymcoderen:entwickler:plugin_manager [2026/10/23 23:22] (current) – kainhofer
Line 1: Line 1:
 ========= Plugin Manager ========= ========= Plugin Manager =========
-{{ :en:entwickler:plugin_manager_overview.png?direct |}} 
-The Plugin Manager module provides a simple user interface and standardized procedures for 
-  * installing, 
-  * uninstalling, 
-  * updating, 
-  * managing and 
-  * integrating 
-plugins in Admidio. 
  
-With the Plugin Manager it is now also possible to easily and quickly integrate your own custom overview plugins.+The Plugin Manager is the part of Admidio that knows which plugins exist, in which state they are 
 +and what has to happen to move them from one state to the next. It provides
  
-A Basic //"Overview Plugin Example"// can by found here: [[https://github.com/Admidio/admidio-overview-plugin-example|Admidio Overview Plugin Example]] +  * a **plugin administration** page under //Administration → Extensions// for installing, enabling, disabling, updating and removing plugins, 
-======== Make your plugin compatible with the Plugin Manager (Admidio 5) ======== +  * a **plugin store** so that a plugin can be installed and updated from a published catalogue, 
-To make your plugin compatible with the Plugin Manager certain requirements must first be met. These include: +  * an **upload** for a plugin that arrives as a ZIP archive, 
-  * [[en:entwickler:plugin_manager#naming_conventions|Naming conventions]] +  * a **runtime** that loads the enabled plugins into every request, and 
-  * A consistent [[en:entwickler:plugin_manager#folder_structure|folder structure]] +  * a set of **admidio plugin:*** command line tasks that do the same as the web interface.
-  * A JSON [[en:entwickler:plugin_manager#configuration_file|configuration file]] +
-  * A [[en:entwickler:plugin_manager#main_plugin_class|main plugin class]] extending the //PluginAbstract// class +
-  * A [[en:entwickler:plugin_manager#plugin_preferences_class|preference presenter class]] with a [[en:entwickler:plugin_manager#single_formpresenter_element|single FormPresenter element]] or with [[en:entwickler:plugin_manager#multiple_formpresenter_elements|multiple FormPresenter elements]] if your plugin preferences should be accessable via Admidios preferences panel+
  
-======= Naming conventions ======= +<WRAP center round important 80%> 
-When using the new Plugin Manager, the following naming conventions must be observed: +This page describes the plugin system of **Admidio 6.0 and later**. It is a rewrite of the plugin manager 
-  - For better readability file and folder names containing multiple words should follow **//kebab-case//** (//dash separated//) notation. +that was introduced for Admidio 5.0 and is not compatible with it — see 
-  - All folder and file names containing multiple words and are placed inside the //classes// folder should follow **//UpperCamelCase//** (//PascalCase//) notation. +[[en:entwickler:plugin_manager#what_changed_since_the_first_plugin_manager|What changed]]. 
-  - No plugin file defining a class may contain delimiters such as //"-"// or //"_"// especially if it will be used via a class import. +</WRAP>
-======= Folder structure ======= +
-The basic folder structure of any plugin is shown below using the "Birthday" overview plugin as an example: +
-{{:en:entwickler:folder_structure_birthday.png?direct&400|}}+
  
-====== classes folder ====== +A complete, commented example plugin is part of the Admidio distribution: 
-===== Main plugin class ===== +**plugins/hello-world/**. It shows every convention in one place, each of them usable on its own, 
-The main [[en:entwickler:plugin_manager#plugin_class|plugin class]] (e.g.: //Birthday//) must be placed directly in the //classes// folder.+and its own ``README.md`` is the short form of this page. Copy that directory, rename it, and you 
 +have a working plugin.
  
-===== Presenter classes ===== +======= What a plugin is =======
-==== Plugin-specific classes ==== +
-Inside the //Presenter// folder all necessary //PagePresenter// classes of the plugin are placed. These should be similar to the module-specific //PagePresenter// classes.+
  
-==== Plugin preferences class ==== +A plugin is a directory below **plugins/** that contains a manifest **plugin.json** and an entry 
-=== Single FormPresenter element === +file **plugin.php**. Nothing else is required. A plugin that only registers a hook is exactly those 
-If the plugin provides preferences there should be a class //"[PluginName]**PreferencesPresenter**"//. This class contains a single static preference method following the naming conventions of Admidio's //Preferences// class.+two files: no class, no base class, no interface, no settings, no page and no language file.
  
-  +The **directory name is the plugin ID** and the only identity a plugin ever has. It is used in 
 +preference names, in URLs and in the components table, so it must consist of lowercase letters, 
 +digits and single hyphens (e.g. ``hello-world``). The display name may change, be translated or be 
 +duplicated by another plugin without any consequence.
  
-Below is an example snippet of the //createBirthdayForm// preference method from the "Birthday" overview plugin: +====== Folder structure ======
-<code php> +
-public static function createBirthdayForm(Smarty $smarty): string +
-{ +
-    global $gL10n, $gCurrentSession;+
  
-    $pluginBirthday = Birthday::getInstance(); +<code text> 
-    $formValues = $pluginBirthday::getPluginConfig();+plugins/hello-world/ 
 +├── plugin.json                        the manifest — the only required metadata 
 +├── plugin.php                         the entry file — included once when the plugin is loaded 
 +├── cli.php                            optional, registers command line tasks of the plugin 
 +├── modules/index.php                  optional pages, ordinary Admidio module files 
 +├── src/Greeting.php                   optional classes, reached through the "autoload" mapping 
 +├── languages/en.xml                   optional language files, added to the language search path 
 +├── templates/plugin.hello-world.tpl   optional Smarty templates, added to the template search path 
 +├── assets/                            optional css/js/images, reachable through getAssetUrl() 
 +├── db_scripts/                        optional install.sql, uninstall.sql and update_x_y.xml 
 +└── tests/                             optional tests, run with the Admidio suite, not distributed 
 +</code>
  
-    $formBirthday = new FormPresenter( +None of the subdirectories has to exist and **none of them has to be declared** in the manifest. 
-        'adm_preferences_form_birthday', +They are conventions the core looks for.
-        $pluginBirthday::getPluginPath() . '/templates/preferences.plugin.birthday.tpl', +
-        SecurityUtils::encodeUrl(ADMIDIO_URL . FOLDER_MODULES . '/preferences.php', array('mode' => 'save', 'panel' => 'birthday')), +
-        null, +
-        array('class' => 'form-preferences') +
-    );+
  
-    $selectBoxEntries = array( +====== Naming conventions ======
-        '0' => $gL10n->get('SYS_DISABLED'), +
-        '1' => $gL10n->get('SYS_ENABLED'), +
-        '2' => $gL10n->get('ORG_ONLY_FOR_REGISTERED_USER') +
-    );+
  
-    $formBirthday->addSelectBox( +  - Folder and file names containing multiple words use **//kebab-case//** (//dash separated//). 
-        'birthday_plugin_enabled', +  - Class files below the autoload directory (conventionally //src//) use **//UpperCamelCase//** and must not contain ``-`` or ``_``. 
-        Language::translateIfTranslationStrId($formValues['birthday_plugin_enabled']['name']), +  - The plugin ID — the directory name — is lowercase letters, digits and single hyphens.
-        $selectBoxEntries, +
-        array( +
-            'defaultValue' => $formValues['birthday_plugin_enabled']['value'], +
-            'showContextDependentFirstEntry' => false, +
-            'helpTextId' => $formValues['birthday_plugin_enabled']['description'] +
-        ) +
-    );+
  
-    $formBirthday->addCheckbox( +======= The manifest =======
-        'birthday_show_names_extern', +
-        Language::translateIfTranslationStrId($formValues['birthday_show_names_extern']['name']), +
-        $formValues['birthday_show_names_extern']['value'], +
-        array('helpTextId' => $formValues['birthday_show_names_extern']['description']) +
-    );+
  
-    [...]+Each plugin has to provide a JSON manifest **plugin.json**. Only ``name`` and ``version`` really 
 +matter to the core; everything else is optional. A manifest that cannot be read, is not valid JSON 
 +or declares no version makes the plugin **broken**: it is still listed and can still be removed, but 
 +it is never loaded and cannot be installed.
  
-    $formBirthday->addSubmitButton( +^ Key ^ Mandatory ^ Description ^ 
-        'adm_button_save_birthday', +| ``name`` |  X  | Display name. May be a language string ID such as ``PLG_BIRTHDAY_PLUGIN_NAME``. | 
-        $gL10n->get('SYS_SAVE'), +| ``version`` |  X  | Version of the files. Compared against the installed version to detect an update. | 
-        array('icon' => 'bi-check-lg', 'class' => 'offset-sm-3') +| ``description`` | | Short description shown in the plugin administration. May be a language string ID. | 
-    );+| ``author`` | | The author of the plugin. | 
 +| ``url`` | | A URL to a wiki page or a project page. | 
 +| ``icon`` | | Bootstrap icon name, e.g. ``bi-cake2``. | 
 +| ``menu`` | | ``false`` suppresses the menu entry below //Extensions//. A plugin without a page never gets one anyway. | 
 +| ``requires`` | | Version constraints and dependencies, see below. | 
 +| ``autoload`` | | PSR-4 mappings, as namespace prefix => directory inside the plugin. | 
 +| ``preferences`` | | Where the settings panel of the plugin appears: ``section`` and ``sequence``. | 
 +| ``settings`` | | The preferences the plugin owns. |
  
-    $formBirthday->addToSmarty($smarty); +A manifest may carry further keys of its own; they are kept and are readable through 
-    $gCurrentSession->addFormObject($formBirthday); +``$plugin->manifest``. 
-    return $smarty->fetch($pluginBirthday::getPluginPath() . '/templates/preferences.plugin.birthday.tpl');+ 
 +====== requires ====== 
 + 
 +<code json> 
 +"requires": { 
 +    "admidio": ">=5.1", 
 +    "php": ">=8.2", 
 +    "extensions": ["curl"], 
 +    "plugins": {"hello-world": ">=1.1"}, 
 +    "modules": ["events"]
 } }
 </code> </code>
  
 +A constraint is a **space separated list of terms that all have to match**, e.g. ``>=5.1 <6.0``.
 +A term is an operator (``>=``, ``>``, ``<=``, ``<``, ``=``, ``!=``) followed by a version; a term
 +without an operator means ``>=``.
  
-=== Multiple FormPresenter elements === +``admidio``, ``php``, ``extensions`` and ``plugins`` are **requirements**: an unmet one makes the 
-With Admidio 5 the preference page has been redesigned. It is now possible to display multiple "subcards" on one preference panel. To use this feature from a plugin the **//hasSubcards//** configuration flag must be set to //true//. +plugin broken, so it cannot be installed.
  
-In your PluginPreferencePresenter class you can now define multiple FormPresenter elements and add different preferences to each form. The first FormPresenter can now be assigned to the smarty variable like the following example shows:+``modules`` is deliberately **not** a requirement. A switched-off Admidio module is neither the 
 +plugin's fault nor permanent — the plugin is fine, it simply has nothing to show until the module is 
 +switched on again. The plugin administration therefore tells the administrator about it instead of 
 +refusing the plugin. A module is named the way Admidio names its component (``documents-files``, 
 +``photos``, ``messages``), not the way its preference is spelled.
  
-<code php> +====== autoload ====== 
-public static function createBirthdayForm(Smarty $smarty): string + 
-{ +<code json> 
-    [...] +"autoload": { 
-     +    "AdmidioPlugin\\HelloWorld\\": "src/" 
-    $formBirthday = new FormPresenter( +}
-        'adm_preferences_form_birthday', +
-        $pluginBirthday::getPluginPath() . '/templates/preferences.plugin.birthday.tpl', +
-        SecurityUtils::encodeUrl(ADMIDIO_URL . FOLDER_MODULES . '/preferences.php', array('mode' => 'save', 'panel' => 'birthday')), +
-        null, +
-        array('class' => 'form-preferences') +
-    ); +
-    $selectBoxEntries = array( +
-        '0' => $gL10n->get('SYS_DISABLED'), +
-        '1' => $gL10n->get('SYS_ENABLED'), +
-        '2' => $gL10n->get('ORG_ONLY_FOR_REGISTERED_USER') +
-    ); +
-    $formBirthday->addSelectBox( +
-        'birthday_plugin_enabled', +
-        Language::translateIfTranslationStrId($formValues['birthday_plugin_enabled']['name']), +
-        $selectBoxEntries, +
-        array('defaultValue' => $formValues['birthday_plugin_enabled']['value'], 'showContextDependentFirstEntry' => false, 'helpTextId' => $formValues['birthday_plugin_enabled']['description']) +
-    ); +
-     +
-    [...] +
-     +
-    $formBirthday->addSubmitButton( +
-        'adm_button_save_birthday', +
-        $gL10n->get('SYS_SAVE'), +
-        array('icon' => 'bi-check-lg', 'class' => 'offset-sm-3') +
-    ); +
-     +
-    $formBirthday->addToSmarty($smarty); +
-    $gCurrentSession->addFormObject($formBirthday); +
-     +
-    [...]+
 </code> </code>
  
-Now you can define a new FormPresenter for the second "card":+The prefix must **not** start with ``Admidio\``, which belongs to the core and could otherwise be 
 +shadowed, and the directory has to stay inside the plugin directory. Both are verified when the 
 +manifest is read; a violation makes the plugin broken.
  
-<code php> +====== preferences ====== 
-    $formBirthday2= new FormPresenter( + 
-        'adm_preferences_form_birthday_2', +<code json> 
-        $pluginBirthday::getPluginPath() . '/templates/preferences.plugin.birthday.2.tpl', +"preferences": { "section": "overview_extensions", "sequence": 20 }
-        SecurityUtils::encodeUrl(ADMIDIO_URL . FOLDER_MODULES . '/preferences.php', array('mode' => 'save', 'panel' => 'birthday')), +
-        null, +
-        array('class' => 'form-preferences') +
-    ); +
-     +
-    [...] +
-     +
-    $formBirthday2->addSubmitButton( +
-        'adm_button_save_birthday_2', +
-        $gL10n->get('SYS_SAVE'), +
-        array('icon' => 'bi-check-lg', 'class' => 'offset-sm-3') +
-    ); +
-     +
-    [...]+
 </code> </code>
  
-This form cannot be added to smarty with the //addToSmarty// method because this would override all values set by the previous form element. Therefore a new method //appendToSmarty// was implemented in the FormPresenter class (currently only available in the //plugin-manager// branch on GitHub). Use this method to add the second form to the smarty variable:+``section`` names one of the preference tabs and ``sequence`` places the panel inside it. The tabs 
 +are ``system``, ``login_security``, ``user_management``, ``communication``, ``content_management``, 
 +``overview_extensions`` and ``extensions``. A plugin is not restricted to the last two: a module that 
 +used to be part of the Admidio core keeps its own tab once it becomes a plugin. A plugin that names 
 +no section, or one that no longer exists, lands in ``extensions``.
  
-<code php> +====== settings ====== 
-    [...] + 
-     +The ``settings`` object declares the preferences the plugin owns. They become **ordinary Admidio 
-    $formBirthday2->appendToSmarty($smarty); +preferences** when the plugin is loaded, get a row in every organization when it is installed and are 
-    $gCurrentSession->addFormObject($formBirthday2); +removed again when it is uninstalled. 
-     + 
-    [..]+Each setting declares a ``type`` (``string``, ``boolean``, ``integer``, ``enum`` or ``array``), a 
 +``default``, and a ``label`` and ``description`` that may be language string IDs. That is enough for 
 +Admidio to **build the settings form by itself**, so a plugin only writes a preferences presenter 
 +when it wants more than the manifest can express. 
 + 
 +A setting **without** a ``label`` is not put into that form. A preference the plugin owns is not 
 +necessarily one this form edits — the position of an overview widget is edited in the overview 
 +preferences, and a plugin may keep state of its own that nobody is meant to type into. 
 + 
 +An ``enum`` lists its permitted values, and may instead name them so that the generated form has 
 +something readable to show: 
 + 
 +<code json> 
 +"values": ["ASC", "DESC"] 
 +"values": {"first_name": "PLG_HELLO_WORLD_ADDRESS_FIRST_NAME", "full_name": "…"}
 </code> </code>
  
-You can add as many forms as you like using this method.+An ``integer`` may bound what the generated form offers with ``min``, ``max`` and ``step``. The 
 +value is stored as an integer either way; a bound only restricts the input field.
  
-When all forms are added to smarty you can use a "wrapper template" to load the form specific template files on one page. For this wrapper template you can add a smarty variable containing informations you want to show. In the following example multiple cards should be used for displaying the different forms:+<code json> 
 +"hello_world_repeat": { "type": "integer", "default": 3, "min": 0, "max": 20, "step": 1 } 
 +</code>
  
-<code php> +Three preference names are conventions the core understands: 
-    [...] + 
-     +^ Preference ^ Meaning ^ 
-    //assign card titles, icons and corresponding template files +| ``plugin_<id>_enabled`` | Registered by the core for every installed plugin. It is what enables the plugin for one organization; a plugin does not declare it. | 
-    $cards = array( +| ``<id>_plugin_enabled`` | Who may see the overview widget of the plugin: ``0`` nobody, ``1`` everybody, ``2`` only logged-in users. | 
-        array('title' => $birthdayName, 'icon' => $birthdayIcon, 'templateFile' => $birthdayPath . '/templates/preferences.plugin.birthday.tpl'), +| ``<id>_overview_sequence`` | Position of the overview widget on the overview page. | 
-        array('title' => $birthdayName2, 'icon' => $birthdayIcon2, 'templateFile' => $birthdayPath2 . '/templates/preferences.plugin.birthday.2.tpl') + 
-    ); +In ``<id>`` the hyphens of the plugin ID are replaced by underscores. 
-     + 
-    $smarty->assign('cards', $cards); +====== A complete manifest ====== 
-    return $smarty->fetch($birthdayPath . '/templates/preferences.birthday.cards.tpl');+ 
 +<code json> 
 +{ 
 +    "name": "PLG_HELLO_WORLD_NAME", 
 +    "description": "PLG_HELLO_WORLD_DESCRIPTION", 
 +    "version": "1.1.0", 
 +    "author": "The Admidio Team", 
 +    "url": "https://www.admidio.org", 
 +    "icon": "bi-globe-americas", 
 +    "requires": { 
 +        "admidio": ">=5.1", 
 +        "php": ">=8.2" 
 +    }, 
 +    "autoload": { 
 +        "AdmidioPlugin\\HelloWorld\\": "src/" 
 +    }, 
 +    "settings": { 
 +        "hello_world_greeting": { 
 +            "type": "string", 
 +            "default": "Hello world", 
 +            "label": "PLG_HELLO_WORLD_GREETING", 
 +            "description": "PLG_HELLO_WORLD_GREETING_DESC" 
 +        }, 
 +        "hello_world_address": { 
 +            "type": "enum", 
 +            "default": "first_name", 
 +            "values": { 
 +                "first_name": "PLG_HELLO_WORLD_ADDRESS_FIRST_NAME", 
 +                "full_name": "PLG_HELLO_WORLD_ADDRESS_FULL_NAME", 
 +                "login_name": "PLG_HELLO_WORLD_ADDRESS_LOGIN_NAME" 
 +            }, 
 +            "label": "PLG_HELLO_WORLD_ADDRESS", 
 +            "description": "PLG_HELLO_WORLD_ADDRESS_DESC" 
 +        }, 
 +        "hello_world_decorate_headline": { 
 +            "type": "boolean", 
 +            "default": false, 
 +            "label": "PLG_HELLO_WORLD_DECORATE_HEADLINE", 
 +            "description": "PLG_HELLO_WORLD_DECORATE_HEADLINE_DESC" 
 +        } 
 +    }
 } }
 </code> </code>
  
-The corresponding wrapper template is shown below:+======= The entry file =======
  
-<code smarty> +**plugin.php** registers what the plugin contributes and returns nothing. There is **no plugin 
-{foreach $cards as $index => $card} +class, no interface and no base class**. It runs inside the fully initialized Admidio request, so it 
-    <div class="card admidio-tabbed-field-group"> +can call any public Admidio API — normally it registers hooks:
-        <div class="card-header"><i class="bi {$card.icon} me-1"></i>{$card.title}</div> +
-        <div class="card-body"> +
-            {include file=$card.templateFile dataIndex=$index} +
-        </div> +
-    </div> +
-{/foreach} +
-</code>+
  
-This template creates cards for each form element defined in the //$cards// variable and loads the corresponding template file. It is necessary to pass the index of the zero-based $cards array to the corresponding template file to retrieve the correct smarty data that is now also available as a zero-based array for each form variable. +<code php> 
-These are: +<?php 
-  * //formType//, +use Admidio\Hooks\Hooks; 
-  * //attributes//, +use AdmidioPlugin\HelloWorld\Greeting;
-  * //elements//, +
-  * //javascript// and  +
-  * //hasRequiredFields//.+
  
-This following example shows a possible implementation of the first form (so with index 0):+/* 
 + * This file is included by the plugin loader and is never an entry point of its own. Without the 
 + * Admidio bootstrap it could do nothing anyway; the guard only turns a PHP error into a message. 
 + */ 
 +if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) { 
 +    exit('This page may not be called directly!'); 
 +}
  
-<code smarty> +Hooks::addFilter('page_headline', array(Greeting::class, 'decorateHeadline'), 20, 2, 'hello-world'); 
-<form {foreach $attributes[$dataIndex] as $attribute} +</code>
-        {$attribute@key}="{$attribute}" +
-    {/foreach}>+
  
-    {include 'sys-template-parts/form.input.tpl' data=$elements[$dataIndex]['adm_csrf_token']} +A plugin with more to announce puts that into a class of its own and calls it from the entry file, 
-    {include 'sys-template-parts/form.select.tpl' data=$elements[$dataIndex]['birthday_plugin_enabled']} +which is what the built-in plugins do:
-    {include 'sys-template-parts/form.checkbox.tpl' data=$elements[$dataIndex]['birthday_show_names_extern']} +
-    {include 'sys-template-parts/form.select.tpl' data=$elements[$dataIndex]['birthday_show_names']} +
-    {include 'sys-template-parts/form.checkbox.tpl' data=$elements[$dataIndex]['birthday_show_age']} +
-    {include 'sys-template-parts/form.input.tpl' data=$elements[$dataIndex]['birthday_show_age_salutation']} +
-    {include 'sys-template-parts/form.checkbox.tpl' data=$elements[$dataIndex]['birthday_show_notice_none']} +
-    {include 'sys-template-parts/form.input.tpl' data=$elements[$dataIndex]['birthday_show_past']} +
-    {include 'sys-template-parts/form.input.tpl' data=$elements[$dataIndex]['birthday_show_future']} +
-    {include 'sys-template-parts/form.input.tpl' data=$elements[$dataIndex]['birthday_show_display_limit']} +
-    {include 'sys-template-parts/form.select.tpl' data=$elements[$dataIndex]['birthday_show_email_extern']} +
-    {include 'sys-template-parts/form.select.tpl' data=$elements[$dataIndex]['birthday_roles_view_plugin']} +
-    {include 'sys-template-parts/form.select.tpl' data=$elements[$dataIndex]['birthday_roles_sql']} +
-    {include 'sys-template-parts/form.select.tpl' data=$elements[$dataIndex]['birthday_sort_sql']} +
-    {include 'sys-template-parts/form.button.tpl' data=$elements[$dataIndex]['adm_button_save_birthday']}+
  
-    <div class="form-alert" style="display: none;">&nbsp;</div> +<code php> 
-</form> +use AdmidioPlugin\Birthday\Birthday; 
-{$javascript[$dataIndex]}+ 
 +Birthday::register();
 </code> </code>
  
-This makes it possible to show seperate forms on the same page. There are no limits to the display options whether as cards (as in the example), tabs, or accordions. With this approach each available Bootstrap/HTML display is possible through the smarty templates.+The entry file is included in a scope of its own, so a variable it assigns cannot collide with 
 +anything in the loader. Top-level code should stay cheap: it runs in **every request** of every 
 +organization the plugin is enabled for.
  
-===== Entity, Service and ValueObjects classes ===== +====== What loading a plugin means ======
-If the plugin has specific //Entity//, //Service//, or //ValueObjects// classes these should be placed in the corresponding subfolders as is done with the Admidio modules.+
  
-====== db_scripts folder ====== +Loading one plugin means four things and nothing else:
-This folder is optional and is used to place plugin-specific database scripts used when installing (//db-install.sql//) or uninstalling (//db-uninstall.sql//) the plugin.+
  
-In addition to database scripts update scripts (e.g.: //update_1_0.xml//) for the plugin can be placed in this folder. These should be structured like the update scripts used by Admidio to update to a new version.+  - its ``autoload`` mappings become resolvable classes, 
 +  - its **languages/** directory is added to the language search path, 
 +  - its ``settings`` and the preference that enables it are registered as ordinary Admidio preferences, 
 +  - its **plugin.php** is included once.
  
-Similar to Admidio's update scripts it is possible to call plugin-specific update steps. To implement these steps a final class named //UpdateStepsCode// must be present in the folder //"[PluginName]\classes\Service\"//.+A plugin that fails while it is being loaded is **reported and skipped**. A broken extension must not 
 +be able to take the whole installation down, because then it could not be disabled any more. This is 
 +the opposite of the policy for hooks, where a failing callback fails the operation it extends: an 
 +operation can be aborted, a request that never reaches the plugin administration cannot be repaired. 
 + 
 +======= Hooks ======= 
 + 
 +Hooks are the general extension mechanism of Admidio and the normal way for a plugin to change what 
 +the core does. Three primitives are offered by ``Admidio\Hooks\Hooks``: 
 + 
 +^ Primitive ^ Purpose ^ Dispatch ^ 
 +| **Action** | Notify that something is happening or has happened. Every callback is called, return values are ignored. | ``Hooks::doAction()`` | 
 +| **Filter** | Transform a value. Every callback receives the result of the previous one. | ``Hooks::applyFilters()`` | 
 +| **Resolver** | Ask for one answer. Callbacks are asked in priority order until one returns something other than ``null``. | ``Hooks::resolve()`` |
  
-The class implementation should look like this: 
 <code php> <code php>
-final class UpdateStepsCode +// transform a value 
-{ +Hooks::addFilter('entity_value', function (mixed $value, string $column) { 
-    /** +    return ($column === 'usd_value') ? trim($value) : $value; 
-     * @var Database +});
-     */ +
-    private static Database $db;+
  
-    /** +// observe an event 
-     * Set the database +Hooks::addAction('user_created', array($myPlugin, 'onUserCreated'), 20);
-     * @param Database $database The database instance +
-     */ +
-    public static function setDatabase(Database $database) +
-    { +
-        self::$db = $database; +
-    }+
  
-    /** +// ask for one answer 
-     * @throws Exception +Hooks::addResolver('translation_missing', 'myTranslationProvider');
-     */ +
-    public static function /*[method name used in update_x_x.xml]*/() +
-    { +
-        [...] +
-    } +
-}+
 </code> </code>
  
-====== languages folder ====== +A lower priority runs earlier, registrations of the same priority run in registration order. A 
-This folder contains the plugin-specific translation files.+callback may be registered with an explicit ID — the last argument in the examples on this page is 
 +the plugin ID — which is unique within one hook regardless of the priority, so registering the same 
 +ID again replaces the previous registration. Exceptions of a callback are logged and rethrown: a 
 +failing extension fails the operation it extends instead of leaving it half done.
  
-====== templates folder ====== +======= The overview widget ======= 
-To move away from the deprecated //HtmlPage// and //HtmlTable// classes plugins should exclusively use the Smarty template engine with files stored in this folder.+ 
 +The overview page used to include one PHP file per plugin through a Smarty function, so a plugin 
 +could only contribute there by being a file at a place the core knew. **A widget is now an ordinary 
 +hook**: the overview asks for the widgets, and every plugin that wants one adds it. 
 + 
 +A plugin that follows the Admidio conventions — one widget, an ``_overview_sequence`` preference for 
 +its position and a ``_plugin_enabled`` preference for who may see it — does not write that hook 
 +itself but calls ``PluginWidget::register()``:
  
-====== Root plugin folder ====== 
-    
-   
-===== Main plugin file ===== 
-Inside the root plugin folder a main plugin file is required. This file can contain plugin-specific logic (similar to Admidio’s module entry files) or simply initialize the [[en:entwickler:plugin_manager#plugin_class|plugin class]] as in the following example: 
 <code php> <code php>
-<?php +PluginWidget::register($plugin, array(self::class, 'renderWidget'), array( 
-use Birthday\classes\Birthday;+    'sequence' => self::DEFAULT_SEQUENCE 
 +)); 
 +</code>
  
-/** +^ Option ^ Default ^ 
- *********************************************************************************************** +| ``name`` | The name of the plugin, translated if it is a language string ID | 
- * Birthday +| ``icon`` | The icon of the plugin | 
- * +| ``sequencePreference`` | ``<plugin id>_overview_sequence`` | 
- * The plugin lists all users who have birthday in a defined timespan. +| ``enabledPreference`` | ``<plugin id>_plugin_enabled`` | 
- * +| ``sequence`` | Position of a widget whose preference has no value |
- * @copyright The Admidio Team +
- * @see https://www.admidio.org/ +
- * @license https://www.gnu.org/licenses/gpl-2.0.html GNU General Public License v2.0 only +
- *********************************************************************************************** +
- */+
  
-try { +The render callback receives the overview page and the plugin and returns the HTML of the widget, or 
-    require_once(__DIR__ . '/../../system/common.php');+an empty string for a widget that has nothing to show. ``PluginWidget::render()`` renders a template 
 +of the plugin for it.
  
-    $pluginBirthday = Birthday::getInstance(); +The **declaration** is what the overview preferences list, so a widget that is switched off is still 
-    $pluginBirthday->doRender(isset($page) ? $page : null);+shown there and can be switched on again.
  
-} catch (Throwable $e) { +A plugin that needs more control adds the hook itself: 
-    echo $e->getMessage(); + 
-}+<code php> 
 +Hooks::addFilter(PluginWidget::HOOK, function (array $widgets, PagePresenter $page): array { 
 +    global $gValidLogin, $gL10n; 
 + 
 +    if (!$gValidLogin) { 
 +        return $widgets;   // a widget that decides not to appear simply does not add itself 
 +    } 
 + 
 +    $plugin = PluginRegistry::get('birthday'); 
 +    $widgets[] = array( 
 +        'id' => $plugin->id, 
 +        'name' => $gL10n->get($plugin->name), 
 +        'sequence' => 20, 
 +        'html' => PluginWidget::render($page, $plugin, 'plugin.birthday.tpl', array('birthdays' => $rows)) 
 +    ); 
 + 
 +    return $widgets; 
 +});
 </code> </code>
  
-===== Plugin configuration file ===== +======= The preferences panel =======
-Each plugin must provide a JSON configuration file containing basic plugin information and configuration (see: [[en:entwickler:plugin_manager#configuration_file|Configuration file]]).+
  
 +A plugin that brings preferences shows them in the Admidio preferences, in the tab its manifest
 +names. It used to get there by being **scanned for**: the core looked through the autoload
 +directories for a file called ``*PreferencesPresenter.php`` and guessed the method to call from its
 +class name. **A panel is now declared**, like a widget, so that a plugin says what it offers instead
 +of the core deducing it from file names.
  
-======= Configuration file ======= +For a plugin whose form is generated from the manifest, the manifest is all there is:
-Each plugin has to provide a JSON configuration file. The following table gives an overview of all keys currently available:+
  
-^ Key: ^ Mandatory: ^ Description: ^ 
-| "name" |  X  | Defines the plugin name (can be a translation ID or plain text). | 
-| "description" | | A short description of the plugin shown on the Plugin Manager overview (can be a translation ID or plain text). | 
-| "version" |  X  | The current version of the plugin. | 
-| "author" | | The author of the plugin. | 
-| "url" | | A URL to a wiki page or a project page. | 
-| "icon" | | The icon displayed in the Admidio sidebar and on the Plugin Manager overview. | 
-| "mainFile" | | The filename of the main plugin entry point. If not set "index.php" or "PluginName.php" is assumed. | 
-| "overviewPlugin" | | This optional flag is used to determinate whether the plugin should be installed as an overview plugin. | 
-| "autoload" |  (X)  | An object containing another object "psr-4" which contains the psr4 autoload mapping(s) of the plugin class(es). | 
-| "preferencesFile" | | The filename of the preferences entry point for the plugin settings. If this key is set there is no integration of the plugin settings into the Admidio Preferences Page. | 
-| "hasSubcards" | | This optional flag is used to determinate whether a plugins preference page contains multiple "cards" to display (for CSS styling purposes only). | 
-| "dependencies" |  (X)  | An array of all dependencies the plugin needs to work properly. If this is not set the Plugin Manager cannot detect whether the plugin can be installed properly. | 
-| "defaultConfig" |  (X)  | An object containing all configuration parameters used by the plugin. Every parameter should contain a "name", "description", "type" and "value" key. | 
- 
-The following example shows the structure based on the "Birthday" overview plugin: 
 <code json> <code json>
-{ +"preferences": { "section": "overview_extensions", "sequence": 20 }
-    "name": "PLG_BIRTHDAY_PLUGIN_NAME", +
-    "description": "PLG_BIRTHDAY_PLUGIN_DESCRIPTION", +
-    "version": "1.0.0", +
-    "author": "Admidio Team", +
-    "url": "https://www.admidio.org", +
-    "icon": "bi-cake2", +
-    "mainFile": "index.php", +
-    "overviewPlugin": true, +
-    "autoload": { +
-        "psr-4": { +
-            "Birthday\\classes\\": "classes/" +
-        } +
-    }, +
-    "dependencies": [ +
-        "Admidio\\Infrastructure\\Plugins\\Overview", +
-        "Admidio\\Infrastructure\\Plugins\\PluginAbstract", +
-        "Admidio\\Infrastructure\\Utils\\SecurityUtils", +
-        "Admidio\\Roles\\Service\\RolesService" +
-    ], +
-    "defaultConfig": { +
-        "birthday_plugin_enabled": { +
-            "name": "ORG_ACCESS_TO_PLUGIN", +
-            "description": "ORG_ACCESS_TO_PLUGIN_DESC", +
-            "type": "integer", +
-            "value" : 1 +
-        }, +
-        "birthday_overview_sequence": { +
-            "type": "integer", +
-            "value" : 2 +
-        }, +
-        "birthday_show_names_extern": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_NAMES_EXTERN", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_NAMES_EXTERN_DESC", +
-            "type": "boolean", +
-            "value" : false +
-        }, +
-        "birthday_show_names": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_NAMES", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_NAMES_DESC", +
-            "type": "integer", +
-            "value" : 0 +
-        }, +
-        "birthday_show_age": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_AGE", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_AGE_DESC", +
-            "type": "boolean", +
-            "value" : false +
-        }, +
-        "birthday_show_age_salutation": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_AGE_SALUTATION", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_AGE_SALUTATION_DESC", +
-            "type": "integer", +
-            "value" : 18 +
-        }, +
-        "birthday_show_notice_none": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_NOTICE_NONE", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_NOTICE_NONE_DESC", +
-            "type": "boolean", +
-            "value" : true +
-        }, +
-        "birthday_show_past": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_PAST", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_PAST_DESC", +
-            "type": "integer", +
-            "value" : 1 +
-        }, +
-        "birthday_show_future": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_FUTURE", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_FUTURE_DESC", +
-            "type": "integer", +
-            "value" : 2 +
-        }, +
-        "birthday_show_display_limit": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_DISPLAY_LIMIT", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_DISPLAY_LIMIT_DESC", +
-            "type": "integer", +
-            "value" : 200 +
-        }, +
-        "birthday_show_email_extern": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_EMAIL_EXTERN", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_EMAIL_EXTERN_DESC", +
-            "type": "integer", +
-            "value" : 0 +
-        }, +
-        "birthday_roles_view_plugin": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_ROLES_VIEW_PLUGIN", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_ROLES_VIEW_PLUGIN_DESC", +
-            "type": "array", +
-            "value" : ["All"] +
-        }, +
-        "birthday_roles_sql": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_ROLES_SQL", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_ROLES_SQL_DESC", +
-            "type": "array", +
-            "value" : ["All"] +
-        }, +
-        "birthday_sort_sql": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_SORT_SQL", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_SORT_SQL_DESC", +
-            "type": "string", +
-            "value" : "DESC" +
-        } +
-    } +
-} ], +
-  "defaultConfig": { +
-    "birthday_plugin_enabled": { +
-      "name": "ORG_ACCESS_TO_PLUGIN", +
-      "description": "ORG_ACCESS_TO_PLUGIN_DESC", +
-      "type": "integer", +
-      "value": 1 +
-    }, +
-    "birthday_show_names_extern": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_NAMES_EXTERN", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_NAMES_EXTERN_DESC", +
-      "type": "boolean", +
-      "value": false +
-    }, +
-    "birthday_show_names": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_NAMES", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_NAMES_DESC", +
-      "type": "integer", +
-      "value": 0 +
-    }, +
-    "birthday_show_age": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_AGE", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_AGE_DESC", +
-      "type": "boolean", +
-      "value": false +
-    }, +
-    "birthday_show_age_salutation": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_AGE_SALUTATION", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_AGE_SALUTATION_DESC", +
-      "type": "integer", +
-      "value": 18 +
-    }, +
-    "birthday_show_notice_none": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_NOTICE_NONE", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_NOTICE_NONE_DESC", +
-      "type": "boolean", +
-      "value": true +
-    }, +
-    "birthday_show_past": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_PAST", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_PAST_DESC", +
-      "type": "integer", +
-      "value": 1 +
-    }, +
-    "birthday_show_future": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_FUTURE", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_FUTURE_DESC", +
-      "type": "integer", +
-      "value": 2 +
-    }, +
-    "birthday_show_display_limit": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_DISPLAY_LIMIT", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_DISPLAY_LIMIT_DESC", +
-      "type": "integer", +
-      "value": 200 +
-    }, +
-    "birthday_show_email_extern": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_EMAIL_EXTERN", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_EMAIL_EXTERN_DESC", +
-      "type": "integer", +
-      "value": 0 +
-    }, +
-    "birthday_roles_view_plugin": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_ROLES_VIEW_PLUGIN", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_ROLES_VIEW_PLUGIN_DESC", +
-      "type": "array", +
-      "value": ["All"] +
-    }, +
-    "birthday_roles_sql": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_ROLES_SQL", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_ROLES_SQL_DESC", +
-      "type": "array", +
-      "value": ["All"] +
-    }, +
-    "birthday_sort_sql": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_SORT_SQL", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_SORT_SQL_DESC", +
-      "type": "string", +
-      "value": "DESC" +
-    } +
-  } +
-}+
 </code> </code>
  
-======= Plugin class ======= +A plugin that builds its form itself registers the callback that builds it:
-The //Plugin// class extends the basic functionality provided by the abstract class //PluginAbstract// and implements the //doRender// method.+
  
-If there is nothing to render the //doRender// method can just return //true//: 
 <code php> <code php>
-namespace Birthday\classes;+Hooks::addFilter( 
 +    PluginPanel::HOOK, 
 +    static function (array $panels) use ($plugin): array { 
 +        global $gL10n;
  
-use Admidio\Infrastructure\Plugins\PluginAbstract; +        $panels[] = array( 
-use Admidio\UI\Presenter\PagePresenter;+            'id' => PluginPanel::normalizeId($plugin->id), 
 +            'title' => $gL10n->get($plugin->name), 
 +            'icon' => $plugin->icon, 
 +            'sequence' => self::DEFAULT_SEQUENCE, 
 +            'create' => array(BirthdayPreferencesPresenter::class, 'createForm') 
 +        );
  
-class Birthday extends PluginAbstract +        return $panels; 
-{ +    }, 
-    public static function doRender(?PagePresenter $page = null) : bool +    PluginPanel::DEFAULT_SEQUENCE, 
-    { +    1, 
-        return true; +    $plugin->id 
-    } +);
-}+
 </code> </code>
  
-In its simplest implementation for displaying something the //doRender// method can just call the //PagePresenter// //show// method: +The panel takes its section and its sequence from the manifest of the plugin it is named after, so 
-<code php> +only a plugin that declares **several** panels has to name a ``section`` here as well.
-namespace Birthday\classes;+
  
-use Admidio\Infrastructure\Plugins\PluginAbstract; +The ``create`` callback receives the ``PreferencesPresenter`` and returns the HTML of the panel. It 
-use Admidio\UI\Presenter\PagePresenter;+is only called for the panel the administrator actually opens, because the preferences page fetches 
 +one panel per request.
  
-class Birthday extends PluginAbstract +The settings of a plugin are additionally reachable as a dialog from the plugin administration 
-{ +itself, so a plugin does not have to be found in the preferences tabs to be configured. 
-    public static function doRender(?PagePresenter $page = null) : bool + 
-    { +======= Pages ======= 
-        if (!isset($page)) { + 
-            throw new Exception("The page parameter must be set."); +**modules/index.php** is a normal Admidio module file: ``require_once`` of ``system/common.php``, 
-        } +``admFuncVariableIsValid()``, ``PagePresenter``, ``FormPresenter``, services, ``Entity``, 
-        $page->show(); +``handleException()``. Nothing about it is plugin-specific. 
-     + 
-        return true; +Only the files **directly below** ``modules/`` are entry points; the directory is not searched 
-    } +recursively, so what a page includes stays private to the plugin. Everything else in the plugin — 
-}+the entry file, the classes below ``src/`` — is refused if it is requested directly. 
 + 
 +A page is always reachable at its real location: 
 + 
 +<code text> 
 +plugins/hello-world/modules/index.php
 </code> </code>
  
-All methods provided by //PluginAbstract// can be overridden in this plugin class and adapted to the plugin's needs. The following code shows an example override of the //getPluginConfig()// method in the "Birthday" overview plugin implementation:+When the administrator switches on the preference **plugin_module_pages**, Admidio additionally 
 +publishes it under the ordinary module URL: 
 + 
 +<code text> 
 +modules/hello-world/index.php 
 +</code> 
 + 
 +Admidio has no front controller and no rewrite rules — every module URL is a real file — so 
 +publishing writes one generated stub per page, which loads the Admidio bootstrap and hands over to 
 +the page inside the plugin. The page itself is unchanged and works through either URL, because 
 +``__DIR__`` is per file: the relative includes of the page still resolve against its real location. 
 +Because this writes into the Admidio core tree, which a Git-managed or read-only deployment does not 
 +want, it is **off by default**. A generated directory carries a marker file naming the plugin it 
 +belongs to, and nothing without that marker is ever deleted, so a core module directory cannot be 
 +removed by a plugin whatever the plugin is called. 
 + 
 +<WRAP center round tip 80%> 
 +Never hardcode either form — ask ``$plugin->getUrl('index.php')`` which one is live. 
 +</WRAP> 
 + 
 +A plugin with a page gets a menu entry below //Extensions// when it is installed. A plugin whose 
 +pages are not destinations — the calendar answers one month of itself for its own buttons — says 
 +``"menu": false`` in its manifest and is left out of the menu. 
 + 
 +======= Languages, templates and assets ======= 
 + 
 +  * **languages/** is added to the language search path when the plugin is loaded, so ``$gL10n->get('PLG_HELLO_WORLD_NAME')`` works and the manifest may use language string IDs. The files have the same format as the Admidio language files (``en.xml``, ``de.xml``, …). 
 +  * **templates/** is added to the Smarty template search path. ``$plugin->renderTemplate($page, 'plugin.hello-world.tpl', $variables)`` renders one. The deprecated ``HtmlPage`` and ``HtmlTable`` classes should not be used any more. 
 +  * **assets/** holds css, js and images. ``$plugin->getAssetUrl('style.css')`` answers the URL of one. 
 + 
 +======= Database scripts ======= 
 + 
 +**db_scripts/** is optional and holds 
 + 
 +  * ``install.sql`` — run when the plugin is installed, 
 +  * ``uninstall.sql`` — run when the plugin is removed, 
 +  * ``update_x_y.xml`` — the update scripts, structured exactly like Admidio's own. 
 + 
 +The update scripts are resolved through the plugin ID, and they may call plugin-specific update 
 +steps. Those live in a class ``UpdateStepsCode`` in the **Service** namespace of the plugin — that 
 +is the first ``autoload`` prefix of the manifest followed by ``Service\``, so for the mapping 
 +``"AdmidioPlugin\\HelloWorld\\": "src/"`` the class is 
 +``AdmidioPlugin\HelloWorld\Service\UpdateStepsCode`` in ``src/Service/UpdateStepsCode.php``: 
 <code php> <code php>
-public static function getPluginConfig() : array+final class UpdateStepsCode
 { {
-    // get the plugin config from the parent class +    private static Database $db;
-    $config = parent::getPluginConfig();+
  
-    // if the key equals 'birthday_roles_view_plugin' and the value is still the default value, retrieve the categories from the database +    public static function setDatabase(Database $database) 
-    if (array_key_exists('birthday_roles_view_plugin', $config) +    { 
-        && $config['birthday_roles_view_plugin']['value'] === self::$defaultConfig['birthday_roles_view_plugin']['value']) { +        self::$db = $database;
-        $config['birthday_roles_view_plugin']['value'] = self::getAvailableRoles(1, true);+
     }     }
  
-    if (array_key_exists('birthday_roles_sql', $config) +    public static function /*[method name used in update_x_y.xml]*/() 
-        && $config['birthday_roles_sql']['value'] === self::$defaultConfig['birthday_roles_sql']['value']) { +    { 
-        $config['birthday_roles_sql']['value'] = self::getAvailableRoles(1, true);+        [...]
     }     }
- 
-    return $config; 
 } }
 </code> </code>
  
-======== PluginAbstract class ======== +======= Command line tasks =======
-<WRAP center round todo 60%> +
-methods documentation +
-</WRAP>+
  
-The //PluginAbstract// class is the base class for all plugins usable with the Plugin Manager.+A plugin owns the CLI command namespace named after its directory, exactly like a module. Where a 
 +module registers its commands in ``modules/<module>/cli.php``, a plugin registers them in 
 +**plugins/<plugin>/cli.php**. The file is loaded for every enabled plugin after the plugin runtime, 
 +so the autoload mappings, settings and languages of the plugin are available. It only registers 
 +metadata and callbacks; the command itself runs later, after the actor and the component have been 
 +authorized.
  
-Provides: +<code php> 
-  * installation / uninstallation +CliTaskRegistry::register( 
-  * updates +    'hello-world:greet', 
-  * configuration handling +    'PLUGINS', 
-  * dependency checks +    array(Greeting::class, 'cliGreet'), 
-  * autoload registration +    'Print the greeting of the plugin.', 
-  * visibility + activation control+    'hello-world:greet [--name=NAME]' 
 +); 
 +</code>
  
-All custom plugins should extend this class.+A command name must have the form ``extension:task`` and must name an Admidio component; a plugin 
 +cannot replace a command that is already registered, and a broken ``cli.php`` is reported without 
 +taking the rest of the command line down.
  
------+====== The plugin:* commands ======
  
-===== Singleton / Basic Handling ===== +The plugin manager itself is fully available from the command line:
-^ Method ^ Description ^ Return ^ +
-| getInstance() | Returns the singleton instance of the plugin class | PluginAbstract | +
-| __construct() | Protected constructor (singleton pattern) | void | +
-| __clone() | Prevents cloning of the singleton | void | +
-| __wakeup() | Prevents unserializing | void |+
  
------+^ Command ^ Purpose ^ 
 +| ``plugin:list [--state=STATE] [--installed] [--updates]`` | List the plugins and their state. | 
 +| ``plugin:show PLUGIN`` | Show the manifest and the state of a plugin. | 
 +| ``plugin:install SOURCE [--replace]`` | Install a plugin from the store or from an archive file. | 
 +| ``plugin:inspect FILE`` | Show what a plugin archive holds, without installing it. | 
 +| ``plugin:enable PLUGIN`` | Enable a plugin for the current organization, preparing it if that has not happened yet. | 
 +| ``plugin:disable PLUGIN`` | Disable a plugin for the current organization, keeping its data. | 
 +| ``plugin:update PLUGIN`` | Update a plugin, fetching newer files where the store publishes them. | 
 +| ``plugin:remove PLUGIN [--yes]`` | Remove a plugin from every organization, with its data and its files. | 
 +| ``plugin:archive PLUGIN [--output=FILE] [--overwrite]`` | Build the distributable ZIP archive of a plugin. | 
 +| ``plugin:store-list [--available] [--updates]`` | List the plugins the store publishes for this Admidio. | 
 +| ``plugin:store-show PLUGIN`` | Show what the store publishes for one plugin. | 
 +| ``plugin:store-refresh`` | Fetch the plugin catalogue now instead of waiting for the cache to expire. |
  
-===== Menu Integration / Preferences ===== +======= The lifecycle =======
-^ Method ^ Description ^ Return ^ +
-| addMenuEntry() | Adds the plugin menu entry to the database | void | +
-| removeMenuEntry() | Removes the plugin menu entry from the database | void | +
-| initPreferencePanelCallback() | Initializes plugin preference panel integration | void |+
  
------+A plugin has **two independent states**:
  
-===== Metadata / Identity ===== +  * **installed** is global, because the database schema of a plugin is global. It is a row in the components table with ``com_type = PLUGIN`` and ``com_name_intern = <plugin ID>``. 
-^ Method ^ Description ^ Return ^ +  * **enabled** is per organization, because an Admidio installation may serve several organizations that do not want the same extensions. It is the preference ``plugin_<id>_enabled``.
-| getName() | Returns the plugin name | string | +
-| getIcon() | Returns the plugin icon from metadata | string | +
-| getVersion() | Returns the plugin version | string | +
-| getMetadata() | Returns the complete metadata definition | array | +
-| getDependencies() | Returns declared dependencies | array | +
-| getSupportedLanguages() | Returns supported languages | array |+
  
------+The plugin administration groups every plugin it knows by six states:
  
-===== Static Files ===== +^ State ^ Meaning ^ 
-^ Method ^ Description ^ Return ^ +| ``available`` | The files are there, but the plugin was never installed. | 
-| getStaticFiles(?string $type = null, string $path = '') | Returns plugin static files (css/js/images/etc.) | array |+| ``disabled`` | Installed, but not enabled for the current organization. | 
 +| ``enabled`` | Installed and enabled for the current organization. | 
 +| ``update`` | Installed, but the files declare a newer version than the database. | 
 +| ``broken`` | The manifest is missing, malformed or the requirements are not met. | 
 +| ``orphaned`` | Installed in the database, but the files are gone. |
  
-Example: +The operations are deliberately separate and mean different things:
-<code php> +
-Plugin::getStaticFiles('css'); +
-</code>+
  
------+^ Operation ^ Scope ^ Touches data ^ 
 +| install | installation | creates the schema and the preferences of the plugin | 
 +| update | installation | runs ``db_scripts/update_x_y.xml`` up to the new version | 
 +| enable | organization | nothing, only the preference that loads the plugin | 
 +| disable | organization | nothing | 
 +| remove | installation | removes the preferences, the data, the component record and the files |
  
-===== Configuration & Component Information ===== +**Disabling is therefore always safe and reversible.** Enabling a plugin that was never installed 
-^ Method ^ Description ^ Return ^ +installs it first, so the administrator has one decision to make and not two. Removing is the only 
-| getPluginConfigValues() | Returns stored config values only (key => value) | array | +destructive operation there is: it takes the plugin away from **every** organization at once, runs 
-| getPluginConfig() | Returns merged configuration (defaults + DB) | array | +``uninstall.sql`` and deletes the files. A plugin of the Admidio distribution is refused, because the 
-| getPluginPath() | Absolute filesystem path | string | +next core update would put its files back and quietly undo the decision.
-| getComponentId() | Database component ID | int | +
-| getComponentName() | Plugin folder name | string | +
-| getPluginSequence() | Overview sorting sequence | int |+
  
------+Reading a plugin never executes plugin code and never throws. An unreadable, malformed or 
 +incompatible manifest produces a plugin whose ``error`` describes the problem, so the plugin 
 +administration can list, disable and remove a broken plugin instead of breaking with it. The reason 
 +comes from the manifest reader and from ``checkRequirements()``, which answer in English for a 
 +developer, so it is shown as the diagnostic it is and not as a translated member-facing message.
  
-===== Dependency Handling ===== +======= Distributing a plugin =======
-^ Method ^ Description ^ Return ^ +
-| checkDependencies() | Verifies all dependencies are available | bool |+
  
-Internal helper: +====== The archive ======
-<code php> +
-private static function findAdmidioClass(string $shortName): ?string +
-</code>+
  
------+``admidio plugin:archive <plugin>`` builds the ZIP archive that distributes a plugin. The archive 
 +holds a single top-level directory named after the plugin ID, and it is checked with the very gate 
 +the installer uses before it is handed back. 
 + 
 +These are left out of the archive: ``.git``, ``.svn``, ``.hg``, ``node_modules``, ``vendor``, 
 +``tests``, ``phpunit.xml``, ``phpunit.xml.dist``, ``.phpunit.result.cache``, ``.DS_Store``, 
 +``Thumbs.db``, ``.gitignore`` and ``.gitattributes``. The tests are excluded because Admidio runs 
 +the tests of every plugin that is present, and a distributed one would run in installations that 
 +have nothing to do with developing the plugin. 
 + 
 +Everything an archive contains is written by somebody the installation does not know, so it is 
 +checked before a single byte of it reaches the plugins directory. An archive may hold at most 5000 
 +entries, 32 MB compressed and 128 MB extracted, and a failure at any point removes the temporary 
 +directory rather than leaving a partial plugin behind. 
 + 
 +====== The plugin store ====== 
 + 
 +The store is one JSON file on a web server — by default ``plugins.json`` next to the ``update.txt`` 
 +that the version check already reads. Admidio fetches it, caches it in ``adm_my_files`` and refreshes 
 +it at most once a day or when an administrator asks. The remote call never happens while a page is 
 +being built, and a catalogue that cannot be reached or cannot be understood makes the store 
 +unavailable rather than breaking the page. 
 + 
 +``plugin:update`` and the //Update// action fetch newer files where the store publishes them and 
 +then run the update scripts — which is all that is needed for a plugin whose files somebody replaced 
 +by hand. Updating is one action whatever the plugin needs. 
 + 
 +====== Tests ====== 
 + 
 +Every test suite of Admidio also reads ``plugins/*/tests/<suite>``, so a plugin is tested from where 
 +it lives and with the same command as the core. The example plugin ships 
 +``plugins/hello-world/tests/Unit/ManifestTest.php`` as a starting point. 
 + 
 +======= API reference ======= 
 + 
 +All classes live in the namespace ``Admidio\Infrastructure\Plugins``. 
 + 
 +====== Plugin ====== 
 + 
 +One plugin as it exists on disk. Reading it never executes plugin code and never throws. 
 + 
 +^ Property ^ Description ^ 
 +| ``id`` | Plugin ID, which is the directory name | 
 +| ``path`` | Absolute path of the plugin directory | 
 +| ``manifest`` | The decoded manifest, so a plugin may carry metadata the class does not interpret | 
 +| ``name``, ``description``, ``version``, ``icon``, ``author``, ``homepage`` | Metadata | 
 +| ``autoload`` | PSR-4 mappings, as prefix => absolute directory | 
 +| ``settings`` | The preferences the plugin owns, as name => definition | 
 +| ``requires`` | ``admidio``, ``php``, ``extensions``, ``plugins``, ``modules`` | 
 +| ``preferences`` | ``section`` and ``sequence`` of the settings panel | 
 +| ``error`` | Why the plugin cannot be used, or ``null`` |
  
-===== Installation State / Visibility ===== 
 ^ Method ^ Description ^ Return ^ ^ Method ^ Description ^ Return ^
-| isInstalled() | Plugin exists in database | bool | +| ``read(string $directory)`` | Read the plugin in a directory | Plugin | 
-| isActivated() | Installed and activated | bool | +| ``isValidId(string $id)`` | Whether a directory name is a usable plugin ID | bool | 
-| isVisible() | Visible depending on config and login state | bool | +| ``isValid()`` | Whether the plugin can be used at all | bool | 
-| isOverviewPlugin() | Registered as overview plugin | bool | +| ``getEntryFile()`` | Absolute path of ``plugin.php`` | string | 
-| isAdmidioPlugin() | Built-in overview plugin | bool | +| ``getDirectory(string $directory)`` | Absolute path of a conventional subdirectory, or ``null`` | ?string | 
-| isUpdateAvailable() | Installed version differs from metadata | bool |+| ``getPages()`` / ``hasPages()`` | The PHP files directly below ``modules/`` | array / bool | 
 +| ``wantsMenuEntry()`` | Whether the plugin should get a menu entry | bool | 
 +| ``getUrl(string $page = '')`` | URL of a page, in whichever form is currently live | string | 
 +| ``getSettingValues()`` | Every declared preference with its value, cast to the declared type | array | 
 +| ``renderTemplate(PagePresenter $page, string $template, array $variables)`` | Render a template of the plugin | string | 
 +| ``getAssetUrl(string $file)`` | URL of a file below ``assets/`` | string | 
 +| ``getEnabledSettingName()`` | ``plugin_<id>_enabled`` | string | 
 +| ``getAccessSettingName()`` | ``<id>_plugin_enabled`` | string | 
 +| ``checkRequirements(array $installedPlugins)`` | One English diagnostic per unsatisfied requirement | array | 
 +| ``getDisabledModules()`` | The Admidio modules the plugin needs that are switched off | array | 
 +| ``versionMatches(string $version, string $constraint)`` | Check a version against a constraint | bool |
  
------+====== PluginRegistry ====== 
 + 
 +The only place that answers "which plugins are there" and "may this one run". It never includes 
 +plugin code. Discovery and the installation states are read once per request.
  
-===== Autoloading ===== 
 ^ Method ^ Description ^ Return ^ ^ Method ^ Description ^ Return ^
-| doClassAutoload(string $class) | Registers PSR-4 autoload mappings | bool |+| ``all()`` | Every plugin below ``plugins/`` | array | 
 +| ``get(string $id)`` | One plugin, or ``null`` | ?Plugin | 
 +| ``getState(Plugin or string $plugin)`` | One of the six ``STATE_*`` constants | string | 
 +| ``isInstalled(string $id)`` | Installed in this installation | bool | 
 +| ``isEnabled(string $id)`` | Enabled for the current organization | bool | 
 +| ``isBuiltIn(string $id)`` | A plugin of the Admidio distribution | bool | 
 +| ``getInstalledVersion(string $id)`` | The version the database records | string | 
 +| ``getComponentId(string $id)`` / ``getComponentUuid(string $id)`` | Component record of the plugin | int / string | 
 +| ``getEnabledOrganizations(string $id)`` | Which organizations have it enabled | array | 
 +| ``getLoadable()`` | The plugins that may be loaded in this request | array | 
 +| ``getEnabledVersions()`` | pluginId => version of every installed and enabled plugin | array | 
 +| ``getOwnerOfSetting(string $name)`` | Which plugin owns a preference | ?Plugin | 
 +| ``requireEnabled(string $id)`` | Refuse the request unless the plugin is enabled | Plugin | 
 +| ``requirePage(string $id, string $page)`` | Guard a plugin page | void | 
 +| ``getScriptPluginId()`` | The plugin the running script belongs to | ?string |
  
-Example metadata: +====== PluginLoader ======
-<code json> +
-"autoload": { +
-  "psr-4": { +
-    "Vendor\\Plugin\\": "classes/" +
-  } +
-} +
-</code>+
  
------+Loads the plugins that are enabled for the current request.
  
-===== Lifecycle Methods =====+^ Method ^ Description ^ Return ^ 
 +| ``loadEnabled()`` | Load every enabled plugin | void | 
 +| ``load(Plugin $plugin)`` | Load one plugin | bool | 
 +| ``getLoaded()`` / ``isLoaded(string $id)`` | What was loaded in this request | array / bool | 
 +| ``getFailures()`` | pluginId => why it could not be loaded | array | 
 +| ``getTemplateDirectories()`` | The template search path contributed by plugins | array | 
 +| ``registerLanguages(Plugin $plugin)`` | Add the language directory of a plugin | void |
  
-==== doInstall(bool $addMenuEntry = true) ==== +====== PluginInstaller ======
-Installs the plugin.+
  
-Steps: +^ Method ^ Description ^ Return ^ 
-  * store default config values +| ``install(Plugin $plugin, bool $addMenuEntry = true)`` | Run ``install.sql``, create the component record, seed the preferences, publish the pages, add the menu entry | void | 
-  * execute install SQL scripts +| ``update(Plugin $plugin)`` | Run the update scripts up to the version the files declare | void | 
-  * register component +| ``updateWithNewerFiles(Plugin $plugin)`` | Fetch newer files where the store publishes them, then update | Plugin | 
-  * run update steps +| ``remove(Plugin or string $plugin)`` | Remove the plugin from every organization, with its data and its files | bool | 
-  * set version +| ``enable(Plugin $plugin)`` / ``disable(Plugin $plugin)`` | The per-organization decision | void | 
-  * optionally create menu entry+| ``getPreferenceNames(Plugin $plugin)`` | Every preference the plugin owns | array |
  
-Returns: bool+====== PluginWidget ======
  
-----+^ Method ^ Description ^ Return ^ 
 +| ``register(Plugin $plugin, callable $render, array $options)`` | Declare the widget and add the hook that renders it | void | 
 +| ``render(PagePresenter $page, Plugin $plugin, string $template, array $variables)`` | Render a template as the widget body | string | 
 +| ``getDeclarations()`` | Every declared widget, whether visible in this request or not | array | 
 +| ``isVisible(string $preference)`` / ``getAccess(string $preference)`` | Who may see the widget | bool / int | 
 +| ``getSequence(string $preference, int $default)`` | Position of the widget | int | 
 +| Constants | ``HOOK``, ``DEFAULT_SEQUENCE``, ``ACCESS_NOBODY``, ``ACCESS_EVERYBODY``, ``ACCESS_REGISTERED_USERS`` | |
  
-==== doUninstall(bool $removeMenuEntry = true, array $options = array()) ==== +====== PluginPanel ======
-Removes the plugin completely.+
  
-Steps: +^ Method ^ Description ^ Return ^ 
-  * execute uninstall SQL scripts +| ``collect()`` | Every declared preferences panel | array | 
-  * delete config values +| ``inSection(string $section)`` | The panels of one preference tab | array | 
-  * remove menu entries +| ``get(string $id)`` | One panel declaration | ?array | 
-  * delete component +| ``create(string $id, PreferencesPresenter $presenter)`` | Build the HTML of one panel | string | 
-  * reset version+| ``normalizeId(string $id)`` | The panel ID a plugin ID maps to | string |
  
-Returns: bool+====== PluginPages ======
  
-----+^ Method ^ Description ^ Return ^ 
 +| ``isAllowed()`` | Whether ``plugin_module_pages`` permits publishing | bool | 
 +| ``isWritable()`` | Whether the modules directory can be written | bool | 
 +| ``getObstacle(Plugin $plugin)`` | Why a plugin cannot be published, or ``null`` | ?string | 
 +| ``isPublished(Plugin or string $plugin)`` | Whether the stubs exist | bool | 
 +| ``getUrl(Plugin $plugin, string $page)`` | The URL that is currently live | string | 
 +| ``publish(Plugin $plugin)`` / ``unpublish(Plugin or string $plugin)`` | Write or remove the stubs | bool / void | 
 +| ``reconcile()`` / ``syncAll()`` | Bring the modules directory in line with the preference | array | 
 +| Constants | ``SETTING`` (``plugin_module_pages``), ``SETTING_APPLIED`` | |
  
-==== doUpdate() ==== +====== PluginStore ======
-Updates an installed plugin.+
  
-Steps: +^ Method ^ Description ^ Return ^ 
-  * add missing config defaults +| ``getPlugins()`` | What the catalogue publishes for this Admidio | array | 
-  * run update classes +| ``getEntry(string $id)`` | One catalogue entry | ?array | 
-  * update stored version+| ``getNewerRelease(string $id)`` | A published release newer than the installed one | ?array | 
 +| ``install(string $id)`` / ``updateFiles(string $id)`` | Fetch and place the files | string | 
 +| ``refresh()`` | Fetch the catalogue now | array | 
 +| ``getUrl()`` / ``isDeveloperCatalogue()`` / ``getError()`` | Where it comes from and why it failed | string / bool / ?string |
  
-Returns: bool+====== PluginPackage ======
  
-----+^ Method ^ Description ^ Return ^ 
 +| ``install(string $archivePath, bool $replace = false)`` | Put the plugin of an archive on disk. Nothing is written to the database and the plugin is not enabled | string | 
 +| ``describe(string $archivePath)`` | The plugin an archive holds | Plugin | 
 +| ``create(Plugin $plugin, string $directory)`` | Build the distributable archive | string | 
 +| ``inspect(string $archivePath)`` | The gate: everything it rejects is rejected before a file is written | string | 
 +| Constants | ``MAX_ARCHIVE_BYTES``, ``MAX_EXTRACTED_BYTES``, ``MAX_ENTRIES``, ``EXCLUDED`` | |
  
-==== initParams(array $params = array()) ==== +======= What changed since the first plugin manager =======
-Optional initialization hook for plugins.+
  
-Returns: bool+The plugin manager of Admidio 5.0 was built around a ``PluginAbstract`` base class and a 
 +``plugin.json`` that described it. The runtime was replaced, and a plugin written for it has to be 
 +converted. The differences that matter to a plugin author:
  
------+^ Then ^ Now ^ 
 +| A main plugin class extending ``PluginAbstract``, with ``doRender()``, ``getInstance()`` and overridable lifecycle methods | **No plugin class at all.** ``plugin.php`` registers what the plugin contributes and returns nothing | 
 +| The main file was named by ``mainFile``, or guessed as ``index.php`` / ``PluginName.php`` | The entry file is always ``plugin.php``; pages are ordinary module files below ``modules/`` | 
 +| ``"dependencies"``: a list of Admidio class names that had to exist | ``"requires"``: version constraints for ``admidio`` and ``php``, plus ``extensions``, ``plugins`` and ``modules`` | 
 +| ``"defaultConfig"``, each entry with ``name``, ``description``, ``type``, ``value`` | ``"settings"``, each entry with ``type``, ``default``, ``label``, ``description``, plus ``values`` for an enum and ``min``/``max``/``step`` for an integer | 
 +| ``"overviewPlugin": true`` made a plugin an overview plugin | The plugin registers an overview widget with ``PluginWidget::register()`` | 
 +| A preferences presenter was found by scanning the autoload directories for ``*PreferencesPresenter.php`` | The panel is declared through the ``PluginPanel`` hook, or generated from the manifest and not written at all | 
 +| ``"preferencesFile"`` and ``"hasSubcards"`` | Replaced by the declared panel; a plugin that wants several cards declares several panels | 
 +| ``classes/`` with ``Presenter/``, ``Entity/``, ``Service/`` | Any layout the ``autoload`` mapping names; ``src/`` by convention | 
 +| Install and uninstall were one pair of operations | Four distinct operations: install and update are installation-wide, enable and disable are per organization, remove is the only destructive one | 
 +| ``db-install.sql`` / ``db-uninstall.sql`` | ``install.sql`` / ``uninstall.sql`` in ``db_scripts/`` | 
 +| Plugins were installed by copying files | Plugin store, ZIP upload and ``admidio plugin:*`` commands |
  
-===== Internal Helpers (private) ===== +The generated settings form is the largest simplification: a plugin that only needs ordinary 
-^ Method ^ Description ^ +preference fields declares them in the manifest and writes no presenter, no template and no form 
-| readPluginMetadata() | Loads and parses metadata file | +code at all.
-| getClassNameFromFile(string $file) | Extracts class name from PHP file | +
-| toggleForeignKeyChecks(bool $enable) | Enables/disables MySQL foreign key checks during install/uninstall |+
  
  • en/entwickler/plugin_manager.1770549047.txt.gz
  • Last modified: 2026/02/08 12:10
  • by mightymcoder