Actors and tracing

An action already knows what it does and what it does it to. Two more pieces turn that into an audit trail: who asked for it, and a row written when it succeeds.

Actors

An actor is whoever is responsible for the call: the signed in user, an admin, a bot account. Add the IsActor trait to that model and it gains act():

use EduLazaro\Laractions\Concerns\IsActor;

class User extends Model
{
    use IsActor;
}

$admin->act(RefundOrder::class)
    ->on($order)
    ->trace()
    ->run();

act() resolves the action through the container and sets the actor on it. on() sets the target. The two traits are independent and a model can use both: HasActions makes a model able to run actions about itself, IsActor makes it able to run actions about something else.

Read the chain out loud and it says the sentence you want in the log: this admin refunds this order.

Without the trait, the same thing works from the action side:

RefundOrder::create()
    ->actor($admin)
    ->on($order)
    ->trace()
    ->run();

An actor set this way is available to handle() as $this->actor, so an action can use it for authorization or to stamp a record, whether or not tracing is on.

Tracing

trace() turns on database tracing for that call. It is off by default and there is no global switch: an action is traced because the call site asked for it, which keeps the noisy ones out of the table.

php artisan vendor:publish --tag=laractions-migrations
php artisan migrate

The action_traces table holds a nullable morph for the actor, a nullable morph for the target, the action class name and a JSON column of parameters. A row is written through EduLazaro\Laractions\ActionTrace, an ordinary Eloquent model with actor() and target() morph relations, so reading the trail is just Eloquent:

use EduLazaro\Laractions\ActionTrace;

ActionTrace::with(['actor', 'target'])
    ->where('action', RefundOrder::class)
    ->latest()
    ->take(20)
    ->get();
$order->morphMany(ActionTrace::class, 'target')->latest()->get();

What ends up in the row, and when

Three details decide whether the trail says what you think it says.

  • Only successful runs are recorded. The row is written after handle() returns. If the action throws, the exception propagates and nothing is stored, so action_traces is a log of what happened, not of what was attempted. Failures belong in the log or in failed_jobs.
  • The parameters stored are the resolved ones. They are the arguments as handle() received them, keyed by parameter name, whatever shape the call used. Positional, named and array calls all produce the same row.
  • Those parameters are written to your database as JSON. An action that takes a password, a token, a full card number or a document body will put it there in clear. Trace the actions whose arguments you would be comfortable reading a year from now, and keep the sensitive ones untraced or take an id instead of the value.

Both morphs are nullable, so tracing works with an actor and no target, a target and no actor, or neither. Actor and target are stored with getMorphClass() and getKey(), which means both have to be Eloquent models: an action bound to a plain object with on() can run traced, but there is nothing to store for the target.

Tracing a queued action

trace() survives the queue, because the flag lives on the action and the action travels with the job. The row is written when the job runs, with the timestamp of the run rather than of the dispatch, and only if it succeeds:

$admin->act(RefundOrder::class)->on($order)->trace()->queue('billing')->dispatch();

If you need to know that something was queued as well as that it ran, log the dispatch with enableLogging() and let the trace record the outcome. See Logging.