binarysolutions / test-data-blueprints
Laravel package for reusable scenario-based test datasets beyond factories and seeders.
Package info
github.com/binarysolutionsnl/test-data-blueprints
pkg:composer/binarysolutions/test-data-blueprints
Requires
- php: ^8.2
- illuminate/support: ^10.0|^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpunit/phpunit: ^10.0|^11.0
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
- Core concepts
- Defining a blueprint
- Steps: create, make, tap
- Ref tokens
- Composition with
->use() - Time travel with
->at() - Snapshots and caching
- ⚠ Test isolation and RefreshDatabase
- Using in tests (PHPUnit)
- Using in tests (Pest)
- Using in local seeders
- Artisan commands
- Configuration
- Troubleshooting
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:
- Test A runs →
$this->blueprint('saas-company')→ creates rows →SnapshotManagercaches theBlueprintResultcontaining Eloquent model instances pointing to ids1, 2, 3. RefreshDatabaserolls back the transaction → rows are gone from the database.- Test B runs →
$this->blueprint('saas-company')→SnapshotManagerreturns the cached result → the models still point to ids1, 2, 3→ those 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 alongsideRefreshDatabase.
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.