Ralph loops aren't just for coding agents in a terminal. Here's how I build one inside a Laravel app with the official AI SDK: durable state in the database, custom tools, and verification the agent doesn't get to vote on.
A few days ago I open-sourced Ralph Loop Junkie, my desktop app for running coding agents in Ralph loops. That version of the pattern lives in the terminal: a shell script, a coding CLI, and a handful of plan files on disk.
But here's the thing I keep telling people: the Ralph loop is a pattern, not a product. Fresh context every iteration, durable state outside the model, completion defined by verifiable conditions. Nothing about that requires a shell script or a coding agent. Which means you can build one inside your Laravel application with the official Laravel AI SDK, and point it at the kind of grinding, thousand-item backlog every real codebase has lurking somewhere.
That's what we're doing today. Real example, real tables, real verification.
If you missed the last post, the Ralph loop (popularized by Geoffrey Huntley) works like this: instead of one ever-growing agent session that slowly degrades as the context window fills with stale reasoning, you re-spawn a fresh agent every iteration. Each iteration reads a small, durable set of state (the plan, the task list, the working notes), does one unit of work, and writes its progress back. The loop exits when the data says the work is done, not when the agent claims it is.
In the terminal version, that durable state lives in files: tasks.json, ralph-state.md. In a Laravel app, we already have something better for durable state. It's called a database.
Here's the scenario, pulled from the kind of work I actually get asked to do. A client migrates off a legacy commerce platform and imports 1,400 products. Every product has a raw_description, a freetext blob mixing marketing copy, specs, supplier codes, and the occasional ALL-CAPS SHOUTING. What they need is a clean title, a short summary, and structured attributes (material: stainless steel, width: 24 in) that match what each category requires.
Too many to clean by hand. Too inconsistent for a regex. Exactly one unit of judgment-work per product. This is a Ralph loop with a database instead of a filesystem.
Here's how the anatomy maps:
| Terminal Ralph loop | Laravel Ralph loop |
|---|---|
PROMPT.md / PRD.md |
The agent's instructions() + per-task prompt |
tasks.json |
A ralph_tasks table |
ralph-state.md (working memory) |
A ralph_notes table, written via a tool |
ralph.sh |
An Artisan command |
| Verification command | Laravel's Validator + final status counts |
Let's build each piece.
The task list and the loop's working memory get their own tables. The tasks table is the source of truth for progress. It's the thing the loop trusts instead of the model's self-report:
Schema::create('ralph_tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained();
$table->string('status')->default('pending'); // pending, done, needs_review
$table->unsignedTinyInteger('attempts')->default(0);
$table->text('failure_reason')->nullable();
$table->timestamps();
});
Schema::create('ralph_notes', function (Blueprint $table) {
$table->id();
$table->string('loop')->index();
$table->text('note');
$table->timestamps();
});
ralph_notes is the database equivalent of ralph-state.md: cross-iteration memory. When iteration 12 discovers that one supplier always lists dimensions in millimeters with no units, iteration 13, a brand-new agent with zero shared context, should get to know that. The notes table is where that knowledge survives between fresh contexts, and you'll see in a minute how it gets read and written.
Seeding the backlog is one line per product:
Product::whereNull('normalized_at')->each(
fn (Product $product) => RalphTask::create(['product_id' => $product->id])
);
The AI SDK's agent classes have a property that makes them perfect for Ralph loops, and it's something the docs don't shout about: every prompt() call is stateless unless you opt into conversation history. No Conversational interface, no RemembersConversations trait, no memory. Each iteration of our loop constructs a new agent and prompts it once, which gives us the "fresh context every iteration" half of the pattern for free.
php artisan make:agent ProductNormalizer --structured
<?php
namespace App\Ai\Agents;
use App\Ai\Tools\GetCategorySpec;
use App\Ai\Tools\RecordWorkingNote;
use App\Models\RalphNote;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Attributes\MaxSteps;
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Attributes\Timeout;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
use Stringable;
#[Provider(Lab::Anthropic)]
#[MaxSteps(8)]
#[Timeout(120)]
class ProductNormalizer implements Agent, HasStructuredOutput, HasTools
{
use Promptable;
/**
* Get the instructions that the agent should follow.
*/
public function instructions(): Stringable|string
{
$notes = RalphNote::query()
->where('loop', 'normalize-products')
->latest()
->limit(20)
->pluck('note')
->reverse()
->map(fn (string $note): string => "- {$note}")
->implode("\n") ?: '- (none yet)';
return <<<INSTRUCTIONS
You normalize one legacy product record per session into clean,
structured catalog data.
Use the get_category_spec tool to learn which attributes the
product's category requires. Extract every required attribute you
can find in the raw data. Never invent values that are not present.
If you discover something that will help future sessions, such as
a recurring supplier quirk, a unit convention, or an ambiguous
abbreviation, record it with the record_working_note tool.
Working notes from previous sessions:
{$notes}
INSTRUCTIONS;
}
/**
* Get the tools available to the agent.
*
* @return Tool[]
*/
public function tools(): iterable
{
return [
new GetCategorySpec,
new RecordWorkingNote,
];
}
/**
* Get the agent's structured output schema definition.
*/
public function schema(JsonSchema $schema): array
{
return [
'title' => $schema->string()->required(),
'summary' => $schema->string()->required(),
'attributes' => $schema->array()->items(
$schema->object(fn ($schema) => [
'name' => $schema->string()->required(),
'value' => $schema->string()->required(),
])
)->required(),
'confidence' => $schema->string()
->enum(['low', 'medium', 'high'])
->required(),
];
}
}
Three things doing real work here:
The instructions are the PROMPT.md. They're rebuilt on every instantiation, and they inject the working notes from previous iterations. That's the Ralph trick in one method: the context is fresh, but the durable memory rides in on the system prompt. Each new agent stands on what every previous agent learned, without inheriting any of their conversational noise.
Structured output is the contract. HasStructuredOutput means each iteration returns data we can validate mechanically, not prose we'd have to parse and hope about. The confidence field gives the model an honest off-ramp: flagging uncertainty beats hallucinating a spec.
MaxSteps(8) bounds the iteration. The agent gets enough steps to consult its tools and finish, and no rope to wander.
In the terminal version, the agent's "tools" are whatever the CLI allows, which usually means the whole filesystem. In a Laravel Ralph loop, you hand the agent a custom, deliberately narrow set of tools. It can do exactly what you've blessed and nothing else, because each tool is a class you wrote with php artisan make:tool.
The first tool lets the agent look up what a category requires:
<?php
namespace App\Ai\Tools;
use App\Models\Category;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;
class GetCategorySpec implements Tool
{
/**
* Get the description of the tool's purpose.
*/
public function description(): Stringable|string
{
return 'Get the list of attributes required for a product category.';
}
/**
* Execute the tool.
*/
public function handle(Request $request): Stringable|string
{
return Category::query()
->where('slug', $request['category'])
->firstOrFail()
->requiredAttributes
->toJson();
}
/**
* Get the tool's schema definition.
*/
public function schema(JsonSchema $schema): array
{
return [
'category' => $schema->string()->required(),
];
}
}
The second is the write-side of the loop's memory, the pen for ralph_notes:
<?php
namespace App\Ai\Tools;
use App\Models\RalphNote;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;
class RecordWorkingNote implements Tool
{
/**
* Get the description of the tool's purpose.
*/
public function description(): Stringable|string
{
return 'Record a short, durable note that future normalization sessions will read. Use for recurring patterns across products, never for details about a single product.';
}
/**
* Execute the tool.
*/
public function handle(Request $request): Stringable|string
{
RalphNote::create([
'loop' => 'normalize-products',
'note' => $request['note'],
]);
return 'Noted for future sessions.';
}
/**
* Get the tool's schema definition.
*/
public function schema(JsonSchema $schema): array
{
return [
'note' => $schema->string()->required(),
];
}
}
Notice the loop's memory is append-only and inspectable. You can open the ralph_notes table mid-run and read what your loop has learned, like "Supplier ACME-* lists dimensions in mm without units", the same way you'd cat ralph-state.md. When a note is wrong, you delete the row. Try doing that to an opaque context window.
Here's ralph.sh, reborn as an Artisan command. One design decision matters more than all the others: the loop owns control flow; the model owns judgment. PHP claims the next task, PHP counts attempts, PHP validates output, PHP decides done-or-retry. The model is consulted exactly once per iteration, about exactly one product.
<?php
namespace App\Console\Commands;
use App\Ai\Agents\ProductNormalizer;
use App\Models\RalphTask;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Validator;
use Throwable;
class RunProductLoop extends Command
{
protected $signature = 'ralph:products {--max=25 : Iteration cap for a single run}';
protected $description = 'Run the product normalization Ralph loop until the backlog is clear.';
public function handle(): int
{
$iteration = 0;
while ($task = $this->claimNextTask()) {
if (++$iteration > (int) $this->option('max')) {
$this->warn('Iteration cap reached. The next run picks up where this one left off.');
break;
}
$this->info("Iteration {$iteration}: product #{$task->product_id}");
$this->runIteration($task);
}
return $this->verify();
}
private function claimNextTask(): ?RalphTask
{
return RalphTask::query()
->where('status', 'pending')
->oldest('id')
->first();
}
private function runIteration(RalphTask $task): void
{
$product = $task->product;
$task->increment('attempts');
try {
$response = (new ProductNormalizer)->prompt(<<<PROMPT
Normalize this product.
Category: {$product->category->slug}
Raw legacy data:
{$product->raw_description}
PROMPT);
} catch (Throwable $e) {
$this->failOrRetry($task, $e->getMessage());
return;
}
$clean = [
'title' => $response['title'],
'summary' => $response['summary'],
'attributes' => $response['attributes'],
];
$validator = Validator::make($clean, [
'title' => ['required', 'string', 'max:120'],
'summary' => ['required', 'string', 'max:500'],
'attributes' => ['required', 'array', 'min:1'],
'attributes.*.name' => ['required', 'string', 'max:50'],
'attributes.*.value' => ['required', 'string', 'max:100'],
]);
if ($validator->fails()) {
$this->failOrRetry($task, $validator->errors()->first());
return;
}
$product->update([
...$clean,
'confidence' => $response['confidence'],
'normalized_at' => now(),
]);
$task->update(['status' => 'done', 'failure_reason' => null]);
}
private function failOrRetry(RalphTask $task, string $reason): void
{
$task->update([
'status' => $task->attempts >= 3 ? 'needs_review' : 'pending',
'failure_reason' => $reason,
]);
}
private function verify(): int
{
$statuses = RalphTask::query()
->selectRaw('status, count(*) as total')
->groupBy('status')
->pluck('total', 'status');
$this->table(
['Status', 'Count'],
$statuses->map(fn ($total, $status) => [$status, $total])->values()->all(),
);
return ($statuses['pending'] ?? 0) === 0 ? self::SUCCESS : self::FAILURE;
}
}
Walk the failure paths, because that's where Ralph loops earn their keep:
Validator rejects it, the task stays pending, and a fresh agent, with fresh context and possibly wiser notes, tries again next pass. Validation rules are this loop's test suite: the agent doesn't get to vote on whether its output was acceptable; the rules do.needs_review with the failure reason attached. A human looks at it. The loop doesn't thrash forever on the one product whose legacy description is an ASCII-art table.And because state lives in tables, resumability is free. Kill the command mid-run, deploy, run it again, and it picks up at the next pending task like nothing happened.
To make it a proper background grinder, schedule it:
use Illuminate\Support\Facades\Schedule;
Schedule::command('ralph:products')
->everyTenMinutes()
->withoutOverlapping();
Now it's chewing through the backlog all day, twenty-five products at a time, and the needs_review rows queue up for your morning coffee. That's the whole Ralph promise: set it going, review the exceptions.
This is where the in-app version beats the shell script hands down. The AI SDK fakes agents wholesale, and for structured-output agents it auto-generates fake data matching your schema, so you can test the loop's machinery without a single API call:
use App\Ai\Agents\ProductNormalizer;
use App\Models\RalphTask;
it('works the backlog until every task is done', function () {
ProductNormalizer::fake();
RalphTask::factory()->count(3)->create();
$this->artisan('ralph:products')->assertSuccessful();
expect(RalphTask::where('status', 'done')->count())->toBe(3);
});
it('claims one fresh agent per task', function () {
ProductNormalizer::fake();
RalphTask::factory()->count(3)->create();
$this->artisan('ralph:products');
ProductNormalizer::assertPrompted(
fn ($prompt) => $prompt->contains('Normalize this product')
);
});
The retry ledger, the three-strikes rule, and the verification exit code are all plain PHP you can put under Pest. Try unit-testing a bash while loop that shells out to a CLI.
A few upgrades that fall out naturally once the loop lives in Laravel:
prompt() for the agent's queue() method and let Horizon run eight iterations at once. The tasks table is already your coordination point.provider: [Lab::Anthropic, Lab::OpenAI], and a provider outage no longer stalls the backlog overnight.#[UseCheapestModel] attribute, or an explicit small #[Model], cuts the per-iteration cost dramatically, and the validator catches it if the cheap model isn't up to the job.needs_review tasks plus failure_reason is one Inertia page away from being a tidy human-in-the-loop queue.What I like most about building this is how little of the Ralph loop had to change. Fresh context per iteration came free from stateless prompt() calls. Durable state moved from files to tables and got more inspectable. The custom tool set is tighter than anything a terminal agent gets. And the completion rule is the same as it ever was: the loop is done when the data says it's done.
The agent doesn't get to vote. The database does.
Just one more iteration.
Written by Jordan Dalton. More at jordandalton.com.
Take the ideas in here from concept to working software in one focused day.
Book a build day →