— Article — № 055

055 —Joomla

Joomla 2.5 component to Joomla 5 plugin: skip the rewrite

An old Joomla 2.5 component still runs the client's catalogue. PHP 8.2 is coming. Here's how to lift it onto Joomla 5 as a plugin without touching the schema.

Two layered paper sheets labeled Joomla 2.5 Component and Joomla 5 Plugin, brass tacks, ruler, pencil, red wax seal.
Hero · staged still№ 055

At 23:41 a Loom landed in our inbox from an agency lead in Utrecht. The clip showed a Joomla 2.5 site still running a custom com_competitions component for a regional sports federation. Twelve years of fixtures, results and athlete records sat in nine tables prefixed jos_comp_. The host had just emailed: PHP 8.2 by August, no exceptions. A full rewrite was off the table. The data, in their words, was sacred.

This post is the playbook we used to lift that codebase onto Joomla 5 as a plugin, with the original schema untouched. If you have a Joomla 2.5 component nobody dares rewrite, the recipe below should map onto your situation with very little change.

The inventory we started with

Before any code moves, you need an honest list of what the old component actually does. For the federation site it broke down like this:

  • Nine tables, naming convention #__comp_*, foreign keys by integer ID, no constraints declared.
  • One frontend list view (views/fixtures) and one detail view per match.
  • A backend with seven list views, five edit forms, and a custom XML form for sanctions.
  • Three router rules in router.php producing /competitions/2025/eredivisie/match-42 style URLs.
  • Around 4,200 lines of PHP, most of it in helpers/ and models/.

What it does not have: tests, namespacing, dependency injection, or any cron jobs. The backend gets used twice a season for sanctions. Fixtures arrive via a CSV import that runs nightly.

Why a plugin, not a port of the component

A 1:1 port from Joomla 2.5 component to Joomla 5 component is real work. You inherit the full MVC surface: backend forms, ACL rules, install scripts, language overrides. Joomla 4 introduced namespaced extension layouts and Joomla 5 enforces them, which means every file gets touched, every JFactory call gets rewritten, every form XML revisited.

The federation site did not need most of that. The fixtures and match detail are read-only on the frontend. The backend gets used twice a year. So the question becomes: can we serve the public URLs from a plugin and handle the rare backend work elsewhere? Yes, and the trick is to lean on Joomla's event system rather than reimplement an MVC stack.

We landed on a hybrid. A system plugin owns routing and rendering for the public pages. A console plugin under plugins/console/ handles the nightly CSV import. The two-times-a-year sanctions edits moved to a tiny AJAX endpoint inside the same system plugin. No component package, no admin menu tree, no toolbars.

Preserving the data model

Rule one: do not touch the tables. Every migration that promises to be "just a rename" turns into a three-week regression hunt. The Joomla 5 Table class is namespaced but otherwise behaves like the old JTable. Point it at the existing table and it will read and write without complaint.

<?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);
    }
}

The only translation step was the prefix. The old install left tables as jos_comp_matches. Joomla 5 substitutes #__ with whatever prefix the new install uses, so one RENAME TABLE per table sorts it out. You keep every row, every primary key, every 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;
-- repeat for the rest

If you cannot rename (live site, replication, audit log) the alternative is to override the prefix in the Table subclass:

parent::__construct('jos_comp_matches', 'id', $db);

Ugly, but it works, and it lets you defer the rename to a maintenance window.

The plugin skeleton

A Joomla 5 system plugin lives at plugins/system/competitions/. The manifest, the service provider, and the plugin class all sit in that folder. Here is the skeleton we shipped, trimmed to the bones:

<?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;
        });
    }
};

The extension class is where the events get wired. For a public-facing read view, two events do most of the work: onAfterRoute to detect the federation's URL pattern, and onAfterDispatch to render. The full list of system events is in the Joomla docs.

<?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();
    }
}

That is the whole pipeline. No component install, no admin menus, no toolbars. The renderer is a 200-line class that loads matches via the MatchTable above, runs them through a Twig template, and returns HTML. Nothing fancy, and nothing the old component did differently.

Catching the old URLs

The federation had twelve years of inbound links to /competitions/2017/eerste-divisie/match-1923 URLs. Breaking them was not an option. The plugin claims the URL space ahead of Joomla's router by hooking onAfterRoute, then short-circuits the dispatch with $app->close() once it has rendered.

For the handful of legacy patterns that changed (the old ?option=com_competitions&view=match&id=42 query strings), a small .htaccess block in front of Joomla translates them:

RewriteCond %{QUERY_STRING} (^|&)option=com_competitions&view=match&id=([0-9]+)
RewriteRule ^index\.php$ /competitions/match-%2? [R=301,L]

301, not 302. Search engines should retire the old URLs cleanly. The Apache mod_rewrite docs are worth a re-read if your patterns are gnarlier than this one.

Admin without a component

Building a full Joomla 5 admin component for two edits a year is overkill. Two patterns work depending on how often editors actually log in.

Tiny AJAX endpoint via com_ajax

For the sanctions table, the same plugin exposes a backend view through com_ajax. A single menu link points at /administrator/index.php?option=com_ajax&plugin=competitions&task=sanctions&format=html. The plugin renders a list, an edit form, and a save handler. Two admin users, both happy.

CSV in via a console plugin

The nightly fixture import was the second biggest piece of the old component. In Joomla 5 it becomes a CLI plugin you can run via php cli/joomla.php fixtures:import path/to/file.csv. The cron call is one line, the code lives in plugins/console/fixtures/, and the import uses the same MatchTable as the frontend. One model, one source of truth.

After the cutover

The lift took eight working days. Four were the plugin work itself, four were testing routes against the live URL inventory we pulled from access logs. The old com_competitions directory got renamed to com_competitions.disabled and left on disk for two weeks in case we needed to roll back. We did not.

A few things were worth doing on the way out:

  • Snapshot the database before the prefix rename. mysqldump --single-transaction with a timestamp in the filename.
  • Pin the host's PHP version to 8.2 explicitly. "Latest" is not a version.
  • Drop the leftover jos_session and jos_messages tables once Joomla 5 has written its own equivalents.
  • Keep a copy of the old configuration.php. The secret key in it is not used by Joomla 5, but anything you encrypted with it (saved API tokens for the CSV feed, in our case) needs the old key to decrypt before re-encrypting under the new one.

When we built Pier we ran into this exact pattern across several agency customers, where the audit happens against live tables and needs a one-click undo behind every change. The way we ended up handling it was to pair the MySQL editor with per-row version history, so a prefix rename or any cleanup query on a legacy site can be reversed without a restore from the dump.

The smallest thing you can do today: open the old component's install.sql, list every CREATE TABLE, and write the matching RENAME TABLE statements into a file. That file is the spine of the migration. Everything else hangs off it.

— Questions —

Do I have to convert the whole component?

No. Most legacy components have one or two views that get the public traffic. Lift those into a plugin and leave the rare admin work to a com_ajax endpoint or direct table tooling.

Will the old URLs still work?

Yes, if you claim them in onAfterRoute and add a 301 .htaccess rule for any query-string legacy patterns. Pull the URL inventory from access logs before you start.

What about ACL?

Use Joomla 5's core ACL on the plugin's com_ajax endpoints. ACL rules apply by user group, not by extension type, so you do not need a component manifest to gate access.

Does this approach work for Joomla 3.x components too?

Yes, and it is simpler. Joomla 3 already used some namespacing, so the syntactic gap is smaller. The data-preservation rule is identical.