How to run an AI agent in Laravel when something happens in your app
A chat assistant waits. Somebody opens it, types, and gets an answer, which means the work only happens when a person remembers to ask for it.
The interesting version is the one nobody opens. A document arrives and something reads it, classifies it and flags what is missing, before anyone has looked at the inbox. Same model, same tools, no chair.
The distance between the two is smaller than it sounds and the parts that are actually hard are not the model call.
Something to run
An agent is a row: a prompt, the tools it may reach for, and what sets it off.
Agent::create([ 'tenant_type' => 'organization', 'tenant_id' => $org->id, 'name' => 'Document triage', 'system_prompt' => 'Read the document, classify it, and flag anything missing.', 'status' => 'active', 'event_triggers' => ['document.uploaded'], 'allowed_tools' => ['read_document', 'classify_document', 'flag_missing'], 'config' => ['model' => 'gpt-4.1-mini', 'max_tasks_per_day' => 200], ]);
A row rather than a class because the people who set these up are not the people who deploy. An office manager decides that arriving documents should be triaged; that is a form, not a pull request.
The tool whitelist is enforced when the tools are handed to the model, so a triage agent genuinely cannot send an email, however the request is phrased.
Something that notices
class DocumentObserver { public function created(Document $document): void { app(AgentTrigger::class)->fire('document.uploaded', $document->organization, $document); } }
public function fire(string $event, Model $owner, ?Model $subject = null): int { $agents = Agent::where('tenant_type', $owner->getMorphClass()) ->where('tenant_id', $owner->getKey()) ->where('status', 'active') ->get() ->filter(fn ($agent) => in_array($event, $agent->event_triggers ?? [], true)); foreach ($agents as $agent) { $task = AgentTask::create([ 'agent_id' => $agent->id, 'taskable_type' => $subject?->getMorphClass(), 'taskable_id' => $subject?->getKey(), 'trigger_event' => $event, 'status' => 'pending', ]); RunAgentTask::dispatch($task->id); } return $agents->count(); }
'document.uploaded' is your string. Nothing compares it against a list of blessed event names, which is what lets the form offer whatever your application actually does.
This is the piece that gets left out, and leaving it out has no symptom. The agents table fills up with rows carrying an event_triggers list that nothing ever reads. The form offered "when a document is uploaded", somebody picked it, and it never fires: no error, no log, no exception, nothing in the failed jobs table. You find out weeks later when somebody asks why the documents were never triaged.
A debounce, because observers lie
An observer fires on every save. One logical change written in three statements is three events, and without a guard the agent answers three times and you are billed three times.
$recent = AgentTask::where('agent_id', $agent->id) ->where('trigger_event', $event) ->where('taskable_id', $subject?->getKey()) ->where('created_at', '>=', now()->subSeconds(30)) ->exists(); if ($recent) { continue; }
Thirty seconds is arbitrary and fine. The point is that the window is per agent, per event, per subject, so two different documents uploaded in the same second both get triaged and the same document saved twice does not.
A ceiling, because loops exist
$today = AgentTask::where('agent_id', $agent->id) ->where('created_at', '>=', now()->startOfDay()) ->count(); if ($today > $agent->setting('max_tasks_per_day', 50)) { $task->markFailed('Daily task limit reached.'); return; }
An agent wired to an event that its own tools cause is a loop, and every turn of that loop is a billed model call. The debounce does not save you, because each iteration is a genuinely new subject. This is the thing standing between a misconfiguration and an invoice, and it wants to be there from the first version, not added after the first bad month.
The clock, for the other half
Some agents are not reacting to anything. They run on Mondays.
Schedule::command('agents:run-due')->everyMinute();
And the part worth getting right:
public function isDue(): bool { $cron = new CronExpression($this->schedule_cron); $since = $this->last_run_at ?? $this->created_at; return $cron->getNextRunDate($since, 0, false) <= now(); }
Not $cron->isDue(now()). That is only true during the exact minute the expression names, so a tick lost to a deploy, an overlapping run or a busy queue takes that execution with it, silently, for ever. Asking instead whether the next run after the last one has already passed means a missed minute is picked up on the following tick.
Using created_at as the baseline for an agent that has never run is what stops it firing the instant somebody switches it on.
Failure has to be visible
try { $response = $loop->run(/* ... */); $task->markCompleted(['content' => $response->content]); } catch (\Throwable $e) { $task->markFailed($e->getMessage()); }
An agent that silently stops is the hardest kind of bug to notice, because no output looks exactly like nothing to do. Nobody is watching an autonomous run, so the run has to write down what happened to it.
The same reasoning applies to the queue. Give agents a queue of their own if you also run a chat: an agent run is a loop of model calls and can take minutes, so sharing a worker with anything interactive means a person waits for a reply while a scheduled job holds the only worker. And make sure something actually consumes it. A queue with no worker is the same shape of failure as the missing observer: nothing breaks, nothing logs, the work simply never happens.
The pieces above are laragents: Agent, AgentTrigger, AgentRunner, the debounce, the daily cap and the laragents:run-due-agents command that registers itself on the scheduler. The agents chapter covers wiring your own event classes through a resolver, and why that resolver is a class rather than a closure.
written by Edu Lazaro · August 2026