Test factories create hydrated DTOs for tests. They live in database/factories/Data/ and use the HasTestFactory trait to enable ::testFactory() on Data classes.
Related guides:
- SKILL.md - Core DTO patterns and structure
- dto-transformers.md - Transformers for domain logic (different purpose)
- Testing - Using test factories in tests
| Aspect | Test Factories | Transformers |
|---|---|---|
| Purpose | Generate fake test data | Transform domain data → DTO |
| Location | database/factories/Data/ |
app/Data/Transformers/ |
| Class naming | {Entity}DataFactory |
{Entity}DataTransformer |
| Used in | Tests only | Domain logic, controllers, handlers |
| Method style | ::testFactory()->make() |
::fromStripe(), ::fromRequest() |
Apply the HasTestFactory trait to your base Data class:
<?php
declare(strict_types=1);
namespace App\Data;
use App\Data\Concerns\HasTestFactory;
use Spatie\LaravelData\Data as BaseData;
abstract class Data extends BaseData
{
use HasTestFactory;
}<?php
declare(strict_types=1);
namespace App\Data\Concerns;
use Database\Factories\Data\DataTestFactory;
use Illuminate\Database\Eloquent\Factories\Factory;
trait HasTestFactory
{
/**
* @return DataTestFactory<static>
*/
public static function testFactory(): DataTestFactory
{
return tap(Factory::factoryForModel(static::class))->setDataClass(static::class);
}
}<?php
declare(strict_types=1);
namespace Database\Factories\Data;
use App\Data\Data;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Collection;
/**
* @template TData
*/
abstract class DataTestFactory
{
use WithFaker;
/** @var null|class-string<Data|TData> */
protected ?string $dataClassName = null;
private array $states = [];
abstract public function definition(): array;
public static function new()
{
return tap(new static, function ($factory) {
$factory->setUpFaker();
});
}
public function setDataClass(string $className): void
{
$this->dataClassName = $className;
}
/**
* @return Collection<int, TData>
*/
public function collect($attributes = [], ?int $count = 1): Collection
{
return $this->dataClassName::collect(
collect(range(1, $count))->map(fn () => $this->make($attributes))
);
}
/**
* @return TData
*/
public function make($attributes = [])
{
return $this->dataClassName::from(
array_replace(
array_replace($this->definition(), ...$this->states),
$attributes
)
);
}
protected function state(callable|array $array): static
{
$this->states[] = value($array);
return $this;
}
}Register the factory resolver in AppServiceProvider:
<?php
declare(strict_types=1);
namespace App\Providers;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->registerModelFactoryResolver();
}
private function registerModelFactoryResolver(): void
{
Factory::guessFactoryNamesUsing(function (string $modelName) {
if (str($modelName)->endsWith('Data')) {
return 'Database\Factories\Data\\'.Str::afterLast($modelName, '\\').'Factory';
}
return 'Database\Factories\\'.Str::afterLast($modelName, '\\').'Factory';
});
}
}<?php
declare(strict_types=1);
namespace Database\Factories\Data;
class AddressDataFactory extends DataTestFactory
{
public function definition(): array
{
return [
'address1' => fake()->streetAddress(),
'address2' => null,
'address3' => null,
'town' => fake()->city(),
'county' => fake()->city(),
'postcode' => fake()->postcode(),
'country' => 'UK',
'fromDate' => fake()->dateTimeBetween('-10 years', '-5 years')->format('d-m-Y'),
'toDate' => fake()->dateTimeBetween('-4 years')->format('d-m-Y'),
'current' => true,
];
}
}<?php
declare(strict_types=1);
namespace Database\Factories\Data;
use App\Enums\TraceType;
use Illuminate\Support\Str;
class TraceDataFactory extends DataTestFactory
{
public function definition(): array
{
return [
'uuid' => Str::uuid()->toString(),
'provider' => fake()->word(),
'policyNumber' => fake()->creditCardNumber(separator: ''),
'employer' => null,
'industry' => null,
'type' => TraceType::Pension,
'fromDate' => fake()->dateTimeBetween('-10 years', '-5 years')->format('d-m-Y'),
'toDate' => fake()->dateTimeBetween('-4 years')->format('d-m-Y'),
'documents' => collect(),
];
}
public function pensionViaProvider(): static
{
return $this->state(fn () => [
'type' => TraceType::Pension,
'provider' => fake()->company,
'policyNumber' => fake()->creditCardNumber(separator: ''),
'employer' => null,
'industry' => null,
]);
}
public function pensionViaEmployment(): static
{
return $this->state(fn () => [
'type' => TraceType::Pension,
'provider' => null,
'policyNumber' => null,
'employer' => fake()->company,
'industry' => fake()->word,
]);
}
public function investment(): static
{
return $this->state(fn () => [
'type' => TraceType::Investment,
'provider' => fake()->company,
'policyNumber' => fake()->creditCardNumber(separator: ''),
'employer' => null,
'industry' => null,
]);
}
}$data = CreateOrderData::testFactory()->make();
// With overrides
$data = CreateOrderData::testFactory()->make([
'customerEmail' => 'test@example.com',
'status' => OrderStatus::Pending,
]);$items = OrderItemData::testFactory()->collect(count: 5);
// With overrides
$items = OrderItemData::testFactory()->collect(
attributes: ['quantity' => 1],
count: 3,
);// Using state methods
$data = TraceData::testFactory()->pensionViaProvider()->make();
$data = TraceData::testFactory()->pensionViaEmployment()->make();
$data = TraceData::testFactory()->investment()->make();
// Chaining states with overrides
$data = TraceData::testFactory()
->pensionViaProvider()
->make(['provider' => 'Specific Provider Ltd']);it('creates an order from DTO', function () {
$user = User::factory()->create();
$data = CreateOrderData::testFactory()->make([
'customerEmail' => $user->email,
]);
$order = resolve(CreateOrderAction::class)($user, $data);
expect($order)
->customer_email->toBe($user->email);
});
it('processes multiple items', function () {
$items = OrderItemData::testFactory()->collect(count: 3);
$data = CreateOrderData::testFactory()->make([
'items' => $items,
]);
expect($data->items)->toHaveCount(3);
});Add PHPDoc to your DTOs for IDE support:
/**
* @see \Database\Factories\Data\CreateOrderDataFactory
* @method static CreateOrderDataFactory testFactory()
*/
class CreateOrderData extends Data
{
public function __construct(
public string $customerEmail,
public ?string $notes,
public OrderStatus $status,
) {}
}database/factories/Data/
├── DataTestFactory.php # Base factory class
├── AddressDataFactory.php
├── CreateOrderDataFactory.php
├── OrderItemDataFactory.php
├── TraceDataFactory.php
└── UserDataFactory.php
- Test factories are for tests only - Never use in domain logic
- Use
::testFactory()->make()- Creates hydrated DTO instances - Use states for variations -
->pensionViaProvider(),->investment() - Override specific attributes -
->make(['email' => 'test@example.com']) - Use
collect()for collections -->collect(count: 5)