Working Without Touching the Core
A worked catalogue of the seams the platform publishes, and what each one can and cannot reach. Use it to pick the right one for the change in front of you.
Overview
Every seam below is a loaded extension point with a named entry file, a contract and a limit.
They are not equivalent. A hook reaches only where one was published. A module owns a directory, its own tables and its own settings, but must fit one of the sixteen type contracts. A theme covers the website and has no admin counterpart.
Prerequisites
- Principles of Upgrade-Safe Work, for what the upgrade run does to a file that is not behind a seam.
- Write access to the installation directory and the ability to reload a page you can see.
- A named target: the surface, the flow or the value you want to alter. "Change checkout" becomes "change the value the cart total filter hands out".
Structure
The Seam Map
Find the row that matches your change, then read its contract below.
| What you need to change | Seam | Its limit |
|---|---|---|
| A value the core calculates (a total, a list, a payload) | A filter: hook | Only where one was published. There is no generic "before every method" interception |
| React to something that happened (sync, notify, audit) | An action: hook | Return value discarded, exception swallowed: failures are invisible unless you log them |
| Refuse an operation under your own rule | A gate: hook | Refuse or allow, nothing in between. You cannot alter the operation |
| Add markup to an existing page | A ui: hook | Only at the positions that exist. There is no template override layer for the admin panel |
| Provision something, take a payment, register a name, send a message | A module of that type | Sixteen types, each with a fixed method contract. A need that fits none of them has no module type |
| A whole admin page of your own | A module admin area | Recognised for eleven of the sixteen module types; the rest register silently as nothing |
| An HTTP endpoint of your own | The API route filter | Your handler must be a real method on the module instance. There is no catch-all dispatch |
| A new admin AJAX action on an existing page | The admin operation fallback hook | None of the wrapper protections apply: no privilege check, no demo guard, no request-header check |
| Work on a schedule | A cron handler registered from the module | The queue owns retries and timing. You do not get to choose when your slice runs |
| The visitor-facing look | A theme directory | Website only. The admin panel is plain PHP templates with no theme layer |
| The wording of a component string | The translation filter | Component lookups only. Package-level strings have no read-time filter |
| Variables reaching a template | The template variable filter | By value, and the last listener that returns an array replaces the whole set |
| Data on a client, ticket or product record | Custom fields, defined by the operator | Configuration, not code. You get storage and display, not behaviour |
Where the Files Go
One directory holds all of it; nothing above reaches outside this tree.
Acme.php # the module class the core instantiates
config.php # settings, written by the module, never by a release
hooks.php # auto-included: every Hook::add lives here
router.php # auto-included for admin routing: registers the admin area
AdminArea.php # your own admin page(s)
lang/en.php # your strings, keyed however you like
cronjobs/Sync.php # your scheduled handler, registered from hooks.php
src/AcmeClient.php # plain classes of your own, constructed with `new`
The hook loader globs hooks.php in every module directory, and admin routing reads router.php. Everything else is reached from those two, by an explicit include_once or through the module class the core builds for you.
Walkthrough
Name What You Are Actually Changing
- Open the surface and find the exact value, control or moment you want to alter.
- Find the code that produces it. Search the literal text on screen, the field name in the form, or the route key in the URL. Follow it back to the helper or operation that owns the value.
- Decide which of the five verbs applies. Transform a value, react to an event, refuse an action, print markup, contribute an entry to a list. That verb is the hook category.
Pick the Seam
- Search the catalogue in
hooks/INDEX.mdfor the domain your change lives in. It gives every hook with its domain and the exact file and line where it fires. - If a hook exists, read its parameters table first. Whether an argument arrives by reference is on that page, and it decides how your listener is written.
- If no hook exists but the work is a whole capability (a panel, a gateway, a provider), it is a module. The type you pick fixes the methods the core will call.
- If neither fits, ask for a generic extension point rather than patching the core. A hook that names your module, flag or table is not generic; one that names the decision is.
Register It
- Create
hooks.phpin your module directory if it is not there. It is included automatically, once per request, on the first hook activity. - Guard the whole file on your module being enabled. Load the configuration in the lightweight mode and read the status before registering anything.
- Register with a priority. Low runs first, and a number already taken is incremented until free, so nothing is silently dropped.
- Build the module instance through the canonical factory inside the listener body, not at the top of the file.
Prove It Survives
- Reload the surface and confirm the behaviour changed. A listener whose registration is fine but whose body throws looks exactly like a hook that never fired. The error is caught and logged.
- Grep your own tree for core paths. Anything under
coremio/classes,coremio/controllers,coremio/helpersortemplates/adminthat you edited is a future outage. - List every hook, class and method you call from outside your own directory. That list is the compatibility contract you re-check after an upgrade.
- Disable the module and reload. The surface must return to its stock behaviour with no error. If it does not, something of yours is running outside the status guard.
Reference
Registration Signatures
// coremio/classes/Hook.php
public static function add($name, $priority, $properties = []): void;
// coremio/classes/Modules.php - load config without including the class ($nominc = true),
// then read it. Both are needed for a cheap "is my module on?" guard in hooks.php.
public static function Load($type = '', $name = '', $nominc = false, $status = '');
public static function Config($type, $module);
public static function getInstance(string $type, string $name, array $params = []): ?object;
// coremio/classes/ModuleAdminArea.php - called from the module's router.php.
public static function register(string $areaClass): void;
public static function get(string $type, string $name): ?array;
// coremio/helpers/CronJobQueue.php - called from a register:cronjobs listener.
public static function register(string $type, string $handlerClass): void;
public static function dispatch(string $type, array $payload = [], array $opts = []): int;
// coremio/cronjobs/CronJobHandler.php - an INTERFACE, so implement it, do not extend it.
// Return true for success, false to fail and be retried, or ['success' => bool,
// 'result' => array] to hand the admin Results tab a payload as well.
public function handle(array $payload, array $job): bool|array;
$nominc = true loads config.php and the language file without including the module class. This is the guard form: cheap enough to run in hooks.php on every request.
Load() returns nothing, which reads as "module disabled" and switches your whole file off. Order is not optional.
slug, slug/(?), slug/(?)/(?)) and registers the menu entry. Returns nothing, including when it rejects the class.
TYPE constant, second is the class name. A discovery handler (a type ending in .discover) also needs a FREQUENCY constant, which is what the scheduler reads.
What Each Seam Expects Back
| Hook | Fired with | Contract |
|---|---|---|
register:admin.operations | run(), arguments: controller name, operation name | Return null to decline. Return an array and it is JSON encoded and sent as the whole response |
filter:api.routes | runRefs(), arguments: route list by reference, audience by reference | Append tuples to the list. Return value unused. First match wins, so priority decides shadowing |
register:cronjobs | run(), no arguments | Include your handler file and call the queue's register method. Return value unused |
filter:i18n.translation | runRefs(), arguments: text by reference, key, language code | Change the text in place. Fires only for component lookups and only when the resolved value is a string |
filter:template.variables | run(), arguments: template path, data array | Return the whole data array, modified. The last listener that returns an array wins outright |
register:admin.menu | run(), no arguments | Write into the menu tree and return true, or return false to contribute nothing |
// [0] METHOD [1] pattern (full path after /api/v1, {x} captures)
// [2] Group [3] Action [4] public? [5] authOnly? [6] audience
$routes[] = ['GET', 'acme/status', 'Module:Addons/Acme', 'status', false, true, 'admin'];
// Group "Module:{Type}/{Name}" dispatches to the module instance method
// api_{Action}(WISECP\Api\Core\Request $request, array $match)
// and method_exists decides: there is no __call fallback. A typo in [3] is a 404,
// not an error you can see.
//
// [4] public = true → no credential at all; the module polices its own access
// [5] authOnly = true → a valid credential is enough, no scope is checked (the default
// on the free surface, because module scopes are not in the catalog)
// [6] audience → 'admin' | 'client' | 'any', only consulted when public is false
Where There Is No Seam
Four gaps. None has a supported workaround.
ui: hook at a position that already exists. A missing position is a request for a new hook.
register() returns having done nothing, with no error anywhere. Ship an Addons module alongside it, or ask for the type to be added.
Example
One small addon on four seams, touching nothing outside its own directory.
<?php
// The cheap guard: config only, class not included. Everything below is inside the if,
// so a disabled module registers nothing and costs one file read.
Modules::Load('Addons', 'Acme', true);
$acme = Modules::Config('Addons', 'Acme') ?: [];
if ($acme['status'] ?? false) {
// 1. An AJAX action on a page this module does not own. Reached as
// operation=acme_resync on the services controller. Nothing wraps this call,
// so the privilege check is ours to make.
Hook::add('register:admin.operations', 1, function ($cname, $operation) {
if ($operation !== 'acme_resync') return null;
if ($cname !== 'services') return null;
if (!Admin::isPrivilege(['SERVICES_OPERATION']))
return ['status' => 'error', 'message' => Language::gc('acme/no-privilege')];
return Modules::getInstance('Addons', 'Acme')->resync_request();
});
// 2. An endpoint of our own on the credentialed admin surface.
Hook::add('filter:api.routes', 1, function (&$routes, &$audience) {
if ($audience !== 'admin') return;
$routes[] = ['GET', 'acme/status', 'Module:Addons/Acme', 'status', false, true, 'admin'];
});
// 3. A scheduled task. The file is included here, not at boot: the cron registry
// only scans the core directory.
Hook::add('register:cronjobs', 1, function () {
include_once __DIR__ . DS . 'cronjobs' . DS . 'Sync.php';
CronJobQueue::register(\WISECP\Modules\Addons\Acme\CronJobs\Sync::TYPE,
\WISECP\Modules\Addons\Acme\CronJobs\Sync::class);
});
// 4. Reword one component string without editing a language file. By reference,
// and only ever for the exact key we own the opinion about.
Hook::add('filter:i18n.translation', 1, function (&$text, $key, $lang) {
if ($key !== 'admin/services/status-active') return;
$text = Language::gc('acme/status-live');
});
}
The other side of the first seam. The method returns the array the core encodes, so it owns the response shape.
public function resync_request(): array
{
$id = (int) Filter::init('POST/id', 'rnumbers');
if (!$id) return ['status' => 'error', 'message' => Language::gc('acme/id-required')];
// Throwing is the module's way of reporting failure; here we are answering an
// AJAX call directly, so the error is shaped by hand instead.
try {
$rows = $this->client()->resync($id);
}
catch (\Throwable $e) {
return ['status' => 'error', 'message' => $e->getMessage()];
}
return ['status' => 'successful', 'message' => Language::gc('acme/resynced'), 'count' => $rows];
}
// The API endpoint registered above lands here. The name is api_ plus the Action
// from the tuple, and method_exists is the only thing that decides.
public function api_status(\WISECP\Api\Core\Request $request, array $match): array
{
return ['data' => ['enabled' => true, 'last_sync' => Config::getd('acme_last_sync')]];
}
The admin page: two files and one line of registration.
// coremio/modules/Addons/Acme/router.php
namespace WISECP\Modules\Addons\Acme;
include_once __DIR__ . DS . 'AdminArea.php';
\ModuleAdminArea::register(AdminArea::class);
// coremio/modules/Addons/Acme/AdminArea.php
namespace WISECP\Modules\Addons\Acme;
class AdminArea extends \ModuleAdminArea
{
public static function manifest(): array
{
return [
'title' => 'Acme',
'slug' => 'acme',
'privileges' => ['TOOLS_ADDONS'],
'menu' => ['path' => ['TOOLS'], 'name' => 'Acme'],
];
}
// /{admin}/acme → page_home()
// /{admin}/acme/report → page_report()
public function page_home(array $params): string|array
{
return ['content' => '<p>Acme</p>', 'page_title' => 'Acme'];
}
// POST to the same URL with operation=refresh
public function op_refresh(\Operation $operation): bool
{
$operation->demo();
return $operation->output(['status' => 'successful']);
}
}
Pitfalls
A normal operation goes through a wrapper. That wrapper checks the privilege list and refuses a request from outside the panel's AJAX layer. It also builds the object whose demo guard blocks writes in demo mode. The fallback hook is reached only after the wrapper declined, so none of it applies. Check the privilege yourself, in the listener, first.
It is fired by value, and the caller assigns each returned array over the previous one. The last listener that returns an array replaces the entire set; two listeners on the same template do not merge. Read the array you were handed, modify it, and return all of it.
The registration returns having done nothing in three cases. The class is not a subclass. The namespace does not have the expected shape. The module type is not one of the eleven recognised ones. No exception, no log line, no menu entry. When your page 404s, check the type before the routes.
The hook loader includes the file whether or not any of your hooks will fire. A database query, a remote call or a full module instantiation outside a listener body is paid on every page load. Load the configuration, check the status, and put everything else inside a closure.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.