For the complete documentation index, see llms.txt. This page is also available as Markdown.

REST API in modules

Exposing a module REST API: the modern v3 attribute-routed pattern with auto-discovered #[ApiResource] controllers, Processor + Action classes, and a DataStructure single source of truth that drives O

A module can publish its own REST endpoints under the PBX core API surface. The recommended way in MikoPBX 2025.1.1+ is the v3 attribute-routed pattern: you annotate a Controller class with PHP 8 attributes, drop it into Lib/RestAPI/{Resource}/, and the core router discovers it automatically. There is no route table to register and no method to override in your Conf.php — the controller's #[ApiResource] attribute is the single declaration of the endpoint.

Throughout this page we use the running example module ModuleBlackList (config class BlackListConf) and imagine it exposes a numbers resource for managing blocked phone numbers. For every pattern below you will also find a pointer to the real, working reference module: Extensions/EXAMPLES/REST-API/ModuleExampleRestAPIv3/. Read those files alongside this page — they compile and run.

v3 is the only pattern you should write for new modules. The older v1 (moduleRestAPICallback()) and v2 (namespaced Processor registered through getPBXCoreRESTAdditionalRoutes()) callback styles still work for backward compatibility, but they are documented separately in ../api/rest-api.md and are not covered in depth here.

How auto-discovery works

The core service MikoPBX\PBXCoreREST\Providers\RouterProvider scans for module controllers at boot. The relevant logic lives in Core/src/PBXCoreREST/Providers/RouterProvider.php, method discoverModuleControllers():

  1. It reads the list of enabled modules from the PbxExtensionModules table (disabled = '0').

  2. For each enabled module {uniqid}, it looks for a Lib/RestAPI directory.

  3. It recursively scans that directory for files named *Controller.php.

  4. For each one it builds the class name Modules\{uniqid}\Lib\RestAPI\{...}\Controller, checks the class exists, and reads its #[ApiResource] attribute.

  5. If the attribute is present, it generates the routes and mounts them.

Your Conf.php stays empty for routing

Because discovery is automatic, the module's config class does not implement any routing method. RestAPIConfigInterface (in Core/src/Modules/Config/RestAPIConfigInterface.php) declares getPBXCoreRESTAdditionalRoutes() and moduleRestAPICallback(), but the base ConfigClass already provides empty stubs, so v3 modules override neither.

Extensions/ModuleBlackList/Lib/BlackListConf.php
<?php

declare(strict_types=1);

namespace Modules\ModuleBlackList\Lib;

use MikoPBX\Modules\Config\ConfigClass;

/**
 * No REST routing code here.
 *
 * v3 controllers under Lib/RestAPI/ are discovered automatically by
 * RouterProvider. We do NOT override getPBXCoreRESTAdditionalRoutes()
 * or moduleRestAPICallback() — those belong to the legacy v1/v2 patterns.
 */
class BlackListConf extends ConfigClass
{
    // Intentionally empty for REST routing.
}

The reference module does exactly this — see Extensions/EXAMPLES/REST-API/ModuleExampleRestAPIv3/Lib/ExampleRestAPIv3Conf.php, whose class body is empty with the comment "No methods needed!".

Per-resource file layout

Each REST resource is a self-contained folder under Lib/RestAPI/. For ModuleBlackList's numbers resource:

The reference module ships the same shape for its Tasks resource. See Extensions/EXAMPLES/REST-API/ModuleExampleRestAPIv3/Lib/RestAPI/Tasks/ — and a second, smaller Status resource alongside it (.../Lib/RestAPI/Status/) that demonstrates a read-only endpoint.

The Controller: attributes only

The controller is a thin declaration layer. Its methods have empty bodies — they exist only to carry attributes that the router and the OpenAPI generator read by reflection. Actual work happens in the Processor and Actions.

Compare every attribute here against the verified original in Extensions/EXAMPLES/REST-API/ModuleExampleRestAPIv3/Lib/RestAPI/Tasks/Controller.php.

The attributes, one by one

All of these live in the MikoPBX\PBXCoreREST\Attributes namespace (Core/src/PBXCoreREST/Attributes/).

Attribute
Where
Purpose

#[ApiResource]

class

Marks the controller as discoverable. Carries path, tags, description, and processor. The router reads path verbatim.

#[HttpMapping]

class

Maps each HTTP verb to a list of operation names, and declares which operations are collection-level vs resource-level. idPattern constrains the {id} segment.

#[ResourceSecurity]

class or method

RBAC + access channel. First argument is the resource permission name; requirements lists SecurityType cases.

#[ApiOperation]

method

OpenAPI summary/description/operationId. May also carry requestBody for uploads.

#[ApiDataSchema]

method

Binds a response shape to a DataStructure class (type: 'list' or 'detail', isArray).

#[ApiParameterRef]

method

References a parameter defined in a DataStructure (or CommonDataStructure for shared pagination params) and can mark it required.

#[ApiResponse]

method

Documents an HTTP status + description for OpenAPI.

URL scheme

By convention the path follows:

For ModuleBlackList:

Custom (non-CRUD) operations use a colon suffix. The reference module demonstrates both forms on its Tasks resource:

The :{action} routes are generated by RouterProvider::generateMappedRoutes() for every HTTP verb that has any operations in #[HttpMapping] (it mounts the handleCustomRequest / handleResourceCustomRequest route shapes alongside the plain CRUD routes); the customMethods list is consulted later, at dispatch time, to tell a custom action from a CRUD one. The idPattern [^/:]+ deliberately excludes the colon so {id} and {action} parse correctly. This is all handled for you — you only declare the operations.

Security types

#[ResourceSecurity] controls who may call the resource. The available channels are the cases of MikoPBX\PBXCoreREST\Attributes\SecurityType (Core/src/PBXCoreREST/Attributes/SecurityType.php):

Case
Wire value
Meaning

SecurityType::LOCALHOST

localhost

Requests from 127.0.0.1/::1 bypass token auth. Not exposed in OpenAPI.

SecurityType::BEARER_TOKEN

bearer_token

Requires Authorization: Bearer <token> — either a short-lived JWT or a long-lived API Key. Documented in OpenAPI.

SecurityType::PUBLIC

public

No authentication at all (OAuth callbacks, webhooks). Use sparingly.

The reference controller declares #[ResourceSecurity('module-example-rest-api-v3-tasks', requirements: [SecurityType::LOCALHOST, SecurityType::BEARER_TOKEN])], which is the right default for a module: callable from the box itself and from authenticated API clients, but never anonymously.

The first argument ('module-black-list-numbers') is the resource permission name used by the RBAC layer (Resource:Action). Keep it unique per resource and stable across releases so API-key path/permission restrictions keep working.

The Processor: action routing

The Processor is a tiny dispatcher. BaseRestController resolves the HTTP request to an action name (from #[HttpMapping]) and calls Processor::callBack($request); the Processor switches on $request['action'] and delegates to an Action class, returning a PBXApiResult.

This mirrors Extensions/EXAMPLES/REST-API/ModuleExampleRestAPIv3/Lib/RestAPI/Tasks/Processor.php exactly (which also routes download, uploadFile, and getDefault).

Actions are static (Action::main($data)) and are not Injectable across the worker-queue boundary. If an action needs the authenticated session or forwarded HTTP headers, the Processor must forward them explicitly from $request['sessionContext'] / $request['httpHeaders']. See the Forwarded HTTP Headers and Session Context sections of Core/src/PBXCoreREST/CLAUDE.md.

DataStructure: the single source of truth

DataStructure is where every field is defined once: its type, validation bounds, default, example, and OpenAPI description. The class extends MikoPBX\PBXCoreREST\Lib\Common\AbstractDataStructure and implements MikoPBX\PBXCoreREST\Lib\Common\OpenApiSchemaProvider. From the central getParameterDefinitions() method, the framework derives:

  • the OpenAPI 3.1 request/response schemas (via getListItemSchema() / getDetailSchema()),

  • the sanitization rules (getSanitizationRules(), inherited from AbstractDataStructure),

  • the parameters referenced by #[ApiParameterRef] in the controller.

getParameterDefinitions() returns three buckets: request (writable fields), response (read-only fields like id, timestamps), and related (nested object schemas).

The verified original is Extensions/EXAMPLES/REST-API/ModuleExampleRestAPIv3/Lib/RestAPI/Tasks/DataStructure.php. Note how it tags response-only fields with 'readOnly' => true and rewrites the description prefix from rest_schema_* to rest_param_* when emitting request parameters — those rest_* keys are translation keys resolved through your module's Messages/ files (see translations.md).

The 7-phase Action pattern

The Action class holds the business logic. MikoPBX standardises a 7-phase order for create/update/patch in SaveRecordAction, documented in Core/src/PBXCoreREST/CLAUDE.md and supported by the helper base Core/src/PBXCoreREST/Lib/Common/AbstractSaveRecordAction.php (sanitizeInputData(), validateRequiredFields(), applyDefaults(), validateRecordExistence(), executeInTransaction()):

  1. Sanitize — run DataStructure::getSanitizationRules() over the input. Never trust raw user data.

  2. Determine operation — new vs existing record (presence of id), and which HTTP verb is in play ($data['httpMethod']). This must come before any verb-dependent validation. For PUT/PATCH against a missing record, validateRecordExistence() returns a 404.

  3. Validate required — fail fast on missing mandatory fields, per verb. POST and PUT require the mandatory fields; PATCH must not, because a partial body legitimately omits everything it does not intend to change.

  4. Apply defaultsCREATE only. Applying defaults on update/patch would clobber the caller's existing values.

  5. Schema validate — validate the complete dataset after defaults.

  6. Business logic / save — wrap writes in executeInTransaction(). For PATCH, write each field only when it is actually present in the payload (isset() / array_key_exists()), so a partial body never blanks out untouched columns.

  7. Format response — return a consistent PBXApiResult (HTTP 201 on create, 200 on update).

The reference SaveRecordAction is a full, DB-backed example

The example module's Extensions/EXAMPLES/REST-API/ModuleExampleRestAPIv3/Lib/RestAPI/Tasks/Actions/SaveRecordAction.php implements all seven phases for real: it sanitizes and validates the input, generates a public uniqid with Tasks::generateUniqueID('TASK'), and persists the Tasks model inside executeInTransaction(). Its sibling actions (GetListAction, GetRecordAction, DeleteRecordAction) are DB-backed too, so the module is a consistent end-to-end CRUD you can install and exercise (create a task, list it, fetch it by id or uniqid, delete it). Your own module follows the same shape — load/create your model (e.g. BlackListNumbers mapping table m_BlackListNumbers), save it inside executeInTransaction(), and return the persisted record.

The action extends AbstractSaveRecordAction, so createApiResult(), sanitizeInputData(), executeInTransaction() and handleError() are inherited helpers; applyDefaults() and validateInputData() are called statically on the DataStructure (they live on AbstractDataStructure). Use these rather than rolling your own — the reference module's Tasks/Actions/SaveRecordAction.php is the implementation to copy.

Emitting the error through TranslationProvider::translate() above is a recommendation, not a description of what Core does today: Core's own ApiKeys/SaveRecordAction.php still hardcodes English strings such as 'Description is required'. There is no core precedent to look for — the module-side benefit is real (module Messages/*.php catalogs are merged into the global dictionary, and translate() falls back to the raw key if lookup fails), so use it in new module code.

For model and migration details (the BlackListNumbers model and m_BlackListNumbers table) see data-model.md.

Calling the endpoint

From an authenticated client (API Key shown):

Every response is the standard PBXApiResult envelope (Core/src/PBXCoreREST/Lib/PBXApiResult.php): success (bool), data (array), messages (error/warning lists), optional httpCode and pagination.

Legacy patterns (for reference only)

If you are maintaining an older module you may encounter two earlier styles. Both are dispatched through RestAPIConfigInterface callbacks and are fully described in ../api/rest-api.md:

  • v1 — moduleRestAPICallback() (interface constant RestAPIConfigInterface::MODULE_RESTAPI_CALLBACK). A single callback in the module's Conf.php dispatches by inspecting the request. Simple, but no OpenAPI and no structure.

  • v2 — namespaced Processor via getPBXCoreRESTAdditionalRoutes() (interface constant RestAPIConfigInterface::GET_PBXCORE_REST_ADDITIONAL_ROUTES). The module returns an explicit route table that RouterProvider merges in. Better organised than v1, but you still hand-register every route.

Lifecycle hooks around requests

Two interface hooks let a module observe every REST request, regardless of pattern: onBeforeExecuteRestAPIRoute(Micro $app) and onAfterExecuteRestAPIRoute(Micro $app) (constants RestAPIConfigInterface::ON_BEFORE_EXECUTE_RESTAPI_ROUTE and ON_AFTER_EXECUTE_RESTAPI_ROUTE). RouterProvider::attachModuleHooks() fires them via PBXConfModulesProvider::hookModulesMethod() on the Phalcon Micro beforeExecuteRoute / afterExecuteRoute events. These belong to the module config class and are covered with the other module hooks in hooks-reference.md.

See also

  • ../api/rest-api.md — the full REST API reference, including the v1/v2 legacy dispatch details.

  • ../api/README.md — API overview and authentication.

  • recipes.md — end-to-end module recipes.

  • hooks-reference.md — every module lifecycle hook, including the REST request hooks above.

  • data-model.md — defining the model and migration behind a resource.

  • Reference module: Extensions/EXAMPLES/REST-API/ModuleExampleRestAPIv3/ — read Lib/RestAPI/Tasks/ and Lib/RestAPI/Status/ as working v3 examples.

Last updated

Was this helpful?