Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Agents Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. The README example — it is the only end-to-end walkthrough and shows the intended wiring order (provider → chat → messages → run).
  2. 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).
  3. The provider package's client class — that is where the HTTP call, model name, and token accounting actually happen.

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.


Implementation Patterns

Bind the provider as a singleton, build agents as factories

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),
));

Prompt context values instead of string interpolation (1.7.0)

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:

  • Treat context values as untrusted input. They are interpolated into the prompt; a user-supplied name of Ignore previous instructions… is a prompt-injection vector. Strip newlines, cap length, and never put raw request payloads or HTML into context.
  • Keep the template itself in config or a Blade-ish file, not in a controller. You want to diff prompt changes in code review.
  • Don't duplicate what the model can fetch. Static facts belong in context; per-request data belongs in a tool. Context costs tokens on every turn.

Format the LLM response into a shape your app can consume (1.7.0)

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:

  • One format per task. Define a small, explicit schema for each extraction task; don't build a god-schema covering five endpoints.
  • Always keep the raw text. Store both the formatted result and the unformatted completion. When the shape is wrong you need the transcript to see why.
  • Treat formatting as a best-effort convenience, not a guarantee. Even with a provider-native structured-output mode, validate the result with Laravel's validator or a typed DTO constructor and handle the failure path — don't assume the SDK threw on a mismatch.
  • Formatting pairs well with the queue pattern below. Run the job, get a validated DTO, persist it, broadcast the DTO — never let a half-parsed payload reach your front end.

Tools as invokable classes, registered in a toolkit

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),
]);

MetadataAwareInterface: attach your own correlation data (1.7.0)

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:

  1. Logging/metrics. Feed the metadata into a Monolog context processor so every line from an agent run is greppable by conversation.
  2. Persistence. If metadata round-trips into stored messages, you get per-run cost attribution and replay for free. Verify whether metadata is serialized into the stored payload before you rely on it — if it is, keep it small and free of secrets (it may end up in a JSON column, a log, or a provider payload).

Persist conversations yourself — the SDK is stateless between runs

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.

Move long runs onto the queue

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.

Streaming to the browser

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.

Supervisor / multi-agent pattern

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.

Decorate for observability

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.

Testing

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.


Gotchas and Tips

  • No Laravel integration means no 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().
  • The agent object is stateful. Injecting it as a singleton, or reusing one instance across two requests under Octane, silently appends messages from the previous conversation. Bind as 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.
  • Loop control is your responsibility. Set a hard maximum number of tool-calling iterations in config and enforce it (either via the SDK's option or by wrapping the run in a counter). Without it, a model that keeps calling the same tool will burn tokens until the PHP timeout.
  • Tool exceptions should become text, not stack traces. Catch inside __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.
  • Validate tool arguments before touching the database. Models hallucinate IDs, send strings where integers are declared, and omit required keys. Validate, and return a corrective message ("order_id must be an integer") so the model can retry — this is far more reliable than throwing.
  • Tool results inflate context fast. Returning a full Eloquent model or a 200-row result set will exhaust the window within a few turns. Return the three or four fields the model actually needs, and truncate free-text results with a …truncated marker.
  • Unbounded history is the other context leak. After N turns, summarise older messages into a single system message or drop tool-result payloads that are no longer relevant. Decide this policy on day one, not after your first 400 error.
  • Formatting does not make the model correct. The 1.7.0 formatting feature shapes the output, it doesn't guarantee the values are right or that every provider honours it identically. Validate before persisting or acting. A formatted-but-wrong refund amount is worse than unformatted prose, because it looks like data.
  • Sanitise prompt context values. Context is interpolation, so it inherits every injection risk of string concatenation — with the added twist that it's now easy to pass a whole model or array in and blow up the prompt. Whitelist the keys, cast scalars, cap lengths.
  • Metadata may be serialized. Before you stuff an API token or a full user object into metadata(), confirm where it goes — a provider payload, a log line, or your agent_messages JSON column. Keep it to IDs and scalars.
  • Retries are not free. If the SDK does not handle 429/5xx for you, wrap calls in Laravel's 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).
  • Pin the model string in config, not in code. Model names are deprecated on a rolling basis; you want to change one env var, not grep for gpt-4 across the codebase.
  • Debugging a bad run: log the exact message array immediately before the provider call and the raw response after. Nine times out of ten the bug is a malformed tool schema, a bad context value, or a tool result the model can't parse — reading the transcript makes all three obvious. A NullProvider that echoes the transcript is handy in local dev.
  • Cost control levers that actually work: a cheap model for routing/classification, a token cap on outputs, aggressive history trimming, and caching tool lookups so a repeated call within one run doesn't re-query. Prompt context is a cost too — it's resent every turn.
  • Extension points: implement the SDK's provider interface to add a backend it doesn't ship (Anthropic, Ollama, an internal gateway), implement the tool contract for anything internal, and implement MetadataAwareInterface on anything you want traced. All are small interfaces — this is the cleanest seam in the package.
  • Upgrade note for 1.7.0: the three changes are additive, so an existing 1.6.x integration should keep working — but the release does not mention breaking changes explicitly, and the changelog is three PR titles. Read the diffs for #18, #20, and #21 before bumping, especially if you already subclass Message or implement the provider interface (new interfaces sometimes arrive with new required methods on adjacent contracts).
  • Maturity risk is still the headline. 169 stars, zero dependents, effectively single-maintainer. The 1.7.0 release is a good sign of life, but it doesn't change the underlying bet. Pin an exact version in 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package