| name | elgg-site-upgrade |
| description | Use when upgrading an entire Elgg installation (core + all plugins) between major versions, or running a production upgrade with backup and rollback.
|
elgg-site-upgrade
Purpose: Upgrade an entire Elgg installation from one major version to the next.
Two workflows: PREPARE (dev) and EXECUTE (production)
Usage: /elgg-site-upgrade <project-path> [--from=2.x] [--to=7.x] [--mode=prepare|execute]
Iron Laws (strict โ do not improvise)
These are the four rules that make site upgrades recoverable. Violate any
one and a bad upgrade stops being a rollback and becomes a restoration.
- ONE MAJOR VERSION AT A TIME. Upgrade 2.xโ3.x, then 3.xโ4.x, and so on.
Skipping versions means skipping the upgrade scripts that migrate schema
and data โ the site will appear to work and then fail weeks later when
something touches an un-migrated table.
- LATEST MINOR BEFORE JUMPING MAJOR. From 2.3.x you can start on 3.x;
from 2.0.x you cannot. Minor releases contain the compatibility shims
that make the major jump safe.
- THE DATA UPGRADES MUST ACTUALLY RUN.
elgg-cli upgrade executes only the
SYSTEM upgrades and silently skips every asynchronous Elgg\Upgrade\Batch.
Use elgg-cli upgrade all -n -f at EVERY tier, and assert zero pending
elgg_upgrade entities before moving to the next one. Skip this and the
upgrades pile up until a later Elgg release deletes their class โ at which
point the work they were carrying is unreachable forever. On bodyology that
cost 85 pages and every discussion reply on the site. See "Finish the
upgrades Elgg deleted" below.
- PREPARE COMPLETELY BEFORE EXECUTING. Part A (prepare) must produce a
fully tested migration branch before Part B (execute) touches production.
Production is not where you discover problems โ it's where you apply
solutions you already verified work.
- TAG MIGRATION BRANCHES โ NO FLOATING DEV REFS IN composer.json. After
migrating a plugin and verifying it activates, tag the migration branch with
a semantic version (
git tag N.0.0 && git push origin N.0.0). Use ^N.0
in the root composer.json, not dev-migrate/elgg-N.x. Floating branch refs
make deployments non-reproducible: any push to the branch changes what
composer install installs. Tags are immutable. Check existing tags with
git tag | sort -V before creating a new one โ don't duplicate tags that
already exist. Read tags from the local plugin workspace; never query Packagist
to determine available versions for VCS repos.
Everything else in Part A is judgment and should be adapted to your project.
Part B is a safety-critical checklist and stays strict.
Four sections in elgg-migrate apply to site upgrades too โ read them
before starting Part A: Cost of failure, When to stop and escalate,
Agent failure modes, and Recovery playbook. They cover the
cross-cutting guidance about which gates to never cut, when to surface a
block to the human instead of forging ahead, the invisible mistakes the
acceptance gates can't catch, and how to recover from the common failure
shapes.
Skill layout (self-contained)
This skill ships the full Docker infra, the orchestrator CLI, AND the
entire AST migration engine. After npx skills add, the installed
directory looks like:
<skill-dir>/
SKILL.md # this file
bin/elgg-migrate-run # per-plugin isolated orchestrator CLI
bin/migrate.php # AST migration engine CLI
bin/migrate-plugin.sh # one-shot per-plugin wrapper
src/ # ElggMigrate\ PHP namespace
rules/{2..6}x-to-{3..7}x/ # per-version rule manifests
composer.json # nikic/php-parser dep + PSR-4 autoload
phpunit.xml # test runner config
tests/ # PHPUnit tests for src/
formulas/ # site-upgrade beads formula
infra/elgg{N}/ # per-target Elgg stack (N = 2..7)
infra/migrate/ # AST engine Docker stack
Resolve $SKILL once at session start as the absolute path of the
directory containing this SKILL.md, and $SKILL_INFRA as $SKILL/infra.
Every $SKILL_INFRA/elgg{N}/... and $SKILL_INFRA/migrate/... path
below is literal. Prefer the bundled bin/elgg-migrate-run CLI for
spawning isolated per-plugin environments; it already knows how to
locate $SKILL_INFRA and writes job state under
$XDG_STATE_HOME/elgg-migrate/. The engine files (src/, rules/, bin/,
composer.json, infra/migrate/) are kept in sync with the canonical
copy in the elgg-migrate skill by bin/gen-elgg-infra.sh.
Container Infrastructure
All operations run inside Docker containers โ nothing executes on the host machine.
| Service | Purpose | Location |
|---|
migrate | AST migration rules (PHP 8.1 + php-parser) | Root docker-compose.yml |
elgg | Plugin activation, PHPUnit, Elgg bootstrap, composer | $SKILL_INFRA/elgg{N}/docker-compose.yml |
node | Playwright and Vitest tests | $SKILL_INFRA/elgg{N}/docker-compose.yml (profile: test) |
db | MySQL database | $SKILL_INFRA/elgg{N}/docker-compose.yml |
Debugging inside containers
docker compose -f $SKILL_INFRA/elgg{N}/docker-compose.yml exec elgg tail -f /var/log/apache2/error.log
docker compose -f $SKILL_INFRA/elgg{N}/docker-compose.yml logs elgg
docker compose -f $SKILL_INFRA/elgg{N}/docker-compose.yml exec elgg bash
docker compose -f $SKILL_INFRA/elgg{N}/docker-compose.yml exec db mysql -uelgg -pelgg elgg
docker compose -f $SKILL_INFRA/elgg{N}/docker-compose.yml ps
docker compose -f $SKILL_INFRA/elgg{N}/docker-compose.yml build --no-cache
PART A: PREPARE (development workflow)
Part A is iterative and safe to break. It runs in a development environment
with Docker and a fresh database. The goal is to produce a tested migration
branch for each version step that can be applied to production with
confidence.
Think of Part A as three nested loops: an outer loop over version steps, a
middle loop over plugins within a step, and an inner loop of fix-and-retry
until the gates pass. The phases below describe the shape of that work, but
the order is a guide โ a plugin you've already tested on a newer fork may
skip most of the inner loop, while a custom plugin with no upstream will
need the full treatment.
Assess: know what you're upgrading before you touch anything
Three things must be clear before preparation work starts:
Where the site is now. grep "elgg/elgg" <project>/composer.json for the
installed core version. Don't trust manifest.xml unless composer is absent.
Where the site is going. The upgrade path follows Iron Laws 1 and 2:
| From | Target | Steps |
|---|
| 2.x | 7.x | 2.3โ3.3โ4.3โ5.1โ6.1โ7.0 |
| 3.x | 7.x | 3.3โ4.3โ5.1โ6.1โ7.0 |
| 4.x | 7.x | 4.3โ5.1โ6.1โ7.0 |
| 5.x | 7.x | 5.1โ6.1โ7.0 |
| 6.x | 7.x | 6.1โ7.0 |
What plugins need attention. Inventory the mod/ directory and put each
plugin into one of three buckets, because the strategy differs per bucket:
| Bucket | How to recognize | Strategy |
|---|
| Core (ships with Elgg) | Lives under vendor/elgg/elgg/mod or equivalent | Upgrades with core; nothing to do |
| Composer-managed with upstream | Listed in composer.json require, has a GitHub repo | Find an upgraded version or migrate via elgg-migrate |
| Custom/private | Only in mod/, no upstream repo | Migrate in-place in the project repo |
A reference bash loop for the inventory:
for d in <project>/mod/*/; do
name=$(basename "$d")
if [ -f "$d/manifest.xml" ]; then
ver=$(grep -A1 'elgg_release' "$d/manifest.xml" | grep version | grep -oP '>[^<]+<' | tr -d '><')
echo "$name: Elgg $ver"
elif [ -f "$d/elgg-plugin.php" ]; then
echo "$name: elgg-plugin.php (3.x+)"
fi
done | sort
Check for upgraded plugin versions (highest-leverage step)
Before migrating any plugin, check whether someone has already done it.
Duplicate migration wastes time and can regress over a known-good upgrade.
The checks mirror those in elgg-migrate Phase 1 โ local branches
(git branch -a), upstream branches and forks (gh api repos/<owner>/<plugin>/branches, forks), Packagist (composer show ... --all inside the Elgg container), the Elgg plugin directory at
https://elgg.org/plugins, and version-prefixed org repos (gh search repos --owner <org> "Elgg4-<plugin>").
When any of these turn up a usable migration:
- Already at target version โ skip migration for this plugin entirely
- Upgraded on Packagist โ
composer require the new version
- Upstream fork has a working migration โ adopt it only after
trust-but-verify (see
elgg-migrate/SKILL.md "Trust but verify:
adopting an upstream migration" for the checks โ abandoned branches,
wrong Elgg target, unrelated feature work mixed in, licensing,
Docker activation, pre-migration tests against the upstream)
- Migration branch exists but incomplete โ continue from it
- Nothing exists anywhere โ migrate via
elgg-migrate
Reading the code is often faster than running checks. Version indicators
that are reliable:
| Indicator | Likely Version |
|---|
start.php with init handler, no elgg-plugin.php | 2.x |
Both start.php and elgg-plugin.php | 3.x (transitional) |
elgg-plugin.php with 'hooks' key, no start.php | 4.x |
elgg-plugin.php with 'events' key only | 5.x+ |
\Elgg\Hook type hints | 4.x |
\Elgg\Event type hints | 5.x+ |
elgg_define_js() / elgg_require_js() | โค5.x |
elgg_register_esm() / elgg_import_esm() | 6.x+ |
AMD define()/require() in JS | โค5.x |
ES module import/export in JS | 6.x+ |
Deployed version โ migrated version (release-lag). A plugin's migrate/* branch
can be fully fixed while the site's composer.lock still pins a pre-fix tag โ so
the built site ships broken plugin code even though the branch is green. When the
site loads from tags (the deploy contract), audit the locked version against the
latest released tag, not the branch tip:
composer show 'hypejunction/*' --locked --format=json | โฆ
git ls-remote --tags <repo> | sort -V | tail -1
Whole-fleet release-lag is one root cause, not N bugs. Tag the fixes, then a single
composer update "<vendor>/*" --ignore-platform-reqs + rebuild clears most of it.
Staging preview before cutover (real data, isolated)
Stand up the migrated site beside the live one before DNS cutover, populated with
real prod data, to eyeball it. Rehearse on ANONYMIZED prod data, not raw PII:
-
Build an anonymized seed with bin/build-anon-seed.sh --dump <prod-2x.sql.gz>.
It restores the dump in a throwaway mysql:5.7, applies
references/anonymize-elgg2x.sql (scrub PII, drop session/auth tables), and โ the
load-bearing part โ encodes three footguns that each silently broke a real
migration: (A) a blanket metastring scrub clobbers login flags (validated/admin/
banned) โ uservalidationbyemail blocks EVERY user, so those names are excluded;
(B) password_hash is reset to a known dev bcrypt so users can actually log in;
(C) __site_secret__ is re-seeded because Elgg 3.x+ BootService HARD-THROWS on an
empty secret, killing every chain tier past 2.x. The script fails if (A)/(B) trip.
Log in on the preview as any user_<guid> / password dev. Copy the SQL and edit
it for site-specific tables/plugins โ but keep the login + site-secret sections.
-
The migration chain migrates the DB only. The Elgg dataroot (avatars/uploads)
is GUID-bucketed and version-stable across 2.xโ7.x, so deploy the prod dataroot
directly (tar --strip-components=1 to drop the elgg-data/ wrapper) โ don't
expect the chain to carry it. After loading, rewrite the __site_secret_wwwroot/
path datalists to the preview URL/dataroot path.
-
EOL host with no native PHP 8? Ship a single Docker container (code baked into
the image, dataroot bind-mounted), bound to 127.0.0.1:<port>, fronted by the
existing nginx. Never rm -rf a bind-mounted path from inside the container.
-
Serve at a subpath (/forum-7.x/) via an nginx strip-prefix reverse proxy:
location ^~ /forum-7.x/ { proxy_pass http://127.0.0.1:<port>/; } (trailing slash
strips the prefix), forward the real Host so Elgg's host-check passes and it
emits /forum-7.x/-prefixed URLs from wwwroot. Basic-auth the location for PII.
-
Smoke it in a real browser, not just curl. HTTP 200 hides JS/importmap failures
(see elgg-migrate/references/migration-lessons.md). Assert console.error +
pageerror empty per page type. Walled-garden sites need a login to reach content.
Set up the workspace
Plugin sourcing: composer VCS repos (preferred)
The cleanest and most reproducible way to manage third-party plugins in a
site upgrade is composer VCS repositories โ not symlinks, rsync copies,
git worktrees, or submodules. Each plugin is declared as a VCS repo in
composer.json; composer install fetches the right branch and
composer/installers drops it into mod/ automatically.
"repositories": [
{ "type": "vcs", "url": "https://github.com/<owner>/<plugin>" }
],
"require": {
"<vendor>/<plugin>": "dev-migrate/elgg-3.x as 3.x-dev"
}
Branch naming convention. Every plugin that spans multiple Elgg major
versions must have migrate/elgg-N.x branches, one per supported version:
| Branch | Code state |
|---|
migrate/elgg-3.x | Elgg 3.x-compatible (PHP-DI 5.x, start.php allowed) |
migrate/elgg-4.x | Elgg 4.x-compatible (PHP-DI 6.x, Bootstrap class) |
migrate/elgg-5.x | Elgg 5.x-compatible (Event API, Bootstrap) |
| ... | ... |
master branch contamination warning. When the 3โ4 migration is done
on master (no feature branch), master ends up with 4.x-only API calls
even while the site is still on 3.x. Don't branch migrate/elgg-3.x from
master HEAD โ find the last commit before the first fix(4.x): /
migrate(4.x): commit message and branch from there:
git log --oneline master | grep -B1 "4\.x" | head -1
git branch migrate/elgg-3.x <commit-hash>
git push origin migrate/elgg-3.x
For a fleet of plugins, automate the check:
for plugin in plugins/*/; do
cd "$plugin"
first4x=$(git log --oneline master | grep "4\.x" | tail -1 | awk '{print $1}')
if [ -n "$first4x" ]; then
parent=$(git log --format="%H" "${first4x}^" -1)
echo "$(basename $plugin): branch from $parent"
fi
cd -
done
Symlink workspace (alternative)
For local-only workflows where composer VCS repos are not practical, use
symlinks from mod/ into a separate plugins workspace:
mkdir -p ~/plugins-workspace
git clone https://github.com/<owner>/<plugin>.git ~/plugins-workspace/<plugin>
cd <project>/mod
rm -rf <plugin>
ln -s ~/plugins-workspace/<plugin> <plugin>
Create migration branches
Create a migration branch in the project repo for each version step
(name is the TARGET version, matching elgg-migrate's convention):
git -C <project> checkout -b migrate/elgg-{N}.x
For each plugin in the workspace, also ensure the target branch exists:
git -C ~/plugins-workspace/<plugin> checkout -b migrate/elgg-{N}.x
Docker bind-mount override for the site app container
When the site's Dockerfile uses COPY . . (baking mod/ into the
image) and docker-compose.yml also bind-mounts ./mod to /opt/plugins,
the baked-in copy wins by default โ the entrypoint's symlink_plugins only
creates symlinks for missing plugins. Any code fix you make on the host
is silently ignored at runtime.
Fix the symlink_plugins function so bind-mounted plugins always win:
for entry in /opt/plugins/*; do
plugin_name=$(basename "$entry")
[[ "$plugin_name" == .* ]] && continue
if [ -d "$entry" ]; then
target="/var/www/html/mod/$plugin_name"
rm -rf "$target" 2>/dev/null || true
ln -sf "$entry" "$target"
count=$((count + 1))
fi
done
After changing this, rebuild the image (docker compose build --no-cache app)
so the updated entrypoint is baked in. The bind mount then wins at every
subsequent docker compose up.
Record the current plugin activation order. Save it to
mod/.plugin-order.txt (one plugin id per line, in priority order). The
verification step reads this to reproduce production's activation sequence.
Before you commit anything, read
elgg-migrate/references/git-hygiene.md. Site upgrades generate a lot
of local state (vendor/, composer-installed plugins under mod/,
dataroot/, docker override files with host paths, simplecache, logs)
and every one of them has landed in git histories before. That reference
ships a paste-ready .gitignore for Elgg sites โ including the
blocklist-then-allowlist mod/* pattern that keeps composer-installed
plugins out of history โ and a pre-commit grep you should run before
every commit during the upgrade.
Establish the test baseline
Boot Docker for the CURRENT version, then make sure there's a green test
suite before touching any migration code. This is the same gate as
the elgg-migrate pre-migration test gate, applied at project scope โ without a baseline
you cannot tell whether the upgrade broke anything.
For each plugin without existing tests, use /elgg-test-writer to write
entity CRUD, registration (actions, routes, hooks, widgets), permission,
and view-rendering coverage. Run the full suite against the current version;
everything must pass before proceeding. Record the passing count โ you'll
compare against it after the upgrade.
Migrate plugins for one version step
For each version step in the upgrade path, run a middle loop over plugins.
The per-plugin work follows elgg-migrate โ the fleet context just means
you're doing it many times rather than once.
For plugins with an upstream repo, migrate in the workspace and commit there.
For custom plugins, migrate in-place in the project repo. Run the automated
AST rules, apply LLM-guided fixes from --report, and commit each logical
group separately. When a manually-migrated reference version exists
upstream, diff --stat against it to catch judgment calls the rules missed.
Don't batch commits across plugins โ keep each plugin's work in its own
commits so a reviewer (and your future self) can follow what changed.
Verify composer consistency on each migration branch
Before running Docker or declaring a step ready, verify that composer install
succeeds from a clean state on the migration branch. This is the gate that
catches the most common silent failure mode: a lock file that was committed
when it was still in sync, but later diverged as upstream packages changed.
cd /tmp
git clone <project-repo> test-composer-check
cd test-composer-check
git checkout migrate/elgg-${N}.x
composer install --no-interaction 2>&1
composer install --no-interaction --ignore-platform-reqs 2>&1
Common root causes of lock file staleness:
-
Packages renamed their default branch from master to main. The lock
file records the resolved reference (dev-master) which no longer exists.
Fix: composer require vendor/package:dev-main or bump to a tagged version.
-
composer.json was updated manually without re-running composer update.
Fix: run composer update and commit the new composer.lock.
-
A lock file was copied from a different version branch.
Fix: delete composer.lock and run composer install (or update).
The gate:
composer install --no-interaction --ignore-platform-reqs 2>&1 && echo PASS || echo FAIL
upgrade-linear.sh includes this three-tier fallback automatically (install โ
install --ignore-platform-reqs โ update --ignore-platform-reqs), but it cannot
fix a broken composer.json that has unresolvable constraints. Those must be
fixed in the migration branch before Part B runs.
Verify in Docker for the target version
Boot the target version's Docker environment and run the gates. These are
safety gates โ they must pass before advancing to the next version step.
All plugins activate without fatal errors. Reproduce production's
activation order using the .plugin-order.txt file:
docker compose exec elgg php -r "
require 'vendor/autoload.php';
\$app = \Elgg\Application::getInstance();
\$app->bootCore();
_elgg_services()->plugins->generateEntities();
\$order = file('/var/www/html/mod/.plugin-order.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
\$failed = [];
foreach (\$order as \$id) {
\$id = trim(\$id);
\$plugin = elgg_get_plugin_from_id(\$id);
if (!\$plugin || \$plugin->isActive()) continue;
try { \$plugin->activate(); }
catch (\Throwable \$e) { \$failed[] = \$id . ': ' . \$e->getMessage(); }
}
if (empty(\$failed)) { echo 'All plugins activated.' . PHP_EOL; }
else { foreach (\$failed as \$f) echo 'FAIL: ' . \$f . PHP_EOL; }
"
Seeding from a production dump? Restore the prod active set with
bin/restore-active-plugins.sh. When the stage DB is overlaid from an
(anonymized) production dump rather than freshly installed, the .plugin-order.txt
loop above is not enough: Elgg <4.x keeps camelCase plugin titles
(hypePrototyper), but the captured prod active list is lowercase, so a naive
case-sensitive match silently skips every camelCase plugin โ leaving active
lowercase dependents calling into a disabled dependency (fatal "Call to
undefined function hypePrototyper()"). The script matches on LOWER(title) and
activates the intersection of (prod-active TSV) โฉ (plugins on disk this
stage), so intentionally-dropped plugins with a stale enabled='yes' entity but
no directory are left inactive. It also runs generateEntities() and enables
enabled='no' entities first, and is idempotent.
skills/elgg-site-upgrade/bin/restore-active-plugins.sh \
--tsv snapshots/prod-active-plugins.tsv \
--db-container <site>${N}x-db-1 \
--app-container <site>${N}x-app-1 \
[--mod-path /var/www/html/mod] [--db elgg] [--dry-run]
Capture the TSV at "Record the current plugin activation order" above
(<plugin_id>\t<priority> per line). Use --dry-run first to preview the
would-activate / dropped sets, then flush caches and restart the app container.
Site renders. curl -sL http://localhost/ | grep -oP '<title>[^<]*</title>'
must return a real title, not "Fatal Error". This is a necessary FIRST check โ
but it is NOT the definition of done. An anonymous homepage-title check passes
while every authenticated page 500s, which on a walled-garden community is
almost the whole site. The real bar is render parity, below.
Render parity (the executable definition of "done"). A site upgrade is
finished only when the migrated site renders what the old one did. Prove it
with a route-render golden master โ every registered GET route, crawled
anonymously AND authenticated, HTTP status per route โ captured before the
step and diffed forward. Any route that was 2xx/3xx and is now 5xx/unreachable
fails the gate. This is bin/verify-parity.sh (wrapping the shared
baseline-golden-master.sh engine); baselines live in the SITE repo
(GM_BASELINE_DIR) so they are versioned with the code.
skills/elgg-site-upgrade/bin/verify-parity.sh capture {from} \
--baseline-dir snapshots/parity --user <admin> --pass <pass>
skills/elgg-site-upgrade/bin/verify-parity.sh check {from} {to} \
--baseline-dir snapshots/parity --user <admin> --pass <pass>
Write paths (the other half of "done"). Parity is GET-only and never runs
action/CRUD/data-layer code, where the nastiest 6โ7 breaks hide (insert/delete
data + relationship signature drift, DBAL :-prefixed param keys, an entity
save() contract mismatch, canWriteToContainer(null)). bin/verify-write-paths.sh
logs in and actually POSTs core journeys (create group, edit user settings) with
DB-delta verification, then GET-sweeps every create/edit form for 5xx (form-build
code == submit code). Point it at your plugins' own add/edit routes:
skills/elgg-site-upgrade/bin/verify-write-paths.sh \
--base http://localhost:{{port}} --user <admin> --pass <pw> \
--db-container <db-container> --forms-file snapshots/plugin-forms.txt
Intended, reviewed changes (a route deliberately removed/redirected) go one
key per line in snapshots/parity/parity-whitelist.tsv (format matches the
golden file's column 1: <anon|auth> <METHOD> <path>) โ so the gate stays
meaningful instead of being switched off. Re-evaluate after editing the
whitelist without re-crawling: verify-parity.sh diff {from} {to}. Capture one
golden master per version step and diff each step forward; a clean forward-diff
at every tier is what "pixel-perfect across 2.xโ7.x" actually means here.
Status parity is the floor โ still open the site in a browser and diff against
the pre-migration render for visual/CSS drift the status code can't see.
Simplecache CSS is non-empty. css-crush v2.4 silently fails on certain
CSS patterns, leaving the stylesheet empty. The site "works" but looks
broken. Always verify:
TS=$(docker compose -f $SKILL_INFRA/elgg{N}/docker-compose.yml exec -T elgg \
curl -sL http://localhost/ | grep -oP 'cache/\K\d+' | head -1)
SIZE=$(docker compose -f $SKILL_INFRA/elgg{N}/docker-compose.yml exec -T elgg \
curl -sL -o /dev/null -w "%{size_download}" "http://localhost/cache/${TS}/default/elgg.css")
echo "elgg.css: ${SIZE} bytes"
Tests pass against the target version. Run PHPUnit and Playwright; the
passing count must match the baseline from Part A's test phase. See
references/testing/elgg-e2e-testing.md for setup details and known
pitfalls (hypeWall interception, foreach-by-reference crashes, OPcache
stale code).
Query-performance parity (the gate render parity can't see). Render parity
is GET-status only: a route that scans 10ร the rows it used to still returns
200, so status parity stays green while the site silently gets slower. A version
step can regress query plans without touching a single route โ a core schema
change (e.g. 4.x absorbed private_settings into metadata, widening every
per-entity scan), a getter rewrite, or a dropped/renamed index. Catch it with
the elgg-benchmark skill's API layer, which runs the real Elgg query shapes
against the site and reports the deterministic Handler_read_* per shape.
bench.php runs the shapes from an installed Elgg root and prints one JSON
snapshot; report.php diffs two snapshots. Capture one on each stack (same
seeded data, only the version differs) and diff:
bench.php needs a DB user with RELOAD (for FLUSH STATUS) and SELECT +
DROP on performance_schema (it truncates the statement digest to isolate SQL
time). The benchmark's own layers/api/bin/up.sh connects Elgg as root, which
has these; the upgrade infra stacks connect as the lower-privilege elgg user,
so grant them on each throwaway stack first (throwaway DBs โ safe to widen):
API=skills/elgg-benchmark/layers/api
mkdir -p results
GRANT="GRANT RELOAD ON *.* TO 'elgg'@'%'; GRANT SELECT, DROP ON performance_schema.* TO 'elgg'@'%'; FLUSH PRIVILEGES;"
docker exec <from-db> mysql -uroot -p<root-pw> -e "$GRANT"
docker cp $API/bench.php <from-app>:/var/www/html/bench_api.php
docker exec -w /var/www/html <from-app> php bench_api.php 30 > results/before.json
docker exec <to-db> mysql -uroot -p<root-pw> -e "$GRANT"
docker cp $API/bench.php <to-app>:/var/www/html/bench_api.php
docker exec -w /var/www/html <to-app> php bench_api.php 30 > results/after.json
php $API/report.php results/before.json results/after.json
The verdict is the Handler_read_next delta on the metadata-hitting (MD) shapes
โ a material rise there with no intended schema change is a regression: find the
dropped index or the getter that stopped using one, fix it, re-run. A fall
(e.g. because the step added the metadata (entity_guid, name) index) is the
win you're proving. Run against the same seeded data on both stacks so only the
version differs (Iron Law: before/after on identical data). See
skills/elgg-benchmark/SKILL.md (Layer 2) and its
examples/api-metadata-index/ for a worked before/after.
When any gate fails, fix it in the workspace, commit the fix, and re-run
the failing gate. Don't mask failures by commenting tests out.
When everything for this step is green, loop back and run the next version
step. Iron Law 1 forbids advancing to N+2 until N+1 is fully done.
Known compatibility gotchas by version transition
These recur across codebases. Check for them early โ they cause site-wide
500s that block all other diagnostic work.
3.x site running plugins with 4.x code
If mod/ plugins were updated from a 4.x-migrated branch (or a master
branch that has 4.x commits merged in), the site will 500 immediately on
boot even though composer.json still requires Elgg 3.x.
Root cause: PHP-DI version mismatch. Elgg 3.x ships PHP-DI 5.x
(DI\object()); Elgg 4.x ships PHP-DI 6.x (DI\create()). Calling
DI\create() on a 3.x install throws Call to undefined function.
Fix: In each plugin's elgg-services.php, replace:
'key' => \DI\create(SomeClass::class)->constructor(\DI\get('dep')),
'key' => \DI\object(SomeClass::class)->constructor(\DI\get('dep')),
How to detect all affected files quickly:
grep -rl "DI\\\\create" <project>/mod/
Global function calls inside PHP namespaces
Any Elgg helper function called from within a namespace Foo\Bar { ... }
block requires the global-namespace \ prefix, or PHP resolves it as
Foo\Bar\function_name() and throws Call to undefined function.
$locale = get_current_language();
$locale = \get_current_language();
Elgg 3.x language API
| Function | Correct version |
|---|
get_current_language() | Elgg 2.x and 3.x (deprecated in 3.x, removed in 4.x) |
elgg_get_language() | Elgg 4.x+ only โ does NOT exist in 3.x |
When migrating a plugin that calls get_current_language() inside a
namespace, fix to \get_current_language() for 3.x. When later migrating
to 4.x, replace with elgg_get_language().
CSS that breaks css-crush v2.4
Certain CSS constructs (complex selectors, vendor prefixes on variables)
cause css-crush v2.4 to silently emit an empty stylesheet when CSS views are
extended via elgg_extend_view('css/elgg', ...). The site loads but has no
styles.
Fix: Serve those stylesheets as external files, not through the
simplecache pipeline:
elgg_extend_view('css/elgg', 'css/theme/custom.css', 999);
elgg_register_css('theme.custom', '/mod/my_theme/views/default/css/theme/custom.css');
elgg_load_css('theme.custom');
Verify the fix: check that elgg.css from simplecache is > 1 KB after a
cache flush.
Composer VCS repos: dependency versioning
Always use tagged version constraints. After migrating a plugin and
verifying it works, tag the migration branch and reference the tag in the
root composer.json. Look up the latest available tag from the local plugin
workspace:
PLUGINS_DIR=~/plugins-workspace
for plugin in "$PLUGINS_DIR"/*/; do
name=$(basename "$plugin")
tags=$(git -C "$plugin" tag | sort -V)
for major in 3 4 5 6; do
latest=$(echo "$tags" | grep "^${major}\." | tail -1)
echo "$name: $major.x โ ${latest:-NONE}"
done
done
"require": {
"acme/wall": "^6.0",
"acme/interactions": "^6.0",
"acme/lists": "^6.0"
}
The ^N.0 constraint, pinned by composer.lock, is the correct approach:
reproducible, standard, and forward-compatible within the major. Do NOT use
dev-migrate/elgg-N.x โ floating branch refs break reproducibility.
Dev-branch aliases (legacy workaround โ avoid). Before all plugin
migration branches were tagged, a workaround was to add inline aliases:
"acme/forms_api": "dev-migrate/elgg-3.x as 1.2.1"
This told Composer to treat the branch as version 1.2.1 so inter-plugin
constraints resolved. This is unnecessary once the branch is tagged and the
constraint is ^N.0. If you encounter aliases in an existing composer.json,
replace them with the proper tagged constraint.
To find which inter-plugin constraints need satisfying (useful when adding a
new plugin to a migration branch), scan every plugin's composer.json:
for p in plugins/*/; do
git -C "$p" show migrate/elgg-N.x:composer.json 2>/dev/null
done | python3 -c "
import sys, json
for line in sys.stdin:
try:
d = json.loads(line)
except Exception:
continue
for pkg, ver in d.get('require', {}).items():
if pkg.startswith('acme/'): # your project's vendor prefix
print(d['name'], '->', pkg, ver)
"
Plugin fzaninotto/faker version mismatch. Elgg 3.x requires fzaninotto/faker ^1.9.
If a plugin's migrate/elgg-3.x branch still has fzaninotto/faker 1.3.* (a leftover from
the pre-3.x era), composer update fails with an unsolvable conflict. Fix the plugin's
branch directly:
sed -i 's/"fzaninotto\/faker": "1\.3\.\*"/"fzaninotto\/faker": "^1.9"/' composer.json
git add composer.json && git commit -m "fix(deps): bump fzaninotto/faker to ^1.9 for Elgg 3.x"
git push origin migrate/elgg-3.x
composer/installers v1 vs Composer 2.3+ (Plugin API mismatch). Elgg 3.x
has a hard dependency chain: elgg/elgg 3.3 โ elgg/login_as ~2.1 โ
composer/installers ^1.0.8. There is no newer version of elgg/login_as with
an updated constraint. composer/installers v1 requires Plugin API ^1.0, but
Composer 2.3+ ships Plugin API 2.x โ causing the plugin to be silently skipped.
When skipped, all elgg-plugin type packages land in vendor/ instead of mod/.
Composer does NOT have an npm-style overrides key that would force a specific
version of a transitive dependency, and the replace key would prevent any
composer/installers from being installed at all.
Fix: pin Composer 2.2 in the Dockerfile. Composer 2.2.x is an actively
maintained LTS branch that supports both PHP 7.x and Plugin API 1.x. Switch the
image copy from composer:2 (which resolves to latest 2.x) to composer:2.2:
# Before
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# After (Elgg 3.x projects โ Plugin API 1.x required for composer/installers v1)
COPY --from=composer:2.2 /usr/bin/composer /usr/bin/composer
For local development with system Composer 2.3+: Composer will still resolve
packages correctly but composer/installers will be skipped. Two options:
- Use
docker exec <app> composer update to run composer inside the container
(which uses Composer 2.2 baked into the image)
- Run
composer self-update --2.2 locally while working on Elgg 3.x projects,
then composer self-update --rollback when done
Update composer/installers constraint in plugin branches. Plugins whose
migrate/elgg-3.x branch has "composer/installers": "~1.0" will block v2
from being used when the site eventually upgrades to a Composer 2.3+ compatible
Elgg version. Proactively update all migration branches:
for p in plugins/*/; do
git -C "$p" checkout migrate/elgg-3.x 2>/dev/null || continue
cj="$p/composer.json"
if grep -q '"composer/installers": "~1\.0"' "$cj"; then
sed -i 's/"composer/installers": "~1\.0"/"composer\/installers": "^1.0 || ^2.0"/' "$cj"
git -C "$p" add composer.json
git -C "$p" commit -m "fix(deps): support composer/installers ^2.0"
git -C "$p" push origin migrate/elgg-3.x
fi
done
elgg/login_as and composer/installers โ use replace to break the chain.
elgg/elgg 3.3 depends on elgg/login_as ~2.1.0. That package (last released 2020,
no newer version) requires composer/installers ^1.0.8. There is no upstream fix.
Do NOT use Composer 2.2 as a workaround โ it's unnecessary. Instead, use the
replace key in the root composer.json to declare that the root project already
provides login_as at the required version. Composer will satisfy elgg/elgg's
constraint without installing the package (and without bringing in the ^1.0.8
constraint). The login_as plugin won't be in mod/; if the site doesn't use it,
that's fine โ activation-order scripts skip plugins not found in mod/.
Also replace the three abandoned sort packages that site_search depends on
(they also block installer v2):
"replace": {
"elgg/login_as": "2.1.0",
"acme/user_sort": "1.1.1",
"acme/group_sort": "1.1.2",
"acme/object_sort": "1.1.3"
}
After these four replaces, the root can safely require "composer/installers": "^2.0",
which resolves to v2.3+ and works with Composer 2.9+.
To find other transitive packages blocking installer v2, inspect the lockfile:
php -r "
\$lock = json_decode(file_get_contents('composer.lock'), true);
foreach (\$lock['packages'] as \$p) {
\$v = (\$p['require'] ?? [])['composer/installers'] ?? null;
if (\$v && strpos(\$v, '||') === false && strpos(\$v, '^2') === false)
echo \$p['name'].' '.\$p['version'].': '.\$v.PHP_EOL;
}"
HTTPS vs SSH for VCS repos. Use HTTPS URLs (https://github.com/...) for
VCS repos, not SSH (git@github.com:). Docker build containers don't have SSH
keys. HTTPS works for public repos without a token; add a GITHUB_TOKEN build
arg for rate-limit headroom:
ARG GITHUB_TOKEN
RUN if [ -n "${GITHUB_TOKEN}" ]; then \
composer config -g github-oauth.github.com "${GITHUB_TOKEN}"; fi
docker compose build --build-arg GITHUB_TOKEN=$(gh auth token)
Installer-name casing mismatches. Some plugins have a lowercase installer-name
in their extra section (e.g., mymaps โ installer-name: "mymaps") but
the site's .plugin-order.txt and database use the original camelCase ID
(e.g., MyMaps). Fix with installer-paths in the ROOT composer.json:
"extra": {
"installer-paths": {
"mod/MyMaps": ["acme/mymaps"]
}
}
Harden PHP dependencies
Once the site is on the latest Elgg version, upgrade PHP dependencies one
at a time. Doing this per-package (rather than composer update) is the
only way to isolate which bump broke what โ and it will break things.
The principle: risk-order the upgrades and test after every single one.
A reasonable risk order, lowest to highest:
- Dev dependencies (phpunit, code sniffers, faker)
- Utility libraries (monolog, symfony/var-dumper)
- Mail/HTTP (laminas/laminas-mail, guzzlehttp/guzzle)
- Image processing (imagine/imagine)
- Template/view (michelf/php-markdown, css-crush)
- Database (doctrine/dbal) โ high risk, test thoroughly
- Framework (symfony/*, php-di/php-di) โ high risk
- Elgg patch updates (elgg/elgg within same major)
The per-package loop: check what will change with composer update <pkg> --dry-run, read the package changelog, apply the update, run the full test
suite (PHPUnit + Playwright), commit if green. If tests break, read the
changelog, fix the calling code, and commit the fix together with the
dependency bump so the diff tells one story. When the fix is too complex
for the scope, pin the package and file an issue for later.
Tests must pass after each individual upgrade. That's the gate โ resist the
urge to batch "just two small ones."
Harden JS/CSS dependencies
Same principle as PHP deps, applied to the npm-asset and bower-asset
packages that Elgg manages via composer. List with composer show | grep "npm-asset\|bower-asset" inside the Elgg container. Upgrade one at a time,
run PHPUnit, run JS unit tests (if the plugin has them), run the Playwright
E2E suite (which is the most likely to catch browser-side regressions),
check error.log for JS console noise, then commit.
A reasonable risk order for the common Elgg JS stack:
- normalize.css (pure reset, very safe)
- sprintf-js (utility)
- cropperjs / jquery-cropper
- jquery-colorbox (check for API changes)
- jquery-ui (medium risk โ widgets may use deprecated methods)
- jQuery (high risk โ major versions have breaking changes)
- tagify (high risk โ custom component, API churn)
Plugins with their own package.json go through the same loop run from the
node profile container, not the host.
For CSS dependencies (css-crush), the only reliable check is flushing
simplecache (elgg_invalidate_caches() via docker compose exec elgg php -r) and verifying the rendered stylesheet is non-empty again.
Before starting this phase, make sure there's JS test coverage in place โ
/elgg-js-test-writer is the right tool. Without it, JS regressions are
only caught by the Playwright suite, which is slow and doesn't pinpoint the
broken module.
Final verification
Before declaring Part A complete, confirm all of the following. Missing any
of them means production isn't ready:
- All Elgg version steps complete (every step in the upgrade path)
composer install succeeds on each migration branch (see "Verify composer consistency" above)
- All PHP dependencies at latest stable
- All JS/CSS dependencies at latest stable
- Full test suite green (PHPUnit + Vitest + Playwright)
- Docker boots with all plugins active using the recorded order
- No PHP errors in
error.log
- No JS console errors in the browser
- Zero pending
elgg_upgrade entities (see the query in "Finish the upgrades Elgg deleted")
- Every object subtype yields a non-empty
getURL() โ an entity whose subtype was never
migrated has no class and no URL, so nothing links to it and nothing 404s. A route crawl
cannot see it. Ask each entity for its own permalink and fetch it.
check-release-lag.sh reports zero lag โ composer.lock pin == newest tag == branch tip.
Source gates read the branch; the site installs the tag. A fix you never tagged is not deployed,
and rebuilding the image will not change that.
- Pretty/SEF URLs resolve on the deployed host (
/@username, /blog/{guid}-{slug}). On a
subpath deployment these are the canary: if the reverse proxy strips the prefix without sending
X-Forwarded-Prefix, every rewritten route 404s and nothing is logged.
When all of these hold, the migration branches are ready for Part B.
FIRST when a feature is missing: diff the active plugin set
Before debugging any "feature X is gone" report, run:
bin/verify-plugin-composition.sh --source-tsv <prod-active-plugins.tsv> --target-db <migrated-db>
Elgg boots each active plugin in a try/catch and auto-disables any plugin that throws at boot.
So a plugin that is active on production but inactive on the migrated site is the single loudest
signal of a migration fatal โ and it silently removes that plugin's whole feature surface (routes,
actions, views, hooks) with no 500 to point at. Two causes:
- Boot fatal โ
elgg-cli plugins:activate <id> reproduces the error; fix it, re-activate.
- Restore gap โ it was never re-activated when prod's active set was restored (not on disk at
restore time, or an id-case mismatch). Re-activate, then confirm it STAYS active after a
cache clear + fresh boot (a boot fatal re-disables it).
A plugin ABSENT from the target disk is usually a 2.x plugin removed/absorbed into 7.x core โ
confirm the feature is covered by core, then ignore. See FC-ALL-17 in the elgg-migrate catalog.
Finish the upgrades Elgg deleted
Run this against the migrated database, at every tier and again at the end:
SELECT e.guid, MAX(CASE WHEN m.name='class' THEN m.value END) AS class
FROM elgg_entities e JOIN elgg_metadata m ON m.entity_guid = e.guid
WHERE e.subtype = 'elgg_upgrade'
GROUP BY e.guid
HAVING MAX(CASE WHEN m.name='is_completed' THEN m.value END) IN ('0')
OR MAX(CASE WHEN m.name='is_completed' THEN m.value END) IS NULL;
Anything it returns is one of three things. references/7x-prune-orphaned-upgrades.php sorts them:
| Kind | Why it never ran | What to do |
|---|
| Never invoked | the chain used elgg-cli upgrade instead of upgrade all | run elgg-cli upgrade all -n -f |
| Dead class | a later Elgg release deleted the upgrade class; getPendingUpgrades() silently drops any upgrade whose batch cannot be instantiated | do the work by hand, then delete the entity |
| camelCase twin | Elgg 4 lowercased plugin ids and an upgrade's identity is {plugin_id}:{version}; hypeMaps:โฆ is orphaned while hypemaps:โฆ completes | delete the camelCase entity |
The two dead-class upgrades that cost bodyology real content, with the SQL that finishes each:
\Elgg\Pages\Upgrades\MigratePageTop โ references/7x-migrate-page-top.sql
\Elgg\Discussions\Upgrades\MigrateDiscussionReply(+River) โ references/7x-migrate-discussion-replies.sql
Verify every other dead upgrade against live data before assuming it was a no-op. Most were
(bodyology's groups had no icons to transfer), but PublicLikesAnnotations had left 80 of 780
likes invisible to other members.
Two more things will abort the run before it reaches any of this:
- Core's
AlterDatabaseToMultiByteCharset ALTERs elgg_private_settings, a table Elgg 4 removed,
and dies before converting anything. Do the conversion yourself
(references/7x-utf8mb4-convert.sh โ it discovers the table list at runtime rather than
hardcoding it, and skips the ARCHIVE/MEMORY tables that cannot be converted), then mark the
upgrade completed. Marking it complete is not enough on its own: on bodyology the preview's
eight core tables (elgg_entities, elgg_metadata, elgg_annotations, โฆ) were still
utf8_general_ci afterwards, because the upgrade had never converted anything.
- One batch reporting a failure rejects Elgg's promise and aborts the whole run. See
FC-UPG-04
and FC-UPG-05 in the elgg-migrate failure catalog โ six of bodyology's own batches looped
forever, and three failed on rows they could never convert.
Then crawl the DEPLOYED preview, anonymously and as a logged-in member:
bin/verify-preview-live.sh. It is the only gate here that reads the running site rather than
source or a local stack. The authenticated pass is the point: /discussion/all was a hard 500 for
every logged-in non-admin while anonymous visitors saw a clean 200.
PART B: EXECUTE (production checklist)
Part B is deterministic on purpose. Production upgrades are high-stakes and
hard to reverse, so improvisation is the failure mode, not the feature. The
checklist below is strict: every step has a verification check, and if any
check fails you STOP and decide between fix-forward and rollback.
The only legitimate variation is the deployment model. A single-server
site runs the checklist top-to-bottom as written. A blue-green or rolling
deployment may move steps 2 (maintenance mode) and 3โ5 (code + core +
upgrade) off the live nodes and into the idle ones, then swap. The gates
don't change โ you still need backup, verified restore, code update, core
update, upgrade script, verification, cache flush, and post-upgrade
monitoring in that dependency order. What changes is where each step runs.
If you're not sure what deployment model applies, run the checklist as
written. For the blue-green model end-to-end โ go/no-go gates, backup, chain
migrate, smoke, DNS flip, seconds-fast rollback, backup/restore round-trip
proof, and the guarded stale-plugin-entity prune โ copy
references/cutover-runbook.md into the site repo and fill its placeholders.
Pre-Flight
Execution Checklist
1. BACKUP
2. ENABLE MAINTENANCE MODE
3. UPDATE PLUGIN CODE
4. UPDATE ELGG CORE
5. RUN UPGRADE
6. VERIFY
7. FLUSH CACHES
8. DISABLE MAINTENANCE MODE
9. POST-UPGRADE
Rollback Procedure
If the upgrade fails and cannot be fixed:
mysql -u root -p elgg < elgg_backup_YYYYMMDD.sql
git checkout pre-upgrade-YYYYMMDD
composer install
tar xzf elgg_data_YYYYMMDD.tar.gz -C /
rm -rf data/system_cache/* data/views_simplecache/*
curl -sL https://your-site.com/ | grep '<title>'
Beads Formula
This workflow is available as a beads formula for structured task tracking:
bd mol pour elgg-site-upgrade --var project=/path/to/project --var from=2.x --var to=3.x --var port=8380
cp formulas/elgg-site-upgrade.formula.json .beads/formulas/
The formula creates a hierarchy of issues with dependencies, ensuring each gate is verified before proceeding. The formula definition lives in formulas/elgg-site-upgrade.formula.json.
See references/REFERENCE.md for version tables, troubleshooting, and migration learnings.