Turn AI Plans into Typed PHP Commands Instead of Executing Raw JSON

The dangerous moment usually comes after the demo works.

Your AI assistant returns a plan like this:

{
  "steps": [
    {
      "command": "refund_order",
      "order_id": "ord_8842",
      "amount_cents": 2500,
      "reason": "Damaged in transit"
    },
    {
      "command": "send_email",
      "to": "customer@example.com",
      "template": "refund_issued"
    }
  ]
}

Then your PHP code loops over steps, reads command, maybe checks a string, and calls a service. It works. Then the model returns "refundOrder" instead of "refund_order". Then it returns "amount": "25.00" instead of amount_cents. Then it invents a command called "delete_user_data" because the customer phrased a request aggressively.

Now you are not building a product feature. You are negotiating with an untrusted remote API that occasionally improvises its own contract.

The fix is not “better prompting.” The fix is to stop treating raw JSON as executable behavior. AI plans should be decoded into typed PHP command objects, validated, authorized, audited, and only then dispatched to handlers you control.

TL;DR

  • Treat AI-generated JSON as untrusted input, not as a trusted internal API.
  • Define a closed set of allowed commands with enums or explicit mappings.
  • Decode each plan step into a typed, immutable PHP command object.
  • Validate business rules before creating the command.
  • Dispatch commands through a handler map, not dynamic method calls.
  • Add approval gates and audit logs for dangerous side effects.
  • Keep raw JSON for transport and debugging, not execution.

📋 Table of Contents


The Dangerous Middle Between AI Output and Side Effects

Most AI-powered PHP features are not dangerous because the model is “smart” or “dumb.” They become dangerous because of the middle layer between model output and application side effects.

That middle layer answers questions like:

  • What commands does the model think it requested?
  • What commands is the application actually allowed to execute?
  • Which arguments were provided?
  • Which arguments were missing, malformed, or outside allowed bounds?
  • Who authorized the action?
  • What happened after execution?

If that middle layer is just json_decode() followed by a switch, you are one model update away from a production incident.

Typed PHP commands give you a proper boundary. They turn vague AI intent into explicit application behavior.


1. Raw JSON Is a Transport Format, Not an Execution Contract

Scenario: Your AI agent returns a plan, and your code executes it like this:

foreach ($plan['steps'] as $step) {
    $service = $container->get($step['service']);
    $service->{$step['method']}(...$step['args']);
}

This is the worst possible pattern. The model can now choose services, methods, and arguments. You have effectively exposed an internal RPC surface to an untrusted text generator.

Even a less extreme version is risky:

foreach ($plan['steps'] as $step) {
    match ($step['command']) {
        'refund_order' => $refunds->process($step['order_id'], $step['amount']),
        'send_email' => $mailer->send($step['to'], $step['template']),
        default => throw new RuntimeException('Unknown command'),
    };
}

This is better, but still fragile. The array keys are not typed. The values are not validated. The command names are strings scattered through the code. The execution path depends on whatever shape the model happened to produce.

Why it matters: JSON is excellent for moving data across a boundary. It is terrible as the final representation of executable behavior. Once the JSON enters your application, it should be transformed into something stricter as soon as possible.

The right mental model:

AI output -> untrusted transport format
PHP command -> validated application intent
Handler -> controlled side effect

Raw JSON can be logged, stored, retried, displayed, or sent over HTTP. But it should not be the object your business logic executes.

⚠️ Gotcha: Structured output from modern LLMs can make JSON syntactically valid, but it does not make the plan safe. A model can still produce valid JSON with invalid business meaning, excessive amounts, wrong IDs, or disallowed actions.


2. Define a Closed Command Vocabulary Before Writing Decoders

Scenario: The model returns "refund", then later "refund_order", then "refundOrder", then "issue_refund". Your decoder grows a series of aliases, and eventually nobody knows which command names are official.

Why it matters: AI systems are probabilistic. If your command vocabulary is loose, the model will explore the edges of that looseness. You need a closed vocabulary that your application owns.

Start with a backed enum:

<?php

declare(strict_types=1);

enum AiCommandName: string
{
    case RefundOrder = 'refund_order';
    case SendEmail = 'send_email';
    case CreateSupportTicket = 'create_support_ticket';
    case CancelSubscription = 'cancel_subscription';
}

This enum is not just a convenience. It is a security boundary. Anything not in the enum is rejected.

$name = AiCommandName::tryFrom($step['command'] ?? '');

if ($name === null) {
    throw new UnsupportedAiCommand(
        sprintf('Unsupported AI command: %s', (string) ($step['command'] ?? 'unknown'))
    );
}

Now your decoder can match against enum cases instead of raw strings:

return match ($name) {
    AiCommandName::RefundOrder => $this->decodeRefundOrder($step),
    AiCommandName::SendEmail => $this->decodeSendEmail($step),
    AiCommandName::CreateSupportTicket => $this->decodeCreateSupportTicket($step),
    AiCommandName::CancelSubscription => $this->decodeCancelSubscription($step),
};

Why this works: The command vocabulary becomes explicit and finite. You can review it, test it, version it, and put it in documentation. When a product manager asks, “What can the AI actually do?” you can point to the enum.

Do not let the model provide class names, service names, method names, or handler names. Those decisions belong to your code.

💡 Practical note: If you need backward compatibility, create aliases in the decoder, not in the command vocabulary. Internally, always normalize to the canonical enum case.


3. Model Each AI Action as a Typed Readonly Command Object

Scenario: Your refund handler expects an integer amount in cents. The model sends "amount": 25.0. Or "amount_cents": "2500". Or "amount": "25". Your code silently coerces the value, and now you are debugging money math.

Why it matters: PHP can be strict, but only if you give it strict structures. Arrays do not protect you. Typed command objects do.

Define a marker interface for all AI-executable commands:

<?php

declare(strict_types=1);

interface AiCommand
{
}

Then model each action as a readonly DTO:

<?php

declare(strict_types=1);

final readonly class RefundOrder implements AiCommand
{
    public function __construct(
        public string $orderId,
        public int $amountCents,
        public string $reason,
    ) {
    }
}
<?php

declare(strict_types=1);

final readonly class SendEmail implements AiCommand
{
    public function __construct(
        public string $to,
        public EmailTemplate $template,
        public array $context = [],
    ) {
    }
}

Use enums for constrained values:

<?php

declare(strict_types=1);

enum EmailTemplate: string
{
    case RefundIssued = 'refund_issued';
    case OrderShipped = 'order_shipped';
    case SubscriptionCancelled = 'subscription_cancelled';
}

This gives you several benefits at once:

  • Handlers receive predictable types.
  • Static analysis tools understand the shape.
  • Refactoring becomes safer.
  • Invalid values fail during decoding, not deep inside business logic.
  • The command object can be logged, serialized, or inspected in tests.

Why this works: A typed command is a contract. It says, “This object has already passed through the boundary and is now a valid expression of intent.”

The command should not contain database entities, service objects, closures, or request objects. It should be a small serializable representation of what the AI asked for.

A good command object usually contains:

  • identifiers,
  • scalar values,
  • enums,
  • small value objects,
  • maybe an idempotency key,
  • nothing environment-specific.

🧠 The important part: Do not let handlers accept raw arrays from the AI layer. Once typed commands exist, handlers should depend only on those commands.


4. Decode at the Boundary, Not Throughout the Application

Scenario: Your controller decodes the AI plan. Then your service checks a few fields. Then a handler checks a few more fields. Six months later, three different parts of the app have conflicting assumptions about what "refund_order" means.

Why it matters: Decoding is policy. If that policy is spread across the application, it becomes impossible to reason about what the AI is allowed to do.

Create one decoder class responsible for converting raw JSON into typed commands.

<?php

declare(strict_types=1);

use function json_decode;
use function json_validate;
use const JSON_THROW_ON_ERROR;

final class AiPlanDecoder
{
    private const MAX_STEPS = 10;

    /**
     * @return list<AiCommand>
     */
    public function decode(string $json): array
    {
        if (!json_validate($json)) {
            throw new InvalidAiPlan('AI plan is not valid JSON.');
        }

        $plan = json_decode($json, true, 512, JSON_THROW_ON_ERROR);

        if (!is_array($plan)) {
            throw new InvalidAiPlan('AI plan must be a JSON object.');
        }

        $steps = $plan['steps'] ?? null;

        if (!is_array($steps)) {
            throw new InvalidAiPlan('AI plan must contain a steps array.');
        }

        if (count($steps) > self::MAX_STEPS) {
            throw new InvalidAiPlan('AI plan contains too many steps.');
        }

        return array_map(
            fn (mixed $step): AiCommand => $this->decodeStep($step),
            array_values($steps),
        );
    }

    private function decodeStep(mixed $step): AiCommand
    {
        if (!is_array($step)) {
            throw new InvalidAiPlan('Each AI plan step must be an object.');
        }

        $name = $step['command'] ?? null;

        if (!is_string($name)) {
            throw new InvalidAiPlan('AI plan step is missing a command name.');
        }

        $commandName = AiCommandName::tryFrom($name);

        if ($commandName === null) {
            throw new UnsupportedAiCommand("Unsupported AI command: {$name}");
        }

        return match ($commandName) {
            AiCommandName::RefundOrder => $this->decodeRefundOrder($step),
            AiCommandName::SendEmail => $this->decodeSendEmail($step),
            AiCommandName::CreateSupportTicket => $this->decodeCreateSupportTicket($step),
            AiCommandName::CancelSubscription => $this->decodeCancelSubscription($step),
        };
    }
}

The exceptions can be simple:

<?php

declare(strict_types=1);

use RuntimeException;

final class InvalidAiPlan extends RuntimeException
{
}

final class UnsupportedAiCommand extends RuntimeException
{
}

Why this works: The decoder is a choke point. If you need to add logging, validation, schema versioning, rate limiting, or command allowlisting by user role, you know where to put it.

This also makes the rest of your application cleaner. Controllers, queues, and CLI commands do not need to know what the raw AI JSON looked like. They receive typed commands or fail with a decode error.

A useful pipeline looks like this:

raw AI JSON
    ↓
AiPlanDecoder
    ↓
list<AiCommand>
    ↓
authorization / approval
    ↓
command bus
    ↓
handlers
    ↓
audit log

The decoder is not the place to execute side effects. It should only transform and validate.


5. Validate AI Arguments Like a Hostile Form Submission

Scenario: The model returns a refund command with a negative amount:

{
  "command": "refund_order",
  "order_id": "ord_8842",
  "amount_cents": -2500,
  "reason": "Customer asked nicely"
}

The JSON is valid. The command name is valid. The type could even be an integer. But the business meaning is wrong.

Why it matters: Type checks are necessary, but they are not enough. AI output needs the same suspicion you would apply to user-submitted forms, webhook payloads, or third-party API input.

Add per-command validation in the decoder:

private function decodeRefundOrder(array $step): RefundOrder
{
    $orderId = $step['order_id'] ?? null;

    if (!is_string($orderId) || preg_match('/^ord_[a-z0-9]{8,64}$/i', $orderId) !== 1) {
        throw new InvalidAiPlan('Refund command requires a valid order_id.');
    }

    $amountCents = $step['amount_cents'] ?? null;

    if (!is_int($amountCents)) {
        throw new InvalidAiPlan('Refund amount_cents must be an integer.');
    }

    if ($amountCents <= 0) {
        throw new InvalidAiPlan('Refund amount_cents must be positive.');
    }

    if ($amountCents > 100_000_00) {
        throw new InvalidAiPlan('Refund amount exceeds allowed limit.');
    }

    $reason = $step['reason'] ?? null;

    if (!is_string($reason)) {
        throw new InvalidAiPlan('Refund command requires a reason.');
    }

    $reason = trim($reason);

    if ($reason === '' || mb_strlen($reason) > 500) {
        throw new InvalidAiPlan('Refund reason must be between 1 and 500 characters.');
    }

    return new RefundOrder(
        orderId: $orderId,
        amountCents: $amountCents,
        reason: $reason,
    );
}

For email commands, validate the recipient and template:

private function decodeSendEmail(array $step): SendEmail
{
    $to = $step['to'] ?? null;

    if (!is_string($to) || filter_var($to, FILTER_VALIDATE_EMAIL) === false) {
        throw new InvalidAiPlan('SendEmail command requires a valid email address.');
    }

    $template = $step['template'] ?? null;

    if (!is_string($template)) {
        throw new InvalidAiPlan('SendEmail command requires a template.');
    }

    $template = EmailTemplate::tryFrom($template);

    if ($template === null) {
        throw new InvalidAiPlan('SendEmail command uses an unsupported template.');
    }

    $context = $step['context'] ?? [];

    if (!is_array($context)) {
        throw new InvalidAiPlan('SendEmail context must be an object.');
    }

    return new SendEmail(
        to: $to,
        template: $template,
        context: $context,
    );
}

Why this works: The command object becomes trustworthy. If a RefundOrder exists, your application knows:

  • the order ID has the expected format,
  • the amount is a positive integer,
  • the amount is below the policy limit,
  • the reason is present and bounded.

That is much stronger than knowing $data['amount_cents'] existed.

Validation rules should come from your domain, not from the model. If the AI says “refund everything,” that is an intent. Your application decides whether that intent can become a valid command.

🚨 Production warning: Do not use floating-point numbers for money. If the model sends "amount": 25.0, reject it or convert it only through an explicit, documented rule. Prefer integer cents or a dedicated money value object.


6. Replace Dynamic Dispatch With a Handler Map

Scenario: Your decoder now produces typed commands. But execution is still a giant match statement inside a controller, and every new command makes that controller harder to test.

Why it matters: Typed commands deserve a typed execution layer. A handler map gives you explicit routing without opening the door to dynamic method invocation.

Define a result object:

<?php

declare(strict_types=1);

enum CommandStatus: string
{
    case Success = 'success';
    case Failed = 'failed';
    case PendingApproval = 'pending_approval';
}

final readonly class CommandResult
{
    public function __construct(
        public CommandStatus $status,
        public string $message = '',
    ) {
    }
}

Then create a simple command bus:

<?php

declare(strict_types=1);

use LogicException;

final class AiCommandBus
{
    /**
     * @param array<class-string<AiCommand>, callable(AiCommand): CommandResult> $handlers
     */
    public function __construct(
        private readonly array $handlers,
    ) {
    }

    public function dispatch(AiCommand $command): CommandResult
    {
        $handler = $this->handlers[$command::class] ?? null;

        if ($handler === null) {
            throw new LogicException(sprintf(
                'No handler registered for command %s',
                $command::class,
            ));
        }

        return $handler($command);
    }
}

Register handlers explicitly:

$bus = new AiCommandBus([
    RefundOrder::class => new RefundOrderHandler($payments, $auditLog),
    SendEmail::class => new SendEmailHandler($mailer, $emailTemplates),
    CreateSupportTicket::class => new CreateSupportTicketHandler($tickets),
    CancelSubscription::class => new CancelSubscriptionHandler($subscriptions),
]);

A handler can be an invokable class:

<?php

declare(strict_types=1);

final class RefundOrderHandler
{
    public function __construct(
        private readonly PaymentGateway $payments,
        private readonly AuditLog $auditLog,
    ) {
    }

    public function __invoke(RefundOrder $command): CommandResult
    {
        try {
            $this->payments->refund(
                orderId: $command->orderId,
                amountCents: $command->amountCents,
                reason: $command->reason,
            );

            $this->auditLog->record($command, 'refund.completed');

            return new CommandResult(
                status: CommandStatus::Success,
                message: "Refunded {$command->amountCents} cents for {$command->orderId}.",
            );
        } catch (PaymentRefundFailed $e) {
            $this->auditLog->record($command, 'refund.failed');

            return new CommandResult(
                status: CommandStatus::Failed,
                message: $e->getMessage(),
            );
        }
    }
}

Why this works: Handlers are isolated. They do not decode JSON. They do not guess command names. They receive a typed object and perform a specific task.

This also makes authorization and decoration easier. You can wrap the bus with middleware for logging, metrics, approval gates, rate limits, or dry-run mode.

Avoid this:

$handler = $handlers[$commandName];
$handler($rawStep);

Prefer this:

$bus->dispatch($typedCommand);

The difference looks small, but it changes the architecture. The first version still treats the AI plan as the execution contract. The second version treats your PHP domain model as the contract.


7. Make Dangerous Commands Explicit, Confirmable, and Idempotency-Aware

Scenario: The AI creates a refund plan. The user clicks “Run plan.” The network times out. The user clicks again. Now the customer has been refunded twice.

Why it matters: AI plans often trigger side effects. Some side effects are harmless. Others are irreversible. Your command layer needs to distinguish between them.

Use marker interfaces for command categories:

<?php

declare(strict_types=1);

interface RequiresApproval
{
}

interface IrreversibleCommand
{
}

interface IdempotentCommand
{
    public function idempotencyKey(): string;
}

Mark dangerous commands:

final readonly class RefundOrder implements AiCommand, RequiresApproval, IdempotentCommand
{
    public function __construct(
        public string $planExecutionId,
        public string $orderId,
        public int $amountCents,
        public string $reason,
    ) {
    }

    public function idempotencyKey(): string
    {
        return sprintf(
            'refund:%s:%s:%d',
            $this->planExecutionId,
            $this->orderId,
            $this->amountCents,
        );
    }
}

Then add an approval wrapper around the bus:

<?php

declare(strict_types=1);

final class ApprovalCommandBus
{
    public function __construct(
        private readonly AiCommandBus $inner,
        private readonly ApprovalGate $approvals,
    ) {
    }

    public function dispatch(AiCommand $command): CommandResult
    {
        if ($command instanceof RequiresApproval && !$this->approvals->approved($command)) {
            return new CommandResult(
                status: CommandStatus::PendingApproval,
                message: 'This action requires explicit approval.',
            );
        }

        return $this->inner->dispatch($command);
    }
}

The approval gate can check user roles, policy limits, or whether a human confirmed the action in the UI.

interface ApprovalGate
{
    public function approved(AiCommand $command): bool;
}

For example:

final class RefundApprovalGate implements ApprovalGate
{
    public function __construct(
        private readonly CurrentUser $user,
        private readonly RefundPolicy $policy,
    ) {
    }

    public function approved(AiCommand $command): bool
    {
        if (!$command instanceof RefundOrder) {
            return true;
        }

        if ($command->amountCents > $this->policy->autoApproveLimit()) {
            return false;
        }

        return $this->user->can('approve-refunds');
    }
}

Why this works: The command object can carry enough context to make approval decisions without inspecting raw JSON. The system can say, “This refund is above the automatic limit,” instead of silently executing it.

Idempotency matters just as much. If the AI plan is executed from a queue or a retryable HTTP request, commands that mutate external systems need deduplication. The idempotency key should come from your execution context, not from the model’s free-form text.

A safe pattern is:

plan_id + step_index + command_name + stable arguments

For example:

$idempotencyKey = sprintf(
    'plan:%s:step:%d:%s',
    $planExecutionId,
    $stepIndex,
    AiCommandName::RefundOrder->value,
);

Then your payment gateway, mailer, or queue system can use that key to prevent duplicate side effects.

🔍 Why this matters: The AI may produce the same plan twice, or a retry may replay the same plan. Typed commands give you a clean place to attach idempotency, approval, and policy checks.


8. Audit the Plan, the Typed Commands, and the Execution Result

Scenario: A customer says, “The assistant refunded my order without asking.” You check the logs and find only “refund completed.” You do not know what the model proposed, what your decoder accepted, whether approval was required, or which handler executed the command.

Why it matters: AI-assisted actions need a stronger audit trail than ordinary user actions because the origin of intent is less obvious. You need to distinguish between what the AI suggested, what your system accepted, and what actually happened.

At minimum, log three layers:

  1. The raw AI plan.
  2. The decoded typed commands.
  3. The execution result.

A simple audit record could look like this:

<?php

declare(strict_types=1);

final readonly class AiCommandExecutionLog
{
    public function __construct(
        public string $planExecutionId,
        public string $rawPlanJson,
        public AiCommand $command,
        public CommandStatus $status,
        public string $message,
        public \DateTimeImmutable $executedAt,
    ) {
    }
}

Be careful with the raw plan: it may contain personal data. Redact sensitive fields before storing it if necessary.

A decorating command bus is a good place to add auditing:

<?php

declare(strict_types=1);

final class AuditingCommandBus
{
    public function __construct(
        private readonly AiCommandBus $inner,
        private readonly AuditLog $auditLog,
        private readonly string $planExecutionId,
        private readonly string $rawPlanJson,
    ) {
    }

    public function dispatch(AiCommand $command): CommandResult
    {
        $result = $this->inner->dispatch($command);

        $this->auditLog->write(new AiCommandExecutionLog(
            planExecutionId: $this->planExecutionId,
            rawPlanJson: $this->rawPlanJson,
            command: $command,
            status: $result->status,
            message: $result->message,
            executedAt: new \DateTimeImmutable(),
        ));

        return $result;
    }
}

Then compose the buses:

$bus = new ApprovalCommandBus(
    inner: new AuditingCommandBus(
        inner: new AiCommandBus($handlers),
        auditLog: $auditLog,
        planExecutionId: $planExecutionId,
        rawPlanJson: $rawJson,
    ),
    approvals: $approvalGate,
);

Why this works: When something goes wrong, you can reconstruct the full chain of responsibility.

You can answer questions like:

  • Did the model request an unsupported command?
  • Did the decoder reject it?
  • Was the command approved?
  • Did the handler fail?
  • Was the failure caused by a downstream service?
  • Did the AI produce a risky plan that policy correctly blocked?

This is also where command objects shine. A serialized command is easier to inspect than a loose array buried in a log message.

For example:

json_encode([
    'plan_execution_id' => $log->planExecutionId,
    'command_class' => $log->command::class,
    'command' => $log->command,
    'status' => $log->status->value,
    'message' => $log->message,
    'executed_at' => $log->executedAt->format(\DateTimeInterface::ATOM),
]);

💡 Practical note: Store the command class name. It gives you a stable way to filter audits by action type even if the command’s internal fields evolve.


9. Test the Decoder Like a Compiler and the Handlers Like Business Logic

Scenario: You test the AI feature by calling the model and hoping it returns the right JSON. The test passes sometimes, fails sometimes, and nobody knows whether the failure came from the prompt, the model, the decoder, or the handler.

Why it matters: The decoder and handlers are deterministic PHP code. They should be tested deterministically. Save real-model testing for a separate evaluation process.

Test the decoder with known JSON fixtures:

use PHPUnit\Framework\TestCase;

final class AiPlanDecoderTest extends TestCase
{
    private AiPlanDecoder $decoder;

    protected function setUp(): void
    {
        $this->decoder = new AiPlanDecoder();
    }

    public function testItDecodesRefundCommand(): void
    {
        $json = <<<JSON
        {
            "steps": [
                {
                    "command": "refund_order",
                    "order_id": "ord_8842abcd",
                    "amount_cents": 2500,
                    "reason": "Damaged in transit"
                }
            ]
        }
        JSON;

        $commands = $this->decoder->decode($json);

        $this->assertCount(1, $commands);
        $this->assertInstanceOf(RefundOrder::class, $commands[0]);

        $command = $commands[0];

        $this->assertSame('ord_8842abcd', $command->orderId);
        $this->assertSame(2500, $command->amountCents);
        $this->assertSame('Damaged in transit', $command->reason);
    }

    public function testItRejectsNegativeRefundAmount(): void
    {
        $json = <<<JSON
        {
            "steps": [
                {
                    "command": "refund_order",
                    "order_id": "ord_8842abcd",
                    "amount_cents": -2500,
                    "reason": "Damaged in transit"
                }
            ]
        }
        JSON;

        $this->expectException(InvalidAiPlan::class);

        $this->decoder->decode($json);
    }

    public function testItRejectsUnknownCommand(): void
    {
        $json = <<<JSON
        {
            "steps": [
                {
                    "command": "delete_all_users"
                }
            ]
        }
        JSON;

        $this->expectException(UnsupportedAiCommand::class);

        $this->decoder->decode($json);
    }
}

Handler tests should use fake services:

final class RefundOrderHandlerTest extends TestCase
{
    public function testItRecordsSuccessfulRefund(): void
    {
        $payments = new FakePaymentGateway();
        $auditLog = new InMemoryAuditLog();

        $handler = new RefundOrderHandler($payments, $auditLog);

        $result = $handler(new RefundOrder(
            planExecutionId: 'plan_01',
            orderId: 'ord_8842abcd',
            amountCents: 2500,
            reason: 'Damaged in transit',
        ));

        $this->assertSame(CommandStatus::Success, $result->status);
        $this->assertTrue($payments->refunded('ord_8842abcd', 2500));
        $this->assertContains('refund.completed', $auditLog->events());
    }
}

This separation gives you a much better testing pyramid:

LayerWhat you testUses real LLM?
Decoder testsJSON to typed command conversionNo
Handler testsBusiness behavior and side effectsNo
Bus testsRouting, approval, auditingNo
Integration testsFull pipeline with fixture JSONNo
Model evaluationPrompt and plan qualityYes, separately

Why this works: You can reproduce failures exactly. If the model produces a bad plan, you can add that plan as a fixture and verify that your system rejects it forever after.

This is also where typed commands pay off. You can build commands directly in tests without inventing JSON strings every time:

$command = new RefundOrder(
    planExecutionId: 'plan_01',
    orderId: 'ord_8842abcd',
    amountCents: 2500,
    reason: 'Damaged in transit',
);

That makes business-rule tests clearer and less brittle.


Choosing the Right Level of Strictness

Not every AI feature needs the same level of ceremony. A low-risk internal summarization tool is different from an assistant that can refund payments, cancel subscriptions, or delete records.

Here is how I would decide.

SituationRecommended approach
AI generates text for human review onlyStore raw output, no command layer needed
AI suggests actions but humans execute themTyped commands useful, but execution can remain manual
AI executes low-risk internal actionsTyped commands, validation, logging
AI executes customer-visible actionsTyped commands, approval gates, audit trail
AI executes financial or destructive actionsTyped commands, strict validation, idempotency, human approval, detailed audit
AI chooses arbitrary services or methodsDo not build this unless you have a very strong sandbox and authorization model

The most common mistake is using the “low-risk” architecture for a “high-risk” feature. The system works until the model returns an unexpected plan, and then the blast radius is much larger than anyone intended.

If I were shipping an AI feature in PHP that performs real actions, I would use this baseline:

  1. Raw AI JSON enters through one endpoint or queue consumer.
  2. The JSON is validated with json_validate() and decoded with json_decode() in strict mode.
  3. A decoder converts each step into a typed command object.
  4. Unsupported commands throw immediately.
  5. Business validation happens before the command object is created.
  6. Commands are dispatched through an explicit handler map.
  7. Dangerous commands implement RequiresApproval.
  8. Mutating commands include idempotency keys.
  9. Every execution is audited.
  10. Raw JSON is kept for debugging, but never executed directly.

That structure gives you a clean separation:

The model proposes.
Your PHP type system defines what is acceptable.
Your handlers decide what happens.
Your audit trail explains what happened.

Raw JSON is fine as a message. It is not fine as a contract. Once an AI plan needs to cause side effects in a PHP application, typed commands are not over-engineering. They are the minimum boundary that keeps the system understandable, testable, and safe.

Post a Comment

Previous Post Next Post