Stateful Tools in PHP - An Agent That Reads and Mutates Inventory
So far the tools have been read-only (weather, search). The more interesting case is a tool that changes state - placing an order, updating a record, firing an action. The agent has to query state, decide, then mutate it, all in one turn.
This example is an inventory agent: the user asks to buy something, the agent checks stock, then places the order.
Simulated state, real mutation
A plain PHP class stands in for a real database - an in-memory array of products, with methods to read and write it. This keeps the example runnable with no setup, while behaving exactly like a DB-backed store would.
class ProductDatabase {
private array $products = [
'p1' => ['name' => 'Quantum Laptop', 'price' => 1500, 'stock' => 5],
'p2' => ['name' => 'Nano Phone', 'price' => 800, 'stock' => 0],
'p3' => ['name' => 'AI Headset', 'price' => 300, 'stock' => 12],
];
public function search(string $query): array {
$out = [];
foreach ($this->products as $id => $p) {
if (stripos($p['name'], $query) !== false) $out[$id] = $p;
}
return $out;
}
order() is the mutating half: it validates the product exists and has enough stock before touching anything, then decrements the count and returns a human-readable result.
public function order(string $id, int $qty): string {
if (!isset($this->products[$id])) return "Error: Product not found.";
if ($this->products[$id]['stock'] < $qty) return "Error: Insufficient stock.";
$this->products[$id]['stock'] -= $qty; // <-- the mutation
return "Success: Ordered $qty of {$this->products[$id]['name']}. "
. "New stock: {$this->products[$id]['stock']}";
}
public function getInventory(): array { return $this->products; }
}
$db = new ProductDatabase();
Two tools: one reads, one writes
The read tool is a thin wrapper around $db->search() - no logic of its own, just a schema the model can call:
use NanoAgent\Agent;
use NanoAgent\Tools\FunctionTool;
$searchTool = new FunctionTool(
name: 'search_products',
description: 'Search for products by name in the store inventory.',
parameters: [
'type' => 'object',
'properties' => ['query' => ['type' => 'string', 'description' => 'Product name or keyword']],
'required' => ['query'],
'additionalProperties' => false
],
callable: fn(array $args) => $db->search($args['query'])
);
The write tool is the same shape, just pointed at $db->order() instead - the mutation itself lives entirely in the ProductDatabase class, not in the tool:
$orderTool = new FunctionTool(
name: 'place_order',
description: 'Place an order for a specific product by its ID.',
parameters: [
'type' => 'object',
'properties' => [
'product_id' => ['type' => 'string', 'description' => 'The unique product ID'],
'quantity' => ['type' => 'integer', 'description' => 'Units to purchase']
],
'required' => ['product_id', 'quantity'],
'additionalProperties' => false
],
callable: fn(array $args) => $db->order($args['product_id'], $args['quantity'])
);
The agent that runs both
$agent = new Agent(
llm: $llmConfig,
systemPrompt: "You are a retail sales assistant. You have tools 'search_products' and "
. "'place_order'. You MUST use these tools to check availability and place "
. "orders. Do not guess stock levels.",
tools: [$searchTool, $orderTool]
);
$agent->enableActivityLogging();
$response = $agent->chat("I want to buy 2 Quantum Laptops. Check stock and order.");
echo $response;
Check the database afterward and the stock is genuinely lower - this wasn't just a chat reply, the agent actually called place_order and it actually ran:
var_dump($db->getInventory()['p1']['stock']); // 5 -> 3
The "do not guess stock levels" line in the prompt is load-bearing. Without it, a model might answer "yes, we have some" from memory. With it, the agent must call searchproducts to read the real number, then placeorder to act on it.
The patterns that make stateful tools safe
"Success: Ordered 2... New stock: 3" lets the model reason about the outcome and report it. A bare array gives it less to work with."Error: Insufficient stock." is returned to the model, which can then tell the user "only 0 in stock" instead of your app 500ing.additionalProperties => false tightens the schema so the model can't pass junk fields.enableActivityLogging() so you can audit exactly which tools fired in what order - non-negotiable once tools can write.Scaling this up
Swap ProductDatabase for real persistence and you've got an agent that can read your DB, make a decision, and write back to your DB - the core of most "agentic" business automation (orders, tickets, inventory, bookings). The agent is the decision layer; your PHP functions are the hands.
Ready to ship an agent?
composer require hammrouni/nanoagent
MIT, zero dependencies. Explore the full series - each article teaches one pattern.
Get each new article by email - one per publish day + the 12-part cheatsheet when it drops.