AI agents in Laravel with Laragents
Creating a call to an AI model is the easy part. A request, a JSON body, a content field on the way back. If that were the work, nobody would need a package. The work is everything around it, and it is the same work in every product that ships an assistant: a loop that runs your tools and knows when

Creating a call to an AI model is the easy part. A request, a JSON body, a content field on the way back. If that were the work, nobody would need a package. The work is everything around it, and it is the same work in every product that ships an assistant: a loop that runs your tools and knows when to stop, conversations that survive their own context window, memory that outlives a session, rules somebody wrote that outrank whatever the model feels like doing, and agents that fire on your events instead of waiting to be typed at. I wrote all of that twice, in two applications, and the second time I extracted it. It is called laragents. composer require edulazaro/laragents php artisan vendor:publish --tag=laragents-config php artisan vendor:publish --tag=laragents-migrations php artisan migrate A model that can call tools does not answer in one go. It asks for a tool, you run it, you hand back the result, it asks for another, and eventually it writes prose. That cycle is the thing. $response = $loop->run( messages: $compressor->buildHistory($session, $systemPrompt), model: 'gpt-4.1-mini', session: $session, context: ToolContext::make(['organization' => $org, 'user' => $user]), tools: $registry->definitions(), ); Two things in there are not obvious until they cost you something. The iteration cap. A model that keeps reformulating the same failing search will loop until something times out, and every turn is billed. The empty-streak guard, which is the more interesting one. The cap stops the spiral, but it stops it in the worst way: several calls spent and no answer. So after two consecutive tool results that found nothing, the loop appends a hint telling the model to say so rather than search again. How it decides a result is empty is the part I like. A package cannot know what your tools return, so it reads the shape: a result carrying items, results, matches or whatever you configure is a search and gets judged. A result carrying none of those says nothing either way, which matters more than it sounds. A tool that files a document neither found nor failed to find anything, so it must not reset the streak. One bookkeeping call between two dead searches would otherwise hide the spiral completely. A tool talks about your domain, so the package does not bring any. It brings the class: class ListProperties extends Tool { public function name(): string { return 'list_properties'; } public function description(): string { return 'List the properties on the market for this office. ' . 'Does not book viewings: use schedule_viewing for that.'; } protected function schema(): array { return ['max_price' => JsonSchema::integer()->description('Upper bound in euros.')]; } public function execute(array $args, ToolContext $context): array { $office = $context->require('office'); return ['items' => $office->properties()->where('price', '<=', $args['max_price'])->get()->toArray()]; } } That second sentence in the description is worth more than the rest of it. Models pick neighbouring tools when the boundary is unstated. The two parameters are two different trust levels, and this is the one thing I would want a reader to take away. $args is what the model asked for: text a language model produced, possibly steered by whatever the user pasted into the chat. $context is what your application put there. Take identity and scope from the second, never the first. Why a bag and not typed parameters? Because the application this came from declared its tools as execute(array $args, ?CaseModel $case, Organization $org), which is two application models nailed into an interface implemented by 95 tools. Adding a third piece of context meant editing all 95, and no package can ever host that signature: a package cannot know what a case is. Every turn replays the whole conversation, so a chat that goes well gets more expensive per turn until it hits the context limit and stops working entirely. $messages = $compressor->buildHistory($session, $systemPrompt); Under the token budget, everything goes verbatim. Over it, the older turns are summarised once by a cheap model and the summary is reused from then on. Two deliberate refusals in there. It never compresses the most recent turns, whatever the budget says, because that is where the thread of the exchange lives and a summary of "what we just said" is where these systems get vague. And it never touches tool calls or their results: those belong to the turn that produced them, and replaying them across turns is how a loop starts re-reading its own plumbing. A conversation ends and everything it established goes with it. The user explained their situation on Tuesday and explains it again on Thursday. Memories hang off three axes, and keeping them apart is what makes the table reusable: tenant whose it is: the firm, the workspace, the account memorable what it is about: a case, a project. NULL = the tenant as a whole memory who it applies to. NULL = everyone in that scope The third one reads like access control and is not. Showing María's preference to Juan is not a leak, it is worse: the model starts behaving with Juan as if he were María. "María prefers bullet points" is not confidential, it simply does not apply to anybody else. Writing them is a queued job, because it is another model call and charging the user's reply for the privilege of remembering it is how a chat gets slow. It fires when history compression happens, which is when a substantial amount has been said, and on a scheduled sweep for conversations that stopped before ever getting that far. Without the second one you remember long conversations and forget short ones, and short ones are most of them. Reading them is one line, and it is the line people forget: $prompt = implode("\n\n", array_filter([ Rule::promptBlock($session), 'You are the assistant of an estate agency.', Memory::promptBlock($session), ])); Without it the table fills up and is never read, which looks exactly like the feature being disappointing rather than absent. Same three axes, opposite treatment. A memory is distilled and weighed. A rule is written by a person and sent verbatim, at the top, worded as an order. Rules answer a question that comes up in every product with an assistant in it: where does "our firm always does X" live? Not in the system prompt, because that is one string for everybody and this varies per tenant. Not in memory, because memory is weighed and might not surface. Not in a tool, because it is not an action. They go above your own prompt, and memories below it. A directive buried under a wall of remembered context stops reading as a directive. Agent::create([ 'tenant_type' => 'organization', 'tenant_id' => $org->id, 'name' => 'Document triage', 'system_prompt' => 'Read the document, classify it, flag what is missing.', 'event_triggers' => ['document.uploaded'], 'allowed_tools' => ['read_document', 'classify_document'], 'config' => ['model' => 'gpt-4.1-mini', 'max_tasks_per_day' => 200], ]); app(AgentTrigger::class)->fire('document.uploaded', $document->organization, $document); Or on a cron expression, checked from the last run rather than against the current minute, so a tick lost to a deploy is picked up next time instead of skipped for ever. Two guards that exist because of two specific bad afternoons. A debounce, because observers fire on every save and one logical change written in three statements is three events, three answers and three invoices. And a daily ceiling, because an agent wired to an event its own tools cause is a loop, and the debounce does not save you there since each iteration is a genuinely new subject. Not a provider wrapper. There are two clients, OpenAI and Anthropic, and they exist because the loop needs something to call. If you want fifteen providers behind one interface, laravel/ai is the framework's own answer and is better at it than this will be. What laravel/ai does not have is any of the above: no memory, no rules, no schedules, no compression. Not a metering package. There is a UsageRecorder contract and an adapter for larameter, and out of the box it records nothing, because a package should not start refusing calls because you have not wired your billing yet. Metering usually arrives bundled inside an AI package and that is the wrong place for it: you should not have to install an LLM client to meter a form submission. Not opinionated about your models. Everything polymorphic is your morph alias, never the package's word. A scope you call matter is addressed as "matter" everywhere, including in the prompt that asks the model which scope a memory belongs to. Nothing has to be translated into our vocabulary, and nothing gets dropped for failing to translate. Turn on PII anonymisation and the conversation is tokenised on the way out and restored on the way back, tool arguments included, so your tools query the database with real values rather than searching for «AP_1» and finding nothing. Most of these features are not providad by Laravel AI, so it's the main reason for this package. 👉 Source: github.com/edulazaro/laragents edulazaro.com/portfolio/laragents
Key Takeaways
- •Creating a call to an AI model is the easy part
- •This story was reported by Dev.to, covering developments in the dev space.
- •AI advancements continue to reshape industries — read the full article on Dev.to for complete coverage.
📖 Continue reading the full article:
Read Full Article on Dev.to →


![[Boost]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F283838%2Faf3610bc-683f-4e9d-8543-3f2117644325.jpg)