{"slug": "building-ai-agents-in-php-tool-calling-with-laravel", "title": "Building AI Agents in PHP: Tool Calling with Laravel", "summary": "A developer detailed a pattern for building AI agents in PHP using Laravel, centered on a tool-calling loop that lets large language models query databases and services. The approach, proven in production for order lookups and support triage, relies on plain Laravel classes for tools, a registry, and a runner with guardrails. The developer emphasized that authorization must live in code, not prompts, treating each tool as a public API endpoint.", "body_md": "The chatbot from earlier in this series can answer questions, but ask it \"how many orders did we ship yesterday?\" and it will confidently make something up. It has no hands — it can't query your database, call your services, or do anything except generate text.\n\nTool calling fixes that. You describe functions to the model; when a user's request needs one, the model responds with \"call this function with these arguments\" instead of prose. Your code runs the function, feeds the result back, and the model writes the final answer grounded in real data. That loop — model picks tool, you execute, model continues — is the whole trick behind \"agents.\" No framework required.\n\nI've shipped this in production Laravel apps for order lookups, report generation, and support triage. Here's the pattern that survived contact with real users.\n\nThree pieces, all plain Laravel:\n\n```\nUser message ──→ AgentController\n                     │\n                     ▼\n              AgentRunner (the loop, max N rounds)\n                     │\n        ┌── model returns text? ──→ done, return answer\n        │\n        └── model returns tool_calls?\n                     │\n                     ▼\n              ToolRegistry ──→ OrderLookupTool\n                            ──→ ShippingStatusTool\n                     │              (each: name, schema, handle())\n                     ▼\n              results appended to messages ──→ back to the model\n```\n\nEach tool is one class. The registry maps names to classes. The runner owns the loop and the guardrails. That's it.\n\n```\n// app/Ai/Tools/Tool.php\ninterface Tool\n{\n    public function name(): string;\n\n    public function description(): string;\n\n    /** JSON Schema for the arguments the model may pass. */\n    public function parameters(): array;\n\n    /** @param array $args validated arguments from the model */\n    public function handle(array $args): string;\n}\n```\n\nTools return strings because that's what goes back into the conversation. JSON-encode structured data — models read it fine.\n\n```\n// app/Ai/Tools/OrderLookupTool.php\nclass OrderLookupTool implements Tool\n{\n    public function name(): string\n    {\n        return 'order_lookup';\n    }\n\n    public function description(): string\n    {\n        return 'Look up an order by its number. Returns status, items, and shipping info.';\n    }\n\n    public function parameters(): array\n    {\n        return [\n            'type' => 'object',\n            'properties' => [\n                'order_number' => [\n                    'type' => 'string',\n                    'description' => 'The order number, e.g. ORD-2041',\n                ],\n            ],\n            'required' => ['order_number'],\n        ];\n    }\n\n    public function handle(array $args): string\n    {\n        $order = Order::where('number', $args['order_number'])\n            ->where('user_id', auth()->id())   // ← the line that matters\n            ->first();\n\n        if (! $order) {\n            return json_encode(['error' => 'Order not found for this account.']);\n        }\n\n        return json_encode([\n            'number' => $order->number,\n            'status' => $order->status,\n            'items' => $order->items->pluck('name'),\n            'shipped_at' => $order->shipped_at?->toDateString(),\n        ]);\n    }\n}\n```\n\nThat `auth()->id()`\n\nscope is the single most important line in this post. The model chooses *which* tool to call and *what arguments* to pass — an attacker can steer both with a crafted prompt. Authorization must live in your code, never in the prompt. Treat every tool as a public API endpoint, because that's what it is now.\n\n``` js\n// app/Ai/AgentRunner.php\nclass AgentRunner\n{\n    private const MAX_ROUNDS = 5;\n\n    public function __construct(private ToolRegistry $tools) {}\n\n    public function run(array $messages): string\n    {\n        foreach (range(1, self::MAX_ROUNDS) as $round) {\n            $response = Http::withToken(config('services.openai.key'))\n                ->timeout(30)\n                ->post('https://api.openai.com/v1/chat/completions', [\n                    'model' => 'gpt-4.1-mini',\n                    'messages' => $messages,\n                    'tools' => $this->tools->schemas(),\n                ])->throw()->json('choices.0.message');\n\n            // Plain text answer — we're done.\n            if (empty($response['tool_calls'])) {\n                return $response['content'] ?? '';\n            }\n\n            $messages[] = $response;\n\n            foreach ($response['tool_calls'] as $call) {\n                $result = $this->tools->execute(\n                    $call['function']['name'],\n                    json_decode($call['function']['arguments'], true) ?? [],\n                );\n\n                $messages[] = [\n                    'role' => 'tool',\n                    'tool_call_id' => $call['id'],\n                    'content' => $result,\n                ];\n            }\n        }\n\n        return 'I could not complete that in a reasonable number of steps.';\n    }\n}\n```\n\n`MAX_ROUNDS`\n\nis not optional. Without it, a confused model can ping-pong between tools forever — each round costing you tokens and your user thirty seconds of spinner. Five rounds covers every legitimate flow I've shipped; anything deeper is a design smell.\n\nThe registry's `execute()`\n\nis where you validate:\n\n``` php\npublic function execute(string $name, array $args): string\n{\n    $tool = $this->map[$name] ?? null;\n\n    if (! $tool) {\n        return json_encode(['error' => \"Unknown tool: {$name}\"]);\n    }\n\n    try {\n        return $tool->handle($args);\n    } catch (Throwable $e) {\n        report($e);\n\n        return json_encode(['error' => 'Tool failed. Try rephrasing.']);\n    }\n}\n```\n\nReturn errors to the model as tool results instead of throwing. Models handle \"that didn't work\" gracefully — they retry with fixed arguments or tell the user. An unhandled exception, by contrast, kills the whole conversation.\n\nEvery round is a full API call carrying the entire conversation plus your tool schemas. A three-round agent turn with five registered tools runs 3–5× the tokens of a plain chat reply. Two mitigations that pay for themselves immediately: register only the tools relevant to the current context (a support agent doesn't need admin reporting tools), and keep descriptions tight — the model reads every schema on every round. On gpt-4.1-mini, my production order-support agent averages under a cent per resolved conversation. The engineer time it replaced cost more per minute.\n\n`MAX_ROUNDS`\n\n, request timeouts, and a per-user rate limit on the endpoint.`json_decode`\n\nthe model's arguments defensively; missing keys and wrong types are routine, not exceptional.`handle()`\n\nis plain PHP — unit test it directly, then one integration test with `Http::fake()`\n\nfor the loop itself.The agent above runs synchronously — fine for lookups, wrong for anything slow. When a tool takes thirty seconds or the user asks for a report across a million rows, you need the loop running in a queued job with progress updates, retries, and a budget. That's the next post in this series: **Queue-Based AI Workflows in Laravel — Jobs, Retries, and Cost Control**.\n\n*I'm Aditya Kumar ( adityakdevin) — Tech Lead & full-stack developer building AI-powered web products with Laravel, Vue, and LLM APIs. Find me at adityadev.in.*", "url": "https://wpnews.pro/news/building-ai-agents-in-php-tool-calling-with-laravel", "canonical_source": "https://dev.to/adityakdevin/building-ai-agents-in-php-tool-calling-with-laravel-4fji", "published_at": "2026-07-17 19:37:25+00:00", "updated_at": "2026-07-17 20:00:12.635410+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "large-language-models", "artificial-intelligence"], "entities": ["Laravel", "OpenAI"], "alternates": {"html": "https://wpnews.pro/news/building-ai-agents-in-php-tool-calling-with-laravel", "markdown": "https://wpnews.pro/news/building-ai-agents-in-php-tool-calling-with-laravel.md", "text": "https://wpnews.pro/news/building-ai-agents-in-php-tool-calling-with-laravel.txt", "jsonld": "https://wpnews.pro/news/building-ai-agents-in-php-tool-calling-with-laravel.jsonld"}}