Adding a Custom Operation
Add a POST endpoint that changes data and answers in JSON, without a new route or error format.
Overview
An operation is a method that changes data and answers in JSON. You post an operation parameter to an existing controller: no route to register. The controller dispatches on the name, checks privileges, and turns a throw into an error.
Operations live in traits, mixed in with use, so a method reaches the controller's model and helpers.
Prerequisites
- A controller that already answers your surface, admin or client.
- A privilege key on the operator panel: an empty declaration lets anyone signed in call it.
- A caller: the panel's request helper acts on your
status,messageandredirectkeys.
Structure
| Piece | Where | What it carries |
|---|---|---|
| The trait | coremio/operations, namespace WISECP\operations | One public method per operation |
| The controller | coremio/controllers, admin or website | use for the trait, plus the declaration array |
| The caller | A template, or your own JavaScript | Posts operation plus your fields, reads the JSON |
Walkthrough
Write It
- Add a public method to a trait: one operation-object parameter, returning
bool. - Call the demo guard on the first line if the method writes.
- Read inputs through the filter helper, never the raw request arrays; a password needs its own sanitiser.
- Validate by throwing; there is no error return value to build.
- Return the response through the output helper, from a variable the after hook can replace.
Declare It
- Import the trait and add it with
usein the controller class. - Merge it into the constructor's declaration array, keyed by the posted name.
- Give it the privileges the surface uses; the check runs first.
- Confirm the entry point forwards to the dispatcher; a client controller does it explicitly.
Call It
- Post to the controller's own address with
operationset to your name. - Send it as an asynchronous call, or an admin operation refuses it.
- Standard keys: a message notifies, a redirect navigates,
reloadreloads. - Use the request helper's callbacks only for what those keys cannot do.
Reference
The Operation Object
class Operation
{
public ?string $name = ''; // the posted operation name
public static ?string $last = ''; // the last name constructed, per request
// Built by the dispatcher: $properties is the declaration array entry.
public function __construct($name = '', $properties = []);
// Throws on a demonstration system. First line of every mutating operation.
public function demo(): void;
// Fires a hook and normalises the answers. 'before' and 'after' are shorthands
// for the two generic admin-operation filters; any other string is used as-is.
// Returns [] or ['overwrite' => array] or ['output' => mixed].
public function hook(string $name = '', array $vars = []): array;
// Sends the response. An array is JSON encoded with the JSON header set;
// anything else is echoed as-is. Always returns true, so `return $op->output(...)`
// satisfies the bool return type.
public static function output($response): bool;
public static function name(): ?string;
public function assertContext(): void;
public static function resolveContext(): bool;
}
The Declaration Array
$this->operations = array_merge($this->operations, [
// The common shape: posted name => the privileges required to run it.
'save_acme_settings' => ['privileges' => ['SETTINGS_OPERATION']],
// 'method' points the posted name at a differently named method, which is how
// two posted names share one implementation.
'acme_retry' => ['privileges' => ['SETTINGS_OPERATION'], 'method' => 'acme_run'],
// 'allow_navigation' drops the asynchronous-request requirement. Only for
// operations meant to be opened as a URL, such as a file download, and only
// together with a one-time token of the operation's own.
'download_acme_log' => ['privileges' => ['SETTINGS_OPERATION'], 'allow_navigation' => true],
// A client-side operation usually declares no privileges: it gates itself on
// the member session inside the method instead.
'acme_client_action' => [],
]);
What the Dispatcher Does
| Step | Behaviour | On failure |
|---|---|---|
| Name cleanup | Filtered to a route-safe string; main goes to not-found | Unknown names are refused |
| Licence check | An active licence; help and polling operations exempt | An error, before privileges |
| Transport check | An asynchronous request with X-Requested-With: XMLHttpRequest | HTTP 403 unless navigation is allowed |
| Privilege check | Runs when the declaration lists privileges | Names the missing privilege |
| Your method | Called with a fresh operation object | Any exception becomes {"status":"error","message":"..."} |
| Fallback | No declaration and no method: a registration hook gets the name | Its array answer is sent |
before shorthand. A listener returns an array: error blocks, overwrite_vars replaces variables, output answers instead. Empty continues.
after shorthand, once the response variable exists. Same return shape, but error throws.
The Response the Caller Reads
| Key | Value | What the panel does |
|---|---|---|
status | successful or error | Anything but success with a message is an error |
message | Text already translated | A notification, or a toast if the caller asked |
redirect | A URL, or reload, or script | Navigates, reloads, or evaluates script; with a message it waits |
redirect_delay | Milliseconds | Overrides the wait: five seconds with a message, immediate without |
successToast | Boolean | Forces the toast style over the caller's preference |
data | Anything | Nothing automatic; your own callback reads it |
Example
An operation that rotates an integration key, and the code that calls it.
namespace WISECP\operations;
use Operation;
use Filter;
use Exception;
trait AcmeSettings
{
public function rotate_acme_key(Operation $operation): bool
{
/** @var \WISECP\controllers\admin\settings $this */
// 1. Refuse on a demonstration system, before anything is read.
$operation->demo();
// 2. Read input. Never $_POST: the filter type is chosen per field, and a
// secret must go through 'password' so its punctuation survives intact.
$id = (int) Filter::init('POST/id', 'numbers');
$label = (string) Filter::init('POST/label', 'hclear');
$confirm = (string) Filter::init('POST/confirm_password', 'password');
// 3. BEFORE HOOKS: after reading, before validating, so a listener can
// substitute the inputs or answer instead of us.
# BEFORE HOOKS
$hook = $operation->hook('before', get_defined_vars());
if ($hook && $hook['overwrite'] ?? []) extract($hook['overwrite']);
if ($hook && $hook['output'] ?? false) return $operation->output($hook['output']);
// 4. Validate by throwing. The dispatcher turns each of these into
// {"status":"error","message":"..."} with no work on your side.
if (!$id) throw new Exception(\Language::gc('admin/settings/error-missing-id'));
if ($label === '') throw new Exception(\Language::gc('admin/settings/error-label-empty'));
$adata = \UserManager::LoginData('admin');
if (!\User::_password_verify('admin', $confirm, $adata['password']))
throw new Exception(\Language::g('needs/permission-delete-item-invalid-password'));
// 5. The work itself, through the model the trait inherits from the controller.
$key = \Utility::generate_hash(48);
$this->model->set_acme_credentials($id, ['label' => $label, 'api_key' => $key]);
// 6. Audit trail. The third argument is a key in the actions language file.
\User::addAction((int) $adata['id'], 'alteration', 'changed-acme-key', ['id' => $id]);
// 7. The response goes into a variable so the after hook can replace it.
$response = [
'status' => 'successful',
'message' => \Language::gc('admin/settings/success-acme-key-rotated'),
'data' => ['masked' => substr($key, 0, 6) . str_repeat('*', 10)],
];
# AFTER HOOKS
$hook = $operation->hook('after', get_defined_vars());
if ($hook && $hook['overwrite'] ?? []) extract($hook['overwrite']);
if ($hook && $hook['output'] ?? false) return $operation->output($hook['output']);
// 8. One exit. output() encodes, sets the header and returns true.
return $operation->output($response);
}
}
namespace WISECP\controllers\admin;
use WISECP\operations\AcmeSettings;
use Controllers;
use Filter;
class settings extends Controllers
{
use AcmeSettings;
public function __construct()
{
parent::__construct();
$this->checkLogin();
// Registration and access control in one place. Without the entry the
// method would still be reachable, and it would run unprivileged.
$this->operations = array_merge($this->operations, [
'rotate_acme_key' => ['privileges' => ['SETTINGS_OPERATION']],
]);
}
public function main()
{
// The fork: a posted operation never reaches the page methods below.
if ($operation = Filter::init('REQUEST/operation')) return $this->operation($operation);
// ... page_* dispatch continues here
return '';
}
}
The calling side.
function rotateAcmeKey(btn) {
WcpRequest(CONTROLLER_LINK, {
method: 'POST',
button: btn, // disabled and given a spinner for the round trip
buttonLoader: saving_loader,
options: { headers: { 'X-Requested-With': 'XMLHttpRequest' } },
data: {
operation: 'rotate_acme_key', // the declared name, exactly
id: ACME_ID,
label: document.getElementById('acmeLabel').value,
confirm_password: document.getElementById('acmeConfirm').value,
},
// status/message/redirect are handled for us; this is only for the extra.
afterDone: (res) => {
if (res.status !== 'successful') return;
document.getElementById('acmeKeyMasked').textContent = res.data.masked;
},
});
}
A client-area operation has no generic before and after hook.
namespace WISECP\operations;
use Operation;
use Filter;
use Exception;
trait ClientAcme
{
public function acme_disconnect(Operation $operation): bool
{
$operation->demo();
// Context is resolved INSIDE the operation: the declaration array is
// registration, not a gate, and a method is dispatchable without an entry.
$member = \UserManager::LoginData('member');
if (!$member) throw new Exception(\Language::gc('website/account/err-auth'));
$uid = (int) ($member['id'] ?? 0);
$sid = (int) Filter::init('POST/service_id', 'numbers');
$service = \Services::get($sid);
if (!$service || (int) ($service['owner_id'] ?? 0) !== $uid)
throw new Exception(\Language::gc('website/account/err-not-found'));
// Your own domain hooks, at the point where they belong. A gate refuses by
// returning a non-empty reason, and the operation turns that into an error.
// Both names live in your module's namespace: never fire a core hook, and
// never invent one that the catalogue does not carry.
foreach (\Hook::run('gate:service.acme_disconnect', $sid, $uid) as $veto)
if ($veto) throw new Exception((string) $veto);
// There is no set_options() helper. Options round-trip through Services::set(),
// which JSON encodes the array for you, so read, merge, write the whole map.
$options = $service['options'] ?? [];
$options['acme_linked'] = 0;
\Services::set($sid, ['options' => $options]);
\Hook::run('action:service.acme_disconnected', $sid, $uid);
return $operation->output([
'status' => 'successful',
'message' => \Language::gc('website/account/acme-disconnected'),
]);
}
}
Pitfalls
Dispatch accepts any name matching a declaration entry or an existing method, so keep helpers private.
API resources reach the helper or the model directly, so a rule added only to an operation stays bypassable over the token.
Echoing your own encoding bypasses the response filter and its flags. Return through the output helper, hook branches included.
An admin operation is refused unless the request identifies itself as an asynchronous call. For a download, declare the exemption and add a one-time token.
On a listing or a preview it breaks the demonstration system for nothing. If the method writes, the guard is its first line.
Related Articles
Merci pour votre retour !
Notre équipe d'assistance est disponible 24h/24 pour tout ce que vous ne trouvez pas ci-dessus.