You wired up an agent with the Laravel AI SDK, shipped it, and wrote zero tests for it: because hitting a real model is slow, costs tokens, needs an API key in CI, and returns something different on every run. The SDK solves this with a first-party fake, the same idea as Http::fake() but for agents. This is the whole Laravel AI SDK testing workflow: swap the provider for a fake, queue canned responses as plain arrays, and assert on exactly what your code sent.
If you're new to the SDK itself, start with the complete guide to the Laravel AI SDK and come back here once you have an agent worth testing.
Install and configure the AI SDK for testing#
The fake is part of the core package, so there's nothing extra to install for tests. If you don't already have the SDK, pull it in, publish the config, and run the migrations.
composer require laravel/ai
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate
For real calls you'd add provider keys to .env (OPENAI_API_KEY, ANTHROPIC_API_KEY, and so on). The entire point of faking is that your test environment needs none of them. Leave phpunit.xml without AI keys and the faked tests still pass, which is exactly the property you want in CI.
Build the agent and the code that calls it#
You need something to test. Generate an agent with php artisan make:agent SupportAgent, then trim it to the essentials: a class that implements Agent, pulls in the Promptable trait, and returns its instructions.
// app/Agents/SupportAgent.php
namespace App\Agents;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Promptable;
use Stringable;
class SupportAgent implements Agent
{
use Promptable;
public function instructions(): Stringable|string
{
return 'You are a support assistant. Answer in one short paragraph and never invent policy.';
}
}
In real code you rarely call the agent from a controller directly. Wrap it in an action so the unit under test is a plain method:
// app/Actions/AnswerSupportQuestion.php
namespace App\Actions;
use App\Agents\SupportAgent;
class AnswerSupportQuestion
{
public function handle(string $question): string
{
return (new SupportAgent)->prompt($question)->text;
}
}
That ->prompt() call is the line that would normally cost money and time. Everything below stops it from doing either.
Fake the agent in your Pest test#
Call the static fake() method on the agent class before the code runs. The SDK swaps that agent's live provider for a fake gateway that records every prompt and returns whatever you queue. Pass an array of responses and they're returned in order, one per prompt() call.
use App\Actions\AnswerSupportQuestion;
use App\Agents\SupportAgent;
it('answers a support question without calling the provider', function () {
SupportAgent::fake([
'You can reset your password from the login screen.',
]);
$answer = (new AnswerSupportQuestion)->handle('How do I reset my password?');
expect($answer)->toBe('You can reset your password from the login screen.');
});
Responses don't have to be strings. A closure receives the prompt and computes a reply, which is handy for echoing input back or simulating provider errors. A TextResponse object gives you full control over usage and metadata. Mix them freely in a sequential array:
use Laravel\Ai\Responses\TextResponse;
use Laravel\Ai\Responses\Data\Meta;
use Laravel\Ai\Responses\Data\Usage;
SupportAgent::fake([
'First reply',
fn (string $prompt) => "Echo: {$prompt}",
new TextResponse('Third reply', new Usage, new Meta),
]);
Pass a single closure instead of an array and it runs for every prompt. Throw from inside it to test how your code handles a provider failure. And if you call fake() with no arguments at all, the SDK returns a deterministic default like Fake response for prompt: ..., which is enough when you only care about the assertions.
SupportAgent::fake(fn (string $prompt) => "You said: {$prompt}");
Assert the prompt that reached the agent#
Recording is half the value. After the code runs, call assertPrompted() on the agent class to verify what was sent. Pass a string for an exact match, or a closure that receives an AgentPrompt and returns a boolean for anything fuzzier. The prompt object exposes ->prompt, ->provider, and ->timeout.
use Laravel\Ai\Prompts\AgentPrompt;
SupportAgent::assertPrompted('How do I reset my password?');
SupportAgent::assertPrompted(function (AgentPrompt $prompt) {
return str_contains($prompt->prompt, 'password');
});
SupportAgent::assertNeverPrompted();
There's no assertPromptedCount() or tool-call assertion in the SDK, so don't reach for one. If you need to check call ordering, queue a sequential array of responses and assert against each prompt with its own closure. To turn an accidental live call into a hard failure rather than a silent network hit, chain preventStrayPrompts() onto the fake:
SupportAgent::fake()->preventStrayPrompts();
Any prompt that wasn't explicitly faked now throws instead of reaching the provider.
Fake structured output and queued prompts#
Structured agents are the easy half. An agent that implements HasStructuredOutput and defines a schema() returns array-accessible data, so you fake it with a plain PHP array and read it back the same way.
// app/Agents/TriageTicket.php
namespace App\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
class TriageTicket implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): string
{
return 'Classify the support ticket by category and priority.';
}
public function schema(JsonSchema $schema): array
{
return [
'category' => $schema->string()->required(),
'priority' => $schema->string()->required(),
];
}
}
use App\Agents\TriageTicket;
it('classifies a ticket from a faked structured response', function () {
TriageTicket::fake([
['category' => 'billing', 'priority' => 'high'],
]);
$result = (new TriageTicket)->prompt('I was charged twice this month!');
expect($result['category'])->toBe('billing')
->and($result['priority'])->toBe('high');
});
Queued agents get their own assertions. When you fake the agent, calling queue() records the prompt instead of dispatching a real InvokeAgent job, so no worker ever touches a provider. Verify it with assertQueued(), which takes the same string-or-closure argument as assertPrompted() but hands your closure a QueuedAgentPrompt.
use App\Agents\SupportAgent;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Ai\QueuedAgentPrompt;
uses(RefreshDatabase::class);
it('queues the prompt instead of dispatching a real job', function () {
SupportAgent::fake();
(new SupportAgent)->queue('Summarise this 40-message thread');
SupportAgent::assertQueued('Summarise this 40-message thread');
SupportAgent::assertQueued(fn (QueuedAgentPrompt $prompt) =>
str_contains($prompt->prompt, 'thread')
);
});
When those queued prompts do run for real, the cost moves to your workers, so it's worth reading up on scaling Laravel queues in production before you turn the volume up.
Run the suite with no live API calls#
Because every agent is faked, the suite needs no keys, no network, and no luck. Run it the same way you run everything else:
composer test
Every faked test is deterministic and free, which is the whole reason this is worth doing: AI code stops being the untested corner of the app. Drop the suite into CI with confidence — there's a full walkthrough in GitHub Actions matrix testing for Laravel — and because preventStrayPrompts() turns any stray call into a failure, you never have to store AI provider secrets in your pipeline.
Gotchas and Edge Cases#
A few things trip people up once they move past the basic text case.
Streaming is faked too. Call stream() on a faked agent and the SDK synthesises stream events from your canned response; drain it with each() and then read ->text for the accumulated output.
SupportAgent::fake(['Hello, how can I help?']);
$response = (new SupportAgent)->stream('Hi');
$response->each(fn ($event) => null); // drain the stream
expect($response->text)->toBe('Hello, how can I help?');
The response also exposes an ->events collection, but the exact event count is an implementation detail of the fake — assert on ->text, not on how many deltas it emitted, or your test will break on an SDK patch release.
Tools are the real edge case. Faking replaces the provider, which means the model never actually decides to call a tool — so there is no "assert this tool was called" helper, and faked runs won't execute your tool callbacks. Test a tool the way you'd test any other class: call its handle() method directly with known input. Keep the agent test focused on the prompt your code built, and the tool test focused on the tool's logic. If you're still building those tool-calling agents, Laravel Prism tool calling covers the execution side in depth.
Finally, the same fake-and-assert pattern extends beyond agents: Files::fake() and Stores::fake() cover file and vector-store operations, and there are matching fakes for images, audio, transcriptions, embeddings, and reranking. That makes the retrieval half of a RAG pipeline on the Laravel AI SDK just as testable as the prompts.
Wrapping Up#
Fake the agent, queue a response, assert the prompt, run composer test — that's the entire loop, and it turns AI features into ordinary, well-covered code. Add preventStrayPrompts() to every fake so a missed stub fails loudly instead of phoning home.
From here, point the same discipline at the rest of your suite: Pest 4 snapshot testing for API responses pairs neatly with structured-output agents, and Pest 4 browser testing with Playwright covers the end-to-end flows your agents feed into.
FAQ#
How do I test AI features in Laravel without calling the API?
Use the SDK's built-in fake. Call fake() on your agent class before the code runs, and the SDK swaps the live provider gateway for a fake that returns whatever responses you queue. Your assertions and the rest of the app run exactly as normal, but nothing leaves your machine — no keys, no latency, no token spend.
What is Agent::fake() in the Laravel AI SDK?
It's the SDK's first-party test double, the AI equivalent of Http::fake() or Mail::fake(). Called on a concrete agent class such as SupportAgent::fake(), it replaces that agent's provider with a fake that records every prompt and returns the responses you give it. You then assert against those recorded prompts. There is no generic Agent::fake() facade — you always call it on your own agent class.
How do I assert a prompt was sent with the Laravel AI SDK?
Call assertPrompted() on the agent class after the code under test runs. Pass the exact prompt string to match it directly, or a closure that receives an AgentPrompt and returns true or false for richer matching against ->prompt, ->provider, or ->timeout. Use assertNeverPrompted() to confirm nothing was sent at all.
How do I fake tool calls and structured output in tests?
Structured output is straightforward: queue a plain PHP array like ['category' => 'billing'] and the faked response is array-accessible just like the real typed one. Tool calls are different because faking replaces the provider, so the model never decides to call a tool and your tool callbacks don't fire. Test the tool's own handle() method as a normal unit, and keep the agent test focused on the prompt your code built.
How do I keep API keys out of my CI when testing AI?
Fake every agent so no code path reaches a provider, and you never need real keys in CI. Chain preventStrayPrompts() onto each fake so any un-faked call throws instead of silently hitting the network. Your composer test run stays deterministic and free, and your CI secrets store can stay empty of AI keys entirely.