I Built a Preview-Before-Execute System for AI Agents in Laravel

Last month, an AI agent I'd integrated into a client's Laravel application decided the most efficient way to "clean up stale records" was to queue a DELETE statement against their orders table. No WHERE clause. No soft delete. Just a cheerful little tool call that would have wiped 2.3 million rows.

The agent wasn't malfunctioning. It was doing exactly what it was told to do. The problem was architectural: there was no gate between deciding to do something and actually doing it.

That incident pushed me to build what I now call a Preview-Before-Execute (PBE) system — a middleware layer where every AI agent action gets serialized into a human-readable plan, queued for approval, and only executed after explicit confirmation. Think of it as Terraform's plan → apply cycle, but for AI tool calls inside a Laravel application.

I've been running this in production for about six weeks now. Here's how it works, what broke along the way, and what I'd do differently if I were starting today.

📋 Table of Contents


The Core Problem: Agents Are Confident and Destructive

Here's the thing about AI agents that tool-call into your application: they don't hesitate. They don't second-guess. They receive a prompt, reason about it, and emit a function call with the same confidence whether they're reading a config value or dropping a table.

Most Laravel AI integrations I've seen wire tool calls directly to service methods:

// The naive approach — don't do this
$tools = [
    'delete_records' => fn(array $params) => DB::table($params['table'])->delete(),
    'send_email' => fn(array $params) => Mail::to($params['to'])->send(new GenericMail($params)),
];

This works beautifully in a demo. It works terribly in production. The moment your agent has write access to anything — database mutations, external API calls, file operations, payment processing — you need a circuit breaker between intention and execution.

The PBE system introduces exactly that: a mandatory preview step where the action is described but not performed, and a human (or policy engine) decides whether to proceed.


1. The Action Contract: Making Intent Explicit

Scenario: Your agent calls a tool named update_pricing. But what does that actually mean? Which products? What's the old price? What's the new price? Does it trigger downstream notifications? Without a structured contract, "preview" is impossible because you can't preview something you can't fully describe.

Why it matters: You can't build a preview layer on top of vague tool definitions. The action contract is the foundation everything else depends on.

Solution: Every agent action must implement a strict interface that declares its inputs, its expected effects, and its reversibility.

<?php

namespace App\Agent\Actions;

use App\Agent\Actions\Concerns\GeneratesPreview;
use App\Agent\Actions\Contracts\Previewable;

interface AgentAction extends Previewable
{
    /** Unique identifier for this action type */
    public function name(): string;

    /** Human-readable description of what this action will do */
    public function describe(array $parameters): string;

    /** The actual execution logic — only called after approval */
    public function execute(array $parameters): ActionResult;

    /** Risk classification: low, medium, high, critical */
    public function riskLevel(array $parameters): RiskLevel;

    /** Whether this action can be rolled back */
    public function isReversible(): bool;
}
<?php

namespace App\Agent\Actions;

enum RiskLevel: string
{
    case Low = 'low';         // Read operations, config lookups
    case Medium = 'medium';   // Creating records, sending notifications
    case High = 'high';       // Updating financial data, modifying users
    case Critical = 'critical'; // Deleting records, external payments, schema changes
}

The riskLevel method accepts parameters because the same action can carry different risk depending on context. Updating one product's price is medium risk. Updating every product's price in a single call is critical.

🔍 Why this matters: The risk level drives your approval policy. Low-risk actions can auto-approve. Critical actions require explicit human confirmation. Without this classification, you're either approving everything (defeating the purpose) or blocking everything (defeating the agent).


2. The Preview Generator: Dry-Running Without Side Effects

Scenario: The agent wants to send a refund email to 847 customers. You need to show the reviewer exactly what will happen — who gets the email, what it says, what database rows change — without actually sending a single email.

Why it matters: A preview that says "will send emails" is useless. A preview that says "will send email #4,211 to sarah@example.com with subject 'Your refund of $42.00 has been processed'" is actionable.

Solution: Each action implements a preview() method that returns a structured description of effects without triggering side effects.

<?php

namespace App\Agent\Actions;

class ProcessRefunds implements AgentAction
{
    use GeneratesPreview;

    public function name(): string
    {
        return 'process_refunds';
    }

    public function describe(array $parameters): string
    {
        $count = count($parameters['order_ids']);
        return "Process refunds for {$count} order(s) totaling \${$parameters['total_amount']}";
    }

    public function preview(array $parameters): ActionPreview
    {
        $orders = Order::whereIn('id', $parameters['order_ids'])
            ->with('customer')
            ->get();

        $effects = $orders->map(fn(Order $order) => [
            'type' => 'email',
            'recipient' => $order->customer->email,
            'subject' => "Refund confirmation #{$order->reference}",
            'amount' => $order->total,
        ])->merge(
            $orders->map(fn(Order $order) => [
                'type' => 'database_update',
                'table' => 'orders',
                'row_id' => $order->id,
                'changes' => ['status' => 'refunded', 'refunded_at' => now()->toIso8601String()],
            ])
        );

        return new ActionPreview(
            action: $this->name(),
            description: $this->describe($parameters),
            affectedRecords: $orders->count(),
            effects: $effects->toArray(),
            riskLevel: $this->riskLevel($parameters),
            reversible: true,
            estimatedDuration: '2-4 seconds',
        );
    }

    public function riskLevel(array $parameters): RiskLevel
    {
        // Single small refund = medium. Bulk refunds over $1000 = critical.
        if (count($parameters['order_ids']) > 50 || $parameters['total_amount'] > 1000) {
            return RiskLevel::Critical;
        }

        return RiskLevel::Medium;
    }

    public function execute(array $parameters): ActionResult
    {
        // Actual refund logic here — only called post-approval
    }

    public function isReversible(): bool
    {
        return true;
    }
}

The ActionPreview is a simple DTO:

<?php

namespace App\Agent\Actions;

readonly class ActionPreview
{
    public function __construct(
        public string $action,
        public string $description,
        public int $affectedRecords,
        public array $effects,
        public RiskLevel $riskLevel,
        public bool $reversible,
        public string $estimatedDuration,
    ) {}

    public function toReviewPayload(): array
    {
        return [
            'action' => $this->action,
            'description' => $this->description,
            'affected_records' => $this->affectedRecords,
            'effects' => $this->effects,
            'risk' => $this->riskLevel->value,
            'reversible' => $this->reversible,
            'eta' => $this->estimatedDuration,
        ];
    }
}

⚠️ Gotcha: The preview method must be pure in the sense that it queries data but never mutates it. I've seen implementations where the "preview" accidentally triggers model events or dispatches jobs. Wrap your preview logic in a read-only database connection if you're paranoid: DB::connection('readonly')->table(...). In Laravel 12, you can also use DB::withoutEvents() if your models have observers that fire on retrieval.


3. The Approval Queue: Where Actions Wait

Scenario: The agent generates a preview at 2:14 PM. The reviewer doesn't check their dashboard until 4:30 PM. In between, the underlying data changed — one of the orders was already refunded manually. The preview is now stale.

Why it matters: A preview is a snapshot. If you execute it hours later against changed data, you get inconsistent results or errors. You need expiration, staleness detection, and a queue that handles the lifecycle.

Solution: Pending actions live in a database table with a TTL, a state machine, and a staleness check.

<?php
// database/migrations/2026_01_15_000000_create_pending_agent_actions_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('pending_agent_actions', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->string('action_name');
            $table->json('parameters');
            $table->json('preview_payload');
            $table->string('risk_level');
            $table->string('status')->default('pending'); // pending, approved, rejected, expired, executed, failed
            $table->foreignUuid('requested_by_agent_session')->nullable();
            $table->foreignUuid('approved_by_user')->nullable();
            $table->timestamp('approved_at')->nullable();
            $table->timestamp('executed_at')->nullable();
            $table->timestamp('expires_at');
            $table->json('execution_result')->nullable();
            $table->text('rejection_reason')->nullable();
            $table->timestamps();

            $table->index(['status', 'expires_at']);
            $table->index('action_name');
        });
    }
};

The status transitions are enforced in the model:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;

class PendingAgentAction extends Model
{
    use HasUuids;

    protected $casts = [
        'parameters' => 'array',
        'preview_payload' => 'array',
        'execution_result' => 'array',
        'expires_at' => 'datetime',
        'approved_at' => 'datetime',
        'executed_at' => 'datetime',
    ];

    public function isExpired(): bool
    {
        return $this->expires_at->isPast();
    }

    public function approve(User $user): void
    {
        if ($this->status !== 'pending') {
            throw new \DomainException("Cannot approve action in '{$this->status}' state.");
        }

        if ($this->isExpired()) {
            $this->update(['status' => 'expired']);
            throw new \DomainException('Action preview has expired. Agent must re-request.');
        }

        $this->update([
            'status' => 'approved',
            'approved_by_user' => $user->id,
            'approved_at' => now(),
        ]);
    }

    public function reject(User $user, string $reason): void
    {
        $this->update([
            'status' => 'rejected',
            'approved_by_user' => $user->id,
            'approved_at' => now(),
            'rejection_reason' => $reason,
        ]);
    }
}

The TTL defaults are set per risk level:

// In your action dispatch service
$ttl = match ($action->riskLevel($parameters)) {
    RiskLevel::Low => now()->addMinutes(30),
    RiskLevel::Medium => now()->addMinutes(15),
    RiskLevel::High => now()->addMinutes(10),
    RiskLevel::Critical => now()->addMinutes(5),
};

Critical actions expire fast. If the reviewer doesn't act within five minutes, the agent needs to re-evaluate and re-request. This prevents stale approvals from causing damage.

A scheduled command cleans up expired entries:

// app/Console/Commands/CleanExpiredAgentActions.php
Schedule::command('agent-actions:expire-stale')->everyMinute();

4. The Execution Engine: Running What Was Approved

Scenario: A reviewer approves the refund action. But between approval and execution, the payment gateway's API is down. The action fails. Now what? The agent needs to know. The reviewer needs to know. The system needs to not retry silently.

Why it matters: Execution isn't just "call the method." It needs transactional boundaries, error capture, and a clear result contract that feeds back into the agent's conversation.

Solution: An execution service that wraps the action call with error handling, timing, and result serialization.

<?php

namespace App\Agent\Execution;

use App\Agent\Actions\ActionResult;
use App\Models\PendingAgentAction;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class ActionExecutor
{
    public function execute(PendingAgentAction $pending): ActionResult
    {
        if ($pending->status !== 'approved') {
            throw new \DomainException('Only approved actions can be executed.');
        }

        $action = app(ActionRegistry::class)->resolve($pending->action_name);

        try {
            $result = DB::transaction(function () use ($action, $pending) {
                return $action->execute($pending->parameters);
            });

            $pending->update([
                'status' => 'executed',
                'executed_at' => now(),
                'execution_result' => $result->toArray(),
            ]);

            return $result;

        } catch (\Throwable $e) {
            Log::error('Agent action execution failed', [
                'action_id' => $pending->id,
                'action_name' => $pending->action_name,
                'error' => $e->getMessage(),
            ]);

            $pending->update([
                'status' => 'failed',
                'execution_result' => [
                    'success' => false,
                    'error' => $e->getMessage(),
                    'error_class' => get_class($e),
                ],
            ]);

            return ActionResult::failure($e->getMessage());
        }
    }
}

The ActionResult is deliberately simple:

readonly class ActionResult
{
    public function __construct(
        public bool $success,
        public string $message,
        public array $data = [],
        public array $affectedIds = [],
    ) {}

    public static function failure(string $message): self
    {
        return new self(success: false, message: $message);
    }

    public function toArray(): array
    {
        return [
            'success' => $this->success,
            'message' => $this->message,
            'data' => $this->data,
            'affected_ids' => $this->affectedIds,
        ];
    }
}

💡 Practical note: I wrap execution in a database transaction, but only for actions that are purely database operations. If your action calls an external API (Stripe, SendGrid, etc.), a DB transaction won't roll back the external call. For those, you need compensating transactions or a saga pattern. I mark those actions as isReversible: false and handle failures with explicit rollback logic rather than relying on DB::transaction().


5. The Feedback Loop: Telling the Agent What Happened

This is where most implementations I've seen fall apart. They build the preview, they build the approval, but the agent never learns the outcome. It just... stops. The conversation continues as if nothing happened.

The agent needs the execution result injected back into its context so it can reason about next steps.

<?php

namespace App\Agent\Orchestration;

use App\Models\PendingAgentAction;
use App\Agent\Execution\ActionExecutor;

class AgentSessionManager
{
    public function __construct(
        private ActionExecutor $executor,
    ) {}

    public function handleApproval(PendingAgentAction $pending): array
    {
        $result = $this->executor->execute($pending);

        // Format the result as a tool response for the agent
        $toolResponse = [
            'tool_call_id' => $pending->requested_by_agent_session,
            'role' => 'tool',
            'content' => json_encode([
                'status' => $result->success ? 'completed' : 'failed',
                'summary' => $result->message,
                'affected_records' => count($result->affectedIds),
                'details' => $result->data,
            ]),
        ];

        // Append to the agent's conversation history
        $this->appendToConversation($pending->requested_by_agent_session, $toolResponse);

        return $toolResponse;
    }

    public function handleRejection(PendingAgentAction $pending): array
    {
        return [
            'tool_call_id' => $pending->requested_by_agent_session,
            'role' => 'tool',
            'content' => json_encode([
                'status' => 'rejected',
                'reason' => $pending->rejection_reason,
                'suggestion' => 'The action was denied by a reviewer. Adjust parameters or ask the user for clarification.',
            ]),
        ];
    }
}

The key insight: rejection is information, not just a dead end. The agent receives the rejection reason and can adapt — maybe it narrows the scope, maybe it asks the user a clarifying question, maybe it proposes an alternative.


6. Multi-Step Actions and Partial Approval

Scenario: The agent wants to: (1) create a discount code, (2) apply it to 200 products, (3) send a promotional email to the subscriber list. The reviewer is fine with steps 1 and 2 but doesn't want the email going out yet.

This is where a single-action preview model breaks down. You need action plans — ordered sequences where each step can be individually approved or rejected.

<?php

namespace App\Agent\Actions;

class ActionPlan
{
    /** @param ActionStep[] $steps */
    public function __construct(
        public readonly string $id,
        public readonly array $steps,
        public readonly string $agentSessionId,
    ) {}

    public function toPreview(): array
    {
        return array_map(fn(ActionStep $step, int $index) => [
            'step' => $index + 1,
            'action' => $step->action->name(),
            'description' => $step->action->describe($step->parameters),
            'risk' => $step->action->riskLevel($step->parameters)->value,
            'depends_on' => $step->dependsOn, // step numbers this relies on
        ], $this->steps, array_keys($this->steps));
    }
}

The approval UI then presents each step as a toggleable row. The reviewer approves steps 1 and 2, rejects step 3. The execution engine runs only the approved steps in dependency order.

public function executePlan(ActionPlan $plan, array $approvedStepIndices): array
{
    $results = [];

    foreach ($approvedStepIndices as $index) {
        $step = $plan->steps[$index];

        // Verify dependencies were satisfied
        foreach ($step->dependsOn as $depIndex) {
            if (!isset($results[$depIndex]) || !$results[$depIndex]->success) {
                $results[$index] = ActionResult::failure(
                    "Skipped: dependency step " . ($depIndex + 1) . " did not succeed."
                );
                continue 2;
            }
        }

        $results[$index] = $this->executor->executeStep($step);
    }

    return $results;
}

🧠 The important part: Dependency checking prevents cascading failures. If step 1 (create discount code) fails, step 2 (apply to products) shouldn't attempt to run against a nonexistent code. This seems obvious, but I've seen implementations that fire all approved steps in parallel and then wonder why half of them errored.


7. Audit Trail: Because "The AI Did It" Isn't a Compliance Answer

Every action that passes through this system — whether approved, rejected, executed, or failed — gets an immutable audit log entry. This isn't optional. If you're in any regulated industry, or if your agent touches financial data, you need a forensic trail.

<?php
// Fired via model observer on PendingAgentAction

class PendingAgentActionObserver
{
    public function updated(PendingAgentAction $action): void
    {
        AuditLog::create([
            'auditable_type' => PendingAgentAction::class,
            'auditable_id' => $action->id,
            'event' => "agent_action.{$action->status}",
            'actor_type' => $action->approved_by_user ? 'user' : 'system',
            'actor_id' => $action->approved_by_user ?? 'scheduler',
            'metadata' => [
                'action_name' => $action->action_name,
                'risk_level' => $action->risk_level,
                'parameters_hash' => hash('sha256', json_encode($action->parameters)),
                'preview_hash' => hash('sha256', json_encode($action->preview_payload)),
            ],
            'ip_address' => request()?->ip(),
        ]);
    }
}

I hash the parameters and preview payload rather than storing them redundantly. The full data lives in the pending_agent_actions row; the audit log stores hashes for integrity verification. If someone tampers with the action record, the hash mismatch reveals it.


8. Production Hardening: The Stuff That Breaks at 2 AM

After six weeks in production, here's what actually caused incidents:

Race conditions on approval. Two reviewers looking at the same dashboard both click "Approve" on the same action. The second click tries to execute an already-executed action. Fix: a database-level lock.

public function approve(User $user): void
{
    $locked = DB::transaction(function () use ($user) {
        $fresh = self::whereKey($this->id)->lockForUpdate()->first();

        if ($fresh->status !== 'pending') {
            return false; // Already handled
        }

        $fresh->update([
            'status' => 'approved',
            'approved_by_user' => $user->id,
            'approved_at' => now(),
        ]);

        return true;
    });

    if (!$locked) {
        throw new \DomainException('Action was already processed.');
    }
}

Queue backlog during approval bursts. If a reviewer approves 40 actions at once (end-of-day cleanup), you don't want 40 synchronous executions hammering your database. Approved actions dispatch to a dedicated queue:

// config/queue.php — dedicated connection for agent actions
'agent_actions' => [
    'driver' => 'redis',
    'queue' => 'agent-execution',
    'retry_after' => 120,
    'max_attempts' => 1, // Do NOT auto-retry agent actions
],

max_attempts => 1 is deliberate. If an agent action fails, you don't want the queue silently retrying a payment or a deletion. Failures surface to the agent and the reviewer. Retries require explicit re-approval.

Preview staleness under concurrent writes. The preview says "will update order #4421 from 50to50 to 50to45." But between preview and execution, another process already changed it to $38. The execution now overwrites that. Fix: optimistic locking via a version column, or re-checking the current state inside the execute() method and aborting if it doesn't match the preview's assumptions.

public function execute(array $parameters): ActionResult
{
    $order = Order::findOrFail($parameters['order_id']);

    // Verify state hasn't changed since preview
    if ($order->total !== $parameters['expected_current_total']) {
        return ActionResult::failure(
            "Order total changed since preview (expected \${$parameters['expected_current_total']}, " .
            "found \${$order->total}). Action aborted for safety."
        );
    }

    // Proceed with update...
}

🚨 Production warning: Set retry_after generously (I use 120s) but keep max_attempts at 1. I learned this the hard way when a Stripe webhook timeout caused the queue to retry a refund three times. Three refunds. To the same customer. On a Friday afternoon.


When You Shouldn't Build This

I want to be honest about the trade-offs, because this system adds real complexity.

Skip PBE if:

  • Your agent only performs read operations (search, summarize, fetch data)
  • All mutations are fully reversible and low-stakes (toggling a UI preference)
  • You're in a prototyping phase where speed matters more than safety
  • The blast radius of any single action is trivially small

You need PBE if:

  • Your agent can modify financial records, send external communications, or delete data
  • Multiple users or agents share the same execution context
  • You're in a regulated environment (healthcare, finance, GDPR-sensitive data)
  • The cost of a wrong action exceeds the cost of the approval delay

The decision matrix I use:

FactorSkip PBEBuild PBE
Action reversibilityFully reversibleIrreversible or expensive to undo
Blast radiusSingle user, single rowBulk operations, external systems
Regulatory pressureNoneAny compliance requirement
Agent autonomyHuman always in the loopAgent operates semi-independently
Error costAnnoyanceFinancial loss, data loss, legal exposure

The Architecture in One Diagram

For those who want the full picture, the request flow looks like this:

Agent decides on action
        │
        ▼
┌─────────────────────┐
│  Action Contract     │  ← Validates params, classifies risk
│  (AgentAction)       │
└────────┬────────────┘
         │
         ▼
┌─────────────────────┐
│  Preview Generator   │  ← Queries current state, builds effect list
│  (preview method)    │     NO side effects
└────────┬────────────┘
         │
         ▼
┌─────────────────────┐
│  Pending Action      │  ← Stored in DB with TTL
│  (pending_agent_     │     Status: "pending"
│   actions table)     │
└────────┬────────────┘
         │
         ▼
┌─────────────────────┐
│  Approval Layer      │  ← Human reviews via dashboard/API
│  (approve/reject)    │     Policy engine for auto-approve
└────────┬────────────┘
         │
    ┌────┴────┐
    ▼         ▼
 Approved   Rejected
    │         │
    ▼         ▼
┌────────┐ ┌──────────┐
│Execute │ │Feed back │
│(queue) │ │rejection │
└───┬────┘ │to agent  │
    │      └──────────┘
    ▼
┌─────────────────────┐
│  ActionResult        │  ← Success/failure + data
│  fed back to agent   │
└─────────────────────┘

What I'd Change Next

Three things are on my list for the next iteration:

  1. Policy-based auto-approval for low-risk actions. Right now, even a "read config value" action goes through the queue. I'm building a policy engine that auto-approves anything classified as RiskLevel::Low with fewer than 10 affected records. The reviewer only sees medium and above.

  2. Streaming previews for long-running actions. If an action affects 10,000 records, the preview endpoint shouldn't block for 30 seconds building the full effect list. I'm moving to a paginated preview with a "first 50 effects shown, 9,950 more available" pattern.

  3. Agent-initiated cancellation. Currently, once an action is pending, only a human can cancel it. I want the agent to be able to cancel its own pending actions if its reasoning changes during the wait — if new information arrives that makes the action unnecessary.

The system isn't elegant. It adds a database table, a queue, an API surface, and a reviewer dashboard. But the alternative — letting an AI agent execute arbitrary mutations with no checkpoint — stopped being acceptable the moment that unqualified DELETE showed up in my logs.

The preview-before-execute pattern isn't about distrusting the agent. It's about giving it the same guardrails you'd give any junior engineer with production database access: show me what you're going to do before you do it.

Post a Comment

Previous Post Next Post