Agentic AI in PHP Is Not a Prototype Idea. It Is a Production Architecture Decision

Agentic AI systems in PHP are not a novelty. They are a structured architecture problem… and PHP solves it cleanly when you treat the AI layer as a domain concern, not a script. Neuron AI provides the primitives: agents, tools, memory, and providers. The missing piece most PHP teams skip is governance… clean interfaces, reusable tool registries, and stateless agent design that survives horizontal scaling. Done correctly, a PHP agentic AI backend handles production SaaS workloads with the same reliability guarantees as any other domain service.
Most PHP engineers approach AI integration the wrong way: they call an LLM API in a controller, return a string, and call it “AI-powered.”
That is not an agent. That is a wrapper.
Think of it this way. A wrapper is like calling a taxi and reading out every turn yourself. An agent is like calling an Uber… you give the destination, it figures out the route. The goal is the same. The level of control you hand over is completely different.
An agent is a system that reasons, selects tools, executes actions, and adapts based on output… in a loop… without you hardcoding the decision path.
The critical architectural insight: the agent is not the feature. The agent is the orchestrator. Your existing PHP domain logic… the services, repositories, and value objects you already have… becomes the tool layer. The agent decides when and how to call them.
This is the pattern that unlocks production viability for PHP AI systems:
- Domain layer first … your business logic stays in PHP classes, completely independent of the AI layer.
- Tool layer second … domain services are wrapped as discrete, describable tools the agent can call.
- Agent layer third … the agent receives a goal and autonomously selects tools to achieve it.
Teams that skip this sequencing end up with AI logic tangled inside controllers, non-reusable prompt strings hardcoded in service methods, and zero ability to swap providers or test agent behaviour in isolation.
The PHP ecosystem… with Neuron AI… supports all three layers cleanly. The architecture discipline to keep them separated is what makes it production-grade.
Why PHP for Agentic AI? The Real Engineering Case
The question is not “can PHP do AI?” It already does. The question is: does PHP give you architectural control over AI behaviour?
What Agentic PHP Systems Actually Need
- A structured way to define tools the agent can invoke… not ad-hoc function calls.
- Provider abstraction… the ability to swap OpenAI for Anthropic or a local model without rewriting agent logic.
- Memory management… passing relevant context to the LLM without blowing the token budget.
- Stateless agent execution… so the same agent class runs in a Lambda, a queue consumer, or a synchronous HTTP handler without modification.
- Testability… the ability to run an agent against a mock provider to assert reasoning behaviour in CI.
Neuron AI satisfies all five. And unlike Python-centric frameworks (LangChain, CrewAI), there is no context-switch away from your existing PHP codebase. Your team stays in one language, one standard, one deployment pipeline.
Core Architecture: The Three Layers
Layer 1 — The Domain (Unchanged)
Definition: The domain layer is your existing PHP business logic. Entities, value objects, repositories, services. The AI layer must not touch it. This layer has zero knowledge that an agent exists.
In the given code example you can see that
- The domain service is testable in pure PHPUnit… no AI dependency in the test.
- If the agent framework changes tomorrow, the domain is untouched.
- Follows Single Responsibility:
ProductSearchServicesearches products. The agent decides when to call it.
<?php
// Domain: Product — pure business logic, zero AI dependency
// Behind Methods Lab Standard: domain layer is AI-agnostic
declare(strict_types=1);
namespace Domain\Catalogue;
final class ProductSearchService
{
public function __construct(
private readonly ProductRepository $products,
) {}
/**
* Returns products matching criteria — knows nothing about agents or LLMs.
*
* @return Product[]
*/
public function findByKeyword(string $keyword, int $limit = 5): array
{
return $this->products->searchByKeyword($keyword, $limit);
}
public function getProductById(ProductId $id): ?Product
{
return $this->products->findById($id);
}
}Layer 2 — The Tool Layer (Agent-to-Domain Bridge)
Definition: Tools are the interface between the agent and your domain. Think of it like a TV remote… the agent is you deciding what to watch, the tools are the buttons (volume, channel, input), and the TV is your domain. You do not rewire the TV every time you want a new option. Each tool wraps one domain action, declares its input schema in natural language, and returns a structured result the agent can reason about.
Again, in this layer 2 you can clear note following. You can keep an eye on these bullets and the code to understand the bridge between agent and domain.
- The
descriptionfield is load-bearing architecture… the LLM uses it to decide when to call the tool. Vague descriptions produce wrong tool selection. Precise descriptions produce reliable routing. - The tool owns the translation between LLM output (raw string arguments) and domain types (
string→ProductId). No leakage of LLM data formats into the domain. - Zero framework dependency… the tool is a plain PHP class the agent receives as a constructor argument.
- Follows Dependency Inversion: the tool depends on the
ProductSearchServiceinterface, not a concrete implementation.
<?php
// Tool: ProductSearchTool — wraps domain service for agent consumption
// Behind Methods Lab Standard: one tool = one domain capability
declare(strict_types=1);
namespace AI\Tools;
use NeuronAI\Tools\Tool;
use NeuronAI\Tools\ToolProperty;
use Domain\Catalogue\ProductSearchService;
class ProductSearchTool extends Tool
{
public function __construct(
private readonly ProductSearchService $searchService,
) {
parent::__construct(
// Name + description are what the LLM reads to decide when to call this tool
name: 'search_products',
description: 'Search the product catalogue by keyword. Use this when the user asks about available products, product features, or product recommendations.',
);
// Declare input schema — the LLM generates arguments matching this shape
$this->addProperty(
new ToolProperty(
name: 'keyword',
type: 'string',
description: 'The search keyword extracted from the user query.',
required: true,
)
);
$this->addProperty(
new ToolProperty(
name: 'limit',
type: 'integer',
description: 'Maximum number of products to return. Default 5.',
required: false,
)
);
}
/**
* Executed when the agent selects this tool.
* Returns a string the agent uses for its next reasoning step.
*/
public function execute(string $keyword, int $limit = 5): string
{
$products = $this->searchService->findByKeyword($keyword, $limit);
if (empty($products)) {
return "No products found matching '{$keyword}'.";
}
// Structured text — not raw JSON dumped directly, but readable for LLM reasoning
$lines = array_map(
fn($p) => "- {$p->name()} (ID: {$p->id()}) — {$p->shortDescription()} [{$p->priceFormatted()}]",
$products,
);
return "Found " . count($products) . " products:\n" . implode("\n", $lines);
}
}A: No. A tool maps to a capability the agent needs, not to every service method that exists. Design tools from the agent’s perspective: what discrete actions does it need to accomplish its goal? A single tool can call multiple domain services internally. The LLM model context window is finite — fewer, well-scoped tools outperform large toolsets every time.
Layer 3 — The Agent (Reasoning Orchestrator)
Definition: The agent is the reasoning loop. It receives a goal, iterates over a provider-backed LLM, selects tools, executes them, evaluates results, and produces a final response… all without you scripting the decision path.
Layer 3 is bit longer but with following two code samples I am trying to explain you following bullets.
- The agent is a constructor-injected class… testable, swappable, container-managed.
- The system prompt is structured, not freeform. Background, steps, and output constraints are declared separately… easier to audit, easier to version in Git.
CatalogueAgenthas no knowledge of HTTP, queues, or Lambda… it is transport-agnostic. The same class runs in a controller or an async worker.- Follows Open/Closed: adding a new tool does not modify the agent class… it registers alongside the existing ones.
<?php
// Agent: CatalogueAgent — autonomous product assistant
// Behind Methods Lab Standard: agent depends on interfaces, not concrete providers
declare(strict_types=1);
namespace AI\Agents;
use NeuronAI\Agent;
use NeuronAI\Providers\AIProviderInterface;
use NeuronAI\SystemPrompt;
use AI\Tools\ProductSearchTool;
use AI\Tools\OrderStatusTool;
use AI\Tools\ProductDetailTool;
use Domain\Catalogue\ProductSearchService;
use Domain\Orders\OrderQueryService;
class CatalogueAgent extends Agent
{
public function __construct(
AIProviderInterface $provider,
ProductSearchService $searchService,
OrderQueryService $orderService,
) {
parent::__construct($provider);
// System prompt defines the agent's role and constraints
// This is part of your architecture — not a freeform string
$this->setSystemPrompt(new SystemPrompt(
background: [
'You are a product assistant for an e-commerce platform.',
'You help users find products, check availability, and track orders.',
'You only answer questions related to the product catalogue and orders.',
],
steps: [
'Identify the user\'s intent from their message.',
'Select the appropriate tool to fetch information.',
'Verify the result is relevant before responding.',
'If no matching product or order is found, say so clearly.',
],
output: [
'Respond concisely. List products with name, ID, and price.',
'Never fabricate product names, prices, or order statuses.',
'If the user asks something outside your scope, decline politely.',
],
));
// Register tools — the agent's capabilities at runtime
$this->registerTools([
new ProductSearchTool($searchService),
new ProductDetailTool($searchService),
new OrderStatusTool($orderService),
]);
}
}<?php
// Usage: resolving the agent from the container and running it in a controller
// or a queue consumer — identical interface regardless of context
declare(strict_types=1);
namespace Application\AI;
use AI\Agents\CatalogueAgent;
use NeuronAI\Chat\Messages\UserMessage;
final class HandleCatalogueQueryHandler
{
public function __construct(
private readonly CatalogueAgent $agent,
) {}
public function handle(string $userQuery): string
{
// The agent manages its reasoning loop internally
// You send a message; you receive a final answer
$response = $this->agent->chat(new UserMessage($userQuery));
return $response->getContent();
}
}A: The tool layer is the guard. The agent can only return what your ProductSearchService returns. If no products match the query, the tool returns "No products found" — and the system prompt instructs the agent to surface that honestly. The LLM cannot manufacture data it never received from a tool. Grounding agent responses in tool results, not LLM memory, is the primary hallucination control mechanism.
Provider Abstraction — Avoiding LLM Lock-In
The worst PHP AI architecture decision is hardcoding OpenAI. It is like buying a flat with one power socket format… if the standard changes, or you need to test a different model, you are rewiring the whole property. Neuron AI’s provider interface is your universal adapter.
<?php
// Provider factory — swap models without touching agent code
// Behind Methods Lab Standard: LLM provider is an infrastructure concern
declare(strict_types=1);
namespace Infrastructure\AI;
use NeuronAI\Providers\AIProviderInterface;
use NeuronAI\Providers\Anthropic\Anthropic;
use NeuronAI\Providers\OpenAI\OpenAI;
final class AIProviderFactory
{
public static function create(): AIProviderInterface
{
$provider = env('AI_PROVIDER', 'anthropic');
return match ($provider) {
'anthropic' => new Anthropic(
key: env('ANTHROPIC_API_KEY'),
model: env('ANTHROPIC_MODEL', 'claude-3-5-sonnet-20241022'),
),
'openai' => new OpenAI(
key: env('OPENAI_API_KEY'),
model: env('OPENAI_MODEL', 'gpt-4o'),
),
default => throw new \InvalidArgumentException(
"Unsupported AI provider: {$provider}"
),
};
}
}Why this matters in production:
- Provider pricing changes. Provider performance varies by task type. Being locked to one vendor means paying their price floor, always.
- A/B testing two providers on the same agent requires zero code changes… only an environment variable swap.
- If a provider has an outage, failover is one config change.
- The agent never imports
AnthropicorOpenAIdirectly… onlyAIProviderInterface. Infrastructure decisions stay in the infrastructure layer.
Memory Architecture — Controlling Context Without Burning Tokens
Stateless agents forget every conversation turn. Stateful agents accumulate context until they exceed the model’s context window. Neither is acceptable in production.
Think of it like a conversation notebook. Stateless means you tear out all the pages after every chat. Stateful means you never throw anything out… until the book is full and the model breaks. TokenBufferMemory keeps the most recent pages and quietly discards the oldest. You always have relevant context… just not every word ever said.
Memory management is an explicit architecture decision.
<?php
// Agent with scoped memory — sliding window keeps context bounded
// Behind Methods Lab Standard: token budget is a first-class constraint
declare(strict_types=1);
namespace AI\Agents;
use NeuronAI\Agent;
use NeuronAI\Memory\ConversationMemory;
use NeuronAI\Memory\TokenBufferMemory;
use NeuronAI\Providers\AIProviderInterface;
class StatefulCatalogueAgent extends Agent
{
public function __construct(
AIProviderInterface $provider,
string $sessionId,
// Memory is injected — the storage backend (Redis, DB) is an infra decision
ConversationMemory $memory,
) {
parent::__construct($provider);
// TokenBufferMemory trims oldest messages when the budget is exceeded
// Protects against unbounded context growth in long sessions
$this->setMemory(
new TokenBufferMemory(
memory: $memory,
maxTokens: 4096, // Reserve space for tools + response
)
);
}
}<?php
// Memory storage backed by Redis — session-scoped, TTL-managed
// The agent does not know or care about Redis; ConversationMemory is the interface
declare(strict_types=1);
namespace Infrastructure\AI;
use NeuronAI\Memory\ConversationMemory;
use Predis\Client as RedisClient;
final class RedisConversationMemory implements ConversationMemory
{
private const TTL_SECONDS = 3600; // 1 hour session window
public function __construct(
private readonly RedisClient $redis,
private readonly string $sessionId,
) {}
public function getMessages(): array
{
$data = $this->redis->get("ai:session:{$this->sessionId}");
return $data ? json_decode($data, true) : [];
}
public function addMessage(array $message): void
{
$messages = $this->getMessages();
$messages[] = $message;
$this->redis->setex(
"ai:session:{$this->sessionId}",
self::TTL_SECONDS,
json_encode($messages),
);
}
public function clear(): void
{
$this->redis->del("ai:session:{$this->sessionId}");
}
}So, what is the user of this memory and why do you need it. The reason is simple. See..
TokenBufferMemoryprevents context window overflow silently… no runtime error, no truncation surprise.- Memory storage is an infrastructure detail. Swapping Redis for DynamoDB or a database table requires no agent changes.
- Session TTL is explicit… no memory leaks in Redis from abandoned sessions.
- The agent is still stateless at the class level… all state lives in the injected memory store. Horizontal scaling works without sticky sessions.
A: Only if you let it. Explicit TTLs and a clear() method on the memory interface are mandatory for GDPR-compliant deployments. Add a memory purge call to your user data deletion webhook and agent session history clears alongside all other user data. The memory interface makes this a 2-line implementation… not an architectural afterthought.
Scaling for Production — The Behind Methods Lab Standard
Single-agent, synchronous execution is fine for development. Production SaaS with concurrent users requires a different topology.
This is the restaurant model. You do not stand at the kitchen pass waiting for your food. You place the order, get a number, and they call you when it is ready. Your table is free immediately. The HTTP layer is your table. The queue worker is the kitchen.
<?php
// Queue consumer — async agent execution via Laravel/Symfony queue or raw SQS
// Behind Methods Lab Standard: decouple agent execution from HTTP response cycle
declare(strict_types=1);
namespace Application\AI\Jobs;
use AI\Agents\CatalogueAgent;
use NeuronAI\Chat\Messages\UserMessage;
use Domain\AgentResults\AgentResultRepository;
final class ProcessAgentQueryJob
{
public function __construct(
private readonly CatalogueAgent $agent,
private readonly AgentResultRepository $results,
) {}
public function handle(string $queryId, string $userQuery): void
{
// Execute the agent — may take 2–15 seconds depending on tool chain length
$response = $this->agent->chat(new UserMessage($userQuery));
// Persist result — HTTP layer polls or receives webhook when ready
$this->results->store($queryId, $response->getContent());
}
}<?php
// HTTP controller — non-blocking agent dispatch
// Behind Methods Lab Standard: HTTP response is immediate; agent runs async
declare(strict_types=1);
namespace Infrastructure\Http\Controllers;
use Application\AI\Jobs\ProcessAgentQueryJob;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Ramsey\Uuid\Uuid;
final class AgentQueryController
{
public function __construct(
private readonly ProcessAgentQueryJob $job,
) {}
public function __invoke(Request $request): JsonResponse
{
$queryId = Uuid::uuid4()->toString();
// Dispatch to queue — HTTP response returns in < 100ms
dispatch($this->job)->with($queryId, $request->input('query'));
return response()->json([
'query_id' => $queryId,
'status' => 'processing',
'poll_url' => "/api/agent/result/{$queryId}",
], 202);
}
}Why this works:
- Agent execution time is non-deterministic… a multi-tool chain can take 15 seconds or 45 seconds. Blocking an HTTP connection for that duration is not acceptable at scale.
- The HTTP layer returns a
202 Acceptedimmediately with a poll URL. The agent runs in a queue worker that scales independently. - Queue workers are stateless… add more workers to handle more concurrent queries without touching agent code.
AgentResultRepositoryis the persistence contract. The polling endpoint reads from it… agent and HTTP handler are fully decoupled.
Frequently Asked Questions
Neuron AI provides a stable agent loop, tool interface, and provider abstraction layer. The core primitives… Agent, Tool, AIProviderInterface, and memory management… map directly to the requirements of production agentic systems. What you bring is the governance layer: clean tool boundaries, async execution, and memory TTL management. The framework gives you the engine. The architecture gives you the reliability.
Inject a mock AIProviderInterface that returns scripted tool call sequences. You assert that given a specific user query, the agent selects the correct tool with the correct arguments… without incurring API cost or latency. The agent’s tool selection logic is deterministic once you fix the provider response. That determinism is what you test. Real provider calls are reserved for integration tests run against a staging environment.
One agent per bounded capability is the correct pattern. A single agent managing order tracking, product recommendations, invoice generation, and customer support simultaneously is a distributed monolith wearing an AI costume. Each agent should do one thing well… like a specialist, not a generalist trying to cover everything. Multi-agent orchestration… where a supervisor agent delegates to specialist agents… is the correct scaling primitive. Keep individual agents narrow. Compose them at the orchestration layer.
Neuron AI exposes a maxIterations configuration on the agent. Set it explicitly… never rely on the default. For production, a maximum of 5–8 tool calls per agent invocation covers the majority of real use cases. Beyond that, the agent is stuck in a reasoning loop or the task decomposition is wrong. Pair maxIterations with execution time monitoring: log a warning if an agent invocation exceeds 30 seconds. Both are non-optional on production deployments.