laravel test factories

Building Robust Laravel Test Factories for Reliable Automated Testing

Last reviewed: May 2026

Laravel test factories are load-bearing infrastructure. Not boilerplate. Not scaffolding you wire up once and forget. They are your domain model in miniature, and in AI-powered applications they carry an additional responsibility: modelling the failure modes your LLM provider will actually produce in production.

Rate limits happen. Responses get truncated at the token ceiling. Output passes structural inspection but fails your schema validation. If your laravel test factories cannot simulate those conditions, your test suite is covering the happy path and little else. In production, the happy path is the minority case.

This guide covers factory design for Laravel 13 applications, preserving the core mechanics (states, sequences, relationships, performance patterns) and building the AI-specific layer on top. Every major section includes both a generic factory example and an AI-context application, so you can apply whichever is relevant to your current work.

This article is part of the AI Architecture module. If you are assembling the contract and governance layer these factories are designed to exercise, that is the place to start.

Why Factory Design Is an Architectural Decision

Most developers treat factories as boilerplate they generate once and move on from. They are not. A factory is a domain model assertion: it defines what “valid data” looks like across your entire test suite. Get it wrong, and every downstream test inherits that wrongness silently.

Weak factories cause tests that fail intermittently due to Faker randomness, unrealistic data that never surfaces edge cases your application actually hits, repeated create() setup scattered across test files, and suites slow enough that developers start skipping them. For the contract and governance layer that sits above all of this, the production-grade AI architecture guide covers how the surrounding structure should be organised.

In AI applications, the stakes are higher. Your service classes depend on external provider contracts. Those contracts behave differently depending on rate limit headers, context window exhaustion, and model availability. If your factories cannot put those service classes into the correct state, you are not testing them, you are demonstrating they work when nothing is wrong.

Laravel’s factory system, backed by the Service Container and Eloquent ORM, is expressive enough to model all of this. Most teams just do not push it far enough.

Prerequisites

You will need:

  • PHP 8.3+
  • Laravel 13
  • A configured testing database (SQLite in-memory is sufficient for most suites)
  • Pest (preferred) or PHPUnit

Laravel 13 ships with bootstrap/app.php as the single middleware registration point and moves scheduled task definitions to routes/console.php. There is no app/Http/Kernel.php or app/Console/Kernel.php. If your project still has those files, you are on an older version and the migration guide applies before anything else here does.

Pest ships as the default test runner in new Laravel 13 projects. If you have not made the switch, you should. The 2026 Laravel development tools breakdown covers where Pest sits in a modern workflow alongside the rest of the stack.

For AI integrations, install the first-party laravel/ai SDK:

composer require laravel/ai

For official factory documentation, see the Laravel Eloquent Factories docs.

Creating a Basic Factory

Laravel factories live in database/factories. Generate one with Artisan CLI:

php artisan make:factory PostFactory --model=Post
namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

class PostFactory extends Factory
{
    public function definition(): array
    {
        return [
            'title'     => fake()->unique()->sentence(),
            'slug'      => str()->slug(fake()->unique()->words(3, true)),
            'content'   => fake()->paragraphs(3, true),
            'published' => false,
        ];
    }
}

Two things here. First: fake()->unique()->sentence(), not fake()->sentence(). Uniqueness constraints on slugs and titles are real, and non-unique fakers cause flaky tests that are miserable to debug. Second: str()->slug() over fake()->slug(). Faker’s slug helper respects locale, which means non-ASCII characters depending on your environment. Do not trust it for slug fields with database-level constraints.

Now the AI counterpart. In production AI applications, you typically persist LLM interactions for audit, token cost tracking, and telemetry. That persistence layer needs its own factory.

php artisan make:factory AiInteractionFactory --model=AiInteraction
namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

class AiInteractionFactory extends Factory
{
    public function definition(): array
    {
        return [
            'provider'         => 'anthropic',
            'model'            => 'claude-sonnet-4-6',
            'prompt_hash'      => fake()->sha256(),
            'input_tokens'     => fake()->numberBetween(200, 2000),
            'output_tokens'    => fake()->numberBetween(100, 800),
            'response_content' => fake()->paragraphs(2, true),
            'finish_reason'    => 'end_turn',
            'status'           => 'success',
            'latency_ms'       => fake()->numberBetween(400, 3000),
        ];
    }
}

This gives you a foundation. The real power comes from states.

Factory States: Your Domain Vocabulary

States are not optional convenience methods. They are your primary tool for communicating domain intent inside a test. For AI service testing, they are where you model the conditions that actually matter.

Generic States

public function published(): static
{
    return $this->state(fn (array $attributes) => [
        'published'    => true,
        'published_at' => now(),
    ]);
}

public function draft(): static
{
    return $this->state(fn (array $attributes) => [
        'published'    => false,
        'scheduled_at' => null,
    ]);
}

Note the return type: : static, not : Factory. This matters for IDE type inference and for chaining states when you are building complex scenarios.

LLM Response Condition States

This is where most AI application test suites are dangerously thin. Every state below represents a condition your provider will produce in production:

// In AiInteractionFactory

public function rateLimited(): static
{
    return $this->state(fn () => [
        'status'           => 'error',
        'finish_reason'    => null,
        'response_content' => null,
        'error_code'       => 429,
        'error_message'    => 'Rate limit exceeded. Retry after 30 seconds.',
        'latency_ms'       => 12, // fails fast
    ]);
}

public function tokenBudgetExceeded(): static
{
    return $this->state(fn () => [
        'status'        => 'success',
        'finish_reason' => 'max_tokens',
        'output_tokens' => 4096, // hit the ceiling
        // response_content is present but truncated mid-sentence
        'response_content' => fake()->sentence(15), // deliberately short
    ]);
}

public function hallucinatedOutput(string $invalidContent = null): static
{
    // Passes structural inspection but violates your schema constraints.
    // This is the state that catches missing schema validation.
    return $this->state(fn () => [
        'status'           => 'success',
        'finish_reason'    => 'end_turn',
        'response_content' => $invalidContent ?? '{"result": null, "confidence": -1}',
    ]);
}

public function forGemini(): static
{
    return $this->state(fn () => [
        'provider'      => 'google',
        'model'         => 'gemini-2.5-pro',
        'finish_reason' => 'STOP', // Gemini uses different finish reason strings than Anthropic
    ]);
}

We introduced hallucinatedOutput after a production incident where our schema validator was not firing on responses with a null confidence score because we had an isset() check instead of array_key_exists(). The state now exists in our factory precisely because we got burned. Every edge case that reaches production deserves a state.

[Architect’s Note] Each of these states maps to an exception class or validation failure path in your application code. If a state exists in the factory but there is no corresponding handler in the service class, that state is surfacing a gap, not a test. Use factory states as a checklist of failure paths your code must handle.

Mocking AI Provider Contracts with the Service Container

This is the section most factory guides skip entirely, and it is the most important one for AI applications.

Your service classes should depend on a provider contract (interface), not a concrete Anthropic or Google client. That contract is what gets bound in the Service Container. In tests, you replace that binding with a fake that returns factory-built data.

// app/Contracts/AiProviderContract.php
interface AiProviderContract
{
    public function complete(string $prompt, array $options = []): AiResponse;
}
// tests/Fakes/FakeAiProvider.php
class FakeAiProvider implements AiProviderContract
{
    private array $queue;

    public function __construct(array $interactions)
    {
        $this->queue = $interactions;
    }

    public function complete(string $prompt, array $options = []): AiResponse
    {
        $interaction = array_shift($this->queue);

        if ($interaction['status'] === 'error' && $interaction['error_code'] === 429) {
            throw new RateLimitException($interaction['error_message']);
        }

        if ($interaction['finish_reason'] === 'max_tokens') {
            throw new TokenBudgetExceededException(
                'Response truncated at token limit.',
                outputTokens: $interaction['output_tokens']
            );
        }

        return new AiResponse(
            content:      $interaction['response_content'],
            inputTokens:  $interaction['input_tokens'],
            outputTokens: $interaction['output_tokens'],
            finishReason: $interaction['finish_reason'],
        );
    }
}

In your test:

public function test_service_handles_rate_limit_with_backoff(): void
{
    $rateLimitedInteraction = AiInteraction::factory()->rateLimited()->make()->toArray();

    $this->app->bind(AiProviderContract::class, function () use ($rateLimitedInteraction) {
        return new FakeAiProvider([$rateLimitedInteraction]);
    });

    $this->expectException(RateLimitException::class);

    app(ContentGenerationService::class)->generate('Summarise this article');
}

This pattern keeps live API calls entirely out of your test suite. The Service Container binding is scoped to the test. The factory state determines the provider behaviour. No Http::fake() patchwork, no .env overrides, no network dependency.

Modeling Relationships

Factories should replicate the relationship structure your application actually depends on.

// PostFactory — lazy relationship resolution
public function definition(): array
{
    return [
        'user_id' => User::factory(),
        'title'   => fake()->unique()->sentence(),
        'content' => fake()->paragraph(),
    ];
}

Laravel resolves User::factory() lazily: it only fires if you do not supply a user_id explicitly. Use it.

For explicit control:

Post::factory()
    ->for(User::factory()->state(['role' => 'author']))
    ->create();

In AI applications, a common relationship is an AiInteraction belonging to a PromptVersion. You want to test that your service picks up the correct active prompt version and logs interactions against it:

// AiInteractionFactory — with explicit prompt version
public function definition(): array
{
    return [
        'prompt_version_id' => PromptVersion::factory(),
        'provider'          => 'anthropic',
        'model'             => 'claude-sonnet-4-6',
        // ...
    ];
}
$version = PromptVersion::factory()->create(['active' => true]);

AiInteraction::factory()
    ->for($version, 'promptVersion')
    ->count(10)
    ->create();

$this->assertDatabaseCount('ai_interactions', 10);
$this->assertDatabaseMissing('ai_interactions', ['prompt_version_id' => null]);

Sequences for Deterministic Variation

When you are testing filtering, pagination, or cost aggregation, you need predictable variation, not random variation. Sequences deliver that.

// Generic: pagination test
Post::factory()
    ->count(4)
    ->sequence(
        ['published' => true,  'published_at' => now()->subDays(3)],
        ['published' => false, 'published_at' => null],
        ['published' => true,  'published_at' => now()->subDay()],
        ['published' => false, 'published_at' => null],
    )
    ->create();

The AI-specific equivalent is useful for token cost reporting. If you are testing a dashboard query that groups spend by provider and model, you need controlled variation across the interaction log:

AiInteraction::factory()
    ->count(6)
    ->sequence(
        ['provider' => 'anthropic', 'model' => 'claude-sonnet-4-6',          'input_tokens' => 800,  'output_tokens' => 400],
        ['provider' => 'anthropic', 'model' => 'claude-haiku-4-5-20251001',   'input_tokens' => 200,  'output_tokens' => 100],
        ['provider' => 'google',    'model' => 'gemini-2.5-pro',              'input_tokens' => 1200, 'output_tokens' => 600],
        ['provider' => 'google',    'model' => 'gemini-2.0-flash',            'input_tokens' => 300,  'output_tokens' => 150],
        ['provider' => 'openai',    'model' => 'gpt-4o',                      'input_tokens' => 1000, 'output_tokens' => 500],
        ['provider' => 'openai',    'model' => 'gpt-4o-mini',                 'input_tokens' => 400,  'output_tokens' => 200],
    )
    ->create();

$report = app(TokenCostReportService::class)->byProvider();

$this->assertCount(3, $report);
$this->assertEquals('anthropic', $report->first()['provider']);

Your cost aggregation test now has deterministic input. You know exactly what the query should return.

Testing Prompt Version Migrations

Prompt migrations are version-controlled changes to your LLM prompt templates. Testing them requires a factory that can model the state of your prompt registry before and after a migration runs. For the full pattern on managing prompt versions as database migrations, the Laravel prompt migrations guide covers the implementation in depth.

php artisan make:factory PromptVersionFactory --model=PromptVersion
class PromptVersionFactory extends Factory
{
    public function definition(): array
    {
        return [
            'name'        => fake()->unique()->slug(2),
            'version'     => '1.0.0',
            'template'    => 'Summarise the following text: {content}',
            'model'       => 'claude-sonnet-4-6',
            'temperature' => 0.7,
            'max_tokens'  => 1024,
            'active'      => true,
        ];
    }

    public function deprecated(): static
    {
        return $this->state(fn () => [
            'active'        => false,
            'deprecated_at' => now()->subWeek(),
        ]);
    }

    public function v2(): static
    {
        return $this->state(fn () => [
            'version'  => '2.0.0',
            'template' => 'You are a technical editor. Summarise the following text concisely: {content}',
        ]);
    }
}

A migration test using these states:

public function test_service_uses_active_prompt_version_after_migration(): void
{
    // Pre-migration state: v1 active
    $v1 = PromptVersion::factory()->create(['name' => 'article-summary', 'version' => '1.0.0']);
    
    // Migration runs: v1 deprecated, v2 active
    $v1->update(['active' => false, 'deprecated_at' => now()]);
    $v2 = PromptVersion::factory()->v2()->create(['name' => 'article-summary']);

    $resolved = app(PromptRegistry::class)->resolve('article-summary');

    $this->assertEquals('2.0.0', $resolved->version);
    $this->assertTrue($resolved->active);
}

This is the pattern that catches “the migration ran but the registry cache was not flushed” bugs, the kind that only show up in production because you never tested the post-migration state explicitly.

Performance Patterns for Large Test Suites

Once your suite crosses a few hundred tests, factory performance starts mattering. Three levers.

Use make() instead of create() when you do not need persistence:

$interaction = AiInteraction::factory()->rateLimited()->make();
// No database write. Instant.
// Use this for unit tests that inspect attributes or call methods on the model directly.

Any test that only inspects model attributes or calls methods that do not touch the database should be using make(). This is the lowest-effort, highest-return optimisation in any test suite.

Use LazilyRefreshDatabase over RefreshDatabase for large suites:

use Illuminate\Foundation\Testing\LazilyRefreshDatabase;

class AiInteractionTest extends TestCase
{
    use LazilyRefreshDatabase;
}

RefreshDatabase runs migrations before every test class. LazilyRefreshDatabase defers until the first database operation actually happens. For suites with many unit tests mixed in, the difference compounds quickly.

Disable model events when seeding test data at volume:

AiInteraction::withoutEvents(
    fn () => AiInteraction::factory()->count(500)->create()
);

If you have Telescope observers, audit trail listeners, or token cost aggregators wired to Eloquent events, they will fire 500 times. That will destroy your suite runtime. withoutEvents() is mandatory for volume seeding.

[Production Pitfall] fake()->unique() resets between test runs, but not between factory calls within the same test. Call UserFactory 200 times in a single test and Faker’s unique generator will eventually throw \OverflowException. For high-volume seeding, reset the pool with fake()->unique(true) or generate uniqueness from a Sequence instead.

Common Anti-Patterns (and What They Cost You)

1. Business logic inside factories

// Do not do this
'status' => $this->computePublishingStatus($this->attributes),

The test should control state explicitly. A factory that computes state is a factory that hides bugs.

2. Deep object graphs as defaults

If your UserFactory automatically creates a Profile, a Subscription, and a BillingAddress by default, every test that touches a user pays that cost, even if it only cares about the user’s email. Keep defaults minimal. Build depth explicitly when tests require it.

3. Non-unique Faker fields on columns with unique constraints

// Fails intermittently on large suites
'email' => fake()->email(),

// Does not
'email' => fake()->unique()->safeEmail(),

Intermittent uniqueness failures are the worst kind: they pass locally, fail in CI, and take 20 minutes to reproduce.

4. Factories that make live API calls

This one is specific to AI applications, and it is more common than it should be. We have seen codebases where the AiInteractionFactory‘s afterCreating hook triggered a real HTTP request to validate a prompt template. The factory “tested” the template by running it.

Do not do this. A factory that depends on network availability is not a factory: it is an integration test masquerading as test setup. It will fail in offline CI environments, consume real API quota, and introduce non-deterministic latency into your suite. Mock the provider contract at the Service Container level. Keep factories pure data builders.

// Wrong: network dependency in afterCreating
return $this->afterCreating(function (PromptVersion $version) {
    $response = Http::post('https://api.anthropic.com/v1/messages', [...]); // Never in a factory
    $version->update(['validated' => $response->successful()]);
});

// Right: control validation state via factory state
public function validated(): static
{
    return $this->state(fn () => ['validated' => true]);
}

Real-World Integration Tests

Standard Application Test

This is the pattern from the original article, preserved because it is clean:

public function test_author_can_publish_their_own_draft(): void
{
    $author = User::factory()->create();
    $post   = Post::factory()->for($author)->draft()->create();

    $this->actingAs($author)
        ->post("/posts/{$post->id}/publish")
        ->assertRedirect();

    expect($post->fresh())
        ->published->toBeTrue()
        ->published_at->not->toBeNull();
}

Minimal setup. Explicit state. Zero ambiguity about what is being tested.

AI Service Test

This is the pattern most projects are missing. It tests the service class that wraps your LLM provider, exercises a real failure path, and does not touch a live API:

public function test_content_generation_service_retries_on_rate_limit_then_succeeds(): void
{
    $rateLimited = AiInteraction::factory()->rateLimited()->make()->toArray();
    $successful  = AiInteraction::factory()->make()->toArray();

    // Queue two responses: first call fails with 429, second succeeds.
    $this->app->bind(AiProviderContract::class, function () use ($rateLimited, $successful) {
        return new FakeAiProvider([$rateLimited, $successful]);
    });

    try {
        $result = app(ContentGenerationService::class)->generate('Write a product summary');
    } catch (RateLimitException $e) {
        $this->fail('Service should have retried and succeeded, not propagated the exception.');
    }

    $this->assertNotNull($result);
    $this->assertDatabaseCount('ai_interactions', 2); // both attempts logged

    // The second interaction should be the successful one
    $this->assertDatabaseHas('ai_interactions', [
        'status'        => 'success',
        'finish_reason' => 'end_turn',
    ]);
}

This test verifies three things at once: the retry logic fires, both attempts are logged to the interaction table (critical for cost auditing), and the final result is the successful response. None of this requires a live API. The schema validation guide for agentic workflows covers what happens after the successful response arrives and how to validate it before it reaches your application layer.

Summary

Well-designed Laravel test factories are not a nicety. They are load-bearing infrastructure for your test suite, and in AI applications they carry the additional responsibility of modelling provider failure modes accurately.

The patterns that matter:

  • Use : static return types on all state methods
  • Keep defaults minimal; model depth explicitly in individual tests
  • Apply ->unique() to any field with a database-level uniqueness constraint
  • Use Sequence for deterministic variation in sort, filter, and aggregation tests
  • Prefer make() over create() wherever persistence is not required
  • Use LazilyRefreshDatabase in large suites
  • Never embed business logic inside a factory state
  • Never make live API calls from a factory
  • Bind fake providers through the Service Container, keyed off factory-built interaction data

The cost of getting this wrong is a test suite that slowly becomes a tax on your team. Invest in the factory layer early. It will hold up as your domain and your AI feature set grow together.


For the full official reference, see the Laravel Eloquent Factories documentation.


Frequently Asked Questions

Should I use Http::fake() or a fake provider class when testing AI service integrations?

Prefer a fake provider class bound through the Service Container. Http::fake() couples your test to the raw HTTP contract of the provider’s API. A fake provider class tests your application’s contract: the interface your code actually depends on. When you swap providers (Anthropic to Gemini, for example), the test does not need to change.

Can factory states be combined, and in what order do they apply?

States chain and apply in the order they are called. The last state to define a given attribute wins. AiInteraction::factory()->rateLimited()->forGemini()->make() will produce a rate-limited interaction with the Gemini provider fields, because forGemini() runs after rateLimited(). Be deliberate about ordering when states overlap on the same attribute.

How do I test a service class that uses the laravel/ai SDK without mocking at the HTTP layer?

The laravel/ai SDK resolves its provider through the Service Container. Bind a fake implementation of the provider contract in your setUp() method or inline in the test using $this->app->bind(). The SDK will pick up the fake binding. You never need to intercept the HTTP layer.

My test suite has grown to 1,000+ tests and factory performance is becoming a bottleneck. Where do I start?

Start with make() versus create(). Audit every test that uses create() and ask whether it actually needs a database record. Most unit tests do not. The second lever is withoutEvents() on any seeding that fires Eloquent observers. The third is LazilyRefreshDatabase. In that order. The combined effect on a 1,000-test suite is typically a 30–50% reduction in runtime before you need to look at anything more invasive.

Is it safe to use fake()->unique() in factories for fields that have no database uniqueness constraint?

Technically yes, but it is unnecessary overhead and will exhaust Faker’s unique pool faster in high-volume test runs. Reserve ->unique() for fields that have a UNIQUE index in the database. Use Sequence instead for controlled variation where uniqueness is a testing concern rather than a schema constraint.

Dewald Hugo

A software architect with 15+ years of experience in the PHP and Laravel ecosystem. Dewald created Origin Main to provide the engineering rigour required to integrate AI into professional, high-concurrency production systems. He writes for developers who care less about "getting it to work" and more about "getting it to last."

Subscribe
Notify of
0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Navigation
Scroll to Top