Skip to main content

Laravel AI SDK Sub-agents: Multi-agent Architecture in PHP for Production

Author
Ignacio AmatIgnacio Amat
Published
Reading Time8 min
PHP code editor showing agent architecture and task automation

Laravel News published a small but significant update on May 12, 2026: Laravel AI SDK now supports sub-agents. An agent can now delegate a focused task to another with its own instructions, tools, provider, model and configuration.

For PHP teams already running Laravel in production, this changes how AI workflows are integrated: from a single monolithic prompt to an architecture of decoupled agents.


Why Sub-agents Change the Rules

Laravel teams already separate responsibilities across controllers, services, jobs, policies, listeners and commands. It would make no sense for the AI layer to collapse every workflow into one overloaded prompt.

Sub-agents apply that same discipline to model-driven flows:

  • Context isolation: each sub-agent receives only the data it needs, not the entire parent conversation history.
  • Minimal tools: a refunds agent should not have access to billing tools.
  • Granular cost tracking: you know exactly which agent consumed which tokens.
  • Independent testing: each agent is tested in isolation with contract-like assertions.

app/AI/
├── Agents/
│   ├── SupportAgent.php          # Orchestrator (parent)
│   ├── RefundsAgent.php          # Specialized sub-agent
│   ├── BillingAgent.php
│   └── DocumentationAgent.php
├── Contracts/
│   └── AgentContract.php         # Common interface
├── DTOs/
│   ├── AgentTask.php
│   └── AgentResult.php
├── Logging/
│   └── AgentLogger.php           # Centralized observability
└── Exceptions/
    └── AgentFailedException.php

1. Common Interface for All Agents

// app/AI/Contracts/AgentContract.php
namespace App\AI\Contracts;

use App\AI\DTOs\{AgentTask, AgentResult};

interface AgentContract
{
    public function identifier(): string;
    public function run(AgentTask $task): AgentResult;
    public function tools(): array;
    public function maxTokens(): int;
}

2. Orchestrator: Coordinates Without Micromanaging

// app/AI/Agents/SupportAgent.php
namespace App\AI\Agents;

use App\AI\Contracts\AgentContract;
use App\AI\DTOs\{AgentTask, AgentResult};
use App\AI\Logging\AgentLogger;

class SupportAgent implements AgentContract
{
    private array $subAgents = [];

    public function __construct(
        private AgentLogger $logger,
    ) {
        $this->subAgents = [
            'refunds' => app(RefundsAgent::class),
            'billing' => app(BillingAgent::class),
            'documentation' => app(DocumentationAgent::class),
        ];
    }

    public function identifier(): string
    {
        return 'support_agent';
    }

    public function tools(): array
    {
        return [
            'classify_ticket',
            'escalate_to_human',
            'search_knowledge_base',
        ];
    }

    public function maxTokens(): int
    {
        return 2048;
    }

    public function run(AgentTask $task): AgentResult
    {
        $start = hrtime(true);
        $this->logger->start($this->identifier(), $task);

        // 1. Classify the ticket
        $classification = $this->classify($task);

        // 2. Delegate to appropriate sub-agent
        if ($classification['delegates_to'] ?? null) {
            $subAgent = $this->subAgents[$classification['delegates_to']] ?? null;

            if (!$subAgent) {
                return AgentResult::error("No sub-agent found for: {$classification['delegates_to']}");
            }

            $subTask = new AgentTask(
                id: $task->id,
                payload: $classification['context'] ?? $task->payload,
                metadata: [
                    'parent_agent' => $this->identifier(),
                    'delegation_reason' => $classification['reason'],
                ],
            );

            $subResult = $subAgent->run($subTask);

            // 3. Consolidate response
            $result = AgentResult::success([
                'classification' => $classification,
                'resolution' => $subResult->data,
            ]);
        } else {
            $result = AgentResult::success([
                'classification' => $classification,
                'resolution' => $classification['direct_response'],
            ]);
        }

        $latencyMs = (hrtime(true) - $start) / 1e6;
        $this->logger->end($this->identifier(), $task, $result, $latencyMs);

        return $result;
    }

    private function classify(AgentTask $task): array
    {
        // Model call to classify and decide delegation or direct response
        return [
            'delegates_to' => 'refunds',
            'reason' => 'ticket_contains_refund_request',
            'context' => $task->payload,
        ];
    }
}

3. Specialized Sub-Agent: Narrow and Reviewable

// app/AI/Agents/RefundsAgent.php
namespace App\AI\Agents;

use App\AI\Contracts\AgentContract;
use App\AI\DTOs\{AgentTask, AgentResult};

class RefundsAgent implements AgentContract
{
    public function identifier(): string
    {
        return 'refunds_agent';
    }

    public function tools(): array
    {
        return [
            'get_order_by_id',
            'get_refund_policy',
            'calculate_refund_amount',
            'draft_refund_response',
        ];
    }

    public function maxTokens(): int
    {
        return 1024; // Lighter than parent
    }

    public function run(AgentTask $task): AgentResult
    {
        $orderId = $task->payload['order_id'] ?? null;
        $reason = $task->payload['reason'] ?? 'not_specified';

        if (!$orderId) {
            return AgentResult::error('Missing order_id in refund request');
        }

        // Model call with narrow instructions
        $response = $this->callModel(
            prompt: $this->buildPrompt($orderId, $reason),
            maxTokens: $this->maxTokens(),
            temperature: 0.2, // Low temperature for deterministic output
        );

        $parsed = $this->validateResponse($response);

        return AgentResult::success([
            'recommendation' => $parsed['recommendation'],
            'reason' => $parsed['reason'],
            'amount_cents' => $parsed['amount_cents'],
            'customer_message_draft' => $parsed['customer_message_draft'],
            'requires_human_approval' => $parsed['requires_human_approval'],
        ]);
    }

    private function buildPrompt(int $orderId, string $reason): string
    {
        $order = \App\Models\Order::find($orderId);

        return <<<PROMPT
You are a refund processing assistant.
Review the following order and reason, then determine the appropriate refund action.

ORDER:
- ID: {$order->id}
- Total: {$order->total_cents} cents
- Status: {$order->status}
- Created: {$order->created_at->format('Y-m-d')}
- Items: {$order->items->count()}

REFUND REASON: {$reason}

POLICY: Full refund within 30 days, prorated after.

Respond in strict JSON:
{
  "recommendation": "approve|partial|manual_review|reject",
  "reason": "short explanation",
  "amount_cents": 0,
  "customer_message_draft": "...",
  "requires_human_approval": true|false
}
PROMPT;
    }

    private function callModel(string $prompt, int $maxTokens, float $temperature): string
    {
        // Real provider call
        return json_encode([
            'recommendation' => 'manual_review',
            'reason' => 'order_has_partial_delivery',
            'amount_cents' => 4999,
            'customer_message_draft' => 'We need to verify the shipment status before proceeding.',
            'requires_human_approval' => true,
        ]);
    }

    private function validateResponse(string $response): array
    {
        $data = json_decode($response, true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            throw new \RuntimeException('Invalid JSON from refunds agent: ' . json_last_error_msg());
        }
        return $data;
    }
}

4. Typed DTOs

// app/AI/DTOs/AgentTask.php
namespace App\AI\DTOs;

class AgentTask
{
    public function __construct(
        public readonly string $id,
        public readonly array $payload,
        public readonly array $metadata = [],
    ) {}
}

// app/AI/DTOs/AgentResult.php
namespace App\AI\DTOs;

class AgentResult
{
    private function __construct(
        public readonly bool $success,
        public readonly ?array $data = null,
        public readonly ?string $error = null,
    ) {}

    public static function success(array $data): self
    {
        return new self(success: true, data: $data);
    }

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

    public function requiresHumanApproval(): bool
    {
        return $this->data['requires_human_approval'] ?? false;
    }
}

Testing: Each Agent as an Independent Unit

// tests/Feature/AI/Agents/RefundsAgentTest.php
use App\AI\Agents\RefundsAgent;
use App\AI\DTOs\{AgentTask, AgentResult};

test('refunds agent returns valid result for known order', function () {
    $order = \App\Models\Order::factory()->create([
        'total_cents' => 9999,
        'status' => 'delivered',
    ]);

    $agent = new RefundsAgent();
    $task = new AgentTask(
        id: 'test-1',
        payload: ['order_id' => $order->id, 'reason' => 'defective_product'],
    );

    $result = $agent->run($task);
  
    expect($result)->toBeInstanceOf(AgentResult::class)
        ->and($result->success)->toBeTrue()
        ->and($result->data)->toHaveKey('recommendation')
        ->and($result->data)->toHaveKey('amount_cents')
        ->and($result->data)->toHaveKey('requires_human_approval');
});

test('refunds agent fails gracefully when order is missing', function () {
    $agent = new RefundsAgent();
    $task = new AgentTask(
        id: 'test-2',
        payload: ['reason' => 'no_order_id_provided'],
    );

    $result = $agent->run($task);
  
    expect($result->success)->toBeFalse()
        ->and($result->error)->toContain('Missing order_id');
});

test('support agent delegates correctly to refunds sub-agent', function () {
    $support = app(\App\AI\Agents\SupportAgent::class);
    $task = new AgentTask(
        id: 'test-3',
        payload: ['ticket' => 'I want a refund for order #123', 'user_id' => 1],
    );

    $result = $support->run($task);
  
    expect($result->success)->toBeTrue()
        ->and($result->data)->toHaveKey('classification')
        ->and($result->data)->toHaveKey('resolution');
});

Observability: Know What Happened Inside Each Agent

// app/AI/Logging/AgentLogger.php
namespace App\AI\Logging;

use App\AI\DTOs\{AgentTask, AgentResult};
use Illuminate\Support\Facades\Log;

class AgentLogger
{
    public function start(string $agentId, AgentTask $task): void
    {
        Log::channel('ai')->info('agent.start', [
            'agent' => $agentId,
            'task_id' => $task->id,
            'payload_size' => strlen(json_encode($task->payload)),
            'timestamp' => now()->toIso8601String(),
        ]);
    }

    public function end(string $agentId, AgentTask $task, AgentResult $result, float $latencyMs): void
    {
        $logData = [
            'agent' => $agentId,
            'task_id' => $task->id,
            'success' => $result->success,
            'latency_ms' => round($latencyMs, 2),
            'timestamp' => now()->toIso8601String(),
        ];

        if ($result->requiresHumanApproval()) {
            $logData['requires_human_approval'] = true;
            Log::channel('ai')->warning('agent.requires_human_approval', $logData);
        } else {
            Log::channel('ai')->info('agent.end', $logData);
        }
    }

    public function delegation(string $parentAgent, string $subAgent, string $reason): void
    {
        Log::channel('ai')->info('agent.delegation', [
            'parent' => $parentAgent,
            'sub_agent' => $subAgent,
            'reason' => $reason,
        ]);
    }
}

Cost Tracking Per Agent

// app/AI/Agents/Traits/HasCostTracking.php
namespace App\AI\Agents\Traits;

use Illuminate\Support\Facades\Cache;

trait HasCostTracking
{
    public function trackCost(string $agentId, int $inputTokens, int $outputTokens, string $model = 'gpt-4o-mini'): void
    {
        $pricing = [
            'gpt-4o-mini' => ['input' => 0.15, 'output' => 0.60],
            'gpt-4o' => ['input' => 2.50, 'output' => 10.00],
            'claude-sonnet-4' => ['input' => 3.00, 'output' => 15.00],
        ][$model] ?? ['input' => 0.15, 'output' => 0.60];

        $costUsd = ($inputTokens * $pricing['input'] / 1_000_000)
                 + ($outputTokens * $pricing['output'] / 1_000_000);

        $dailyKey = "ai:cost:daily:" . date('Y-m-d') . ":{$agentId}";
        $monthlyKey = "ai:cost:monthly:" . date('Y-m') . ":{$agentId}";

        Cache::increment($dailyKey, (int) ($costUsd * 1_000_000));
        Cache::increment($monthlyKey, (int) ($costUsd * 1_000_000));
    }
}

Where to Apply It First

The best initial targets are low-risk, high-volume workflows:

FlowParent AgentSub-agentsRisk
Classify support ticketsSupportAgentRefundsAgent, BillingAgent, DocsAgentLow
Summarize reproducible bugsBugAgentPriorityAgent, AssignAgentLow
Draft billing responsesBillingAgentInvoiceAgent, PaymentAgentMedium
Generate documentation draftsDocsAgentTechWriterAgent, ReviewerAgentLow
Review content before publishReviewAgentSEOAgent, GrammarAgentMedium

Avoid starting with authorization, direct payments or irreversible actions. AI can assist there, but the final decision must remain protected by traditional business rules.


Conclusion: Agents as First-Class Citizens

Laravel AI SDK with sub-agents lets you treat agents as first-class citizens in your architecture: with contracts, tests, logging, attributable costs and a governed lifecycle.

The real shift is not technical — it’s design: moving from “one prompt that does everything” to small, auditable agents connected to real product use cases. That’s the kind of integration that belongs in production.


Take This to Your Team?

Available for technical consulting or hiring as Senior who ships multi-agent workflows in production:

  • Agent architecture (2-3 days): design the agent tree, tools and policies for your product.
  • Hands-on implementation (1-2 weeks): sub-agents, testing, observability, cost control.
  • Existing AI integration audit (2 days): identify quick wins and reduce risk.

Modalities: EOR (Deel, Remote, Oyster), freelance B2B, or direct permanent if you have a Spanish entity.

View My Profile, Full Stack & Terms →


Related articles

Review my developer profile

If this article matches the kind of product work your team is facing, review my stack or professional availability.

Tell me what you need

You can reach out about a role, contract, technical collaboration, question, or general inquiry. A few lines of context are usually enough for me to reply within 24 business hours.

0/500
Availability