Command bus, idempotency and audit trail
Updated
Every change can go through one bus, so a dashboard, an Artisan command, a job and an AI agent all get the same rules: the actor must hold the command's ability, every request carries an idempotency key (a retry replays the stored result instead of running twice), and each outcome is written to an audit trail.
use ByteMinus\AiBlog\Bus\Actor;
use ByteMinus\AiBlog\Bus\CommandBus;
use ByteMinus\AiBlog\Bus\CommandContext;
use ByteMinus\AiBlog\Bus\CommandHandler;
use ByteMinus\AiBlog\Bus\CommandOutcome;
use ByteMinus\AiBlog\Bus\CommandRequest;
final class RenamePost extends CommandHandler
{
public function ability(): ?string { return 'posts.write'; }
public function rules(): array { return ['title' => ['required', 'string', 'max:120']]; }
public function handle(CommandContext $context): CommandOutcome
{
// ...call your service here...
return CommandOutcome::completed(['title' => $context->params['title']], 'post', '42');
}
}
// config/ai-blog.php
'commands' => ['handlers' => ['post.rename' => RenamePost::class]],
// AppServiceProvider: nobody manages the blog until you say who may
Gate::define('manageAiBlog', fn ($user) => $user->is_admin);
// anywhere
$receipt = app(CommandBus::class)->dispatch(
Actor::user($request->user()),
new CommandRequest('post.rename', idempotencyKey: 'rename-42-v1', params: ['title' => 'New title']),
);
$receipt->result(); // what the caller may see
$receipt->replayed; // true when this was a retry
Pass dryRun: true to preview a command without changing anything. Publish and run the migrations to create the
commands and audit_events tables.