Eventos, RBAC, entitlements, jobs, health y variables de entorno¶
Events¶
EventDispatcher provides a synchronous in-process event bus. Inject it anywhere via the container.
// Subscribe in ServiceProvider::boot()
$dispatcher->listen('usuario.created', function (array $payload): void {
// send welcome email, log audit trail, etc.
// $payload = ['id' => 42, 'email' => 'user@example.com']
});
// Dispatch from a Service
$this->dispatcher->dispatch('usuario.created', [
'id' => $id,
'email' => $data['email'],
]);
// Check if anyone is listening
$dispatcher->hasListeners('usuario.created'); // bool
Events are synchronous β the caller waits for all listeners to finish. For fire-and-forget behaviour wrap the listener body in a try/catch.
RBAC β permission-based access control¶
Assign permission keys to roles via the roles_permisos table (each row links a rol_id to a permiso_id). Use PermissionMiddleware on routes that require a specific permission:
use App\Http\Middleware\PermissionMiddleware;
$router->group([AuthMiddleware::class, TenantMiddleware::class], function ($router) {
$router->get('/facturas', [FacturaController::class, 'index']);
$router->post('/facturas', [FacturaController::class, 'create'], [PermissionMiddleware::class . ':facturas.write']);
$router->delete('/facturas/{id}', [FacturaController::class, 'delete'], [PermissionMiddleware::class . ':facturas.delete']);
});
The middleware throws ForbiddenException (403) if the authenticated user's role does not have the requested permission. AdminMiddleware still covers simple admin-only gates; use PermissionMiddleware for fine-grained per-operation control.
Entitlements β tenant feature gating¶
Entitlements answer "what does this tenant have?" β which modules/features, how many
seats, what quotas β independently of who the user is (RBAC) or what a credential may touch
(scopes). They live in tenant_entitlements and are read through
EntitlementResolverInterface (App\Support\Entitlements\DbEntitlementResolver).
Three types: flag (has / hasn't), quota (numeric limit per cycle), seat (seats).
limit_value null = unlimited. Features are namespaced (ia.rag, bots.outbound).
Gate a route with the parametrized middleware (after TenantMiddleware):
$router->post('/ia/ask', [IAController::class, 'ask'],
[AuthMiddleware::class, TenantMiddleware::class,
EntitlementMiddleware::class . ':ia.rag']);
Missing/disabled feature β 402 Payment Required (an actionable "upgrade your plan" signal, distinct from 403). In code:
$set = $resolver->for($tenantId);
$set->allows('ia.rag'); // bool (flag / gating)
$set->limit('api.calls'); // ?int (null = unlimited)
$set->remaining('api.calls', $used);// ?int, used passed in (no I/O in the value object)
The base only reads tenant_entitlements. It's populated by the optional billing
module (source = 'billing:*') or by hand (source = 'manual') β so product modules
(e.g. modux-ia) never depend on billing.
Usage metering & quotas¶
Record usage via UsageRecorderInterface (App\Support\Usage\DbUsageRecorder, table
usage_events). Recording is explicit β the consuming code decides the cost per call:
$usage->record($tenantId, 'api.calls', 1, $idempotencyKey); // idempotency_key dedupes retries
$usage->record($tenantId, 'ia.tokens', $tokensUsed);
QuotaMiddleware:<feature> enforces the limit (after TenantMiddleware). It counts
usage_events from the entitlement's period_start (or the calendar month start when there's
no billing) and compares against the limit:
$router->post('/ia/ask', [IAController::class, 'ask'],
[AuthMiddleware::class, TenantMiddleware::class, QuotaMiddleware::class . ':api.calls']);
- no entitlement / disabled β 402, unlimited (
limit_valuenull) β passes, - quota exhausted β 429 with
Retry-After(seconds until the cycle resets).
Quota cycles are anchored to the subscription's period_start/period_end (denormalized into
tenant_entitlements by billing). Moving the window resets the quota without deleting
usage_events (kept for audit/rating). As a safety net for missed renewals:
See docs/adr/0001-saas-identity-entitlements-billing.md for the full design.
Database transactions¶
App\Support\DB wraps operations in a PDO transaction with automatic rollback on any exception:
class FacturaService
{
public function __construct(
private FacturaRepository $facturas,
private LineaRepository $lineas,
private DB $db,
) {}
public function create(array $data): array
{
return $this->db->withTransaction(function () use ($data) {
$factura = $this->facturas->create($data);
foreach ($data['lineas'] as $linea) {
$this->lineas->create($factura['id'], $linea);
}
return $factura;
});
}
}
Inject DB in any service; the container auto-wires it with the registered PDO singleton.
Job queue¶
DB-backed async queue. Jobs are stored in a jobs table and processed by a worker process. Multiple workers can run in parallel β claiming is done with an atomic UUID UPDATE.
Defining a job¶
namespace App\Modules\Notificaciones\Jobs;
use App\Support\Container;
use App\Support\Job;
class SendWelcomeEmailJob extends Job
{
public string $email = '';
public string $name = '';
public string $queue = 'emails'; // override the default queue
public function handle(Container $container): void
{
$container->get(MailService::class)->sendWelcome($this->email, $this->name);
}
}
Public properties (except the framework-reserved queue, maxAttempts, delaySeconds) are serialized as JSON payload in the DB. Service dependencies are resolved from the Container when handle() runs.
Dispatching¶
// Inject JobDispatcher in any service constructor
public function __construct(private JobDispatcher $dispatcher) {}
$job = new SendWelcomeEmailJob();
$job->email = $data['email'];
$job->name = $data['nombre'];
$this->dispatcher->dispatch($job);
// Dispatch with a delay (seconds before the job becomes available)
$job->delaySeconds = 300;
$this->dispatcher->dispatch($job);
Running the worker¶
php modux queue:work # process 'default' queue, sleep 3s between polls
php modux queue:work --queue=emails # process a specific queue
php modux queue:work --queue=emails --sleep=5 # custom sleep interval
php modux queue:work --once # process one job then exit (useful for cron)
php modux queue:work --timeout=10 # release jobs stuck > 10 minutes
SIGINT / SIGTERM (Ctrl-C) triggers a graceful shutdown β the worker finishes the current job before stopping.
For production, manage the worker with supervisord or systemd so it restarts automatically if it crashes.
Failed jobs¶
On failure the job is retried up to maxAttempts times (default 3) with exponential back-off: 2^attempts seconds between retries. After the last attempt the job row is marked status = 'failed' with the full error message stored.
php modux queue:failed # list all failed jobs
php modux queue:retry 42 # reset job #42 to 'pending' so the worker picks it up again
php modux queue:flush # delete all failed jobs
jobs table schema¶
| Column | Type | Description |
|---|---|---|
id |
INT AUTO_INCREMENT | Primary key |
queue |
VARCHAR(100) | Queue name |
payload |
MEDIUMTEXT | JSON-serialized class + data |
attempts |
INT | How many times the worker tried |
max_attempts |
INT | Copied from Job at dispatch time |
status |
ENUM | pending, running, failed |
available_at |
DATETIME | When the job becomes eligible (supports delay) |
reserved_at |
DATETIME | When a worker claimed it |
reserved_by |
CHAR(36) | UUID of the worker that claimed it (atomic lock) |
failed_at |
DATETIME | When the job was finally marked failed |
error |
TEXT | Exception message + trace |
Health check¶
Checks dependencies with different severities: the DB is critical (if it fails β
status: down + HTTP 503, takes the instance out of the load balancer); the cache is
a degradation (rate limiting / anti-replay), reported but it does not change the status code.
{ "success": true, "data": { "status": "ok", "php": "8.2.0", "checks": { "db": "ok", "cache": "ok" } } }
{ "success": true, "data": { "status": "down", "php": "8.2.0", "checks": { "db": "unreachable", "cache": "degraded" } } }
Use this endpoint for load balancer health probes, uptime monitors, and deploy scripts.
Environment variables¶
Copy .env.example β .env. Required at boot (missing variables throw immediately):
| Variable | Description |
|---|---|
JWT_SECRET |
Min 32 chars. Generate: php -r "echo bin2hex(random_bytes(32));" |
DB_HOST |
Database host |
DB_NAME |
Database name |
DB_USER |
Database user |
DB_PASS |
Database password |
Optional:
| Variable | Default | Description |
|---|---|---|
APP_ENV |
local |
local / production |
APP_DEBUG |
false |
Expose exception details in JSON responses |
JWT_TTL |
86400 |
Access token lifetime in seconds |
JWT_REFRESH_TTL |
604800 |
Refresh token lifetime in seconds (7 days) |
JWT_ALGO |
HS256 |
JWT signing algorithm |
DB_PORT |
3306 |
Database port |
DB_PERSISTENT |
false |
Persistent PDO connections (better latency; tune max_connections before enabling) |
LOG_CHANNEL |
file |
file or stderr |
LOG_LEVEL |
debug |
Minimum log level to write |
CORS_ALLOWED_ORIGINS |
(none) | Comma-separated list of allowed origins |
MAIL_HOST, MAIL_PORT, MAIL_USER, MAIL_PASS, MAIL_FROM |
β | SMTP credentials for EmailHelper |