Every CiviCRM site eventually needs something CiviCRM doesn't do out of the box: a changed form, a workaround for a bug, a Joomla 6 compatibility fix. The quickest way is to edit the file that causes the problem. It is also the way that guarantees the problem comes back. This post is about where fixes should live instead.
Why core edits fail
A CiviCRM upgrade on Joomla installs the new package over the old one. Every file CiviCRM ships – in administrator/components/com_civicrm, in its Joomla plugins, in components/com_civicrm – is replaced. Your edit is gone, silently, and usually nobody remembers it existed until a donation form breaks.
The cost is not the first fix. It is every update after it: either someone re-applies the patch by hand each month, or the site stops getting updates – including security releases.
Where fixes should live
| Kind of change | Put it in |
|---|---|
| Change CiviCRM behavior (validation, form fields, totals, permissions) | A CiviCRM extension using hooks such as hook_civicrm_buildForm, validateForm, post, alterAPIPermissions |
| Change how a CiviCRM page looks | A CiviCRM extension with template overrides or CSS, or the Joomla template |
| New reports, searches, forms | SearchKit and FormBuilder (saved as managed entities in an extension) |
| Joomla-side behavior (user sync, routing, Joomla 6 compatibility) | Your own Joomla plugin, packaged as a separate extension |
| A real bug in CiviCRM | A patch sent upstream, plus a temporary hotfix extension until the release includes it |
CiviCRM itself works this way for urgent problems: for the August 2026 APIv4 security issue, one of the official mitigations was a small "hotfix" extension you could install instead of upgrading. See CiviCRM 6.17.1 security release.
A small example: a hook instead of an edit
Say a contribution form must reject donations below 5 in your currency. Instead of editing the form class, a CiviCRM extension adds a validation hook:
function myorg_civicrm_validateForm($formName, &$fields, &$files, &$form, &$errors) {
if ($formName === 'CRM_Contribute_Form_Contribution_Main') {
$amount = (float) ($fields['total_amount'] ?? 0);
if ($amount > 0 && $amount < 5) {
$errors['total_amount'] = ts('The minimum donation is 5.');
}
}
}
The extension has its own folder, its own version number and its own repository. A CiviCRM upgrade doesn't touch it.
Joomla 6: the same principle
On Joomla 6, CiviCRM's own Joomla plugins need updating for the event system (explained here). Rewriting them in place would be a core edit. Instead, the compatibility code goes in a separate Joomla extension of your own. That is how we run CiviCRM on Joomla 6 on NorCal CarciNET and WARMTH: zero CiviCRM core files changed.
Prove it survives an update
A fix is only "outside core" if an update proves it. On staging:
- Note what each fix does and how to see it working.
- Install a CiviCRM update – or reinstall the current version over itself.
- Check every fix again.
If anything disappears, it was in the wrong place. Move it before the next real update finds it for you.
Keep a register
For every customization, write down: what it does, why, where it lives (extension name and version), and how to test it. One page is enough. It turns "what did the last developer change?" into a five-minute read.
Start the extension with civix
CiviCRM's scaffolding tool civix creates an extension with the right structure, info.xml and hook wiring in a minute, so there is no excuse to put a fix anywhere else:
# terminal – in CiviCRM's extensions directory
civix generate:module org.example.donationmin
cd org.example.donationmin
# add myorg-style hook functions to donationmin.php, then:
cv ext:enable org.example.donationmin --cwd=/var/www/example.org
Keep the extension in its own Git repository and deploy it like any other code. The register of customisations then points at a repository, not at a person's memory.
Inherited a CiviCRM site with core edits? We find them, move them into extensions and hooks, and prove they survive the next update.
CiviCRM Joomla 6 upgrade →
