atannex / action
Headless action engine for Laravel: register named operations once and run them from code, HTTP, JSON APIs, queues, the scheduler or other packages with mandatory authorization, validated input, transactions, bulk processing, idempotency and structured results.
Requires
- php: ^8.3
- atannex/atannex-provider: ^1.1
- atannex/foundation: ^1.1
- illuminate/auth: ^12.0|^13.0
- illuminate/bus: ^12.0|^13.0
- illuminate/cache: ^12.0|^13.0
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/events: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/queue: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/validation: ^12.0|^13.0
Requires (Dev)
- atannex/bridge: ^1.0
- larastan/larastan: ^3.0
- laravel/pint: ^1.20
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.5|^12.0
Suggests
- atannex/bridge: To call actions from TypeScript and mobile clients through Bridge operations (^1.0).
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-03 01:04:12 UTC
README
A headless action engine for Laravel. Register an operation once, such as "publish article" or "approve application", and run it the same way from code, an HTML form, a JSON API, a queue, the scheduler or another package. Every run goes through one engine that does four things:
- Authorizes on the server. Actions deny by default.
- Validates input. Only validated keys reach your handler.
- Optionally runs in a transaction.
- Returns one structured result.
Bulk runs, queued runs, idempotency keys and server-verified confirmations are built in. It renders no buttons or dialogs; your frontend does that.
It is built on atannex/foundation, which provides the action registry, middleware extension point, execution context and diagnostics. Its Laravel wiring (config, routes, migrations, commands, bindings, config checks) comes from atannex/atannex-provider. Both are installed with it. Requires PHP 8.3+ and Laravel 12 or 13.
composer require atannex/action
php artisan atannex:action:install # optional: publish config and the status-tracking migration
An action
use Atannex\Action\Actions\Action; use Atannex\Action\Context\ActionContext; use Atannex\Action\Definitions\{ActionScope, Exposure}; use Atannex\Action\Input\ActionInput; final class PublishArticle extends Action { public function scope(): ActionScope { return ActionScope::Record; } public function ability(): string { return 'publish'; } // ArticlePolicy::publish($user, $article) public function exposure(): Exposure { return Exposure::authenticated(web: true, api: true); } public function rules(): array { return ['notify_subscribers' => ['boolean']]; } public function resolveRecord(string|int $key, ActionContext $context): ?object { return Article::query()->whereKey($key)->first(); // the only path from a client key to a record } public function handle(ActionInput $input, ActionContext $context): mixed { $article = $context->record(); $article->update(['status' => 'published']); return ['id' => $article->id, 'status' => 'published']; } }
Each run gets a fresh instance built through the container. Everything about the run (actor, tenant, record, locale, correlation id) is in ActionContext. Expected failures are results, not exceptions: $this->fail('Enrolment is closed.', 'enrolment.closed'), $this->conflict('Already decided.', 'application.already_decided').
Every method below is optional:
| Method | Default | Purpose |
|---|---|---|
authorize(ActionContext) / ability() |
deny | Your check, or a Gate ability / Policy method |
scope() |
Standalone |
Record actions need a record |
mode() |
Sync |
ExecutionMode::Queued to run on the queue |
exposure() |
Exposure::none() |
authenticated(web:, api:), public(...) (guests allowed; authorize() still runs) |
rules(), messages(), defaults(), casts(), withValidator() |
none | Laravel validation |
transaction() |
none | Transaction::on('mysql')->retryOnConcurrencyErrors(3) |
idempotency() |
none | Idempotency::optional(ttl: 3600) / required() |
confirmation() |
none | Confirmation::required('Archive?') (metadata) / challenge('Delete?') (server-verified token) |
bulk() |
none | Bulk::allowed(chunk: 50, max: 500) |
queue() |
config | new QueueOptions(queue: 'reports', tries: 3, timeout: 120) |
present(mixed, ActionContext) |
plain values only | What transports may see; objects become null |
version(), acceptsQueuedVersion(?string) |
null / same version only |
Contract version stamped on queued jobs (see below) |
allowsSystem(), sources(), rateLimit(), middleware(), label(), description() |
safe defaults |
For tenant-owned Eloquent models, use ResolvesTenantRecords; and return the model from recordModel(). The tenant (tenant_id, or tenantColumn()) becomes part of the query, so a key from another tenant never loads a record and the result is a 404-style not_found. Without a trusted tenant, only untenanted records are found. Narrow further (ownership, visibility) in scopeRecords(). The engine's after-load tenant check still runs as a second layer.
Register and run
use Atannex\Action\Facades\Actions; // In a provider's boot() (registration stores the name and class only): Actions::register('articles.publish', PublishArticle::class); $result = Actions::execute('articles.publish', input: ['notify_subscribers' => true], record: $article); Actions::action('articles.publish')->recordKey(42)->as($editor)->idempotencyKey($key)->execute(); Actions::bulk('articles.archive', [12, 15, 19]); // per-record authorization, own transactions Actions::allows('articles.publish', $article, $user); // authorization only: show the button? Actions::action('reports.generate')->asSystem()->execute(); // only if allowsSystem()
Classes in app/Atannex that extend Action and return a name from static definition() are registered automatically.
Every run follows the same pipeline:
- Build a trusted context.
- Authorize: source, system, tenant match, then
authorize(). - Validate the input.
- Verify the confirmation.
- Check idempotency.
- Run middleware, then the transaction, then
handle(). - Return the result.
Queued actions do steps 1–5 in the caller and are refused there if invalid. Only identifiers go into an encrypted job, dispatched after the surrounding transaction commits, and the worker authorizes again.
Queued actions: retries, cancellation, progress
Delivery. Queues deliver at least once, so a job can run again after a crash or a duplicate delivery. With status tracking on (status.driver=database), each delivery must claim the execution first:
- Claiming is one conditional
UPDATEon the status row. - Its attempt number acts as a fence: an execution that already finished, or that a later attempt took over, is never run again. The duplicate gets the stored outcome.
- A superseded attempt cannot overwrite a newer one's result, and a finished state is never overwritten.
- Without status tracking there is nothing durable to fence on, so every delivery runs.
Retries and timeouts come from queue() (tries, backoff, timeout) or action.queue.*. Unexpected exceptions are retried by Laravel. Expected outcomes (fail(), validation, denial) are final and never retried.
Deployments. Jobs carry the action's version(). A job queued for another version ends with action.version_mismatch instead of running under new semantics. Override acceptsQueuedVersion() to accept and adapt older payloads. Jobs for an action that no longer exists end with action.not_registered, and those from a newer release with action.incompatible_payload. Jobs queued by 1.0 (unversioned) still run.
Cancellation (Actions::cancel($id) or POST …/executions/{id}/cancel) is authorized like status viewing and is idempotent.
- Before the job runs: it never starts.
- Bulk runs: they stop before the next chunk.
- A running handler: it stops at its next check:
public function handle(ActionInput $input, ActionContext $context): mixed { foreach ($batches as $i => $batch) { $context->throwIfCancellationRequested(); // result "cancelled"; the action's transaction rolls back $this->import($batch); $context->progress($i + 1, count($batches), 'Importing'); } }
PHP code that never checks runs to completion, and emails or HTTP calls already made are not undone.
Progress appears in Actions::status($id) and the status endpoint as progress.current, .of, .percent and .message. Both checks are no-ops for synchronous runs.
Progress and cancel signals travel through a cache store (action.status.signals_store), not the status table. Inside a transaction, a table update would stay hidden until commit, roll back with it, and lock the row a cancel request needs. Use a store shared by your web and worker processes, and not the database connection your actions run transactions on. atannex:action:diagnose checks this.
Results
Every entry point returns ActionResult: successful(), failed(), queued(), status(), data() (server-side), presented(), message(), errors(), code, details, correlationId, executionId, items and summary() for bulk runs, and replayed.
| Status | HTTP | When |
|---|---|---|
succeeded |
200 | Handler returned |
queued |
202 | Handed to the queue (execution_id to poll) |
partially_succeeded |
207 | Bulk run with mixed outcomes |
failed |
400 | fail(), a failed Foundation Result, a bulk run with no successes |
unauthorized |
403 | Any authorization step denied |
not_found |
404 | resolveRecord() returned null |
conflict / cancelled |
409 | conflict(), idempotency conflicts / cancelled before start |
validation_failed |
422 | Invalid or oversized input, missing required idempotency key |
confirmation_required |
428 | Challenge token missing or invalid |
Unexpected exceptions are never turned into results: they are rethrown (and counted as action.error items in bulk runs).
Bulk runs:
- Each record runs at most once per run:
12,"12"and the model with key 12 are one record. - Records are authorized one by one, each in its own transaction, in the given order.
- One failure never stops the rest.
Events (Atannex\Action\Events, toggled by action.events) carry identifiers only (action, actor, tenant, source, correlation and execution ids), never input:
- On every run:
ActionStarting,ActionAuthorized,ActionValidated,ActionQueued. - On the outcome:
ActionSucceeded(after commit) orActionFailed. Both carrydurationMswhen the handler ran; it isnullfor refusals before it. - Queued and bulk runs:
ActionCancelled,ActionProgressUpdated,BulkActionStarted,BulkActionCompleted.
These events are the hook for audit logs and metrics. Use low-cardinality labels such as the action name and status; avoid record or user ids.
HTTP and JSON
No controllers are needed. With action.http.enabled (the default), routes are registered under _atannex/actions with the web middleware (session and CSRF) plus a per-user rate limit:
POST {prefix}/{action} execute POST {prefix}/{action}/confirm challenge token
POST {prefix}/{action}/bulk bulk (_records) GET {prefix}/executions/{id} status
POST {prefix}/executions/{id}/cancel
Request fields:
_record: the record key (resolved throughresolveRecord())._redirect: a same-site path to return to._bag: the error bag._confirmationor theX-Action-Confirmationheader: the challenge token.- The
Idempotency-Keyheader. - Everything else is the action's input.
The actor is always $request->user(), and the tenant always comes from Foundation's resolver. Fields like actor, tenant_id or an X-Tenant header are just input. Unknown, unexposed and wrong-channel actions all return the same 404.
HTML requests redirect back, with validation errors and the outcome flashed under atannex.action. JSON requests and the API channel get one envelope with the status code from the table above.
The API channel (ATANNEX_ACTIONS_API=true, api/_atannex/actions) authenticates with auth:sanctum by default. On a fresh Laravel app, run php artisan install:api first, or change action.http.api.middleware; atannex:action:diagnose reports an undefined guard. A public() action on the API still needs a signed-in caller there; guests only reach it on the web channel.
{
"action": "articles.publish",
"status": "succeeded",
"message": null,
"code": "action.succeeded",
"data": {"id": 42, "status": "published"},
"errors": {},
"details": {},
"correlation_id": "4f0c…",
"execution_id": null,
"replayed": false
}
Bulk responses add items. For Livewire, use InteractsWithActions gives runAction() and runBulkAction(), which map validation errors onto the component. For TypeScript and mobile clients through atannex/bridge, extend Adapters\Bridge\ActionOperation.
Commands
php artisan atannex:actions [articles] [-v] [--json] # list (optionally by name prefix); -v adds classes php artisan atannex:action:inspect articles.publish [--json] php artisan atannex:action:diagnose [-v] [--json] [--fail-on-warnings] # exit 0 ok, 1 errors, 2 warnings php artisan atannex:action:prune [--days=7] # schedule daily when status tracking is on
articles.archive ............................ record · sync · authenticated · bulk
articles.archive-later ...................... record · queued · authenticated · bulk
articles.publish ............................ record · sync · authenticated
3 actions. Details: php artisan atannex:action:inspect <name>; -v shows classes.
diagnose has the same layout and exit codes as Foundation's atannex:diagnose. It flags:
- Errors: actions that can't be built, exposed, queued or bulk record actions without
resolveRecord(), an API channel without authentication middleware or with an auth guard that isn't defined, an unreachable or missing status table, and an idempotency store without atomic locks. - Warnings: actions that deny everyone (no
authorize()orability()), public actions that guests can actually reach, queued actions on thesyncconnection, and a signals store that can't reach workers (array, or the database cache on the transaction connection).
The --json output of every command is stable, for CI.
Configuration
php artisan vendor:publish --tag=atannex-action-config. atannex:provider:diagnose and php artisan optimize validate the values.
| Key | Default | Env |
|---|---|---|
http.enabled, http.prefix, http.middleware, http.rate_limit |
true, _atannex/actions, ['web'], 60/min |
ATANNEX_ACTIONS_HTTP |
http.api.enabled, .prefix, .middleware |
false, api/_atannex/actions, ['api', 'auth:sanctum'] |
ATANNEX_ACTIONS_API |
input.max_bytes, .max_fields, .max_depth |
65536, 500, 10 |
|
queue.connection, .queue, .tries, .backoff, .timeout |
app default, default, 1, 0, none |
ATANNEX_ACTIONS_QUEUE_CONNECTION, ATANNEX_ACTIONS_QUEUE |
status.driver (database or off), .table, .items_table, .retention_days |
off, atannex_action_executions, …, 7 |
ATANNEX_ACTIONS_STATUS_DRIVER |
status.signals_store |
default cache store | ATANNEX_ACTIONS_SIGNALS_STORE |
idempotency.store, .ttl, .lock_seconds |
default store, 86400, 60 |
ATANNEX_ACTIONS_IDEMPOTENCY_STORE |
confirmation.store, .ttl |
default store, 300 |
|
bulk.chunk, .max_records |
100, 1000 |
|
events |
true |
Status tracking (Actions::status($id), Actions::cancel($id)) needs status.driver=database and the published migration. Idempotency needs a cache store with atomic locks (redis, database, file or array).
Foundation integration
ActionPackage is a Foundation package:
- Definition type:
action.Actions::register()isFoundation::register('action', …); there is no second registry. - Extension point:
actions.middleware, for global middleware. - Capabilities:
actions,actions.execution,actions.bulk, … These are also declared on the provider for provider-level checks. - Context:
ActionContextwraps Foundation'sExecutionContext. - Results: handlers may return Foundation
Results.
Sibling packages (form, table, infolist) list atannex/action as optional in their descriptor and check $foundation->hasCapability('actions.execution') in bootFoundation(). They never composer require it.
Security
Enforced, with tests:
- Authorization: mandatory authorization on every entry point, with no skip flag.
- Registered names only: class names in URLs return 404.
- Exposure: nothing is exposed unless
exposure()says so. - Input: only validated input reaches handlers, with size limits.
- Tenant isolation: tenant matching, and tenant-scoped idempotency, confirmation and status.
- Confirmations: HMAC-signed, bound, single-use tokens.
- Queues: encrypted jobs holding only identifiers.
- Output: transports only see
present()output. - Redirects and flashing: safe redirects, and passwords are never flashed back.
- Isolation: request-scoped services, so nothing leaks under Octane.
Yours:
- correct policies, and
resolveRecord()scoped by tenant and ownership; - a trusted
TenantResolver; - deliberate
present()fields; - keeping
web(CSRF) and API authentication middleware; - treating
Exposure::public()actions as internet-facing; - keeping
APP_KEYsecret; - making external side effects idempotent on their own side.
Report vulnerabilities privately; see SECURITY.md.
Troubleshooting
| Symptom | Fix |
|---|---|
Always unauthorized |
Override authorize() or set ability() (actions deny by default). The code action.tenant_mismatch means no or wrong tenant: use actingAs() / forTenant(). |
| HTTP 404 for an existing action | Check exposure() for that channel (API needs api: true and ATANNEX_ACTIONS_API=true), _record with resolveRecord(), and cached routes. |
| HTTP 419 | Missing CSRF token; token clients use the API channel. |
| Queued actions run immediately | The queue connection is sync. |
action.idempotency_unavailable |
Use a cache store with atomic locks. |
confirmation_required with a token |
Tokens are single-use, expiring, and bound to action, actor, tenant and record. |
| Cancel requested but the action finished anyway | The handler never calls throwIfCancellationRequested(), or the signals store isn't shared with the worker (atannex:action:diagnose). |
action.version_mismatch after a deploy |
The job was queued for the action's previous version(). Accept it with acceptsQueuedVersion(), or re-run it. |
Testing and contributing
Test actions through the real pipeline with Orchestra Testbench (ProviderServiceProvider, FoundationServiceProvider, ActionServiceProvider). Use ->synchronously() for queued actions, and assert on ActionResult and the HTTP envelope.
Contributing: composer install, then composer quality (validate, Pint, PHPStan max, PHPUnit) and composer audit. Every behaviour change needs a test against real Laravel behaviour, and user-facing changes go in CHANGELOG.md.
License
MIT. See LICENSE.md.