Overview
AtroCore allows you to expose backend actions directly in the UI — on list views, detail pages, and relationship panels — without writing any frontend JavaScript. You define the action entirely in the entity's clientDefs metadata file and a corresponding PHP controller method. The frontend picks it up automatically.
This is useful for triggering business logic (approve, reject, export, sync, etc.) with a button or menu item directly tied to a record.
How It Works
Each action entry in clientDefs maps an action name to:
- a backend URL that receives a POST request when the action is triggered
- optional UI behavior (confirmation dialog, icon, display order, etc.)
The action label and confirmation/success messages come from the entity's i18n translation files.
Action visibility per record is controlled by the backend via the _meta.permissions map returned with each record. If _meta.permissions.myAction is not truthy, the action is hidden for that record.
Action Placement
listActions — row actions in list view
Adds items to the per-record action menu (the three-dot menu on each row in the list).
// app/Atro/Resources/metadata/clientDefs/MyEntity.json
{
"listActions": {
"approve": {
"url": "MyEntity/action/approve",
"confirm": true,
"refresh": true,
"iconClass": "ph ph-check",
"sortOrder": 10
}
}
}
detailActions — actions on the detail page
Adds items to the action menu (or as a standalone button) on the record detail page.
{
"detailActions": {
"publish": {
"url": "MyEntity/action/publish",
"confirm": false,
"refresh": true,
"sortOrder": 10
},
"archive": {
"url": "MyEntity/action/archive",
"confirm": true,
"singleButton": true,
"style": "primary",
"sortOrder": 20
}
}
}
relationshipPanels.[relationName].actions — row actions inside a relationship panel
Adds per-record actions inside a relationship panel on the detail page. Also supports disabling built-in actions (e.g. quickEdit, quickView) for a specific panel.
{
"relationshipPanels": {
"orderLines": {
"actions": {
"quickEdit": {
"disabled": true
},
"ship": {
"url": "OrderLine/action/ship",
"confirm": true,
"refresh": true,
"iconClass": "ph ph-truck",
"sortOrder": 10
}
}
}
}
}
listActions as massActions
Setting "massAction": true on a listAction automatically makes it available as a mass action in the list view. The same URL is called, but the payload contains either an idList array (for selected records) or a where clause (when "select all" is used).
{
"listActions": {
"reject": {
"url": "MyEntity/action/reject",
"massAction": true,
"refresh": true,
"iconClass": "ph ph-x",
"sortOrder": 20
}
}
}
When used as a mass action, the frontend automatically shows a confirmation before calling the endpoint, and displays a success message afterward. Both messages are sourced from the entity's i18n file (see Translations below).
Action Properties Reference
| Property | Type | Applies to | Description |
|---|---|---|---|
url |
string | all | Backend endpoint. Called via POST. Format: EntityName/action/methodName. |
confirm |
bool | all | Show a confirmation dialog before executing. Default: false. |
refresh |
bool | all | Refresh the record/collection after the action completes. Default: false. |
disabled |
bool | all | Hide the action from the UI entirely. Useful to suppress built-in actions. |
iconClass |
string | listActions, relationshipPanels | Phosphor icon class shown next to the label. Example: "ph ph-check". |
sortOrder |
int | all | Controls position in the menu. Lower = higher up. Built-in actions start at 110. |
massAction |
bool | listActions only | Also expose this action as a mass action. |
singleButton |
bool | detailActions only | Render as a standalone button rather than a dropdown item. |
style |
string | detailActions only | Button style when singleButton is true. Values: "primary", "default", "secondary". |
Backend Endpoint
The action calls POST to the specified url. Implement it as a method on the entity's controller using the signature actionXxx($params, $data, $request), where $data is a stdClass object containing the POST body.
Single record action
When triggered from a row or detail page, $data contains:
$data->id— the ID of the record
// app/Atro/Controllers/MyEntity.php
public function actionApprove($params, $data, $request)
{
if (!$request->isPost()) {
throw new \Atro\Core\Exceptions\BadRequest();
}
if (!property_exists($data, 'id') || empty($data->id)) {
throw new \Atro\Core\Exceptions\BadRequest('ID is required.');
}
if (!$this->getAcl()->check('MyEntity', 'edit')) {
throw new \Atro\Core\Exceptions\Forbidden();
}
$entity = $this->getRecordService()->getEntity((string)$data->id);
if (empty($entity)) {
throw new \Atro\Core\Exceptions\NotFound();
}
return $this->getRecordService()->approve($entity);
}
Action supporting both single record and mass action
When massAction: true is set on a listAction, the same endpoint is called for both single-record and bulk execution. The $data object will contain either:
$data->id— single record (triggered from row action)$data->idList— array of selected record IDs (mass action with specific selection)$data->where— filter clause (mass action with "select all" across pages)
Handle all three cases in one method:
public function actionReject($params, $data, $request)
{
if (!$request->isPost()) {
throw new \Atro\Core\Exceptions\BadRequest();
}
if (!$this->getAcl()->check('MyEntity', 'edit')) {
throw new \Atro\Core\Exceptions\Forbidden();
}
$actionParams = [];
if (property_exists($data, 'where')) {
$actionParams['where'] = json_decode(json_encode($data->where), true);
}
if (property_exists($data, 'idList')) {
$actionParams['ids'] = $data->idList;
}
if (empty($actionParams) && !empty($data->id)) {
$actionParams['ids'] = [$data->id];
}
if (empty($actionParams)) {
throw new \Atro\Core\Exceptions\BadRequest('Provide an id, idList, or where filter.');
}
return $this->getRecordService()->reject($actionParams);
}
Return ['count' => $n] from the service method so the success message can use the {count} placeholder.
Service method
The controller delegates to a service method that uses executeMassAction() — a built-in helper in Atro\Services\Record that decides whether to run synchronously or dispatch background jobs based on record count.
// app/Atro/Services/MyEntity.php
public function reject(array $params): array
{
$params['action'] = 'reject'; // must match the listAction key in clientDefs
$params['maxCountWithoutJob'] = $this->getConfig()->get('massUpdateMaxCountWithoutJob', 200);
$params['maxChunkSize'] = $this->getConfig()->get('massUpdateMaxChunkSize', 3000);
$params['minChunkSize'] = $this->getConfig()->get('massUpdateMinChunkSize', 400);
$params['singleActionMethod'] = 'rejectItem'; // service method called by background jobs
[$count, $errors, $sync] = $this->executeMassAction($params, function (string $id) {
$this->rejectItem($id); // used for synchronous execution
});
return ['count' => $count, 'sync' => $sync, 'errors' => $errors];
}
public function rejectItem(string $id): bool
{
$entity = $this->getEntity($id);
if (empty($entity)) {
throw new \Atro\Core\Exceptions\NotFound();
}
// ... your per-record business logic ...
$this->getEntityManager()->saveEntity($entity);
return true;
}
There are two execution paths — both must be covered:
Synchronous (total ≤ maxCountWithoutJob): executeMassAction runs the closure inline, one ID at a time.
Asynchronous (total > maxCountWithoutJob): executeMassAction creates a MassActionCreator job that splits the IDs into chunks and dispatches one UniversalMassAction background job per chunk. That job instantiates the entity service and calls $service->{singleActionMethod}($id) for each ID in its chunk.
This is why both $params['singleActionMethod'] and the closure are required — they serve different paths but both call the same per-record method. The closure handles sync; singleActionMethod is the string the background job uses to reach back into the service.
executeMassAction resolves $params['ids'] or $params['where'] automatically — the service does not need to handle that distinction.
Controlling Visibility per Record
Actions are only shown for a record if the backend sets the action name to true in _meta.permissions. The correct way to do this is to override putAclMeta() in the entity's service class and call $entity->setMetaPermission(). This method is called automatically by the framework for every record returned by the API.
// app/Atro/Services/MyEntity.php
public function putAclMeta(\Espo\ORM\Entity $entity): void
{
parent::putAclMeta($entity); // always call parent — sets edit/delete/stream permissions
$isPending = $entity->get('status') === 'pending';
$entity->setMetaPermission('approve', $isPending && $this->getAcl()->check($entity, 'edit'));
$entity->setMetaPermission('reject', $isPending && $this->getAcl()->check($entity, 'edit'));
}
For relationship panel actions (called via putAclMetaForLink), override that method instead:
public function putAclMetaForLink(\Espo\ORM\Entity $entityFrom, string $link, \Espo\ORM\Entity $entity): void
{
parent::putAclMetaForLink($entityFrom, $link, $entity);
$entity->setMetaPermission('ship', $this->getAcl()->check($entity, 'edit'));
}
The ClusterItem service is a real-world reference showing both patterns — it overrides putAclMeta() for list/detail actions (confirm, reject, unmerge) and putAclMetaForLink() for relationship panel actions (unreject, unlink).
Translations
Add labels and messages to the entity's i18n file for each locale.
// app/Atro/Resources/i18n/en_US/MyEntity.json
{
"actions": {
"approve": "Approve",
"reject": "Reject"
},
"massActions": {
"reject": "Reject Selected"
},
"massActionConfirmMessages": {
"reject": "Are you sure you want to reject the selected records?"
},
"massActionSuccessMessages": {
"reject": "{count} record(s) rejected successfully."
}
}
The actions key provides the label shown in the UI for both list/detail and mass actions. The massActionConfirmMessages key is shown in the confirmation dialog before a mass action runs. The massActionSuccessMessages key is shown after success, and supports the {count} placeholder if the backend returns a count value.
Real-World Example
The ClusterItem entity uses listActions with mass action support:
// app/Atro/Resources/metadata/clientDefs/ClusterItem.json
{
"listActions": {
"quickEdit": { "disabled": true },
"confirm": {
"url": "ClusterItem/action/confirm",
"refresh": true,
"iconClass": "ph ph-check",
"sortOrder": 10
},
"reject": {
"url": "ClusterItem/action/reject",
"massAction": true,
"refresh": true,
"iconClass": "ph ph-x",
"sortOrder": 20
},
"unmerge": {
"url": "ClusterItem/action/unmerge",
"massAction": true,
"refresh": true,
"iconClass": "ph ph-arrows-split",
"sortOrder": 30
}
}
}
This configuration:
- Disables the built-in
quickEditrow action - Adds three custom actions to each row: Confirm, Reject, and Unmerge
- Makes Reject and Unmerge available as mass actions in the list toolbar