The open item that stands between CiviCRM and official Joomla 6 support (dev/joomla#61) is not about CiviCRM at all. It is about how Joomla plugins are written. This post explains the change with an example in the spirit of CiviCRM's user plugin: a plugin that reacts when Joomla users are saved or deleted.
The code below is a generic plugin written for this post, not CiviCRM's own code.
What changed in Joomla
Since Joomla 4, the CMS has been moving plugins to its event dispatcher. A modern plugin:
- is a namespaced class, created by a service provider (
services/provider.php), - implements
SubscriberInterfaceand says which events it listens to ingetSubscribedEvents(), - receives one event object per call, with typed getters, instead of a list of loose arguments.
The old style – a class named plgUserSomething whose public methods are matched to event names by reflection – has been kept working through backward-compatibility code and is deprecated. CiviCRM's three Joomla plugins (user, system, quick icon) are still written that way, which is why they are the work item for Joomla 6.
Before: a legacy user plugin
// plugins/user/contactsync/contactsync.php
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
class PlgUserContactsync extends CMSPlugin
{
// Called after a Joomla user is saved.
// $user is an array, $isNew a bool – positional arguments.
public function onUserAfterSave($user, $isNew, $success, $msg)
{
if (!$success) {
return;
}
$this->syncContact((int) $user['id'], $user['email'], $user['name']);
}
private function syncContact(int $userId, string $email, string $name): void
{
// create or update the matching CRM contact
}
}
After: the same plugin for Joomla 5 and 6
Three files. The service provider:
// plugins/user/contactsync/services/provider.php
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Example\Plugin\User\Contactsync\Extension\Contactsync;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->set(PluginInterface::class, function (Container $container) {
$plugin = new Contactsync(
$container->get(DispatcherInterface::class),
(array) PluginHelper::getPlugin('user', 'contactsync')
);
$plugin->setApplication(Factory::getApplication());
return $plugin;
});
}
};
The plugin class:
// plugins/user/contactsync/src/Extension/Contactsync.php
namespace Example\Plugin\User\Contactsync\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Event\User\AfterSaveEvent;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\SubscriberInterface;
final class Contactsync extends CMSPlugin implements SubscriberInterface
{
public static function getSubscribedEvents(): array
{
return ['onUserAfterSave' => 'onUserAfterSave'];
}
public function onUserAfterSave(AfterSaveEvent $event): void
{
if (!$event->getSavingResult()) {
return;
}
$user = $event->getUser(); // array, as before
$this->syncContact((int) $user['id'], $user['email'], $user['name']);
}
private function syncContact(int $userId, string $email, string $name): void
{
// create or update the matching CRM contact
}
}
And the manifest declares the namespace and the folders:
<extension type="plugin" group="user" method="upgrade">
<name>plg_user_contactsync</name>
<namespace path="src">Example\Plugin\User\Contactsync</namespace>
<files>
<folder plugin="contactsync">services</folder>
<folder>src</folder>
</files>
</extension>
What to watch for when converting
- Return values. Legacy methods sometimes returned
true/falseto signal a result. With event objects, results are added to the event (for events that collect results) or the plugin stops propagation – check the event class for the right method. - One event, one handler. List every event in
getSubscribedEvents(). A method that exists but is not listed is never called. - Priorities. If your plugin must run before or after another one, subscribe with a priority:
['onAfterRoute' => ['onAfterRoute', Priority::HIGH]]. - Generic events. Not every event has a concrete class. For those, accept
Joomla\Event\Eventand read arguments with$event->getArgument('name'). - The application. Use
$this->getApplication(), notFactory::getApplication(), inside the plugin.
How this applies to CiviCRM today
Until CiviCRM ships Joomla 6-ready plugins, a site on Joomla 6 needs the same behavior provided from outside the files CiviCRM installs – a small plugin of your own that does what the CiviCRM plugin would do, written the modern way. Keep it in its own extension and repository, so a CiviCRM update never touches it. More on that approach in Keep CiviCRM fixes out of core.
When CiviCRM's own plugins are updated, retire yours – and test that nothing is left running twice.
What CiviCRM's real plugin looks like today
For reference, the method signatures in civicrm-joomla's user plugin (October 2026), shortened:
// admin/plugins/civicrm/civicrm.php (civicrm-joomla, shortened)
class plgUserCivicrm extends _J3_to_J4_plgUserCivicrm
{
function onUserAfterSave($user, $isnew, $success, $msg) { self::civicrmResetNavigation(); }
function onUserAfterSaveGroup($var) { self::civicrmResetNavigation(); }
function onUserAfterDelete($user, $succes, $msg) { CRM_Core_BAO_UFMatch::deleteUser($user['id']); }
public function onUserLogin($user, $options = []) { /* admin only: reset navigation */ }
}
Four legacy listeners with positional arguments. Converting them is mechanical: AfterSaveEvent, AfterSaveGroupEvent, AfterDeleteEvent and LoginEvent carry the same data through getters. Joomla 6 still calls the legacy form, but Joomla has marked that support "will be removed in 7.0". That's the real deadline for this work.
Need this on a live site? We have run CiviCRM on Joomla 6 since early 2026, with the compatibility code in its own extension and tested through CiviCRM updates.
CiviCRM Joomla 6 upgrade →
