- FK naming: keep detail in Gotchas, reference from Reglas importantes - CmsApi.hook JS: keep callback+Promise in CmsApi section, reference from Cómo Llamar Hooks - cms_uploads INSERT: keep full example in Uploads desde Hooks, reference from Gotchas Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
16 KiB
Hooks & Server-Side API
Hooks
Hooks son archivos PHP en hooks/ que ejecutan lógica server-side. También pueden estar dentro de un módulo en template/estandar/modulos/<module-id>/hook.php.
Hooks de módulo vs hooks de archivo
IMPORTANTE: Los hooks de módulo (modulos/*/hook.php) y los hooks de archivo (hooks/*.php) reciben parámetros de forma DIFERENTE.
Hooks de módulo (modulos/MODULE_ID/hook.php)
Los parámetros se inyectan como variables directamente:
<?php
// Si llamas hook con {param1: 100}, tendrás $param1 = 100
$resultado = $param1 * 2;
return [
"success" => true,
"message" => "Valor procesado: " . $resultado,
"value" => $resultado
];
?>
Hooks de archivo (hooks/*.php)
Se ejecutan dentro de function returnData() {} via eval(). NINGUNA variable del request se inyecta. Hay que leer manualmente:
<?php
// SIEMPRE hacer esto al inicio de cada hook de archivo:
$params = json_decode(file_get_contents('php://input'), true) ?: [];
$nombre = trim(@$params['nombre']);
$email = trim(@$params['email']);
return [
"success" => true,
"nombre" => $nombre
];
?>
Testing Hooks
El Docker debe estar corriendo. Hacer curl al endpoint del hook:
curl http://localhost:8080/hooks/example_hook/
No usar X-Hooks-Token en desarrollo local.
Gotchas de Hooks
tipo es variable reservada
El .htaccess de Acai añade tipo=barra a cada request. Si envías tipo como parámetro en un hook, se sobrescribe → 404 o comportamiento inesperado.
Usar nombres alternativos: account_type, user_tipo, record_tipo, etc.
Módulos fantasma interceptan hooks
Si save_module crea un directorio modulos/auth_signup/hook.php, este intercepta la URL /hooks/auth_signup/ porque addModulesHooksToLayout() se ejecuta ANTES que addFilesHooksToLayout().
Regla: Nunca crear un directorio en modulos/ solo para tener un hook. Para lógica server-side, usar siempre hooks generales en hooks/hooks.{nombre}.php. Si aparecen módulos fantasma por accidente, borrar el directorio.
Cómo Llamar Hooks
Desde HTML (recomendado para módulos):
<hook result="myVar" endpoint="/hooks/module_id/" :param1="value1" :param2="'string'"></hook>
<p>{{ myVar.message }}</p>
Desde Twig:
{% set resultado = 'hooks/mimodulo/' | hook({param1: 100, param2: 'texto'}) %}
<p>{{ resultado.message }}</p>
Desde JavaScript: (ver CmsApi JS para callback + Promise)
CmsApi.hook('/hooks/mimodulo/', {param1: 100, param2: 'texto'}).then(res => console.log(res));
Desde otro Hook PHP:
<?php
$result = hook("/hooks/mimodulo/", ["param1" => 100, "param2" => "texto"]);
$mensaje = $result["message"];
?>
Desde c-form: Los hooks se ejecutan automáticamente al enviar el formulario si están configurados.
CmsApi (PHP)
API server-side para operaciones de base de datos. Disponible en todos los hooks.
Read — CmsApi::get()
// Todos los registros
$products = CmsApi::get('productos');
// Con condición WHERE
$active = CmsApi::get('productos', ['active' => 1]);
// Con orden y límite
$latest = CmsApi::get('noticias', [], 'fecha DESC', 5);
// Con condición string
$activos = CmsApi::get('productos', 'activo=1');
// Condición compleja como array
$caros = CmsApi::get('productos', [
["column" => "precio", "operator" => ">", "value" => 100]
]);
// Múltiples condiciones (AND)
$resultados = CmsApi::get('productos', [
["column" => "activo", "operator" => "=", "value" => 1],
["column" => "stock", "operator" => ">", "value" => 0]
]);
// Con operadores
$expensive = CmsApi::get('productos', ['precio' => ['>=' => 100]]);
$search = CmsApi::get('productos', ['nombre' => ['LIKE' => '%keyword%']]);
$inList = CmsApi::get('productos', ['categoria_num' => ['IN' => [1, 2, 3]]]);
// Con opciones
$datos = CmsApi::get('productos', '', '', '', [
'translates' => true,
'uploads' => true,
'relations' => true,
'relationsDepth' => 2
]);
Insert — CmsApi::insert()
// Un registro
CmsApi::insert('contacto', [
["nombre" => "John", "email" => "john@example.com", "mensaje" => "Hello"]
]);
// Múltiples registros
CmsApi::insert('productos', [
["nombre" => "Producto A", "precio" => 100],
["nombre" => "Producto B", "precio" => 200]
]);
// Con retorno del último ID
CmsApi::insert('productos',
[["nombre" => "Nuevo", "precio" => 150]],
[],
['return_last_id' => true]
);
Update — CmsApi::update()
// Con condición string
CmsApi::update('productos', ["precio" => 150], "num=1");
// Con condición array
CmsApi::update('productos',
["activo" => 1],
[["column" => "num", "operator" => "=", "value" => 1]]
);
// Múltiples registros
CmsApi::update('productos', ["activo" => 0], "precio < 50");
Delete — CmsApi::delete()
CmsApi::delete('productos', "num=5");
CmsApi::delete('productos',
[["column" => "activo", "operator" => "=", "value" => 0]]
);
Reglas importantes
- Nombres de tabla sin prefijo
cms_ - Primary key siempre es
num, nuncaid - Foreign keys: consultar siempre el schema (ver Gotchas de BD)
- Upload fields: no se manejan via insert/update
- Operadores:
=,!=,>,>=,<,<=,LIKE,IN - Traducción en PHP: Usar
t_var()para todo texto visible al usuario:return ['error' => t_var('Debes iniciar sesión')]; return ['success' => t_var('Datos guardados correctamente')];
CmsApi (JavaScript — Client-Side)
// Llamar hook (callback)
CmsApi.hook('/hooks/module_id/', { param: 'value' }, function(response) {
// response es la salida del hook
});
// Llamar hook (Promise)
CmsApi.hook('/hooks/module_id/', { param: 'value' }).then(function(res) {
// res = lo que devuelve el PHP del hook
}).catch(function(err) {
// error de red
});
// Leer registros (si está expuesto via hooks)
CmsApi.get('tableName', { where: conditions }, function(records) {
// records array
});
CocoDB
Capa de abstracción de BD de bajo nivel usada internamente por CmsApi. Usar directamente desde hooks cuando necesites más control.
CocoDB::get($table, $where, $order, $limit, $options)
// Básico
$records = CocoDB::get('productos', ['activo' => 1], 'orden ASC', 10);
// Where con operadores avanzados
$records = CocoDB::get('productos', [
['column' => 'precio', 'value' => 100, 'operator' => '>='],
['column' => 'categoria_num', 'value' => [1, 2, 3], 'operator' => 'IN'],
]);
// Condiciones OR
$records = CocoDB::get('productos', [
['column' => 'nombre', 'value' => '%keyword%', 'operator' => 'LIKE'],
['column' => 'descripcion', 'value' => '%keyword%', 'operator' => 'LIKE', 'or' => true],
]);
// NOT
$records = CocoDB::get('productos', [
['column' => 'estado', 'value' => 'borrador', 'operator' => '=', 'not' => true],
]);
// IS NULL
$records = CocoDB::get('productos', [
['column' => 'fecha_baja', 'value' => '', 'operator' => 'IS NULL'],
]);
// Limit con offset
$records = CocoDB::get('productos', [], 'num DESC', ['limit' => 10, 'offset' => 20]);
Opciones de get()
| Option | Type | Default | Description |
|---|---|---|---|
uploads |
bool | true |
Incluir datos de upload fields |
relations |
bool/array | true |
Resolver foreign keys. Array para limitar: ['category'] |
relationsDepth |
int | 2 | Profundidad de relaciones anidadas |
translates |
string | current lang | Código de idioma |
groupBy |
string | null | GROUP BY clause |
aggregates |
array | [] |
Funciones de agregación |
onlyFields |
array | null | Seleccionar solo campos específicos |
debug |
bool | false | Mostrar SQL query |
redis |
bool | null | Forzar cache Redis |
redis_expire |
int | 60 | TTL de cache Redis (segundos) |
CocoDB::insertRecords($table, $records, $functions, $options)
// Un registro
$count = CocoDB::insertRecords('contacto', [
'nombre' => 'John',
'email' => 'john@example.com',
]);
// Usar mysql_insert_id() para obtener el nuevo num
// Múltiples
$count = CocoDB::insertRecords('productos', [
['nombre' => 'Product A', 'precio' => 10],
['nombre' => 'Product B', 'precio' => 20],
]);
Opciones de insert/update
| Option | Description |
|---|---|
forceNum |
Permite setear el campo num manualmente |
ignoreSchema |
Saltar validación de schema |
ignoreFields |
Array de campos a ignorar |
CocoDB::updateRecords($table, $records, $where, $functions, $options)
CocoDB::updateRecords('productos',
['precio' => 29.99, 'activo' => 1],
['num' => 42]
);
// Con operador en where
CocoDB::updateRecords('productos',
['activo' => 0],
[['column' => 'stock', 'value' => 0, 'operator' => '<=']]
);
CocoDB::deleteRecords($table, $where, $options)
CocoDB::deleteRecords('productos', ['num' => 42]);
CocoDB::deleteRecords('logs', [
['column' => 'fecha', 'value' => '2024-01-01', 'operator' => '<']
]);
Parámetro $functions
Permite aplicar funciones MySQL a valores durante insert/update:
CocoDB::insertRecords('logs', [
'mensaje' => 'Login exitoso',
'fecha' => '',
], [
'fecha' => 'NOW()',
]);
Where Clause — Formatos
Simple (key-value):
['campo' => 'valor'] // campo = 'valor'
Avanzado (array de condiciones):
[
'column' => 'field_name',
'value' => 'match_value',
'operator' => '=', // =, !=, <, >, <=, >=, LIKE, IN, IS NULL
'or' => false, // OR en vez de AND
'not' => false, // Negar la condición
'raw_key' => false, // Saltar check de existencia de columna
]
Creación y Actualización de Registros
Flujo correcto
- Consultar el esquema de la tabla (leer
cms/data/schema/{tabla}.ini.php) - Revisar los tipos de campo
- Rellenar según el tipo de dato
- Enviar con la estructura correcta
Tipos de campo y formato
| Tipo | Formato | Ejemplo |
|---|---|---|
| Text field | String | "Texto" |
| Text box | String multilínea | "Línea 1\nLínea 2" |
| Date/time | YYYY-MM-DD HH:mm:ss |
"2025-12-03 10:30:00" |
| Wysiwyg | String HTML | "<p class=\"font-bold\">Texto</p>" |
| List | String o número | "activo" o "1" (num si es foreign key) |
| Checkbox | Número 1/0 | 1 o 0 |
| Multivalores | String JSON | "[{\"producto\":\"1\"}]" |
| Upload | NO enviar — usar upload_record_image después de crear el registro |
Table Schemas
Los schemas están en cms/data/schema/ como archivos .ini.php. Definen:
- Nombres y tipos de campo
- Reglas de validación
- Relaciones (foreign keys)
- Configuración de display
Ejemplos Prácticos
Hook de Cálculo de Precio
<?php
// hook.php del módulo "calcular_precio"
$precioUnitario = 50;
if ($tipo === 'mayoreo' && $cantidad > 10) {
$precioUnitario *= 0.85; // 15% descuento
}
return [
"success" => true,
"precioUnitario" => round($precioUnitario, 2),
"total" => round($precioUnitario * $cantidad, 2),
"descuento" => $tipo === 'mayoreo' ? 15 : 0
];
?>
<hook result="precio" endpoint="/hooks/calcular_precio/" :cantidad="10" :tipo="'mayoreo'"></hook>
<p>Total: ${{ precio.total }}</p>
Hook con Operaciones de BD
<?php
// hook.php del módulo "procesar_compra"
$producto = CmsApi::get('productos', "num=$producto_id");
if (empty($producto)) {
return ["success" => false, "message" => "Producto no encontrado"];
}
$total = $producto[0]['precio'] * $cantidad;
// Crear venta
CmsApi::insert('ventas', [[
"usuario_num" => $usuario_id,
"producto_num" => $producto_id,
"cantidad" => $cantidad,
"total" => $total,
"fecha" => date('Y-m-d H:i:s')
]], [], ['return_last_id' => true]);
// Actualizar stock
$stock = CmsApi::get('stocks', "producto_num=$producto_id");
if (!empty($stock)) {
CmsApi::update('stocks',
["cantidad" => $stock[0]['cantidad'] - $cantidad],
"producto_num=$producto_id"
);
}
return ["success" => true, "total" => $total];
?>
Gotchas de Base de Datos
Nombres de columnas = nombres en el schema
Los campos foreign key NO añaden _num automáticamente. El nombre es exactamente el definido en el .ini.php. Siempre consultar DESCRIBE cms_<tabla> o el schema antes de escribir INSERT/UPDATE.
Campos name vs title según menuType
El campo principal depende del menuType de la tabla:
| menuType | Campo principal | Ejemplos |
|---|---|---|
category |
name |
apartados, categorias_productos, productos |
multi |
title |
users, localizaciones, correos, favoritos |
single |
no aplica | configuracion, configuracion_tienda |
Las tablas category generan name automáticamente como campo de navegación. Las multi generan title como campo scaffold por defecto.
Orden de tablas y campos
Al crear tablas o campos, usar menuOrder / order alto (99+) para que aparezcan al final del listado admin sin alterar el orden existente.
cms_uploads tiene esquema diferente
La tabla usa createdTime (no createdDate). No tiene createdByUserNum, updatedDate, ni updatedByUserNum. Ver Uploads desde Hooks para el ejemplo completo de INSERT.
Upload fields en PHP
Incluso con una sola imagen, los uploads son arrays:
$image = @$p['foto'][0]['urlPath'] ?: '';
CocoDB Cache en Memoria
CocoDB::localCache() se llama en cada request. Todos los CmsApi::get() se cachean en memoria por hash de SQL+options. Si insertas un registro y luego haces get con la misma query exacta, devuelve el resultado cacheado (sin el nuevo registro).
Solución: Usar opciones diferentes en la segunda query (ej: ignoreSchema: true en una, sin en la otra) para que el hash sea distinto.
Generación de Slug (enlace)
CmsApi::insert() desde hooks NO genera el slug automáticamente (el panel admin sí lo hace). Generar manualmente:
$slug = strtolower(trim($name));
$slug = preg_replace('/[áàâäã]/u', 'a', $slug);
$slug = preg_replace('/[éèêë]/u', 'e', $slug);
$slug = preg_replace('/[íìîï]/u', 'i', $slug);
$slug = preg_replace('/[óòôöõ]/u', 'o', $slug);
$slug = preg_replace('/[úùûü]/u', 'u', $slug);
$slug = preg_replace('/[ñ]/u', 'n', $slug);
$slug = preg_replace('/[^a-z0-9\-]/', '-', $slug);
$slug = preg_replace('/-+/', '-', $slug);
$slug = trim($slug, '-');
$enlace = '/' . $slug . '-' . substr(uniqid(), -6) . '/';
Uploads desde Hooks
CmsApi no gestiona uploads directamente. Para subir archivos desde un hook:
- Recibir base64 via
php://input - Decodificar y guardar en
$_SERVER['DOCUMENT_ROOT'] . '/cms/uploads/' - Insertar manualmente en
cms_uploads
$params = json_decode(file_get_contents('php://input'), true) ?: [];
$data = base64_decode($params['file_b64']);
$filename = $num . '_' . time() . '.' . $extension;
$uploadsDir = $_SERVER['DOCUMENT_ROOT'] . '/cms/uploads';
file_put_contents($uploadsDir . '/' . $filename, $data);
global $TABLE_PREFIX;
$sql = "INSERT INTO ".$TABLE_PREFIX."uploads (createdTime, tableName, recordNum, fieldName, urlPath, `order`)
VALUES (NOW(), 'productos', $num, 'foto', '/cms/uploads/$filename', 1)";
mysql_query($sql);
Email (CocoEmail)
Requiere template en BD
CocoEmail::send('IDENTIFICADOR', $params, $to) busca el template en cms_correos por identificador. Si no existe → excepción "Correo no encontrado". Si $to tiene emails inválidos/vacíos → "No hay destinatarios válidos".
Siempre envolver en try/catch:
try {
hook("/hooks/customEmailHeader/", ["remote" => @$_SESSION["REMOTE_URL"]]);
CocoEmail::send("ACTIVAR_CUENTA", $user, [$user["email"]]);
} catch (Exception $e) {
// Email falló pero la operación principal sigue
}
Variables en templates de email
Las variables se reemplazan con {nombre_campo}:
Hola {nombre}, tu código es {codigo}
El segundo parámetro de send() es el array de datos con las variables.