Laravel Daily's

Deterministic AI Testing in Laravel: Fake Every Provider Call with the AI SDK

Bright illustration of Laravel and PHP icons connected to deterministic AI tests

AI features are easy to prototype and difficult to test well.

A provider response can change between runs. A prompt can consume tokens during CI. A temporary API failure can turn a passing test into a failure. These problems become more costly when one request uses several AI capabilities.

The Laravel AI SDK gives you a consistent testing layer for those integrations. You can fake agents, image generation, audio, transcription, embeddings, reranking, files, and vector stores.

Your tests can then verify prompts, options, queued work, structured output, and stored files without contacting a provider.

This tutorial shows how to build that coverage in a Laravel application.

Why deterministic AI tests matter

AI provider calls are external side effects. They introduce several variables that ordinary application tests should not depend on.

  • Cost: Tests should not spend tokens or generate paid media.
  • Speed: A local fake returns immediately.
  • Flakiness: Provider latency, rate limits, and model variation disappear.
  • CI safety: A test suite should pass without production API keys.
  • Clear failures: Assertions can identify an incorrect prompt or missing call.

This approach fits Laravel’s broader testing model. Just as you fake mail, queues, notifications, and HTTP requests, you can fake AI operations at their application boundary.

Laravel is a PHP web framework. The AI SDK extends that same testing style to agents and multimodal features.

Install the Laravel AI SDK

Install the package with Composer:

composer require laravel/ai

Publish the configuration and migrations:

php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate

The migration creates tables used by conversation-aware agents. You can configure provider credentials and default models in config/ai.php.

For a new agent, use Artisan:

php artisan make:agent SupportAgent

For an agent that returns structured data, use:

php artisan make:agent TicketClassifier --structured

Tools use a similar command:

php artisan make:tool SearchKnowledgeBase

The generated classes live in app/Ai/Agents and app/Ai/Tools. Each agent uses the Promptable trait.

<?php

namespace App\Ai\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 concise customer support assistant.';
    }
}

You can prompt this agent from a controller, job, action, or route:

$response = (new SupportAgent)->prompt(
    'Explain how to reset an account password.'
);

return (string) $response;

The production call stays unchanged when you test it.

Fake an agent with a fixed response

Call fake() on the agent class before running the code under test:

use App\Ai\Agents\SupportAgent;

SupportAgent::fake();

$response = (new SupportAgent)->prompt(
    'Explain how to reset an account password.'
);

The SDK returns a deterministic fake response. No provider request is made.

You can provide a sequence of responses when the code prompts the agent more than once:

SupportAgent::fake([
    'First response.',
    'Second response.',
]);

The responses are consumed in order. This is useful for workflows that ask an agent to classify a message and then generate a reply.

You can also use a closure. The closure receives an AgentPrompt, so the fake can inspect the incoming prompt:

use Laravel\Ai\Prompts\AgentPrompt;

SupportAgent::fake(function (AgentPrompt $prompt) {
    if ($prompt->contains('refund')) {
        return 'Refund requests must include the original order number.';
    }

    return 'Please provide more information.';
});

This keeps the test deterministic while still exercising prompt-dependent application logic.

Prevent unexpected network calls

A fake should not silently hide an unplanned invocation. Use preventStrayPrompts() when every prompt needs an explicit fake response:

SupportAgent::fake([
    'The account has been updated.',
])->preventStrayPrompts();

If the application prompts the agent more times than the configured responses allow, the test fails.

This is a useful default for CI. It catches new prompt paths before they can reach a real provider.

Assert prompts and queued agents

Agent fakes record the prompts they receive.

Assert an exact prompt:

SupportAgent::assertPrompted(
    'Explain how to reset an account password.'
);

Assert with a closure when the prompt contains dynamic data:

SupportAgent::assertPrompted(function (AgentPrompt $prompt) {
    return $prompt->contains('reset an account password');
});

You can also prove that a prompt did not occur:

SupportAgent::assertNotPrompted('Delete the customer account.');
SupportAgent::assertNeverPrompted();

Queued agents use a separate set of assertions:

SupportAgent::assertQueued('Generate the weekly support summary.');

SupportAgent::assertQueued(function ($prompt) {
    return $prompt->contains('weekly support summary');
});

SupportAgent::assertNotQueued('Send an email immediately.');
SupportAgent::assertNeverQueued();

These assertions test behavior rather than provider output. That distinction matters. The agent’s wording belongs in a smaller set of provider or evaluation tests. Your application tests should verify that the correct input reached the correct agent.

Test structured agent output

Structured output is useful when the rest of your application needs predictable fields.

Implement HasStructuredOutput and define a schema:

<?php

namespace App\Ai\Agents;

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;

class TicketClassifier implements Agent, HasStructuredOutput
{
    use Promptable;

    public function instructions(): string
    {
        return 'Classify support tickets using the provided output schema.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'category' => $schema
                ->string()
                ->enum(['billing', 'technical', 'account'])
                ->required(),

            'priority' => $schema
                ->string()
                ->enum(['low', 'normal', 'high'])
                ->required(),
        ];
    }
}

The fake can generate data that matches the schema:

use App\Ai\Agents\TicketClassifier;

TicketClassifier::fake();

$response = (new TicketClassifier)->prompt(
    'The customer was charged twice.'
);

expect($response['category'])->toBeString();
expect($response['priority'])->toBeString();

For a more specific test, provide structured responses as an array:

TicketClassifier::fake([
    [
        'category' => 'billing',
        'priority' => 'high',
    ],
    [
        'category' => 'technical',
        'priority' => 'normal',
    ],
]);

$first = (new TicketClassifier)->prompt('The customer was charged twice.');
$second = (new TicketClassifier)->prompt('The dashboard returns a 500 error.');

expect($first['category'])->toBe('billing');
expect($first['priority'])->toBe('high');

expect($second['category'])->toBe('technical');
expect($second['priority'])->toBe('normal');

This test checks the application’s handling of structured data. It does not depend on a model producing the same JSON twice.

Test the schema itself as well. Verify required keys, allowed enum values, and how your application handles missing or invalid data. A fake can prove that your controller reads category correctly, but schema tests protect the contract sent to the provider.

Fake images, audio, and transcription

The SDK applies the same pattern to generated media.

Image generation

use Laravel\Ai\Image;
use Laravel\Ai\Prompts\ImagePrompt;

Image::fake();

$image = Image::of('A product photo on a white background')
    ->landscape()
    ->generate();

Image::assertGenerated(function (ImagePrompt $prompt) {
    return $prompt->contains('product photo')
        && $prompt->isLandscape();
});

Image::assertNotGenerated('A dark studio portrait.');

You can provide fake binary content or use a closure:

Image::fake([
    base64_encode('fake-image-content'),
]);

Image::assertNothingGenerated();

assertNothingGenerated() is valuable for branches that should skip media generation.

Queued image work has matching assertions:

Image::assertQueued(fn ($prompt) => $prompt->contains('product'));
Image::assertNotQueued('Unapproved image prompt.');
Image::assertNothingQueued();

Audio generation

use Laravel\Ai\Audio;
use Laravel\Ai\Prompts\AudioPrompt;

Audio::fake([
    base64_encode('fake-audio-content'),
]);

$audio = Audio::of('Your report is ready.')
    ->female()
    ->generate();

Audio::assertGenerated(function (AudioPrompt $prompt) {
    return $prompt->contains('Your report is ready.')
        && $prompt->isFemale();
});

Queued audio uses assertQueued, assertNotQueued, and assertNothingQueued.

Transcription options

Transcription tests can verify both the language and diarization settings:

use Laravel\Ai\Prompts\TranscriptionPrompt;
use Laravel\Ai\Transcription;

Transcription::fake([
    'Welcome to the support call.',
]);

$transcript = Transcription::fromStorage('calls/demo.mp3')
    ->language('en')
    ->diarize()
    ->generate();

expect((string) $transcript)
    ->toBe('Welcome to the support call.');

Transcription::assertGenerated(function (TranscriptionPrompt $prompt) {
    return $prompt->language === 'en'
        && $prompt->isDiarized();
});

Other useful assertions include:

Transcription::assertNotGenerated(
    fn (TranscriptionPrompt $prompt) => $prompt->language === 'fr'
);

Transcription::assertNothingGenerated();

For queued transcription:

Transcription::assertQueued(
    fn ($prompt) => $prompt->isDiarized()
);

Transcription::assertNothingQueued();

Use preventStrayTranscriptions() when an unexpected transcription should fail immediately:

Transcription::fake([
    'Fixed transcript.',
])->preventStrayTranscriptions();

Fake embeddings, reranking, files, and vector stores

AI search introduces more provider boundaries. The SDK includes fakes for each one.

Embeddings

use Laravel\Ai\Embeddings;
use Laravel\Ai\Prompts\EmbeddingsPrompt;

Embeddings::fake();

$response = Embeddings::for([
    'Laravel is a PHP framework.',
])->generate();

Embeddings::assertGenerated(function (EmbeddingsPrompt $prompt) {
    return $prompt->contains('Laravel');
});

The default embedding fake uses the requested dimensions. You can also provide explicit vectors:

Embeddings::fake([
    [[0.10, 0.20, 0.30]],
]);

You can assert that no embedding was generated:

Embeddings::assertNothingGenerated();

Reranking

use Laravel\Ai\Reranking;
use Laravel\Ai\Responses\Data\RankedDocument;

Reranking::fake([
    [
        new RankedDocument(
            index: 1,
            document: 'Laravel is a PHP web application framework.',
            score: 0.95,
        ),
    ],
]);

$response = Reranking::of([
    'Django is a Python web framework.',
    'Laravel is a PHP web application framework.',
])->rerank('PHP frameworks');

expect($response->first()->index)->toBe(1);

Reranking also provides assertReranked, assertNotReranked, and assertNothingReranked.

Files

Fake provider file storage with Files::fake():

use Laravel\Ai\Files;
use Laravel\Ai\Files\Document;

Files::fake();

Document::fromString(
    'Laravel supports elegant application development.',
    mimeType: 'text/plain',
)->as('laravel.txt')->put();

Files::assertStored(function ($file) {
    return $file->name() === 'laravel.txt'
        && $file->mimeType() === 'text/plain';
});

Files::assertNothingStored();

You can verify deletions too:

Files::assertDeleted('file-id');
Files::assertNotDeleted('other-file-id');
Files::assertNothingDeleted();

When a feature needs a provider file identifier, use a fake ID rather than a real one:

$fileId = Files::fakeId();

$document = Document::fromId($fileId);

This keeps attachment and retrieval tests independent from provider state.

Vector stores

Stores::fake() prevents vector store calls and also fakes related file operations:

use Laravel\Ai\Stores;

Stores::fake();

$store = Stores::create(
    name: 'Product Knowledge Base',
    description: 'Product documentation for support agents.',
);

Stores::assertCreated('Product Knowledge Base');

$store->add('file-123');
$store->remove('file-123');

$store->assertAdded('file-123');
$store->assertRemoved('file-123');

You can assert store deletion with:

Stores::assertDeleted('store-id');
Stores::assertNotDeleted('other-store-id');
Stores::assertNothingDeleted();

These fakes let you test RAG orchestration without uploading documents or maintaining a remote vector index.

Illustration of Laravel’s test harness faking agents, media, embeddings, and vector stores

Test tools in isolation

An agent tool is application code. You do not need a model to test it.

Create a tool with:

php artisan make:tool LookupOrder

A tool’s handle() method receives a Laravel\Ai\Tools\Request instance:

<?php

namespace App\Ai\Tools;

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

class LookupOrder implements Tool
{
    public function description(): Stringable|string
    {
        return 'Find an order by its public order number.';
    }

    public function handle(Request $request): Stringable|string
    {
        return (string) Order::where(
            'number',
            $request['order_number']
        )->value('status');
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'order_number' => $schema->string()->required(),
        ];
    }
}

Invoke the method directly in a unit test:

use App\Ai\Tools\LookupOrder;
use Laravel\Ai\Tools\Request;

$result = (new LookupOrder)->handle(new Request([
    'order_number' => 'ORD-1001',
]));

expect((string) $result)->toBe('shipped');

This test checks database queries, authorization, formatting, and error handling without relying on the model to select the tool.

Keep agent tests focused on whether the agent receives the right prompt and has the expected tool available. Keep tool tests focused on the tool’s behavior.

A REST endpoint feature test

Now connect the pieces in a small endpoint.

The controller prompts an agent and returns JSON:

<?php

namespace App\Http\Controllers;

use App\Ai\Agents\SupportAgent;
use Illuminate\Http\Request;

class SupportReplyController
{
    public function __invoke(Request $request)
    {
        $request->validate([
            'message' => ['required', 'string'],
        ]);

        $response = (new SupportAgent)->prompt(
            $request->string('message')->toString()
        );

        return response()->json([
            'reply' => (string) $response,
        ]);
    }
}

Register the route:

use App\Http\Controllers\SupportReplyController;
use Illuminate\Support\Facades\Route;

Route::post('/api/support/reply', SupportReplyController::class);

The feature test fakes the provider call and verifies the complete HTTP path:

<?php

namespace Tests\Feature;

use App\Ai\Agents\SupportAgent;
use Laravel\Ai\Prompts\AgentPrompt;
use Tests\TestCase;

class SupportReplyTest extends TestCase
{
    public function test_it_returns_a_deterministic_support_reply(): void
    {
        SupportAgent::fake([
            'Restart the application, then try signing in again.',
        ])->preventStrayPrompts();

        $response = $this->postJson('/api/support/reply', [
            'message' => 'I cannot sign in after changing my password.',
        ]);

        $response
            ->assertOk()
            ->assertJson([
                'reply' => 'Restart the application, then try signing in again.',
            ]);

        SupportAgent::assertPrompted(function (AgentPrompt $prompt) {
            return $prompt->contains('cannot sign in')
                && $prompt->contains('changing my password');
        });
    }

    public function test_it_requires_a_message(): void
    {
        SupportAgent::fake()->preventStrayPrompts();

        $this->postJson('/api/support/reply', [])
            ->assertUnprocessable();

        SupportAgent::assertNeverPrompted();
    }
}

This is enough to prove the endpoint validates input, invokes the intended agent, returns the expected JSON shape, and never touches a real provider.

It is also a practical example of how to build REST API with PHP while keeping AI behavior safe in tests.

Illustration of a Laravel REST API request flowing through an AI agent and returning tested JSON

Practical testing habits

Enable stray-call protection in CI

Use preventStrayPrompts() and the equivalent methods for media and data operations. A missing fake should fail the test instead of using a configured API key.

For example:

SupportAgent::fake()->preventStrayPrompts();
Image::fake()->preventStrayImages();
Audio::fake()->preventStrayAudio();
Transcription::fake()->preventStrayTranscriptions();
Embeddings::fake()->preventStrayEmbeddings();

Apply only the fakes relevant to each test. This keeps failures easy to interpret.

Use closures for meaningful branches

A single fixed response proves that the call happened. A closure can prove that the application sends the right context.

SupportAgent::fake(function (AgentPrompt $prompt) {
    return $prompt->contains('urgent')
        ? 'Escalate this request.'
        : 'Reply using the standard support workflow.';
});

Test structured schemas as contracts

Do not only assert that a structured response contains values. Test the schema’s required keys and permitted values. Those constraints protect the boundary between the model and your PHP code.

Keep real provider tests separate

A small integration suite can verify provider configuration and model compatibility. Keep it separate from the default feature suite. Run it deliberately with credentials and an explicit environment flag.

Most application tests should remain local, fast, and deterministic.

Ship AI features with confidence

The Laravel AI SDK brings AI calls into the same testing workflow as the rest of your application.

Fake the agent. Inspect the AgentPrompt. Assert queued work. Validate structured output. Test transcription flags. Exercise tools directly. Fake files and vector stores. Prevent stray calls before they reach CI.

These are practical PHP developer tools for building AI features without making your test suite depend on a model, a provider, or a network connection.

Read the full Laravel AI SDK documentation, explore the Laravel AI package on GitHub, and keep provider calls at the edge of your application. The closer your tests stay to your own contracts, the more confidently you can ship.

Previous
Streaming AI Responses in Laravel: Real-Time Chat with SSE and the AI SDK
Next
The Head Component in Inertia 3.x: Page Titles, Meta Tags, and SEO for Your Laravel + Vue App