AWS Bedrock & Tool Use with Laravel

Christopher Espiritu

Christopher Espiritu

Mastering AWS Bedrock Tool Use with Laravel: Building Agentic Workflows

The ability for Large Language Models (LLMs) to "act" rather than just "talk" is the defining feature of the next generation of AI applications. AWS Bedrock calls this Tool Use (often known as function calling in the broader AI ecosystem).

In this guide, we will implement a robust service in Laravel that allows models like Claude 3.5 Sonnet or Nova to request the execution of PHP code—fetching live data, running database queries, or interacting with APIs—and then using that data to generate a final answer.

The Architecture: text-in, Action-out

Unlike a standard chatbot where you send text and get text back, Tool Use involves a "loop." The model analyzes your prompt, decides it needs external data, and asks you to run a function. You run it, give the model the result, and then it answers the user.

Here is the flow we will build:

sequenceDiagram
    participant User
    participant Laravel
    participant Bedrock

    User->>Laravel: "Compare AAPL to its 52-week high"
    Note over Laravel: Agent Loop Starts
    Laravel->>Bedrock: Converse API (Prompt + Tool Definitions)
    Bedrock-->>Laravel: Stop Reason: "tool_use" (Run `get_stock_price`)
    Note over Laravel: Execute get_stock_price('AAPL')
    Laravel->>Bedrock: Converse API (Tool Result: "$150.25")
    Bedrock-->>Laravel: Stop Reason: "tool_use" (Run `get_stock_history`)
    Note over Laravel: Execute get_stock_history('AAPL')
    Laravel->>Bedrock: Converse API (Tool Result: "52wk high: $180")
    Bedrock-->>Laravel: Stop Reason: "end_turn" + Final Response
    Note over Laravel: Agent Loop Ends
    Laravel-->>User: "AAPL is trading 16% below its 52-week high"

Prerequisites

Ensure you have the AWS SDK for PHP installed in your Laravel project.

composer require aws/aws-sdk-php

Add your AWS credentials to your .env file. Ensure the user associated with these keys has bedrock:InvokeModel permissions.

AWS_ACCESS_KEY_ID=your_key
AWS_SECRET_ACCESS_KEY=your_secret
AWS_DEFAULT_REGION=us-east-1
BEDROCK_MODEL_ID=anthropic.claude-3-sonnet-20240229-v1:0

Step 1: The Bedrock Service

We'll create a dedicated service to handle the complexity of the Converse API. This keeps your controllers clean and allows for easy swapping of models.

Create app/Services/BedrockService.php:

<?php

namespace App\Services;

use Aws\BedrockRuntime\BedrockRuntimeClient;
use Aws\Exception\AwsException;

class BedrockService
{
    protected BedrockRuntimeClient $client;

    public function __construct()
    {
        $this->client = new BedrockRuntimeClient([
            'region'  => config('services.aws.region', 'us-east-1'),
            'version' => 'latest',
            'profile' => 'default', // Optional: Remove if using Env vars directly
        ]);
    }

    /**
     * The main entry point for the conversation.
     *
     * @param array $messages The history of the conversation
     * @param array $tools The definition of tools available to the model
     * @param string|null $systemPrompt Instructions for the model's persona
     */
    public function converse(array $messages, array $tools = [], ?string $systemPrompt = null)
    {
        $payload = [
            'modelId'  => config('services.bedrock.model_id', 'anthropic.claude-3-sonnet-20240229-v1:0'),
            'messages' => $messages,
            'inferenceConfig' => [
                'maxTokens'   => 2000,
                'temperature' => 0.5,
            ]
        ];

        // Add System Prompt if provided (Crucial for defining the agent's behavior)
        if ($systemPrompt) {
            $payload['system'] = [['text' => $systemPrompt]];
        }

        // Only add toolConfig if tools are provided
        if (!empty($tools)) {
            $payload['toolConfig'] = [
                'tools' => $tools
            ];
        }

        try {
            return $this->client->converse($payload);
        } catch (AwsException $e) {
            // In production, log this error
            throw $e;
        }
    }
}

Step 2: Defining the Tools

Bedrock expects tools to be defined in a specific JSON Schema format. In PHP, this translates to a nested array structure.

Crucial Gotcha: PHP treats empty arrays [] as JSON arrays []. However, JSON Schema often requires objects {} for empty properties.

If a tool has no parameters, or you have an empty properties list, you must cast it to an object: (object)[]. Failure to do this will result in a Bedrock Validation Exception.

Here is a definition for a tool that gets stock prices:

$stockTool = [
    'toolSpec' => [
        'name' => 'get_stock_price',
        'description' => 'Retrieves the current stock price for a given ticker symbol.',
        'inputSchema' => [
            'json' => [
                'type' => 'object',
                'properties' => [
                    'symbol' => [
                        'type' => 'string',
                        'description' => 'The stock ticker symbol (e.g., AAPL, MSFT).'
                    ]
                ],
                'required' => ['symbol']
            ]
        ]
    ]
];

Step 3: The Controller Loop (The Agent)

Now, let's tie it all together. This is where the "loop" logic lives.

The key insight here is that the model may need multiple rounds of tool calls. A simple if statement allows for one tool call. A while loop allows for "Chain of Thought" reasoning (e.g., Get Price -> Get History -> Compare -> Final Answer).

<?php

namespace App\Http\Controllers;

use App\Services\BedrockService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class ChatController extends Controller
{
    protected BedrockService $bedrock;

    public function __construct(BedrockService $bedrock)
    {
        $this->bedrock = $bedrock;
    }

    public function chat(Request $request)
    {
        $userPrompt = $request->input('message', "Compare AAPL to its 52-week high.");
        
        // 1. Initialize Conversation History
        $messages = [
            [
                'role' => 'user',
                'content' => [['text' => $userPrompt]]
            ]
        ];

        // 2. Define Tools
        $tools = $this->getToolDefinitions();
        
        // 3. Define System Prompt
        $systemPrompt = "You are a helpful financial assistant. Always use data tools before making claims.";

        // 4. Safety limits
        $maxIterations = 5;
        $iterations = 0;

        // 5. THE AGENTIC LOOP
        while ($iterations < $maxIterations) {
            $iterations++;

            // Call Bedrock
            $response = $this->bedrock->converse($messages, $tools, $systemPrompt);
            
            $output = $response['output']['message'];
            $stopReason = $response['stopReason'];

            // CASE A: Model is done reasoning
            if ($stopReason === 'end_turn') {
                return response()->json([
                    'response' => $output['content'][0]['text'],
                    'iterations' => $iterations
                ]);
            }

            // CASE B: Model wants to use tools
            if ($stopReason === 'tool_use') {
                // IMPORTANT: You must append the model's "intent" to the history
                // so it remembers what it asked for in the next loop.
                $messages[] = $output;

                $toolResults = [];

                foreach ($output['content'] as $block) {
                    if (isset($block['toolUse'])) {
                        $toolUse = $block['toolUse'];

                        // Execute PHP Code
                        $result = $this->executeTool(
                            $toolUse['name'],
                            $toolUse['input']
                        );

                        // Structure the result specifically for Bedrock
                        $toolResults[] = [
                            'toolResult' => [
                                'toolUseId' => $toolUse['toolUseId'], // ID is required to link result to request
                                'content' => [
                                    ['json' => ['result' => $result]]
                                ]
                            ]
                        ];
                    }
                }

                // Append the results as a new "user" message
                $messages[] = [
                    'role' => 'user',
                    'content' => $toolResults
                ];
                
                // The loop continues... Bedrock will see the result and decide what to do next.
                continue;
            }

            // Handle unexpected stop reasons
            break;
        }

        return response()->json(['error' => 'Max iterations reached'], 500);
    }

    // ... (getToolDefinitions and executeTool methods below)
}

The Tool Execution Logic

This method acts as the router between the LLM's string request and your actual PHP logic.

private function executeTool(string $name, array $input): mixed
{
    // Log the tool usage for debugging
    Log::info("Agent executing tool: {$name}", $input);

    return match ($name) {
        'get_stock_price' => $this->getStockPrice($input['symbol']),
        'get_stock_history' => $this->getStockHistory($input['symbol']),
        default => ['error' => 'Tool not found']
    };
}

private function getStockPrice(string $symbol): array
{
    // In production: call a real API like Alpha Vantage, Yahoo Finance, etc.
    $prices = ['AAPL' => 150.25, 'MSFT' => 310.50];
    
    return [
        'symbol' => $symbol,
        'price' => $prices[$symbol] ?? 0,
        'currency' => 'USD',
    ];
}

// ... implement getStockHistory similarly

Conclusion and Final Thoughts

Tool Use transforms Claude from a conversational assistant into an autonomous agent capable of taking real-world actions. The agentic loop pattern demonstrated here—where the model iteratively requests tools, receives results, and reasons toward a final answer—is the foundation for building sophisticated AI-powered applications.

flowchart TD
    subgraph "The Agentic Loop Pattern"
        A[User Prompt] --> B[Send to Bedrock]
        B --> C{Stop Reason?}
        C -->|end_turn| D[Return Final Response]
        C -->|tool_use| E[Extract Tool Requests]
        E --> F[Execute PHP Functions]
        F --> G[Append Results to History]
        G --> B
    end

    D --> M[Autonomous Agent Complete]

Key takeaways from this implementation:

  1. The loop is everything. A single tool call is useful, but the real power comes from allowing the model to chain multiple calls together, reasoning through complex problems step by step.

  2. Message history matters. Always append the model's tool request to the conversation before adding your results. Without this context, the model loses track of what it asked for.

  3. Watch for PHP's array quirks. The (object)[] cast for empty properties will save you hours of debugging cryptic validation errors.

  4. Set iteration limits. Agentic loops can theoretically run forever. A sensible cap (5-10 iterations) prevents runaway API costs and infinite loops.

  5. Log everything. When debugging agent behavior, knowing exactly which tools were called with which inputs is invaluable.

From here, you can extend this pattern by adding more tools, implementing error handling within tool results, or building a tool registry that dynamically loads available functions. The Converse API's unified interface means you can also swap between Claude, Nova, and other Bedrock models with minimal code changes—letting you optimize for cost, speed, or capability as your application evolves.