Home / Articles

Automating Laravel Development With AI Agents: Build, Test, Fix, Repeat

Use Laravel Boost and the OpenCode CLI to run a build, test, fix loop with queued jobs, an orchestrator, retry caps and human review before anything ships.

Publisher: REACTAPP.TOP14 min read3315 words

This article is published in English.

Sources: Build → Test → Fix → Repeat: Automating Laravel Development with AI Agents

The copy-paste treadmill

Count how often a typical day with an AI coding assistant goes like this. Write a prompt. Copy the output into the editor. Run php artisan test. See a failure. Copy the error text, paste it back to the AI, say "please fix this", and start over from the copy step.

Some developers repeat that cycle a dozen times daily. It gets labelled AI-assisted development, yet the busiest worker in the loop isn't the AI. It's the person ferrying messages between the model and the terminal: deciding when tests should run, reading the errors, judging whether another fix is needed, and pasting everything back and forth. The AI sits idle until the next instruction arrives.

A better arrangement removes the human courier. A small system can run the build, test, fix, repeat cycle on its own until the work is truly finished, and a person steps in only for the final review. The pieces used here are Laravel Boost and the OpenCode CLI, two tools that suit today's Laravel ecosystem. The explanations go slowly on purpose, so someone who has never opened either should be able to follow.

Vocabulary

A few terms come up repeatedly:

  • Agent: an AI that has been given a defined task and a defined set of tools, comparable to a staff member with a written role.
  • MCP (Model Context Protocol): a connector through which the AI can put questions straight to your system, whether the database or the logs, so it stops guessing from whatever you happened to type.
  • CLI (command line interface): operating software by entering commands at a terminal prompt in place of clicking around a graphical screen.
  • Job and queue: how Laravel handles background work, one unit after another, so that no user is left staring at a spinner.
  • Orchestrator: the traffic controller that sets the sequence of work, tracks whose turn it is and knows where to resume after a failure.

The question has moved on from "can AI write code"

Anyone who has used Claude Code, Cursor or a similar tool on Laravel already knows AI can produce migrations, models, policies and tests. The remaining problem is the manual workflow around it:

Developer → AI → Developer → Test → Developer → AI → Developer → Test → ...

Each arrow means a person moving context from one step to the next by hand, deciding that it's time to test or that an error needs to go back to the AI.

Compare an automated version:

Developer → Orchestrator → Build → Test → (failed? → Fix → Test again) → Review → Done

The gap looks small but changes plenty. An orchestrator now exists, a layer that knows the order of work, when to retry and when to stop, with nobody hovering every thirty seconds.

Laravel has been moving this way too. Laravel Boost was built so AI coding agents receive real context about a Laravel application instead of guessing from generic patterns.

The two main tools

Laravel Boost: a translator between AI and application

An AI can be strong at Laravel in general and still be effectively blind in your specific project. It doesn't know which Laravel version you run, how your database is actually structured, whether you use Livewire or Inertia, or what naming conventions your team has agreed on.

Boost is a Composer package that opens the AI's eyes to the real state of the project. It works through MCP, so the AI can query the application directly: inspect the database schema, look at existing routes, see the most recent error, execute Artisan commands, and look things up in Laravel docs that correspond to the package versions actually installed.

Installation takes two commands:

composer require laravel/boost --dev

php artisan boost:install

The second one is interactive. It asks which IDE or AI tool you use and installs the matching resources.

It's worth stating plainly that Boost translates context and doesn't make decisions. It won't say "run the tests now" or "launch the fix agent on failure". Its only duty is keeping agents from being blind to the app's real state.

OpenCode: where the agent does the work

If Boost translates context, OpenCode is the workshop: a CLI built specifically for AI coding. Unlike an ordinary chatbot, it lets you define several agents with different responsibilities, for example one that writes code and another that reads test results, each with its own instructions and boundaries.

OpenCode can also connect to MCP servers, Boost among them. Once connected, everything Boost offers becomes available to whichever agent runs inside OpenCode.

Hands-on: connecting Boost to OpenCode

Begin by trying things by hand in the terminal, before automating anything.

1. Point OpenCode at Boost

With Boost installed, OpenCode must learn where its MCP server lives. That information goes into an opencode.json file at the project root:

{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "laravel-boost": {
      "type": "local",
      "command": ["php", "artisan", "boost:mcp"],
      "enabled": true
    }
  }
}

The exact command can vary by Boost version, so follow whatever php artisan boost:install generates instead of copying this blindly. It's among the parts most likely to change between releases.

Confirm that OpenCode can see the server:

opencode mcp list

If laravel-boost is listed as connected, the agents have access to everything Boost exposes.

2. Create agents with distinct responsibilities

Rather than one agent for all jobs, split the duties. Create a .opencode/agent/builder.md file in the project:

---
description: Build Agent for new features
mode: primary
tools:
  write: true
  edit: true
---
You are the Build Agent.
Use Laravel Boost tools to understand the application structure
before writing any code. Never assume the database structure -
always check the schema first. Write tests for every new behavior.
At the end of your work, return ONLY JSON in this format:
{"status": "success", "files_changed": [...], "summary": "..."}

The line tools.write: true permits this agent to modify files. A later agent will get write: false, making it read-only and unable to touch anything, and the reason that matters comes shortly.

List the registered agents:

opencode agent list

3. Run it manually first

opencode run --agent builder "Add a task assignment feature to TaskFlow according to the requirements in TASK.md"

Some notes on that command:

  • --agent selects which agent runs, whether builder, tester or anything else defined.
  • Adding --format json makes the output raw data instead of a display meant for people. That's what a PHP call will use later.
  • --session and --continue let the agent remember the earlier conversation instead of starting fresh each time.

Run it by hand a few times to build a feel before automating.

A concrete case: task assignment in TaskFlow

A single worked example keeps things grounded. Imagine a simple task management app called TaskFlow that needs the following capability. Users may hand a task to another user, but strictly within a project that both of them have access to. Broken into requirements:

1. A task has an assignee column (belongs to a User).
2. A user can only assign tasks within a project they're a member of.
3. A user must not assign or access tasks from other projects.
4. All of the above must be covered by automated tests.

It's a common requirement, which is what makes it a good demonstration. Cases like this often need two or three attempts before the question of who may access what, technically called authorization, is airtight. That is exactly where an automated loop earns its place.

Why the process needs a logbook

Once the work runs in the background, nobody can rely on memory for "what was that error again?". Everything must be recorded, and here that means a database table acting as a progress logbook:

Schema::create('ai_tasks', function (Blueprint $table) {
    $table->id();
    $table->string('type');
    $table->string('status')->default('pending');
    $table->text('prompt');
    $table->json('result')->nullable();
    $table->unsignedTinyInteger('iteration')->default(0);
    $table->timestamps();
});

The model:

class AiTask extends Model
{
    protected $fillable = [
        'type', 'status', 'prompt', 'result', 'iteration',
    ];

    protected function casts(): array
    {
        return ['result' => 'array'];
    }
}

The status column carries the weight. It moves through these stages:

pending → building → testing → (fixing → testing)* → reviewing → completed / failed

Why not a plain done flag? Because the orchestrator must know which stage the work is in, not merely whether it's finished. Stage tracking also lets a run resume after a worker crashes mid-flight and makes live progress visible somewhere.

Bridging OpenCode into PHP

People often skip this step: don't scatter OpenCode commands across many files. If the tool is swapped later, dozens of places shouldn't need editing. Build one entry point, an interface:

interface AgentRunner
{
    public function run(string $agent, string $prompt): array;
}

Then the real implementation, which calls OpenCode from PHP:

class OpenCodeAgentRunner implements AgentRunner
{
    public function run(string $agent, string $prompt): array
    {
        $result = Process::timeout(600)->run([
            'opencode', 'run',
            '--agent', $agent,
            '--format', 'json',
            $prompt,
        ]);

        if (! $result->successful()) {
            return [
                'status' => 'error',
                'error' => $result->errorOutput(),
            ];
        }

        return json_decode($result->output(), true) ?? [
            'status' => 'error',
            'error' => 'Agent output was not valid JSON',
        ];
    }
}

Bind it in a service provider:

$this->app->bind(AgentRunner::class, OpenCodeAgentRunner::class);

With this pattern every Job talks only to AgentRunner and doesn't care whether OpenCode or something else stands behind it.

One Job per stage

For anyone new to Laravel Jobs: a Job is a task that runs in the background through a queue, so nobody sits waiting for it to finish.

The build Job

class BuildFeature implements ShouldQueue
{
    public function __construct(protected AiTask $task) {}

    public function handle(AgentRunner $agent): void
    {
        $result = $agent->run(
            agent: 'builder',
            prompt: $this->task->prompt,
        );

        $this->task->update([
            'status' => 'testing',
            'result' => $result,
        ]);

        RunTests::dispatch($this->task);
    }
}

Here the Build Agent runs, its result is saved, and the flow moves straight to the next Job (RunTests) with no manual clicking.

The test Job

This Job is kept deliberately dumb. It needs no understanding of code. It runs the test suite and reads the outcome.

class RunTests implements ShouldQueue
{
    public function __construct(protected AiTask $task) {}

    public function handle(): void
    {
        $result = Process::timeout(300)->run('php artisan test --compact');

        if ($result->successful()) {
            $this->task->update(['status' => 'reviewing']);
            ReviewFeature::dispatch($this->task);
            return;
        }

        $this->task->update(['status' => 'failed']);
        AnalyzeFailure::dispatch($this->task, $result->output());
    }
}

The flow is simple:

Test
 ├── passed → move to Review
 └── failed → move to Analyze

Don't pass raw errors to the agent

This lesson is the easiest to skip and has the biggest effect on quality. Telling the agent only that a test failed and asking for a fix gives too little. It restarts from zero, rereading every file and guessing what went wrong.

Structure the failure first:

{
  "status": "failed",
  "failed_tests": [
    {
      "name": "user_cannot_assign_task_from_other_project",
      "error": "Expected response status code [403] but received 200."
    }
  ]
}

Then hand it to an agent whose sole job is diagnosis, call it the Debug Agent, with instructions that tightly limit what it may do:

You are the Debug Agent.

Analyze the failed tests below. DO NOT modify any files.
Inspect: relevant models, policies, migrations, and tests.
Return JSON with:
- root_cause
- affected_files
- recommended_fix
- risk_of_regression

Why separate diagnosis from the fix? To create a gap between understanding the problem and executing the repair. If the agent is simply told to fix things, the trail showing why a given file changed disappears, and the final review gets much harder to verify.

For TaskFlow, the Debug Agent's output would plausibly resemble:

{
  "status": "analyzed",
  "root_cause": "TaskPolicy does not verify project membership before allowing assignment",
  "affected_files": ["app/Policies/TaskPolicy.php"],
  "recommended_fix": "Add a project membership check inside the assign() method",
  "next_action": "fix"
}

That would be a believable finding, since it's one of the most common authorization bugs around: a policy that checks only whether the user is logged in, not whether they actually belong to the project.

Fix, then test again, never fix then done

class FixFeature implements ShouldQueue
{
    public function __construct(protected AiTask $task, protected array $analysis) {}

    public function handle(AgentRunner $agent): void
    {
        $result = $agent->run(
            agent: 'fixer',
            prompt: json_encode($this->analysis),
        );

        $this->task->increment('iteration');
        $this->task->update([
            'status' => 'testing',
            'result' => $result,
        ]);

        RunTests::dispatch($this->task);
    }
}

Notice that after fixing, the status returns to testing instead of jumping to completed. The principle is firm: an agent has no authority to declare itself finished just because a file changed. The only valid proof of correctness is a test suite that genuinely ran again, not the agent's own claim.

The orchestrator as traffic controller

At first, chaining the Jobs in a straight line seems tempting:

Bus::chain([
    new BuildFeature($task),
    new RunTests($task),
    new ReviewFeature($task),
])->dispatch();

But that's only a line. As soon as a branch appears, such as returning to fix when a test fails, a linear chain falls short. What's needed resembles a traffic controller that, each time it's called, checks the current status and picks the next move from there:

class AgentOrchestrator
{
    public function next(AiTask $task): void
    {
        match ($task->status) {
            'pending'   => BuildFeature::dispatch($task),
            'testing'   => RunTests::dispatch($task),
            'failed'    => AnalyzeFailure::dispatch($task),
            'fixing'    => RunTests::dispatch($task),
            'reviewing' => ReviewFeature::dispatch($task),
            'completed', 'stopped' => null,
            default => throw new LogicException("Unknown status: {$task->status}"),
        };
    }
}

The shift in thinking is significant. The orchestrator no longer asks what the next Job is. It asks which transition is valid given the current status. That's easier to debug, and a new stage such as a security review can be added without tearing the flow apart.

Cap the retries

Without a limit, a scenario like this can unfold:

Fix → Test fails → Fix → Test fails → Fix → Test fails → ...

That wastes time, and every AI call carries a cost too. The cure is a hard cap:

class RunTests implements ShouldQueue
{
    protected const MAX_ITERATIONS = 5;

    public function handle(): void
    {
        if ($this->task->iteration >= self::MAX_ITERATIONS) {
            $this->task->update(['status' => 'needs_human_review']);
            return;
        }
        // ... run tests as usual
    }
}

If five fix attempts don't suffice, the system stops and flags the task for a human, instead of trying forever. That doesn't necessarily mean the AI is poor at the job. Sometimes the requirement is ambiguous, or the decision truly needs a person.

Green tests don't equal a finished feature

It's easy to forget this once the system running by itself feels exciting. Suppose the agent writes something like:

public function assign(User $user, Task $task): bool
{
    return $user->isAdmin();
}

Tests might all pass, because tests cover only the scenarios someone imagined at the time. The real requirement said the user must be a project member, not that the user must be an admin. The logic is wrong while every test stays green.

That's why this flow is unsafe:

Build → Tests pass → Deploy immediately

The safer one looks like this:

Build → Test → (if failed: Debug → Fix → Test again) → Human review → Deploy

The review stage should still involve a person, at minimum for anything touching who-can-access-what. For security-sensitive changes like this one, requiring human review before merging is sensible however clean the automated loop looks.

Parallel agents and file conflicts

The idea of running a Security Agent and a Research Agent simultaneously brings a trap. If two agents may write the same file at about the same time, a race condition appears, meaning two processes overwrite each other's work.

Agent A → writes Task.php
Agent B → reads Task.php (nearly at the same time)
Agent A → writes Task.php again

The governing rule is to divide responsibilities by who may write and who may only read:

Research Agent   → read-only
Review Agent     → read-only
Security Agent   → read-only
Build Agent      → write access
Fix Agent        → write access

That also explains the tools.write: true line in the earlier builder.md. It controls which agents can change files and which can only look.

Resist the super-agent

The biggest temptation once this works is a single agent that plans, builds, tests, debugs, fixes, reviews and deploys. It tends to backfire. The more responsibility one agent carries, the more information it must process, the more tools it holds and the likelier it is to make a wrong call somewhere. Splitting by responsibility works better:

Planner   → designs the approach
Builder   → writes the new feature's code
Tester    → runs the test suite
Debugger  → diagnoses failures (read-only)
Fixer     → executes the fix
Reviewer  → final quality check (read-only)

It's the same principle as avoiding one giant class that handles everything in an application. Don't build one agent that does everything either.

A minimal checklist before real use

For experiments in a real project, these belong from day one rather than bolted on later:

  • a hard cap on retries, so the fix loop can't run forever
  • a time limit on each command an agent executes
  • file-access limits matched to the agent's role, read-only or write
  • tests that exist and pass before the status may move to reviewing
  • human approval before anything reaches production
  • logging of every decision the agent makes, not only the final result
  • protection for secret files such as .env, so an agent never reads them by accident

The last item is often underrated. Once an agent can run commands on the system, through the Process::run() calls used above, it can effectively execute almost anything on that server. Access restrictions are a day-one requirement, not an option.

Boost's actual role

Here is the full picture:

Developer (us)
    │
    ▼
Orchestrator (traffic controller, running on Laravel Queue)
    │
    ├── Build Agent
    ├── Test Agent
    ├── Debug Agent
    └── Fix Agent
          │
          ▼
      OpenCode (where the agent does its work)
          │
          ▼
    Laravel Boost (context translator, via MCP)
          │
          ▼
    Our Laravel application

Boost never decides to run the tests or to launch the fix agent on failure. It does one thing, an important one: making sure every agent on the project understands the real state of the app and doesn't guess from generic patterns. The questions of who acts, at what moment, and when everything halts stay with the orchestrator built on Laravel Queue.

The question to ask now

A few years ago the interesting question was whether AI could write correct Laravel code. It plainly can, and keeps improving. The sharper question today is whether AI can run its own feedback loop of build, test, fail, understand why, fix and test again until it's genuinely done, without a human as the manual courier at every step.

The TaskFlow scenario suggests the answer is yes, provided three things aren't forgotten. Progress needs clear logging, retries need a firm cap, and green tests should count as one signal, not the finish line.

Boost helps agents understand the application's context. OpenCode gives them somewhere to work. Laravel Queue becomes the backbone sequencing everything. And developers get to move up a level, from manual operator at every step to reviewer of the final result.

Vibe coding speeds up the writing. Orchestration like this lets the coding process run itself, and that's where AI coding experiments get interesting.