llm-agents/agents
A Laravel package for building LLM-powered agents with tool calling, prompt management, and structured multi-step workflows. Integrates AI agents into PHP apps through a clean, framework-native API for automating tasks and composing agent pipelines.
This is a framework-agnostic PHP SDK, not a Laravel package. There is no service provider, no facade, no config file to publish, and no auto-discovery. Expect to wire it yourself in app/Providers/AppServiceProvider.php or a dedicated LlmServiceProvider.
Install the SDK plus a provider driver. The agents package defines the abstractions (agent loop, chat, messages, tools); the concrete LLM backend lives in a sibling package from the same org:
composer require llm-agents/agents llm-agents/openai
Confirm the exact provider package name and PHP constraint in the org's package list before committing — the SDK moves fast relative to its release cadence. 1.7.0 added prompt context values, response formatting, and a MetadataAwareInterface; if you are coming from a 1.6.x example online, the wiring is the same but the ergonomics around prompt building and output parsing have changed for the better.
Where to look first, in this order:
src/ top-level interfaces. Skim for the core contracts: the agent/chat entry point, Message (roles: system/user/assistant/tool), the tool contract, the toolkit/collection, and the provider interface. Since 1.7.0 also look for MetadataAwareInterface and whatever contract backs the new response-formatting and prompt-context features (names may differ from the release notes).First use case — a one-shot agent in Tinker or an Artisan command. Don't start with a chat UI. Start with something you can dd():
// config/llm.php (create it yourself)
return [
'provider' => env('LLM_PROVIDER', 'openai'),
'api_key' => env('OPENAI_API_KEY'),
'model' => env('LLM_MODEL', 'gpt-4o-mini'),
'max_steps' => 6,
];
// routes/console.php or a temporary command
$provider = new OpenAiProvider(config('llm.api_key'));
$chat = new Chat($provider); // verify exact constructor in README
$chat->addMessage(Message::system('You are a terse assistant.'));
$chat->addMessage(Message::user('Summarise order #1042 status.'));
$response = $chat->run();
dump((string) $response);
Once that returns text, add exactly one tool (e.g. LookupOrderTool that queries Eloquent by order number) and re-run. That single step is where 90% of integration surprises live: tool schema shape, how arguments arrive, and what the tool must return.
Then take the two 1.7.0 features for a spin in the same scratch command — ask for structured output via the new formatting API and pass a couple of prompt context values instead of concatenating strings. If those two work, you have exercised the parts of the release that matter day-to-day.
The provider client usually holds the HTTP client and config; make it a singleton. The chat/agent object is stateful (it accumulates messages), so it must be transient — never a singleton.
$this->app->singleton(ProviderInterface::class, fn () => new OpenAiProvider(
apiKey: config('llm.api_key'),
model: config('llm.model'),
));
$this->app->bind(AgentFactory::class, fn ($app) => new AgentFactory(
$app->make(ProviderInterface::class),
$app->make(Toolkit::class),
));
The new prompt-context feature lets you populate placeholders/variables in a prompt template rather than building the system message with sprintf or heredocs. That matters because it keeps the prompt text reviewable in one place and the values injected at runtime — and it gives you a natural seam for logging what was actually sent.
final class SupportAgentFactory
{
public function __construct(private AgentFactory $agents) {}
public function forUser(User $user): Chat
{
$chat = $this->agents->make();
// Exact method names: check the README / PR #18 in the release notes.
$chat->withContext([
'company' => config('app.name'),
'customer' => $user->name,
'plan' => $user->plan->label(),
'today' => now()->toDateString(),
'locale' => $user->locale,
]);
return $chat;
}
}
Practical rules:
name of Ignore previous instructions… is a prompt-injection vector. Strip newlines, cap length, and never put raw request payloads or HTML into context.The formatting feature is the answer to "the model returned prose and my json_decode threw." Use it instead of hand-rolled parsing whenever you need structured data back — extraction, classification, routing, generating DTOs.
// Illustrative — verify method names against PR #20 / README.
$response = $chat
->format(\App\Agent\Formats\OrderSummary::class) // or a schema/array definition
->run();
$summary = $response->formatted(); // typed/array result, not raw text
Patterns that work:
Wrap each capability in a small class with a precise name, description, and JSON-schema parameters. Keep descriptions written for the model, not for humans — they are the prompt.
final class LookupOrderTool implements ToolInterface
{
public function __construct(private OrderRepository $orders) {}
public function name(): string { return 'lookup_order'; }
public function description(): string
{
return 'Fetch status, total and shipping date for a single order by its numeric ID.';
}
public function parameters(): array
{
return [
'type' => 'object',
'properties' => [
'order_id' => ['type' => 'integer', 'description' => 'Numeric order ID'],
],
'required' => ['order_id'],
];
}
public function __invoke(array $arguments): string
{
$order = $this->orders->findOrFail((int) $arguments['order_id']);
return json_encode([
'status' => $order->status,
'total' => $order->total,
'shipped' => $order->shipped_at?->toDateString(),
]);
}
}
Resolve tools from the container so they get their own dependencies:
$toolkit = new Toolkit([
$app->make(LookupOrderTool::class),
$app->make(RefundOrderTool::class),
]);
The new MetadataAwareInterface is the cleanest hook you have for observability. Implement it on your tools and/or on a provider decorator so every tool call and model call carries the same identifiers — conversation_id, user_id, tenant_id, trace_id.
final class LookupOrderTool implements ToolInterface, MetadataAwareInterface
{
public function metadata(): array
{
return [
'tenant' => tenant()->id,
'conversation_id' => $this->conversationId,
];
}
}
Two uses worth building on day one:
There is no built-in storage. Use a conversations + agent_messages pair, or a single messages JSON column on your existing model. Store role, content, and the raw tool-call/tool-result payloads (as JSON) so you can rehydrate the exact message array on the next turn. Rehydrating only the text content will break multi-turn tool use.
foreach ($conversation->messages as $row) {
$chat->addMessage(Message::fromArray($row->payload)); // check helper name
}
Note that prompt context is not conversation history. Context values are re-applied per run from live data (that's the point); don't persist them as messages and don't let stale context values accumulate.
Agent loops are multi-round-trip and slow. Wrap each invocation in a job with an explicit timeout, and broadcast progress:
final class RunAgentJob implements ShouldQueue
{
public $tries = 2;
public $timeout = 120;
public $backoff = [10, 60];
public function __construct(public int $conversationId, public string $prompt) {}
public function handle(AgentFactory $factory): void
{
$agent = $factory->forConversation($this->conversationId);
$result = $agent->run($this->prompt);
broadcast(new AgentReplied($this->conversationId, $result));
}
}
Keep HTTP requests out of the job's critical path by not streaming from the queue — stream only from a live controller.
For chat UIs, the controller should return an SSE stream and flush per token. Disable output buffering and set_time_limit(0); wrap the loop in try/finally so you always emit a terminal event.
return response()->stream(function () use ($chat) {
foreach ($chat->runStreamed() as $chunk) { // confirm stream API in README
echo "data: " . json_encode(['delta' => (string) $chunk]) . "\n\n";
ob_flush(); flush();
}
echo "event: done\ndata: {}\n\n";
}, 200, ['Content-Type' => 'text/event-stream', 'X-Accel-Buffering' => 'no']);
Streaming and response formatting are awkward bedfellows — you generally can't parse a format until the stream ends. Pick one per endpoint: stream for prose chat, format for structured jobs.
Rather than a bespoke orchestrator, register the sub-agents as tools on a cheap router agent. The router decides which specialist to call; each specialist is a full Chat with its own toolkit. With 1.7.0 you can push this further: force the router's response into a format like {"route": "billing", "reason": "…"} instead of describing the routing grammar in prose and regexing the output.
Wrap the provider (or the agent) in your own decorator that logs model, token counts, latency, and the full message array on failure. This is a one-class investment that pays off the first time an agent loops or returns nonsense, and combined with MetadataAwareInterface it gives you per-user cost attribution the SDK won't provide.
Bind a fake provider in the test container that returns canned assistant messages, including a scripted tool call, so you exercise the tool-dispatch path without network access. Assert on the persisted message rows and on your tool's side effects — not on the final prose.
For 1.7.0 specifically, add two cheap tests: one that your formatted response parses into the expected DTO from a fixed canned string, and one that it fails gracefully when given near-miss JSON. That second test is the one that saves you in production.
config:cache safety net. If you call env('OPENAI_API_KEY') inside a service provider closure rather than a config file, it returns null after php artisan config:cache. Always route through config().bind, not singleton, and never store an Agent/Chat on a singleton or in a static. This applies to context values too — a singleton agent accumulates another user's context.__invoke() and return a short, model-readable error ("Order 1042 not found"). Letting the exception escape aborts the whole run and discards the conversation turn.…truncated marker.metadata(), confirm where it goes — a provider payload, a log line, or your agent_messages JSON column. Keep it to IDs and scalars.retry(3, fn () => ..., 500, fn ($e) => $e instanceof RateLimitException) with jitter. Blindly retrying a mid-loop agent call can duplicate tool side effects — make tools idempotent (upserts, not inserts; a refund tool that checks state first).gpt-4 across the codebase.NullProvider that echoes the transcript is handy in local dev.MetadataAwareInterface on anything you want traced. All are small interfaces — this is the cleanest seam in the package.Message or implement the provider interface (new interfaces sometimes arrive with new required methods on adjacent contracts).composer.json, and hide the SDK behind your own thin AgentRunner interface with three or four methods (run, stream, continue, runFormatted). If the package stalls or a breaking change lands, that adapter is a day's rewrite instead of a codebase-wide refactor. Budget time to read src/ directly — there is not enough community material to rely on search results, and the newest features have essentially no documentation beyond the PRs.How can I help you explore Laravel packages today?