Joomla Installer Scripts and SQL Updates

This content was generated with the assistance of AI and should be verified against the official Joomla documentation before being relied on in production.

This sub-section’s Joomla bibliography lists the reference material consulted while preparing these pages.

Most extensions need to do more than copy files. A component creates tables, a plugin has to be enabled once, an update has to rename a column and move data, and a release that drops a template file has to delete the old copy. Joomla gives an extension two hooks for this: an optional install script (a PHP file named by <scriptfile> in the manifest) and SQL files declared in the <install>, <uninstall> and <update> elements. This page explains both in depth, how the installer calls them, what happens when they fail, and how to write them for the current platform.

It was checked on 2026-10-30 against Joomla 6.2.x and PHP 8.3 or later (the minimum of Joomla 6), using the Joomla Developer Manual, the installer classes of the 6.2 source tree (libraries/src/Installer) and the core component com_admin. It is written for the extension developer. The running example is the Tallpine Hiking Club package pkg_tallpine, whose component com_tallpine (namespace Tallpine\Component\Tallpine) owns the table #__tallpine_trails. The manifest as a whole is described in Extension types, manifests and packaging; the administrator screens that run an installation are described in Extensions: installing, updating and update sites; publishing the next version to sites is covered in Update servers and the JED.

What the installer does, in order

Every installer adapter (component, module, plugin, template, library, file, package, language) shares one generic install() method in InstallerAdapter. The order matters because it decides what each hook can rely on.

flowchart TD A[Unzip and read manifest] --> B[setupScriptfile: require script.php] B --> C{preflight returns false?} C -- yes --> X[abort and roll back] C -- no --> D[Copy files, media, languages] D --> E[Write the extension row to the extensions table] E --> F{Route} F -- install or discover_install --> G[Run install SQL, record highest update file in schemas] F -- update --> H[Run update files newer than the schemas version] G --> I[install or update method of the script] H --> I I --> J{returned false?} J -- yes --> X J -- no --> K[Copy manifest and script, remove stale updates record] K --> L[postflight] L --> M[onExtensionAfterInstall]

Several consequences follow from reading InstallerAdapter::install():

  • preflight runs after the manifest has been parsed and the script file loaded, but before any file of the extension is copied and before any database change. It is the only hook that can refuse an installation without leaving anything behind.

  • The extension row in #__extensions exists by the time the SQL runs, so the SQL and the install()/update() methods can use the new extension_id.

  • install() or update() runs after the SQL. An update script therefore sees the new schema, not the old one. Data migrations that need the old shape must be written as SQL update files instead.

  • A false result (or an exception) from preflight, install or update aborts the whole operation and the installer rolls back what it can: copied files and the extension record are removed. SQL statements already executed are not undone, because MySQL and MariaDB commit DDL statements implicitly. A failing update therefore cannot be assumed to leave the schema as it was.

  • A false result from postflight, and from uninstall, is ignored: the installer only aborts for the other three hooks. A script that must stop an uninstall cannot do so from uninstall(); use preflight for that, because on the uninstall route preflight is called first and its failure does abort.

  • Everything a script prints is captured in an output buffer and appended to the installer message shown after postflight (and after uninstall). Prefer enqueueMessage() for messages meant for the administrator.

The route (the $type argument given to preflight and postflight) is one of install, update, discover_install or uninstall. discover_install is used when an extension that is already on disk is registered with the Discover tool or extension:discover:install; the interface documents the values as "install or discover_install, update, uninstall".

The three ways to supply a script

The loader in InstallerAdapter::setupScriptfile() does the following with the file named by <scriptfile>:

  1. It resolves the file inside the unpacked package and loads it with require_once. If the manifest names a file that does not exist, it raises a deprecation notice that "in version 7.0 this will crash".

  2. If the file returns a service provider, the installer registers it in a child container created just for this extension. If the file returns an object that implements InstallerScriptInterface, that object is put into the container directly.

  3. If the container still has no InstallerScriptInterface entry, the installer falls back to the legacy way (see below), triggering the deprecation message "Legacy installer files are deprecated and will be removed in 7.0".

  4. The script object is fetched from the container. If it has a setApplication() method it receives the current application, and if it implements DatabaseAwareInterface it receives the database from the container, so the DatabaseAwareTrait works in a script without any wiring.

So there are three shapes, in order of preference:

Shape How it is written Status

Service provider returned from script.php

return new class () implements ServiceProviderInterface that registers an InstallerScriptInterface implementation under InstallerScriptInterface::class.

Documented approach since Joomla 4.2, the one used in this page.

Object returned directly

return new class () implements InstallerScriptInterface { ... }; with no provider.

Supported by the loader. Simpler, but the script receives no constructor-injected services.

Legacy class

A named class <element>InstallerScript (for example Com_TallpineInstallerScript; the element is filtered to letters and digits and hyphens are removed) with any of the five methods, usually extending Joomla\CMS\Installer\InstallerScript.

Still loaded in 6.2 and wrapped in LegacyInstallerScript, but deprecated and announced for removal in Joomla 7.0.

LegacyInstallerScript is the adapter that keeps old scripts alive. It implements InstallerScriptInterface, forwards each call to the old object when the method exists, treats a method that returns nothing (null) as success, injects the database into scripts that implement DatabaseAwareInterface, and proxies __get, __set and __call to the wrapped object. A legacy script that does not implement DatabaseAwareInterface has no $this->db supplied by the installer and must obtain the database itself; do not call the removed Factory::getDbo(). The core component com_admin still ships such a legacy JoomlaInstallerScript class, which shows both that the style works and that core has not yet migrated every script.

The interface and what each method is for

Joomla\CMS\Installer\InstallerScriptInterface has existed since Joomla 4.2.0 and declares five methods. All receive the InstallerAdapter that is running the operation, and the two that bracket the process also receive the route.

<?php

namespace Joomla\CMS\Installer;

interface InstallerScriptInterface
{
    public function install(InstallerAdapter $adapter): bool;
    public function update(InstallerAdapter $adapter): bool;
    public function uninstall(InstallerAdapter $adapter): bool;
    public function preflight(string $type, InstallerAdapter $adapter): bool;
    public function postflight(string $type, InstallerAdapter $adapter): bool;
}
Method Runs Typical use and effect of false

preflight($type, $adapter)

First, before files and database. Called for install, update, discover_install and uninstall.

Minimum PHP and Joomla checks, refusing a downgrade, checking that a required extension is installed. Returning false aborts with a message.

install($adapter)

After files and install SQL, on a fresh installation only.

Seed rows, enable a plugin, create a default category. Returning false aborts and rolls back.

update($adapter)

After files and pending update SQL, when an existing extension is replaced.

Data migrations that run after the schema change, clean-up of obsolete settings. Returning false aborts.

uninstall($adapter)

After the uninstall SQL has run, when the extension is removed.

Remove data the SQL file cannot, such as media files. The return value is ignored.

postflight($type, $adapter)

Last, after the manifest has been copied and the update record cleaned.

Delete files shipped by earlier releases, show a closing message, enable the extension. The return value is ignored.

The $adapter gives access to the parsed manifest (getManifest()), the extension name (getName()), the element (getElement()) and the parent Installer (getParent()), which knows the source path (getPath('source')) and the installed paths (getPath('extension_administrator'), getPath('extension_root') and so on).

InstallerScriptTrait (Joomla 6.0)

Before 6.0, a script that wanted the usual checks had to extend a base class that was tied to the pre-4.2 style. Joomla 6.0.0 added Joomla\CMS\Installer\InstallerScriptTrait, which gives a class that implements InstallerScriptInterface the common behaviour without inheritance. It provides default install(), update() and uninstall() methods that return true, plus working preflight() and postflight() methods. Because the trait is new in 6.0.0, a package that must also install on 5.x cannot use it: a 5.x site has no such trait. The trait is described in PHP traits; this section covers only what it does.

Member Default Meaning

protected string $minimumPhp

unset (no check)

Lowest PHP version, compared with version_compare(PHP_VERSION, ..., '<'). A value that is empty or not set is skipped.

protected string $minimumJoomla

unset (no check)

Lowest Joomla version, compared with JVERSION.

protected bool $allowDowngrades

false

When false, an update to a lower <version> than the installed one is refused.

protected array $deleteFiles

[]

Files to delete in postflight, as paths starting with / relative to the site root (JPATH_ROOT).

protected array $deleteFolders

[]

Folders to delete recursively in postflight, same convention.

protected string $extension

set in preflight

The extension name taken from $adapter->getName(), used by addDashboardMenuModule() for the language key.

What the trait’s methods do:

preflight($type, $adapter)

Stores the extension name, then calls checkCompatibility() and checkDowngrade(). A failed PHP or Joomla check logs the language strings JLIB_INSTALLER_MINIMUM_PHP or JLIB_INSTALLER_MINIMUM_JOOMLA as a warning and returns false. The downgrade check applies only when $type is update, reads the installed manifest with getOldManifest() and, if its version is higher than the version in the package, logs JLIB_INSTALLER_ERROR_DOWNGRADE and returns false. Both checks are protected and can be overridden.

postflight($type, $adapter)

Calls removeFiles(), which deletes every entry of $deleteFiles that is a file and every entry of $deleteFolders that is a directory. Each path is run through Path::check(), which throws for a path that contains .. or escapes the root, and a failed deletion is logged and does not abort anything. postflight runs for every route, so a delete list is also applied on a fresh install, where there is simply nothing to delete.

getOldManifest(InstallerAdapter $adapter): ?SimpleXMLElement

Returns the manifest of the currently installed copy (found under the administrator extension path) or null when no such file exists. Use it to compare the installed <version> with the new one inside update().

setApplication() and getApplication()

The installer calls setApplication() on a script that has the method, passing Factory::getApplication(). A script that uses the trait and also receives the application in its constructor, as in the example below, still gets the installer’s call.

addDashboardMenuModule(string $dashboard, string $preset)

Creates an administrator mod_submenu module in the position cpanel-<dashboard> with a menu preset, through the com_modules model. It tries a translated title from the key COM_<EXTENSION>_DASHBOARD_<DASHBOARD>_TITLE and otherwise falls back to a title such as "Tallpine Dashboard". It is meant for components that add their own administrator dashboard (see the administrator tour, dashboards and guided tours).

The trait’s preflight and postflight are declared public, so a class that wants to add its own logic cannot simply redefine them and still call the trait. Importing them under an alias is the documented pattern, and the Manual’s example does exactly that.

A complete service-provider script for com_tallpine

The following script.php sits at the root of pkg_tallpine's component folder (next to com_tallpine.xml). It refuses PHP older than 8.3 and Joomla older than 6.2, refuses a downgrade, refuses a site without the mbstring extension, deletes a layout file and a media folder that an earlier release shipped, reports a fresh install, and on update fills in a default difficulty for trails that have none.

<?php

/**
 * @package     Tallpine.Administrator
 * @subpackage  com_tallpine
 */

\defined('_JEXEC') or die;

use Joomla\CMS\Application\AdministratorApplication;
use Joomla\CMS\Installer\InstallerAdapter;
use Joomla\CMS\Installer\InstallerScriptInterface;
use Joomla\CMS\Installer\InstallerScriptTrait;
use Joomla\CMS\Language\Text;
use Joomla\Database\DatabaseInterface;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;

return new class () implements ServiceProviderInterface {
    public function register(Container $container): void
    {
        $container->set(
            InstallerScriptInterface::class,
            new class (
                $container->get(AdministratorApplication::class),
                $container->get(DatabaseInterface::class)
            ) implements InstallerScriptInterface {
                use InstallerScriptTrait {
                    InstallerScriptTrait::preflight as traitPreflight;
                    InstallerScriptTrait::postflight as traitPostflight;
                }

                protected string $minimumPhp = '8.3.0';
                protected string $minimumJoomla = '6.2.0';
                protected bool $allowDowngrades = false;
                protected array $deleteFiles = [
                    '/components/com_tallpine/tmpl/trails/legacy.php',
                ];
                protected array $deleteFolders = [
                    '/media/com_tallpine/js/legacy',
                ];

                public function __construct(
                    private AdministratorApplication $app,
                    private DatabaseInterface $db
                ) {
                }

                public function preflight(string $type, InstallerAdapter $adapter): bool
                {
                    if (!$this->traitPreflight($type, $adapter)) {
                        // The trait has already logged the reason.
                        return false;
                    }

                    if ($type !== 'uninstall' && !extension_loaded('mbstring')) {
                        $this->app->enqueueMessage(Text::_('COM_TALLPINE_ERROR_MBSTRING'), 'error');

                        return false;
                    }

                    return true;
                }

                public function install(InstallerAdapter $adapter): bool
                {
                    // The tables already exist: the install SQL has run.
                    $this->app->enqueueMessage(Text::_('COM_TALLPINE_INSTALLED'));

                    return true;
                }

                public function update(InstallerAdapter $adapter): bool
                {
                    $old = $this->getOldManifest($adapter);

                    // Idempotent: administrators re-run failed updates.
                    $query = $this->db->createQuery()
                        ->update($this->db->quoteName('#__tallpine_trails'))
                        ->set($this->db->quoteName('difficulty') . ' = ' . $this->db->quote('moderate'))
                        ->where($this->db->quoteName('difficulty') . ' = ' . $this->db->quote(''));
                    $this->db->setQuery($query)->execute();

                    $this->app->enqueueMessage(
                        Text::sprintf('COM_TALLPINE_UPDATED_FROM', (string) ($old?->version ?? '?'))
                    );

                    return true;
                }

                public function uninstall(InstallerAdapter $adapter): bool
                {
                    // Return value is ignored on uninstall; the SQL file has already dropped the tables.
                    return true;
                }

                public function postflight(string $type, InstallerAdapter $adapter): bool
                {
                    return $this->traitPostflight($type, $adapter);
                }
            }
        );
    }
};

Points worth noticing in the example:

  • The guard is \defined('_JEXEC') or die;. Joomla 6 no longer defines JPATH_PLATFORM unless the Behaviour - Backward Compatibility 6 plugin is enabled, so a guard on that constant would stop the script on a default Joomla 6 site.

  • The classes come from the container of the extension being installed. AdministratorApplication and DatabaseInterface are registered by the core, so a script never needs Factory::getApplication() or Factory::getDbo(). The script could equally use DatabaseAwareTrait (the installer then calls setDatabase() for you) instead of a constructor argument.

  • Building queries with quoteName() and quote() is acceptable for constants. Anything that comes from a variable goes through bind(); see the Joomla database API.

  • update() is written to be run twice with the same result. A failed update leaves the extension row in the #__extensions table with the old version, and the administrator runs it again.

The manifest line that activates the script is a single element, a path relative to the zip root:

<?xml version="1.0" encoding="utf-8"?>
<extension type="component" method="upgrade">
    <name>com_tallpine</name>
    <version>1.2.0</version>
    <namespace path="src">Tallpine\Component\Tallpine</namespace>

    <scriptfile>script.php</scriptfile>

    <install>
        <sql>
            <file driver="mysql" charset="utf8">sql/install.mysql.utf8.sql</file>
            <file driver="postgresql" charset="utf8">sql/install.postgresql.utf8.sql</file>
        </sql>
    </install>
    <uninstall>
        <sql>
            <file driver="mysql" charset="utf8">sql/uninstall.mysql.utf8.sql</file>
            <file driver="postgresql" charset="utf8">sql/uninstall.postgresql.utf8.sql</file>
        </sql>
    </uninstall>
    <update>
        <schemas>
            <schemapath type="mysql">sql/updates/mysql</schemapath>
            <schemapath type="postgresql">sql/updates/postgresql</schemapath>
        </schemas>
    </update>

    <files folder="site">
        <folder>src</folder>
        <folder>tmpl</folder>
    </files>
    <administration>
        <files folder="admin">
            <filename>access.xml</filename>
            <filename>config.xml</filename>
            <folder>services</folder>
            <folder>sql</folder>
            <folder>src</folder>
            <folder>tmpl</folder>
        </files>
    </administration>
</extension>

The method="upgrade" attribute lets the same package install over an existing copy. The installer copies the script file to the extension’s administrator folder so that it is available on uninstall, when the original package is gone.

The older InstallerScript base class

Joomla 3.6 introduced Joomla\CMS\Installer\InstallerScript as a base class for the legacy style, and it is still present in 6.2. It is not marked deprecated in its docblocks, but its constructor-less, property-based design belongs to the legacy script shape that the loader reports as deprecated, so new code should use the interface and trait. Its useful members are:

Member Behaviour

$minimumPhp, $minimumJoomla, $allowDowngrades

Same meaning as in the trait. They are protected and untyped.

preflight($type, $parent)

Checks the PHP and Joomla minimums, records $this->extension, $this->release and $this->paramTable (#__modules for names starting with mod, otherwise #__extensions), and on update refuses a package with a lower version than the installed manifest (JLIB_INSTALLER_INCORRECT_SEQUENCE). It is written for install and update; a script that wants it for uninstall must not rely on the version logic.

$deleteFiles, $deleteFolders, removeFiles()

Delete lists with the same site-root-relative paths. Unlike the trait, the base class does not call removeFiles() for you: the subclass calls it from its own postflight.

$cliScriptFiles, moveCliFiles()

Moves old-style CLI scripts from the extension into cli/. Useful only for pre-4 CLI scripts that are replaced today by console command plugins.

getParam(), setParams(), getInstances(), getItemArray()

Helpers that read and write the JSON params column of the extension row or of every module instance. Prefer the table and database API in new code.

addDashboardMenu($dashboard, $preset)

Since 4.0.0, creates a mod_submenu dashboard module. The trait’s addDashboardMenuModule() is its successor.

A legacy script for the same component looks like this. It still works in 6.2, with the deprecation notice, and shows what a migration has to translate.

<?php

\defined('_JEXEC') or die;

use Joomla\CMS\Installer\InstallerAdapter;
use Joomla\CMS\Installer\InstallerScript;

class Com_TallpineInstallerScript extends InstallerScript
{
    protected $minimumPhp = '8.3.0';
    protected $minimumJoomla = '6.2.0';
    protected $deleteFiles = ['/components/com_tallpine/tmpl/trails/legacy.php'];

    public function postflight($type, $parent)
    {
        $this->removeFiles();

        return true;
    }
}

Migrating it to the current shape means: return a service provider from script.php, implement the five interface methods (all five are now mandatory, unlike the legacy script where missing methods were skipped), switch the property declarations to the typed ones of the trait, drop extends, and call the trait’s traitPostflight instead of removeFiles().

Minimum PHP and Joomla checks

There are three places that can state a minimum, and they do different jobs:

  • The script ($minimumPhp, $minimumJoomla). Runs in preflight, before anything is copied, and prints a warning that names the required version. This is the strongest check, because it aborts the operation.

  • The update server file (<targetplatform name="joomla" version="6.[2-9]"/> and <php_minimum>). Joomla reads these when it offers an update and hides releases whose platform or PHP minimum does not match the site; they do not stop a manual upload. Detailed in Update servers and the JED.

Use the script check for correctness and the update server entries to avoid offering an update that cannot succeed. The platform rule matters because the core raises its own requirements only in the first release of a major version, so a minimum of 6.2.0 expresses a feature requirement of your extension and not a core policy.

The downgrade check compares the manifest versions with version_compare(), so 1.10.0 is newer than 1.9.0. Keep versions numeric and dotted, or suffix them in a form version_compare() understands (1.2.0-rc1).

SQL: install, uninstall and update files

The SQL declared in the manifest is executed by the installer itself, not by the script. The rules come from Installer::parseSQLFiles() (install and uninstall) and Installer::parseSchemaUpdates() (update).

Install and uninstall files

Each <file> element in <install><sql> or <uninstall><sql> names an SQL file. The installer runs a file only if both of these hold:

  • its driver attribute matches the site’s database type (mysql, or postgresql; the aliases mysqli, pdomysql and pgsql are normalised to these), and

  • its charset attribute is utf8. A <file> that omits charset="utf8" is silently skipped, and so is its SQL, which is a frequent cause of "the tables were not created".

A file that is named in the manifest but missing from the package makes the installer log JLIB_INSTALLER_ERROR_SQL_FILENOTFOUND and fail. A file that is empty is allowed. The installer splits the text into statements with Installer::splitSql(), which understands quoted strings and comments, and executes them one by one. The prefix #__ is replaced with the site’s table prefix.

-- admin/sql/install.mysql.utf8.sql
CREATE TABLE IF NOT EXISTS `#__tallpine_trails` (
    `id`             int unsigned NOT NULL AUTO_INCREMENT,
    `asset_id`       int unsigned NOT NULL DEFAULT 0,
    `catid`          int unsigned NOT NULL DEFAULT 0,
    `title`          varchar(255) NOT NULL,
    `alias`          varchar(400) NOT NULL DEFAULT '',
    `distance_km`    decimal(6,2) NOT NULL DEFAULT 0.00,
    `elevation_gain` int unsigned NOT NULL DEFAULT 0,
    `difficulty`     varchar(20) NOT NULL DEFAULT 'moderate',
    `state`          tinyint NOT NULL DEFAULT 0,
    `access`         int unsigned NOT NULL DEFAULT 0,
    `created`        datetime NOT NULL,
    PRIMARY KEY (`id`),
    KEY `idx_catid` (`catid`),
    KEY `idx_state` (`state`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 DEFAULT COLLATE = utf8mb4_unicode_ci;
-- admin/sql/uninstall.mysql.utf8.sql
DROP TABLE IF EXISTS `#__tallpine_trails`;

The query builder does not make raw install SQL portable between databases. A component that supports both MySQL/MariaDB and PostgreSQL ships both sets of files, using each database’s own types (serial, bigint, timestamp without time zone in PostgreSQL) and quoting (double quotes instead of backticks); a component that supports only MySQL and MariaDB declares only the mysql entries. The install file must always describe the newest schema, because a fresh installation runs no update file.

Update files named by version

The <update><schemas><schemapath type="..."> element names a folder per database type. Every *.sql file in it is an update step, and the file name is the version: 1.1.0.sql, 1.2.0.sql, or with a suffix such as 1.2.0-2026-10-30.sql, the form core itself uses (6.2.0-2026-09-15.sql in com_admin). The names are sorted with PHP’s version_compare(), so 1.9.0 precedes 1.10.0.

-- admin/sql/updates/mysql/1.1.0.sql
ALTER TABLE `#__tallpine_trails` ADD COLUMN `difficulty` varchar(20) NOT NULL DEFAULT 'moderate';
-- admin/sql/updates/mysql/1.2.0.sql
ALTER TABLE `#__tallpine_trails` ADD COLUMN `gpx_file` varchar(255) NOT NULL DEFAULT '' /** CAN FAIL **/;
CREATE INDEX `idx_difficulty` ON `#__tallpine_trails` (`difficulty`) /** CAN FAIL **/;
-- admin/sql/updates/postgresql/1.1.0.sql
ALTER TABLE "#__tallpine_trails" ADD COLUMN IF NOT EXISTS "difficulty" varchar(20) NOT NULL DEFAULT 'moderate';

The marker /** CAN FAIL **/ placed just before the terminating semicolon of a statement tells the installer to continue if that statement fails. It is how core and extensions make an ALTER or CREATE INDEX safe to re-run on MySQL, which has no ADD COLUMN IF NOT EXISTS in every supported version. The marker must be the last thing before the semicolon.

The #__schemas table

The table #__schemas has two columns, extension_id and version_id, and one row per extension that has an update folder. version_id holds the name (without .sql) of the last update file that ran. It is the only record of where an extension’s database stands. The installer uses it as follows.

flowchart LR A{Route} -- install --> B[Run install SQL] B --> C[Delete old schemas row, insert the highest file name] A -- update --> D[Read version_id, default 0.0.0 if no row] D --> E[Sort files with version_compare] E --> F{File newer than version_id?} F -- yes --> G[Run its statements, then store its name in version_id] F -- no --> H[Skip] G --> F
  • On a fresh install the installer runs the install file, then calls setSchemaVersion(), which deletes any existing row and writes the highest update file name. No update file is run, which is why the install file must already contain the final schema.

  • On an update it reads version_id and, for every file newer than it, runs the statements and then writes that file’s name into version_id. The row is updated after each file, so a failure in 1.2.0.sql leaves version_id at 1.1.0 and the next attempt resumes at 1.2.0.

  • If no row exists for the extension, the version is taken as 0.0.0 and every update file runs. This is the usual explanation when an extension that was copied in with Discover, or whose row was deleted, replays all its migrations on the next update and fails on ADD COLUMN for a column that is already there.

  • A statement that fails (without the CAN FAIL marker) logs the statement and the database error to the update log, shows the message to the administrator, stops the update and aborts the installation. Statements of the same file that ran before the failure stay applied, because DDL in MySQL commits implicitly.

  • The <version> in the manifest and version_id are independent. Name update files after the manifest versions that introduced the change, so both move together; a release without a schema change needs no file. The one thing to avoid is an update file whose version is lower than version_id for an installed site, which is skipped for ever.

Update files are never edited after a release. Fixing a mistake means a new, higher file.

The database screen in System > Maintenance

Joomla checks extension schemas in the administrator under System > Maintenance > Database. For each extension that has an update folder, com_installer's database model reads the files, finds the highest name and compares it with version_id; a difference is reported as a schema error. It also compares the version recorded in the extension’s manifest_cache with the <version> in the manifest on disk. The Fix button runs fixSchemaVersion(), which deletes the extension’s #__schemas row and inserts the highest file name; it does not run any SQL, so it only repairs the record and not the missing columns. Use it when the database really has the changes and the record is behind, never to "complete" an update that failed half way. The check looks for the update folder at sql/updates/ under the extension’s administrator folder first and only then reads the manifest.

Running and testing the scripts locally

A script and its SQL are best verified on a throw-away site. This Compose file starts Joomla 6.2 with MariaDB; the extension zip is mounted read-only and installed with the console command so the run is repeatable.

services:
  db:
    image: mariadb:11.4
    environment:
      MARIADB_DATABASE: joomla
      MARIADB_USER: joomla
      MARIADB_PASSWORD: CHANGE_ME
      MARIADB_ROOT_PASSWORD: CHANGE_ME_ROOT
    volumes:
      - dbdata:/var/lib/mysql
  joomla:
    image: joomla:6.2-php8.3-apache
    depends_on:
      - db
    ports:
      - "8080:80"
    environment:
      JOOMLA_DB_HOST: db
      JOOMLA_DB_USER: joomla
      JOOMLA_DB_PASSWORD: CHANGE_ME
      JOOMLA_DB_NAME: joomla
    volumes:
      - ./dist:/dist:ro
      - joomla:/var/www/html
volumes:
  dbdata:
  joomla:
# Install, then update, from the zip built by your pipeline.
docker compose exec joomla php cli/joomla.php extension:install --path=/dist/pkg_tallpine-1.1.0.zip
docker compose exec joomla php cli/joomla.php extension:install --path=/dist/pkg_tallpine-1.2.0.zip

# What did the installer record?
docker compose exec db mariadb -ujoomla -pCHANGE_ME joomla \
  -e "SELECT e.name, s.version_id FROM jos_schemas s JOIN jos_extensions e USING (extension_id)"

Test three paths: a clean install of the latest zip, an update from the previous release (to exercise the update files and update()), and an uninstall (to prove that nothing is left behind). The image tag and the table prefix (jos_ here) depend on the image you use and on the value chosen in the installer. How to build the zip is explained in Build and CI for extensions, and how to test classes that the script calls in Testing extensions.

Security

An install script runs with the rights of the web server and with the administrator’s session, before the extension’s own access checks exist. Treat it as the most privileged code in the package.

  • Installing is restricted to users who may install extensions; a malicious or compromised package can do anything the script can, which is why extensions are vetted before installation (see Extensions: installing, updating and update sites).

  • Write only inside the extension’s own folders and tables. Never execute strings that come from the archive, a remote URL or the database, and never include a path built from input.

  • $deleteFiles and $deleteFolders are relative to the site root and joined to JPATH_ROOT. Keep the lists literal and never build them from a variable, so a typo cannot delete a core folder.

  • Use bind() for any value in a query run from update() and escape any text from the database before putting it in an enqueueMessage() call that is rendered as HTML.

  • Install SQL contains no credentials, tokens or secrets; configuration that must differ per site belongs in config.xml parameters or the environment, never in an SQL seed.

  • Do not use the script to open network connections, change configuration.php or disable security plugins. Those actions belong to the administrator.

Common errors and pitfalls

  • Tables are not created on install. The <file> lacks charset="utf8", its driver does not match the site’s database, or the sql folder was not listed in the administrator <files> and is missing from the install.

  • An update replays every migration. The #__schemas row is missing (extension registered with Discover or copied by hand). Insert it with the highest file that has already been applied.

  • "The update stops with a SQL error and the extension is half updated." A statement failed. Fix the cause, add /** CAN FAIL **/ only to statements that are genuinely safe to fail, and ship a new higher update file instead of editing the released one.

  • Update files are not found. The folder in <schemapath> is not copied to the site because it is not in <files>, or the file names contain letters that version_compare() orders unexpectedly (1.2.0b and 1.2.0-b differ).

  • The script runs, nothing happens. The <scriptfile> is not at the zip root, the script file returns nothing and defines no legacy class, or the legacy class name does not equal <element>InstallerScript with hyphens removed.

  • Class "Joomla\CMS\Installer\InstallerScriptTrait" not found. The package is being installed on Joomla 5.x or earlier. Limit the update server’s <targetplatform> accordingly and do not use the trait in a package that must support both generations.

  • false from uninstall() or postflight() has no effect. Only preflight, install and update can abort.

  • die guard fails on Joomla 6. The guard checks JPATH_PLATFORM. Use \defined('_JEXEC') or die;.

  • A message from preflight never shows. Output echoed by a hook is buffered by the installer; use enqueueMessage() and return false.

Version notes and corrections to older material

  • InstallerScriptInterface and the service-provider style appeared in Joomla 4.2.0. InstallerScriptTrait appeared in 6.0.0. The legacy <element>InstallerScript class is loaded in 6.2 with a deprecation notice and is announced for removal in 7.0, which also turns a missing <scriptfile> file into an error. See Deprecations and preparing for Joomla 7.

  • Joomla 6.0 no longer defines JPATH_PLATFORM (unless Behaviour - Backward Compatibility 6 is enabled) and moved the legacy J* class aliases behind the same plugin, so scripts that call JFactory, JText or JLog break on a default site.

  • Older books such as Developing Extensions for Joomla! 5 (2023) show an installer script as a plain class that extends nothing. That works only through the legacy loader. The documented approach is InstallerScriptInterface with InstallerScriptTrait (6.0), optionally returned from a service provider. Material for Joomla 3 shows scripts that call JFactory::getDbo(), JText::_() and JPATH_PLATFORM; none of that is available on Joomla 6 by default.

  • The Manual page Install process and script files does not list the values of $type, which are install, update, discover_install and uninstall as the interface documents, and does not describe the legacy loading path; the source of InstallerAdapter::setupScriptfile() is the reference for both.

  • Older tutorials describe the update files as running "from the installed version to the new manifest version". The installer compares file names with #__schemas and never reads the manifest version for this.

  • Query-builder examples in older books that claim the builder translates install SQL for every database are wrong for installation files: MySQL and PostgreSQL still need their own files.

For the combined list of outdated claims, see Where older books and tutorials are outdated. Class names and the namespaces used by the script’s container are explained in Namespaces, autoloading and dependency injection.