Working Without Touching the Core

4 views Markdown

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 changeSeamIts limit
A value the core calculates (a total, a list, a payload)A filter: hookOnly where one was published. There is no generic "before every method" interception
React to something that happened (sync, notify, audit)An action: hookReturn value discarded, exception swallowed: failures are invisible unless you log them
Refuse an operation under your own ruleA gate: hookRefuse or allow, nothing in between. You cannot alter the operation
Add markup to an existing pageA ui: hookOnly 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 messageA module of that typeSixteen types, each with a fixed method contract. A need that fits none of them has no module type
A whole admin page of your ownA module admin areaRecognised for eleven of the sixteen module types; the rest register silently as nothing
An HTTP endpoint of your ownThe API route filterYour handler must be a real method on the module instance. There is no catch-all dispatch
A new admin AJAX action on an existing pageThe admin operation fallback hookNone of the wrapper protections apply: no privilege check, no demo guard, no request-header check
Work on a scheduleA cron handler registered from the moduleThe queue owns retries and timing. You do not get to choose when your slice runs
The visitor-facing lookA theme directoryWebsite only. The admin panel is plain PHP templates with no theme layer
The wording of a component stringThe translation filterComponent lookups only. Package-level strings have no read-time filter
Variables reaching a templateThe template variable filterBy value, and the last listener that returns an array replaces the whole set
Data on a client, ticket or product recordCustom fields, defined by the operatorConfiguration, 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.

coremio/modules/Addons/Acme/
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`
Two files are loaded for you, the rest you include yourself

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

  1. Open the surface and find the exact value, control or moment you want to alter.
  2. 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.
  3. 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

  1. Search the catalogue in hooks/INDEX.md for the domain your change lives in. It gives every hook with its domain and the exact file and line where it fires.
  2. 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.
  3. 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.
  4. 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

  1. Create hooks.php in your module directory if it is not there. It is included automatically, once per request, on the first hook activity.
  2. Guard the whole file on your module being enabled. Load the configuration in the lightweight mode and read the status before registering anything.
  3. Register with a priority. Low runs first, and a number already taken is incremented until free, so nothing is silently dropped.
  4. Build the module instance through the canonical factory inside the listener body, not at the top of the file.

Prove It Survives

  1. 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.
  2. Grep your own tree for core paths. Anything under coremio/classes, coremio/controllers, coremio/helpers or templates/admin that you edited is a future outage.
  3. 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.
  4. 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

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;
Modules::Load() Third argument $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.
Modules::Config() Reads from the load cache only. Calling it before Load() returns nothing, which reads as "module disabled" and switches your whole file off. Order is not optional.
ModuleAdminArea::register() Takes the class name, resolves type and name from its namespace, adds three routes (slug, slug/(?), slug/(?)/(?)) and registers the menu entry. Returns nothing, including when it rejects the class.
CronJobQueue::register() First argument is the handler's 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

HookFired withContract
register:admin.operationsrun(), arguments: controller name, operation nameReturn null to decline. Return an array and it is JSON encoded and sent as the whole response
filter:api.routesrunRefs(), arguments: route list by reference, audience by referenceAppend tuples to the list. Return value unused. First match wins, so priority decides shadowing
register:cronjobsrun(), no argumentsInclude your handler file and call the queue's register method. Return value unused
filter:i18n.translationrunRefs(), arguments: text by reference, key, language codeChange the text in place. Fires only for component lookups and only when the resolved value is a string
filter:template.variablesrun(), arguments: template path, data arrayReturn the whole data array, modified. The last listener that returns an array wins outright
register:admin.menurun(), no argumentsWrite into the menu tree and return true, or return false to contribute nothing
the API route tuple, position by position
// [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.

Admin templates cannot be overridden The website has themes; the admin panel does not. Its templates are plain PHP, loaded from one fixed directory. The only way in is a ui: hook at a position that already exists. A missing position is a request for a new hook.
Five module types cannot open an admin page The admin area resolver accepts eleven types: Servers, Payment, Registrars, Product, Addons, SMS, Mail, Authentication, Pipe, Imports and Fraud. A SocialAuth, Captcha, Currency, IP or Storage module is rejected by the identity check. register() returns having done nothing, with no error anywhere. Ship an Addons module alongside it, or ask for the type to be added.
Package strings have no read-time filter The translation filter fires inside the component lookup only; strings read through the package lookup pass through no hook. The only ways to change one are the operator's language editor and your own key in your own file.
A column you add to a core table is unowned Nothing forbids it, and the migration importer will not trip over it: a duplicate-column error counts as already applied. But no core code reads or writes it. If a later release adds the same column name, its definition is the one skipped. Yours stays, and the core's expectation does not hold. Prefix the name with your module, and prefer your own table.

Example

One small addon on four seams, touching nothing outside its own directory.

coremio/modules/Addons/Acme/hooks.php
<?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.

coremio/modules/Addons/Acme/Acme.php
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.

router.php and AdminArea.php
// 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

The operation fallback hook runs outside every wrapper

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.

The template variable filter is winner-takes-all

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.

A rejected admin area registration is silent

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.

Work done at file scope in hooks.php runs on every request

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.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.