Search by

atannex / action

Atanga1920

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.

Package info

github.com/atannex/action

pkg:composer/atannex/action

Statistics

Installs: 8

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v2.0.0 2026-11-03 00:52 UTC

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:

  1. Build a trusted context.
  2. Authorize: source, system, tenant match, then authorize().
  3. Validate the input.
  4. Verify the confirmation.
  5. Check idempotency.
  6. Run middleware, then the transaction, then handle().
  7. 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 UPDATE on 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) or ActionFailed. Both carry durationMs when the handler ran; it is null for 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 through resolveRecord()).
  • _redirect: a same-site path to return to.
  • _bag: the error bag.
  • _confirmation or the X-Action-Confirmation header: the challenge token.
  • The Idempotency-Key header.
  • 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() or ability()), public actions that guests can actually reach, queued actions on the sync connection, 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() is Foundation::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: ActionContext wraps Foundation's ExecutionContext.
  • 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_KEY secret;
  • 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.