-
Notifications
You must be signed in to change notification settings - Fork 542
Plugin development
- Introduction
- Directory structure
- Required files
- Main plugin class
- Configuration and settings
- Installation and uninstallation
- Doctrine entities
- Symfony integration (Event Subscribers)
- Display regions (Twig)
- Translations
- Course-oriented plugins
- Lifecycle hooks
- PluginHelper service
- Core file reference
The Chamilo 2.x plugin system allows extending the platform without modifying the core codebase. Plugins live under public/plugin/MyPlugin/ and are discovered automatically when that directory is scanned.
There are two integration layers:
-
Legacy (plain PHP): the
Pluginclass manages settings, translations, and the administration interface. -
Modern Symfony: automatic compiler passes register event subscribers and Doctrine entities located inside the plugin's
src/directory.
public/plugin/MyPlugin/
├── plugin.php # Plugin metadata (REQUIRED)
├── index.php # Entry point / region rendering
├── install.php # Installation script
├── uninstall.php # Uninstallation script
├── admin.php # Administration interface (optional)
├── lang/ # Translations
│ ├── en_US.php
│ └── es.php
├── src/
│ ├── MyPluginPlugin.php # Main plugin class
│ ├── Entity/ # Doctrine entities
│ │ └── MyEntity.php
│ ├── Repository/ # Doctrine repositories
│ │ └── MyEntityRepository.php
│ └── EventSubscriber/ # Symfony event subscribers
│ └── MyPluginEventSubscriber.php
├── templates/ # Twig templates
│ └── my_view.html.twig
└── resources/ # Static CSS / JS
└── MyPlugin.css
This is the only truly required file. AppPlugin uses it to discover the plugin and display its information in the administration panel.
<?php
// public/plugin/MyPlugin/plugin.php
$plugin_info = MyPluginPlugin::create()->get_info();For very simple plugins without a dedicated class, the
$plugin_infoarray can be defined inline:$plugin_info = [ 'title' => 'My Plugin', 'comment' => 'Short description', 'version' => '1.0', 'author' => 'Your Name', ];
The main class extends Plugin (located at public/main/inc/lib/plugin.class.php) and follows the static factory (singleton) pattern.
<?php
// public/plugin/MyPlugin/src/MyPluginPlugin.php
declare(strict_types=1);
class MyPluginPlugin extends Plugin
{
// Optional: name of the plugin's own database table
public const TABLE_LOG = 'my_plugin_log';
protected function __construct()
{
// Settings define the fields of the configuration form.
// Available types: 'boolean', 'text', 'select', 'wysiwyg', 'html', 'checkbox', 'user'
$settings = [
'enabled' => 'boolean',
'max_items' => 'text',
'display_mode' => 'select',
];
parent::__construct(
'1.0', // Version
'Your Name', // Author
$settings
);
}
public static function create(): self
{
static $instance = null;
return $instance ??= new self();
}
}| Property | Type | Effect |
|---|---|---|
$isCoursePlugin |
bool |
Registers the plugin as a course tool |
$isAdminPlugin |
bool |
Indicates the plugin has its own administration interface |
$isMailPlugin |
bool |
Indicates the plugin integrates with the mail system |
$addCourseTool |
bool |
Shows an icon on the course home page |
$hasPersonalEvents |
bool |
Indicates the plugin provides personal calendar events |
$course_settings |
array |
Additional per-course settings |
| Method | Description |
|---|---|
get_lang(string $key) |
Returns a translated string from the lang/ directory |
get(string $name) |
Reads a saved setting from the database |
get_settings() |
Returns all plugin settings for the current access URL |
getFieldNames() |
Returns the names of all declared settings fields |
isEnabled(bool $checkEnabled) |
Checks whether the plugin is installed (and optionally active) |
renderRegion(string $region) |
Override to render plugin content in a Twig region |
getAdminUrl() |
Returns the URL of the administration panel (admin.php or start.php) |
addTab(bool $installInHomePage) |
Adds the plugin as a tab in the main navigation menu |
deleteTab(bool $uninstallFromHomePage) |
Removes the plugin tab from the main navigation menu |
getToolIconVisibilityPerUserStatus() |
Override to restrict the course icon to students or teachers only |
Use these constants with getToolIconVisibilityPerUserStatus() to control course-tool icon visibility:
| Constant | Value | Effect |
|---|---|---|
Plugin::TAB_FILTER_NO_STUDENT |
'::no-student' |
Icon visible only for teachers/admins |
Plugin::TAB_FILTER_ONLY_STUDENT |
'::only-student' |
Icon visible only for students |
public function getToolIconVisibilityPerUserStatus(): string
{
return self::TAB_FILTER_NO_STUDENT; // hides the icon from students
}Settings are declared in the constructor as a name => type array. Chamilo automatically generates the configuration form from that array.
$settings = [
'api_key' => 'text', // Plain text field
'show_sidebar' => 'boolean', // Radio Yes/No
'welcome_text' => 'wysiwyg', // WYSIWYG editor
'layout' => 'select', // Drop-down list
'notify_me' => 'checkbox', // Checkbox
'responsible' => 'user', // Ajax user selector
];For select fields with dynamic or translated options, use array notation:
$settings = [
'mode' => [
'type' => 'select',
'options' => ['auto' => 'Automatic', 'manual' => 'Manual'],
'translate_options' => true, // calls get_lang() on each option label
],
];To read a setting inside the plugin code:
$plugin = MyPluginPlugin::create();
// Single setting
$apiKey = $plugin->get('api_key');
// All settings
$config = $plugin->get_settings();Settings are stored in the access_url_rel_plugin table (JSON configuration column), which allows different configurations per access URL (multi-tenant installations). get_settings() automatically reads the configuration for the current access URL.
Help text for a field is loaded automatically if a translation key {field_name}_help exists in the plugin's language files.
<?php
// public/plugin/MyPlugin/install.php
MyPluginPlugin::create()->install();<?php
// public/plugin/MyPlugin/uninstall.php
MyPluginPlugin::create()->uninstall();use Doctrine\ORM\Tools\SchemaTool;
use Chamilo\PluginBundle\MyPlugin\Entity\MyEntity;
public function install(): void
{
$em = Database::getManager();
$schemaManager = $em->getConnection()->createSchemaManager();
// Avoid creating tables that already exist
if ($schemaManager->tablesExist([self::TABLE_LOG])) {
return;
}
$schemaTool = new SchemaTool($em);
$schemaTool->createSchema([
$em->getClassMetadata(MyEntity::class),
]);
}
public function uninstall(): void
{
$em = Database::getManager();
if (!$em->getConnection()->createSchemaManager()->tablesExist([self::TABLE_LOG])) {
return;
}
$schemaTool = new SchemaTool($em);
$schemaTool->dropSchema([
$em->getClassMetadata(MyEntity::class),
]);
}The PluginEntityPass compiler pass automatically scans each plugin's Entity/ and src/Entity/ directories and registers entities under the Chamilo\PluginBundle\{PluginName} namespace.
No manual registration in config/ is needed. Entities must use PHP attributes (not XML or YAML mapping).
<?php
// public/plugin/MyPlugin/src/Entity/MyEntity.php
declare(strict_types=1);
namespace Chamilo\PluginBundle\MyPlugin\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Table(name: 'my_plugin_entity')]
#[ORM\Entity(repositoryClass: MyEntityRepository::class)]
class MyEntity
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 255)]
private string $title = '';
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getTitle(): string
{
return $this->title;
}
public function setTitle(string $title): self
{
$this->title = $title;
return $this;
}
}<?php
// public/plugin/MyPlugin/src/Entity/MyEntityRepository.php
declare(strict_types=1);
namespace Chamilo\PluginBundle\MyPlugin\Entity;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class MyEntityRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, MyEntity::class);
}
}Table naming convention: use a unique prefix to avoid collisions, e.g.
my_plugin_*.
The PluginEventSubscriberPass compiler pass scans public/plugin/*/src/ looking for files whose name ends in EventSubscriber.php and automatically registers them as Symfony services tagged with kernel.event_subscriber.
<?php
// public/plugin/MyPlugin/src/EventSubscriber/MyPluginEventSubscriber.php
declare(strict_types=1);
namespace Chamilo\PluginBundle\MyPlugin\EventSubscriber;
use Chamilo\CoreBundle\Event\CourseCreatedEvent;
use Chamilo\CoreBundle\Event\Events;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
readonly class MyPluginEventSubscriber implements EventSubscriberInterface
{
private \MyPluginPlugin $plugin;
public function __construct(
private EntityManagerInterface $entityManager,
) {
$this->plugin = \MyPluginPlugin::create();
}
public static function getSubscribedEvents(): array
{
return [
Events::COURSE_CREATED => 'onCourseCreated',
];
}
public function onCourseCreated(CourseCreatedEvent $event): void
{
// Always check that the plugin is active
if (!$this->plugin->isEnabled(true)) {
return;
}
$course = $event->getCourse();
// plugin logic...
}
}Events are defined in src/CoreBundle/Event/Events.php.
Users
| Constant | When fired |
|---|---|
Events::USER_CREATED |
When a user is created |
Events::USER_UPDATED |
When a user is updated |
Courses and sessions
| Constant | When fired |
|---|---|
Events::COURSE_CREATED |
When a course is created |
Events::SESSION_RESUBSCRIPTION |
When a user is re-subscribed to a session |
Authentication
| Constant | When fired |
|---|---|
Events::LOGIN_CREDENTIALS_CHECKED |
When login credentials are verified |
Events::LOGIN_CONDITION_CHECKED |
When additional login conditions are evaluated |
Documents
| Constant | When fired |
|---|---|
Events::DOCUMENT_ITEM_ACTION |
When an action is performed on a document |
Events::DOCUMENT_ACTION |
When a general document action is executed |
Events::DOCUMENT_ITEM_VIEW |
When a document is viewed |
Learning paths
| Constant | When fired |
|---|---|
Events::LP_CREATED |
When a learning path is created |
Events::LP_ITEM_VIEWED |
When a learning path item is viewed |
Events::LP_ENDED |
When a learning path is completed |
Exercises
| Constant | When fired |
|---|---|
Events::EXERCISE_QUESTION_ANSWERED |
When an exercise question is answered |
Events::EXERCISE_ENDED |
When an exercise is finished |
Events::EXERCISE_REPORT_ACTION |
When an action is performed on an exercise report |
Student tracking
| Constant | When fired |
|---|---|
Events::MY_STUDENTS_EXERCISE_TRACKING |
When a student's exercise tracking is queried |
Events::MY_STUDENTS_LP_TRACKING |
When a student's learning path tracking is queried |
Portfolio
| Constant | When fired |
|---|---|
Events::PORTFOLIO_ITEM_ADDED |
When a portfolio item is added |
Events::PORTFOLIO_ITEM_EDITED |
When a portfolio item is edited |
Events::PORTFOLIO_ITEM_VIEWED |
When a portfolio item is viewed |
Events::PORTFOLIO_ITEM_DELETED |
When a portfolio item is deleted |
Events::PORTFOLIO_ITEM_VISIBILITY_CHANGED |
When the visibility of a portfolio item changes |
Events::PORTFOLIO_ITEM_COMMENTED |
When a portfolio item is commented on |
Events::PORTFOLIO_ITEM_HIGHLIGHTED |
When a portfolio item is highlighted |
Events::PORTFOLIO_DOWNLOADED |
When the portfolio is downloaded |
Events::PORTFOLIO_ITEM_SCORED |
When a portfolio item is scored |
Events::PORTFOLIO_COMMENT_SCORED |
When a portfolio comment is scored |
Events::PORTFOLIO_COMMENT_EDITED |
When a portfolio comment is edited |
Notifications
| Constant | When fired |
|---|---|
Events::NOTIFICATION_CONTENT_FORMATTED |
When a notification's content is formatted |
Events::NOTIFICATION_TITLE_FORMATTED |
When a notification's title is formatted |
Administration
| Constant | When fired |
|---|---|
Events::ADMIN_BLOCK_DISPLAYED |
When an administration panel block is rendered |
Plugins can inject content into 18 predefined regions of Chamilo's Vue frontend. Each region is rendered by the PluginRegion.vue component, which calls the GET /plugin-regions/{region} endpoint (PluginRegionController). That controller iterates over the active plugins, calls AppPlugin::loadRegion() (and Plugin::renderRegion() for course plugins), and returns the resulting HTML as JSON. Override the renderRegion() method in the plugin class to return the HTML to inject.
content_bottom content_top course_tool_plugin
footer_center footer_left footer_right
header_center header_left header_main
header_right login_bottom login_top
main_bottom main_top menu_administrator
menu_bottom menu_top pre_footer
- The Vue layout includes
<PluginRegion region="header_right" />(or whichever region). - The
usePluginRegioncomposable fetchesGET /plugin-regions/header_right. -
PluginRegionControllerreturns{ blocks: [{ pluginName, region, html }] }. - The component renders each block's HTML via
v-html.
public function renderRegion(string $region): string
{
if ('header_right' !== $region) {
return '';
}
// Return the HTML to inject
return '<div class="my-plugin-widget">Hello from MyPlugin!</div>';
}Create one PHP file per language inside lang/. The fallback language is en_US.php; if the user's language file does not exist, the English strings are used.
<?php
// public/plugin/MyPlugin/lang/en_US.php
$strings['plugin_title'] = 'My Plugin';
$strings['plugin_comment'] = 'Short description of my plugin.';
$strings['welcome'] = 'Welcome to My Plugin!';
$strings['items_count'] = 'Number of items';
// Optional help text for a setting field (key: {field_name}_help)
$strings['api_key_help'] = 'Enter the API key provided by the service.';<?php
// public/plugin/MyPlugin/lang/es_ES.php
$strings['plugin_title'] = 'Mi Plugin';
$strings['plugin_comment'] = 'Descripción breve de mi plugin.';
$strings['welcome'] = '¡Bienvenido a Mi Plugin!';
$strings['items_count'] = 'Número de elementos';// Usage inside the plugin code
$plugin = MyPluginPlugin::create();
echo $plugin->get_lang('welcome'); // Welcome to My Plugin!
echo $plugin->get_lang('items_count'); // Number of itemsThe language is selected automatically based on the user's interface setting. Language files are loaded in order: en_US.php first (as base), then the user's language file (which overrides matching keys).
A plugin can be added as a tool in courses by setting $isCoursePlugin = true.
class MyPluginPlugin extends Plugin
{
public bool $isCoursePlugin = true;
public bool $addCourseTool = true; // Shows an icon on the course home page
// Additional per-course settings (shown in the course settings form)
public array $course_settings = [
[
'name' => 'my_plugin_enabled',
'type' => 'checkbox',
'default' => false,
],
];
// Called when course settings are saved
public function course_settings_updated(array $values): void
{
// React to changes in the course configuration
}
}// Install in all existing courses
$plugin->install_course_fields_in_all_courses(add_tool_link: true);
// Install in a single course
$plugin->course_install(courseId: 42, addToolLink: true);
// Uninstall from a single course
$plugin->uninstall_course_fields(courseId: 42);
// Uninstall from all courses
$plugin->uninstall_course_fields_in_all_courses();Override these methods in the plugin class to react to platform events:
| Method | When called |
|---|---|
install() |
When the plugin is installed from the admin panel |
uninstall() |
When the plugin is uninstalled |
performActionsAfterConfigure() |
After the plugin configuration form is saved |
course_settings_updated(array $values) |
After course-level settings are saved (requires $course_settings_callback = true) |
validateCourseSetting(string $variable) |
To validate a course setting value (return false to reject) |
doWhenDeletingUser(int $userId) |
When a user is deleted — clean up user-related plugin data |
doWhenDeletingCourse(int $courseId) |
When a course is deleted — clean up course-related plugin data |
doWhenDeletingSession(int $sessionId) |
When a session is deleted — clean up session-related plugin data |
public function performActionsAfterConfigure(): static
{
// e.g., flush an external API cache when settings change
$apiKey = $this->get('api_key');
MyApiClient::clearCache($apiKey);
return $this;
}
public function doWhenDeletingUser(int $userId): void
{
$em = Database::getManager();
// remove plugin records linked to this user
}PluginHelper (src/CoreBundle/Helpers/PluginHelper.php) is a Symfony service that provides a safe way to query plugin state from core Symfony code (controllers, services, event subscribers of other bundles).
use Chamilo\CoreBundle\Helpers\PluginHelper;
class SomeService
{
public function __construct(
private readonly PluginHelper $pluginHelper
) {}
public function doSomething(): void
{
// Check if a plugin is installed and active for the current access URL
if ($this->pluginHelper->isPluginEnabled('MyPlugin')) {
$value = $this->pluginHelper->getPluginConfigValue('MyPlugin', 'api_key');
}
}
}| Method | Description |
|---|---|
isPluginEnabled(string $pluginName) |
Returns true if the plugin is installed and active for the current access URL |
loadLegacyPlugin(string $pluginName) |
Loads and returns the plugin singleton object |
getPluginSetting(string $pluginName, string $settingKey) |
Returns a single setting value via the legacy plugin object |
getPluginConfiguration(string $pluginName) |
Returns the full configuration array for the current access URL |
getPluginConfigValue(string $pluginName, string $key, mixed $default) |
Returns a configuration value, falling back to the legacy get() method |
getPluginConfigValuetries both the plain key and a legacy-prefixed key ({pluginName}_{key}) for backwards compatibility.
| Component | Path |
|---|---|
Plugin base class |
public/main/inc/lib/plugin.class.php |
AppPlugin manager |
public/main/inc/lib/plugin.lib.php |
Plugin entity (Doctrine) |
src/CoreBundle/Entity/Plugin.php |
AccessUrlRelPlugin entity |
src/CoreBundle/Entity/AccessUrlRelPlugin.php |
PluginHelper |
src/CoreBundle/Helpers/PluginHelper.php |
| Event subscriber compiler pass | src/CoreBundle/DependencyInjection/Compiler/PluginEventSubscriberPass.php |
| Entity compiler pass | src/CoreBundle/DependencyInjection/Compiler/PluginEntityPass.php |
| Chamilo Twig extension | src/CoreBundle/Twig/Extension/ChamiloExtension.php |
| Plugin region controller | src/CoreBundle/Controller/PluginRegionController.php |
| Plugin region Vue component | assets/vue/components/layout/PluginRegion.vue |
| Plugin region composable | assets/vue/composables/pluginRegion.js |
| Administration controller | src/CoreBundle/Controller/Admin/PluginsController.php |
| Event constants | src/CoreBundle/Event/Events.php |
| Example plugin (simple) | public/plugin/HelloWorld/ |
| Example plugin (complex) | public/plugin/TopLinks/ |
- Create directory
public/plugin/MyPlugin/ - Create
plugin.phpwith theget_info()call - Create
src/MyPluginPlugin.phpwith the class extendingPlugin - Declare settings in the constructor
- Create
install.phpanduninstall.php - Implement
install()anduninstall()in the class (if the plugin has its own tables) - Create Doctrine entities in
src/Entity/(if applicable) - Create an event subscriber in
src/EventSubscriber/(if applicable) - Add translations in
lang/en_US.php(minimum) - Override lifecycle hooks as needed (
doWhenDeletingUser,performActionsAfterConfigure, etc.) - Test installation from Administration > Plugins
-
Home
- Tools and sessions
- Quiz: Importing
- Releases
- Community
- Development
- Getting started
- Development principles
- Technical design decisions
- Coding conventions v1
- Coding conventions v2
- Add a new Chamilo setting
- Database structure
- Date and time management
- Permissions
- Password management
- Session expiry time
- Code annotation types
- Code quality checkers
- Converting legacy SQL
- Settings migration v1 → v2
- Configurations
- Secure development policy
- Plugin development
- Adding page types
- Design
- Integration
