binarysolutions/test-data-blueprints

Laravel package for reusable scenario-based test datasets beyond factories and seeders.

Maintainers

Package info

github.com/binarysolutionsnl/test-data-blueprints

pkg:composer/binarysolutions/test-data-blueprints

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.2 2026-05-20 10:53 UTC

This package is auto-updated.

Last update: 2026-07-20 13:07:16 UTC


README

Reusable, composable, scenario-based test datasets for Laravel.

Factories solve object construction. Seeders solve bulk population.
Neither solves the problem of named, reusable domain scenarios — the
"company with an active subscription and 3 admin users in the onboarding state"
you need to set up consistently across dozens of feature tests.

Blueprints fill that gap.

Contents

Installation

composer require binarysolutions/test-data-blueprints --dev

⚠ Always install with --dev. This package must not be present in production.

Optionally publish the config file:

php artisan vendor:publish --tag=test-data-blueprints-config

Blueprint files live in database/blueprints/ by default (alongside migrations, factories, and seeders — all database-layer concerns in one place). Create the directory:

mkdir database/blueprints

Core concepts

Concept What it is
Blueprint A named, parameterisable scenario definition stored as a PHP file in database/blueprints/.
Step One unit of work inside a blueprint: ->create(), ->make(), or ->tap().
Ref A named value produced by a step and stored in the BlueprintResult. Later steps (and tests) access refs by name.
@ref token A string like '@company' or '@users[0].id' that resolves to a previously stored ref at runtime.
Composition ->use('other-blueprint') imports all refs from another blueprint before this blueprint's own steps run.
Snapshot In-memory caching of a BlueprintResult for the duration of a test process. Safe only for in-memory blueprints.

Defining a blueprint

Generate a stub:

php artisan make:blueprint SaasCompany
# Creates: database/blueprints/saas_company.php
# Blueprint name: saas-company

A blueprint file returns a Blueprint instance:

<?php
// database/blueprints/saas_company.php

use BinarySolutions\TestDataBlueprints\Fluent\Blueprint;
use App\Models\Company;
use App\Models\User;
use App\Models\Subscription;

return Blueprint::define('saas-company')
    ->at('2025-01-01')
    ->create(Company::factory()->state(['name' => 'ACME BV']), as: 'company')
    ->create(User::factory()->count(3)->state(['role' => 'admin']), as: 'admins', for: '@company')
    ->create(Subscription::factory()->active(), as: 'subscription', for: '@company');

Blueprint names are arbitrary strings. Use colons, hyphens, or slashes for namespacing:

saas-company
company:onboarding
company:suspended
invoice:overdue

Steps: create, make, tap

->create() — persist to the database

->create(
    factory: Company::factory()->state(['vat_number' => 'NL123456789B01']),
    as: 'company',
)

// With count and a @ref token for the relationship
->create(User::factory(), as: 'users', count: 5, for: '@company')

Calls factory->create(). Use this in feature tests where you need real database rows.

->make() — build without persisting

->make(Company::factory(), as: 'company')
->make(User::factory(), as: 'users', count: 3, for: '@company')

Calls factory->make(). Use this in unit tests where you only need model instances and do not want a database round-trip. Safe to combine with ->snapshot().

->tap() — arbitrary callable

->tap(function (BlueprintContext $ctx, BlueprintResult $result) {
    $n = (int) $ctx->param('users', 3);
    return collect(range(1, $n))->map(fn ($i) => (object) ['id' => $i]);
}, as: 'users')

Use ->tap() for computed values, pivot records, plain objects, or anything that does not fit a factory call.

Ref tokens

Any step can reference the output of a previous step using @refName:

->create(Company::factory(), as: 'company')

// Pass the actual company model to the factory's ->for() method
->create(User::factory(), as: 'users', for: '@company')

// Or inline in a state array
->create(Invoice::factory(), as: 'invoice', state: ['company_id' => '@company.id'])

Supported token syntax:

Token Resolves to
@company The entire value stored under company
@company.id ->id on an object, or ['id'] on an array
@users[0] First element of the users collection/array
@users[0].email email property of the first user

Composition with ->use()

// database/blueprints/company_overdue.php
return Blueprint::define('company-overdue')
    ->use('saas-company')           // imports: company, admins, subscription
    ->create(Invoice::factory()->overdue()->count(3), as: 'overdue_invoices', for: '@company')
    ->create(AuditLog::factory()->state(['event' => 'payment_failed']), as: 'payment_log', for: '@company');

->use() runs the named blueprint first and merges all its refs into the current result. If the dependency and the current blueprint define the same ref key, the dependency's value wins (existing refs are not overwritten).

Multiple ->use() calls are supported and resolved in order.

Time travel with ->at()

return Blueprint::define('overdue-invoice')
    ->at('2024-01-01')                          // Carbon::setTestNow('2024-01-01')
    ->create(Invoice::factory()->due(), as: 'invoice')
    // Carbon is restored to its previous value after the blueprint runs

->at() wraps the entire blueprint run in Carbon::setTestNow() and restores the previous value (including null) in a finally block, so it never leaks into the surrounding test.

Snapshots and caching

return Blueprint::define('in-memory-scenario')
    ->make(Company::factory(), as: 'company')
    ->make(User::factory()->count(3), as: 'users', for: '@company')
    ->snapshot(); // cache the result for the lifetime of the test process

When ->snapshot() is set, the SnapshotManager caches the BlueprintResult in memory keyed by name + params. Subsequent calls with the same name and params return the same instance without re-running steps.

⚠ Test isolation and RefreshDatabase

Do not use ->snapshot() on blueprints that call ->create() when your test suite uses RefreshDatabase.

Here is why:

  1. Test A runs → $this->blueprint('saas-company') → creates rows → SnapshotManager caches the BlueprintResult containing Eloquent model instances pointing to ids 1, 2, 3.
  2. RefreshDatabase rolls back the transaction → rows are gone from the database.
  3. Test B runs → $this->blueprint('saas-company')SnapshotManager returns the cached result → the models still point to ids 1, 2, 3those rows no longer exist.

The UsesBlueprints trait guards against this by calling clearBlueprintSnapshots() automatically before each test (via PHPUnit's setUp<TraitName>() convention). This means:

  • Each test gets a fresh blueprint run.
  • Models created in one test's transaction are never shared with another test.
  • Blueprints using ->create() work correctly alongside RefreshDatabase.

If all your blueprints use only ->make() or ->tap() (no DB writes), you can opt out of the auto-clear for a small performance gain:

class MyTest extends TestCase
{
    use UsesBlueprints;

    // Disable auto-clear when no blueprints in this class touch the database
    protected bool $autoClearBlueprintSnapshots = false;
}

Using in tests (PHPUnit)

use BinarySolutions\TestDataBlueprints\Testing\UsesBlueprints;
use Illuminate\Foundation\Testing\RefreshDatabase;

class SubscriptionTest extends TestCase
{
    use RefreshDatabase, UsesBlueprints;
    // UsesBlueprints clears the snapshot cache before each test automatically.
    // Do NOT add ->snapshot() to blueprints that use ->create().

    public function test_suspended_company_cannot_access_dashboard(): void
    {
        $refs    = $this->blueprint('company-suspended');
        $company = $refs->get('company');
        $admin   = $refs->get('admins')->first();

        $this->actingAs($admin)
             ->get(route('dashboard', $company))
             ->assertForbidden();
    }

    public function test_blueprint_accepts_runtime_parameters(): void
    {
        $refs = $this->blueprint('saas-company', ['users' => 10]);
        $this->assertCount(10, $refs->get('admins'));
    }
}

Using in tests (Pest)

In tests/Pest.php:

use BinarySolutions\TestDataBlueprints\Testing\UsesBlueprints;

uses(UsesBlueprints::class)->in('Feature', 'Unit');

In a test file:

it('sends an overdue reminder to all admins', function () {
    Mail::fake();
    $refs = $this->blueprint('company-overdue');

    $this->artisan('invoices:send-reminders');

    Mail::assertSent(OverdueReminderMail::class, $refs->get('admins')->count());
});

Using in local seeders

Blueprints are plain PHP — you can call the engine directly from a DatabaseSeeder to populate your local environment with realistic scenario data:

// database/seeders/DevelopmentSeeder.php
class DevelopmentSeeder extends Seeder
{
    public function run(): void
    {
        if (! app()->isLocal()) {
            throw new \RuntimeException('DevelopmentSeeder must not run outside local.');
        }

        $engine = app(\BinarySolutions\TestDataBlueprints\BlueprintEngine::class);

        $engine->run(app(), 'saas-company', ['company_name' => 'Demo Corp']);
        $engine->run(app(), 'company-overdue');

        $this->command->info('Development seed complete.');
    }
}

Artisan commands

All commands are only registered in local and testing environments.

make:blueprint

php artisan make:blueprint SaasCompany
php artisan make:blueprint CompanyWithSubscription --force

Generates a stub in database/blueprints/.

blueprints:list

php artisan blueprints:list

Lists all discovered blueprints and their class types.

blueprints:run

# Local/testing only — no prompt
php artisan blueprints:run saas-company --param=users=5

# With a confirmation prompt if run outside local/testing (e.g. staging)
php artisan blueprints:run saas-company --force

Runs a blueprint against the current database connection and displays its refs.
Useful for quickly inspecting a blueprint or seeding a local environment.

Configuration

// config/test-data-blueprints.php
return [
    // Directories scanned for blueprint files
    'paths' => [
        base_path('database/blueprints'),
    ],

    // Glob patterns applied within each path
    'patterns' => ['*.php'],

    // Cache blueprint instances in memory after the first file load
    'cache_in_memory' => true,
];

You can register additional paths to load blueprints from packages or other modules:

'paths' => [
    base_path('database/blueprints'),
    base_path('packages/billing/database/blueprints'),
],

Troubleshooting

Unknown blueprint 'my-blueprint'. Available: ...
The blueprint name in the error is what the file returns (Blueprint::define('...')), not the filename. Check that the name in define() matches what you are calling. Run blueprints:list to see all discovered names.

Two blueprints have the same name
Each *.php file in a scanned path must return a blueprint with a unique name. If two files return the same name, the last one loaded (by filesystem sort order) silently wins. Rename one of them.

Tests pass individually but fail when the full suite runs
This is almost always snapshot contamination. Make sure you are using RefreshDatabase alongside UsesBlueprints, and that the blueprints involved do not use ->snapshot(). The trait's auto-clear (setUpUsesBlueprints) clears the cache before each test, but only if $autoClearBlueprintSnapshots is true (the default).

->at() is not restoring Carbon after a test
->at() restores inside a finally block, so it always restores even on exception. If Carbon's test now is set outside the blueprint (e.g. by TestCase::setUp), the blueprint will restore to that external value, not null.

Commands are not available
Commands are registered only when app()->environment('local', 'testing') is true. If you are running in a custom environment (e.g. APP_ENV=development), either add it to the service provider's environment list or set APP_ENV=local locally.