Edits made to an installed module
A module on a site is a copy of a package. An update replaces that copy, so
every file edited in app/Modules/<Name>/ is temporary by definition: the next
release of the module — or the next nightly cms9:auto-update — brings its own
files.
ECMS9 will not do that silently. Every package ships a .baseline.json (a
sha256 per file, written by the package builder), and the installer compares
the tree with it before touching anything:
- files that differ or were added stop the update with
local_changesand are listed by name; - the automatic updater skips the module and raises a banner in the admin panel instead of overwriting it;
- the edits are written down as a patch — see below — so they can be carried into the new version.
That makes an edit survivable, not permanent. The supported ways to change behaviour are further down this page; use them for anything that must outlive an update.
See what this site changed: cms9:module-drift
php spark cms9:module-drift # every module with a baseline
php spark cms9:module-drift my_module # one module
php spark cms9:module-drift my_module --diff # ... and the edits themselves
php spark cms9:module-drift --all --json # machine-readable
The command only reads: it never writes into a module, and without --diff it
makes no network call at all — the answer comes from .baseline.json on disk.
Exit codes are meant for CI and rollout scripts:
| code | meaning |
|---|---|
0 | nothing differs from the packages |
1 | at least one module was edited on this site |
7 | usage error (unknown module name) |
What --diff can and cannot show
--diff needs the original bytes of the version installed here. The site
does not keep them (.baseline.json holds hashes, not files), so they are
fetched from the catalog — and the catalog's download only ever serves the
newest package of a module. Two consequences, both stated in the output
rather than guessed around:
- if the site runs an older version than the catalog, the original of that version is not obtainable and the command says so and lists the files without a diff;
downloadstampslast_seenon the licence row of the update server, so--diff(and only--diff) touches the licence. Plain runs never do.
The downloaded package is unpacked into writable/store_drift_tmp/ and removed
again when the command ends.
The patch: writable/store_local_changes/
Both moments an update meets a hand-edited module write
writable/store_local_changes/<Studly>-<version>-<date>.patch:
- the update was refused (
local_changes) — from the CLI, from the Store tab, or from an unattendedcms9:auto-updaterun; - the update overwrote the edits on purpose (
--overwrite-local, or «Update anyway» in the Store tab).
The file is a unified diff with a/ b/ paths relative to the module root:
cd app4/app/Modules/MyModule
patch -p1 --dry-run < /path/to/writable/store_local_changes/MyModule-1.0.4-20260924-011500.patch
# no conflict reported → run it again without --dry-run
# conflicts → *.rej files hold the hunks that need a decision
A header comment in the patch itself repeats those two lines, names the module and the version, and says why it was written.
Where an original cannot be proved — the catalog no longer has that
version, or the file is binary — nothing is invented: the whole edited file is
copied next to the patch (<same name>/files/… plus a manifest.json) and the
patch says which files those are. A patch that guessed at an original would
apply cleanly and produce wrong code, which is worse than no patch.
A file deleted locally is named in the patch but never deleted by it: the update restores it, and removing it again is a decision for a human.
Writing the patch is best effort. If it fails, the update still runs (or is still refused) and a warning goes into the log — the patch must never be the reason a site cannot update.
--overwrite-local also moves the whole edited tree to
writable/store_backups/<Studly>-<date>/, and cms9:module-rollback restores
it. The patch is the portable form of the same thing: the backup is the old
module, the patch is only your part of it.
Carrying the edits somewhere they survive
A patch re-applied after every release is a chore, not a solution. Three supported places to put the change instead:
1. The theme, when the change is presentation
Templates, markup, CSS and JS of the storefront belong to the theme, and a
theme is not part of any package — it survives every core and module update.
A module widget that must look different is overridden in the theme, not edited
in app/Modules/.
2. A small module of your own, listening to events
This is the general answer for behaviour. A module of your own never conflicts
with an update of someone else's, and CI4 auto-discovery loads its
Config/Events.php as soon as the namespace is known:
// app/Modules/MyShopTweaks/Config/Events.php
use CodeIgniter\Events\Events;
Events::on('order_placed', static function (array $payload): void {
// A listener must never break the operation it observes.
if (! module_enabled('my_shop_tweaks')) {
return;
}
try {
service('myTweaks')->exportOrder((int) $payload['order_id']);
} catch (\Throwable $e) {
log_message('error', 'my_shop_tweaks: ' . $e->getMessage());
}
});
Storefront markup is contributed the same way, through
render points — a
storefront_render_point:<name> listener — instead of editing views.
3. Ask the module for an event
If the module you need to change fires no event where you need one, the fix is an event (or a registry key) in that module, released with it — not a local edit of its code. That is a small, reviewable change that every site then gets, and it is the difference between a one-line patch in the module and a patch you re-apply forever.
Checklist
- Edited a module to get something working? Run
cms9:module-drift --diffand keep the output — that is the specification of what has to move. - Moving it into a theme or a module of your own? Do it before the next release of the edited module, not after.
- Cannot move it (no event yet)? Open a task for the event, and expect the
update to stop on
local_changesuntil it exists — with the patch waiting inwritable/store_local_changes/. - Re-applied a patch? Re-run
cms9:module-driftafterwards: it should now show exactly the files you meant to keep and nothing else.
See also: Module structure, Render points, Store and updates.