Operations
Every change to data goes through an operation. That is why permissions, demo mode, hooks and error formatting are decided once instead of in every screen.
Overview
An operation is a method that a request can name. When a request carries an operation name, the controller builds no page. It checks the privilege attached to that operation, calls the method, and turns whatever happens into a JSON response.
The method itself is written in a trait, so a controller gains a family of related operations by using one. The controller declares which operations it accepts and what each one requires.
Structure
What the Dispatcher Does For You
// operation(): accepted when the name is REGISTERED or simply EXISTS as a method
$isOperation = isset($operations[$name]);
$isMethod = method_exists($this, $name);
$properties = $operations[$name] ?? [];
if ($isOperation || $isMethod) return $this->run_operation($name, $properties);
// Neither: modules get the last word, and an array answer becomes the response.
foreach (Hook::run("register:admin.operations", Controllers::$cname, $name) as $hfn)
if ($hfn && is_array($hfn)) { echo Utility::jencode($hfn); return true; }
// Still nothing: {"status":"error","message":"Undefined operation: <name>"}
// run_operation(), in order:
// 1. privileges from the registration -> Admin::isPrivilege(), else throw
// 2. method_exists($this, $method), else throw
// 3. $this->$method(new Operation($name, $properties));
// 4. catch (Exception $e) -> {"status":"error","message":$e->getMessage()}
The last step is the one that changes how you write. You do not build error responses; you throw, and the message you throw is what the caller reads.
Reference
The Operation Object
public ?string $name = ''; // this operation's registered name
public static ?string $last = ''; // the last operation constructed in this process
public function __construct($name = '', $properties = []);
public function demo(): void; // throws in demo mode
public function hook(string $name = '', array $vars = []): array; // ['overwrite' => array] | ['output' => mixed] | []
public static function output($response): bool; // always returns true
public static function name(): ?string; // reads $last, NOT $this->name
output([]) answers with an empty body the caller cannot parse. Static, but called through the instance by convention, and returned so the method stops there.
$last, not the instance. Inside an operation the two agree; from a listener that runs later, prefer the name the hook already passes you.
The Hook Contract
Two names are shorthand for the shared extension points, and anything else is used verbatim. Listeners always receive three arguments: the controller name, the operation name and the variables you passed.
// Inside the operation. "before" => filter:admin.operation.before
// "after" => filter:admin.operation.after
$hook = $operation->hook('before', get_defined_vars());
if ($hook && $hook["overwrite"] ?? []) extract($hook["overwrite"]); // listener changed your locals
if ($hook && $hook["output"] ?? false) return $operation->output($hook["output"]);
// In a module's hooks.php. The listener decides by returning ONE of three shapes:
Hook::add('filter:admin.operation.before', 1, function ($controller, $operation, $vars) {
if ($controller !== 'widgets' || $operation !== 'save_widget') return null;
// 1. refuse: hook() throws with this message, run_operation turns it into the error JSON
if (!$vars["name"]) return ['status' => "error", 'message' => "Name is required"];
// 2. rewrite: these become local variables in the operation, via extract()
if ($vars["name"] === "x") return ['overwrite_vars' => ['name' => "X"]];
// 3. answer: the operation returns this instead of doing the work
return ['output' => ['status' => "successful", 'id' => 0]];
});
output() on every array response, by reference, with the operation name as the second argument. The last chance to add or strip a field.
The Response
Success and failure share one field, so a caller can branch on status alone. The error shape is produced for you by the dispatcher. The success shape is whatever you pass; by convention it carries the same field plus what the screen needs.
{ "status": "successful", "id": 42, "message": "Changes saved" }
{ "status": "error", "message": "Name is required" }
Example
namespace WISECP\Operations;
use Exception;
use Filter;
use Language;
use Operation;
trait AdminWidgets
{
public function save_widget(Operation $operation): bool
{
// Demo mode refuses every write; this is always the first line.
$operation->demo();
$id = (int) Filter::init("POST/id", "rnumbers");
$name = Filter::init("POST/name", "hclear");
if (!$name) throw new Exception(Language::gc("widgets/error-name-required"));
$hook = $operation->hook('before', get_defined_vars());
if ($hook && $hook["overwrite"] ?? []) extract($hook["overwrite"]);
if ($hook && $hook["output"] ?? false) return $operation->output($hook["output"]);
$saved = $id
? $this->model->update($id, ['name' => $name])
: $this->model->add(['name' => $name]);
if (!$saved) throw new Exception(Language::gc("widgets/error-save-failed"));
$response = ['status' => "successful", 'id' => (int) ($id ?: $saved)];
$hook = $operation->hook('after', get_defined_vars());
if ($hook && $hook["overwrite"] ?? []) extract($hook["overwrite"]);
if ($hook && $hook["output"] ?? false) return $operation->output($hook["output"]);
return $operation->output($response);
}
}
The other side is an ordinary form post to the controller's own address, with the operation name as a field.
curl -X POST "$PANEL/widgets" \
-H "X-Requested-With: XMLHttpRequest" \
-d "operation=save_widget&id=42&name=Sidebar"
# {"status":"successful","id":42}
# {"status":"error","message":"You don't have privileges to access this operation."}
Pitfalls
Without it, a demo installation performs the write and only looks like it refused. It goes first, before any reading or validation, so nothing can slip past it.
The dispatcher runs a method that merely exists, with an empty property array. An empty privilege list means the check is skipped entirely. Every public method a controller gains from a trait is an entry point. Register it, or do not put it on the controller.
The message you throw is shown to whoever made the request. A literal string means the installation answers everyone in one language.
output() writes the response and returns true; it does not stop the method. Anything after an unreturned call still runs and can print a second body after the JSON.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.