055 —Joomla
Joomla 2.5 component naar Joomla 5 plugin: geen rewrite
Een oud Joomla 2.5 component draait nog de catalogus van de klant. PHP 8.2 komt eraan. Zo til je het naar Joomla 5 als plugin zonder het schema aan te raken.
Om 23:41 belandde er een Loom in onze inbox van een agency lead uit Utrecht. De clip liet een Joomla 2.5 site zien die nog altijd een custom com_competitions component draait voor een regionale sportbond. Twaalf jaar wedstrijden, uitslagen en spelersdossiers stonden in negen tabellen met prefix jos_comp_. De hostingmaatschappij had net gemaild: PHP 8.2 in augustus, geen uitzonderingen. Een volledige rewrite was geen optie. De data, in hun woorden, was heilig.
Dit is de playbook die we gebruikten om die codebase op Joomla 5 te tillen als plugin, met het originele schema intact. Heb je een Joomla 2.5 component dat niemand durft te herschrijven, dan past het onderstaande recept met minimale aanpassingen op jouw situatie.
De inventaris waarmee we begonnen
Voordat er ook maar één regel code verschuift, heb je een eerlijke lijst nodig van wat het oude component eigenlijk doet. Voor de bondsite zag dat er zo uit:
- Negen tabellen, naamconventie
#__comp_*, foreign keys op integer ID, geen constraints gedeclareerd. - Eén frontend lijstview (
views/fixtures) en één detailview per wedstrijd. - Een backend met zeven lijstviews, vijf edit-formulieren en een custom XML-formulier voor schorsingen.
- Drie router-regels in
router.phpdie URL's produceren in de stijl/competitions/2025/eredivisie/match-42. - Rond de 4.200 regels PHP, het meeste in
helpers/enmodels/.
Wat het niet heeft: tests, namespacing, dependency injection of cron jobs. De backend wordt twee keer per seizoen gebruikt voor schorsingen. Wedstrijden komen binnen via een CSV-import die 's nachts draait.
Waarom een plugin, en geen port van het component
Een 1:1 port van Joomla 2.5 component naar Joomla 5 component is serieus werk. Je erft het volledige MVC-oppervlak: backend-formulieren, ACL-regels, installscripts, taaloverrides. Joomla 4 introduceerde namespaced extension layouts en Joomla 5 dwingt ze af, wat betekent dat ieder bestand wordt aangeraakt, iedere JFactory-call wordt herschreven en iedere form XML opnieuw bekeken.
De bondsite had het meeste daarvan niet nodig. De fixtures en wedstrijddetails zijn read-only aan de frontend. De backend wordt twee keer per jaar gebruikt. De vraag wordt dus: kunnen we de publieke URL's serveren vanuit een plugin en het schaarse backendwerk elders afhandelen? Ja, en de truc is om te leunen op het event-systeem van Joomla in plaats van een MVC-stack opnieuw te bouwen.
We kwamen uit op een hybride opzet. Een system plugin verzorgt de routing en rendering voor de publieke pagina's. Een console plugin onder plugins/console/ doet de nachtelijke CSV-import. De twee schorsingsmomenten per jaar verhuisden naar een klein AJAX-endpoint binnen diezelfde system plugin. Geen component-pakket, geen admin-menustructuur, geen toolbars.
Het datamodel behouden
Regel één: blijf van de tabellen af. Iedere migratie die belooft 'gewoon een rename' te zijn, wordt een regressiejacht van drie weken. De Joomla 5 Table-klasse is namespaced maar gedraagt zich verder als de oude JTable. Wijs hem aan op de bestaande tabel en hij leest en schrijft zonder morren.
<?php
namespace Federation\Plugin\System\Competitions\Table;
use Joomla\CMS\Table\Table;
use Joomla\Database\DatabaseDriver;
class MatchTable extends Table
{
public function __construct(DatabaseDriver $db)
{
parent::__construct('#__comp_matches', 'id', $db);
}
}
De enige vertaalstap was de prefix. De oude installatie liet tabellen achter als jos_comp_matches. Joomla 5 vervangt #__ door de prefix die de nieuwe installatie gebruikt, dus één RENAME TABLE per tabel lost dat op. Je behoudt iedere rij, iedere primary key en iedere foreign reference:
RENAME TABLE jos_comp_matches TO j5x_comp_matches;
RENAME TABLE jos_comp_seasons TO j5x_comp_seasons;
RENAME TABLE jos_comp_teams TO j5x_comp_teams;
-- herhaal voor de rest
Als je niet kunt renamen (live site, replicatie, audit log) is het alternatief om de prefix te overschrijven in de Table-subklasse:
parent::__construct('jos_comp_matches', 'id', $db);
Lelijk, maar het werkt en het laat je de rename uitstellen naar een onderhoudsvenster.
Het plugin-skelet
Een Joomla 5 system plugin staat in plugins/system/competitions/. Het manifest, de service provider en de plugin-klasse zitten allemaal in die map. Dit is het skelet dat we hebben uitgerold, teruggebracht tot de kern:
<?php
// plugins/system/competitions/services/provider.php
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Federation\Plugin\System\Competitions\Extension\Competitions;
return new class implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->set(PluginInterface::class, function (Container $c) {
$plugin = new Competitions(
$c->get(DispatcherInterface::class),
(array) PluginHelper::getPlugin('system', 'competitions')
);
$plugin->setApplication(\Joomla\CMS\Factory::getApplication());
return $plugin;
});
}
};
De extension-klasse is waar de events worden aangesloten. Voor een publieke read-view doen twee events het meeste werk: onAfterRoute om het URL-patroon van de bond te herkennen en onAfterDispatch om te renderen. De volledige lijst system events staat in de Joomla-documentatie.
<?php
namespace Federation\Plugin\System\Competitions\Extension;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\SubscriberInterface;
final class Competitions extends CMSPlugin implements SubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
'onAfterRoute' => 'handleRoute',
'onAfterDispatch' => 'renderIfOurs',
];
}
public function handleRoute(): void
{
$path = trim(\Joomla\CMS\Uri\Uri::getInstance()->getPath(), '/');
if (!str_starts_with($path, 'competitions/')) {
return;
}
$this->getApplication()->input->set('comp_route', explode('/', $path));
}
public function renderIfOurs(): void
{
$segments = $this->getApplication()->input->get('comp_route', null, 'array');
if (!$segments) {
return;
}
echo (new Renderer())->render($segments);
$this->getApplication()->close();
}
}
Dat is de hele pipeline. Geen component-installatie, geen admin-menu's, geen toolbars. De renderer is een klasse van 200 regels die wedstrijden laadt via de MatchTable hierboven, ze door een Twig-template haalt en HTML teruggeeft. Niks bijzonders en niks wat het oude component anders deed.
De oude URL's opvangen
De bond had twaalf jaar aan inbound links naar URL's als /competitions/2017/eerste-divisie/match-1923. Die breken was geen optie. De plugin claimt de URL-ruimte vóór de router van Joomla door onAfterRoute aan te haken en sluit het dispatch-proces vervolgens kort met $app->close() zodra hij gerenderd heeft.
Voor de handvol legacy-patronen die wél veranderden (de oude ?option=com_competitions&view=match&id=42 querystrings) vertaalt een klein .htaccess-blok vóór Joomla ze:
RewriteCond %{QUERY_STRING} (^|&)option=com_competitions&view=match&id=([0-9]+)
RewriteRule ^index\.php$ /competitions/match-%2? [R=301,L]
301, geen 302. Zoekmachines moeten de oude URL's netjes uitfaseren. De Apache mod_rewrite docs zijn de moeite van een herlezing waard als jouw patronen pittiger zijn dan deze.
Admin zonder component
Een volledig Joomla 5 admin-component bouwen voor twee edits per jaar is overkill. Twee patronen werken, afhankelijk van hoe vaak editors echt inloggen.
Klein AJAX-endpoint via com_ajax
Voor de schorsingstabel exposeert dezelfde plugin een backend-view via com_ajax. Eén menulink wijst naar /administrator/index.php?option=com_ajax&plugin=competitions&task=sanctions&format=html. De plugin rendert een lijst, een edit-formulier en een save-handler. Twee admin-gebruikers, allebei tevreden.
CSV erin via een console plugin
De nachtelijke fixture-import was het op één na grootste stuk van het oude component. In Joomla 5 wordt het een CLI-plugin die je draait via php cli/joomla.php fixtures:import path/to/file.csv. De cron-aanroep is één regel, de code staat in plugins/console/fixtures/ en de import gebruikt dezelfde MatchTable als de frontend. Eén model, één bron van waarheid.
Na de cutover
De lift kostte acht werkdagen. Vier waren het plugin-werk zelf, vier waren het testen van routes tegen de live URL-inventaris die we uit de access logs trokken. De oude com_competitions-map werd hernoemd naar com_competitions.disabled en twee weken op schijf gelaten voor het geval we terug moesten rollen. Dat hoefde niet.
Een paar dingen waren het waard om onderweg te doen:
- Maak een snapshot van de database vóór de prefix-rename.
mysqldump --single-transactionmet een timestamp in de bestandsnaam. - Pin de PHP-versie van de hosting expliciet op 8.2. 'Latest' is geen versie.
- Gooi de overgebleven
jos_session- enjos_messages-tabellen weg zodra Joomla 5 zijn eigen equivalenten heeft weggeschreven. - Bewaar een kopie van de oude
configuration.php. De secret key erin gebruikt Joomla 5 niet, maar alles wat je ermee versleuteld hebt (opgeslagen API-tokens voor de CSV-feed, in ons geval) heeft de oude sleutel nodig om te decrypten voordat je het opnieuw versleutelt onder de nieuwe.
Toen we Pier bouwden liepen we bij meerdere agency-klanten tegen precies dit patroon aan, waarbij de audit op live tabellen plaatsvindt en achter iedere wijziging een one-click undo nodig is. Het werd uiteindelijk een combinatie van de MySQL-editor met per-rij versiegeschiedenis, zodat een prefix-rename of een opschoonquery op een legacy site teruggedraaid kan worden zonder een restore vanuit de dump.
Het kleinste wat je vandaag kunt doen: open de install.sql van het oude component, lijst iedere CREATE TABLE op en schrijf de bijbehorende RENAME TABLE-statements weg in een bestand. Dat bestand is de ruggengraat van de migratie. Al het andere hangt eraan.
— Vragen —
Moet ik het hele component converteren?
Nee. De meeste legacy components hebben één of twee views die het publieke verkeer trekken. Til die in een plugin en laat het schaarse adminwerk over aan een com_ajax-endpoint of directe table-tooling.
Blijven de oude URL's werken?
Ja, mits je ze claimt in onAfterRoute en een 301 .htaccess-regel toevoegt voor eventuele legacy querystring-patronen. Trek de URL-inventaris uit je access logs voordat je begint.
Hoe zit het met ACL?
Gebruik de core ACL van Joomla 5 op de com_ajax-endpoints van de plugin. ACL-regels werken per usergroup, niet per extensietype, dus je hebt geen component-manifest nodig om toegang af te schermen.