• Stop Writing Arrays in Your Tests: Laravel Factories for Data Objects

    Last updated on Nov 2, 2025 by Francisco Barrento

    Building the Laravel Orrto SDK taught me something: test setup shouldn't be harder than the actual testing. When you are integrating with an API that expects complex, nested payloads, raw arrays turn your tests into unreadable nightmares fast.

    Here's what I mean. The Ortto API's person merge endpoint expects payloads like this:

     1$payload = [
     2    'people' => [
     3        [
     4            'fields' => [
     5                'str::ei' => 'user_12345',
     6                'str::email' => '[email protected]',
     7                'str::first' => 'Francisco',
     8                'str::last' => 'Barrento',
     9                'geo::city' => ['name' => 'Lisbon'],
    10                'geo::country' => ['name' => 'Portugal'],
    11                'str::postal' => '1000-001',
    12                'bol::p' => true,
    13                'bol::sp' => false,
    14            ]
    15        ],
    16        [
    17            'fields' => [
    18                'str::ei' => 'user_67890',
    19                'str::email' => '[email protected]',
    20                'str::first' => 'Jane',
    21                'str::last' => 'Smith',
    22                'geo::city' => ['name' => 'Porto'],
    23                'geo::country' => ['name' => 'Portugal'],
    24                'str::postal' => '4000-001',
    25                'bol::p' => true,
    26                'bol::sp' => true,
    27            ]
    28        ],
    29        // ... imagine 52 more of these
    30    ]
    31];
    

    Now multiply that by every test case that needs person data. Different scenarios, edge cases, validation tests. You're writing the same structure dozens of times, changing a field here and there. When the API structure changes (and it will), you're updating arrays scattered across 30 test files.

    There's a better way.

    #The Pattern That Changed Everything

    What if instead of building arrays manually, you could write:

     1$people = PersonData::factory()->count(54)->make();
     2
     3$payload = [
     4    'people' => $people->toArray()
     5];
    

    That's it. Fifty-four realistic person objects with random, valid data. One line. And when the structure changes, you update one factory definition, not 30 tests.

    This is what Laravel's factory pattern does for Eloquent models, but we can hijack it for Data Objects.

    #Setting Up Factories for Data Objects

    First, we need a Data Object. Mine implements Laravel's Arrayable interface because our HTTP client (Saloon) transforms arrays into JSON payloads for the API:

     1class PersonData implements Arrayable
     2{
     3    public function __construct(
     4        public string|int $id,
     5        public string $email,
     6        public ?string $firstName = null,
     7        public ?string $lastName = null,
     8        public ?string $name = null,
     9        public ?string $city = null,
    10        public ?string $country = null,
    11        public ?string $postalCode = null,
    12        public ?CarbonImmutable $birthdate = null,
    13        public bool $emailPermission = false,
    14        public bool $smsPermission = false,
    15    ) {}
    16
    17    public static function factory(): PersonFactory
    18    {
    19        return PersonFactory::new();
    20    }
    21
    22    public function toArray(): array
    23    {
    24        return [
    25            'fields' => [
    26                'str::ei' => (string) $this->id,
    27                'str::email' => $this->email,
    28                'str::first' => $this->firstName,
    29                'str::last' => $this->lastName,
    30                'str::name' => $this->name,
    31                'geo::city' => [
    32                    'name' => $this->city,
    33                ],
    34                'geo::country' => [
    35                    'name' => $this->country,
    36                ],
    37                'str::postal' => $this->postalCode,
    38                'dtz::b' => [
    39                    'year' => $this->birthdate?->year,
    40                    'month' => $this->birthdate?->month,
    41                    'day' => $this->birthdate?->day,
    42                    'timezone' => $this->birthdate?->getTimezone()->getName(),
    43                ],
    44                'bol::p' => $this->emailPermission,
    45                'bol::sp' => $this->smsPermission,
    46            ]
    47        ];
    48    }
    49
    50    public function newCollection(array $items = []): Collection
    51    {
    52        return new Collection($items);
    53    }
    54}
    

    Now for the factory. Laravel's Illuminate\Database\Eloquent\Factories\Factory class is built for Eloquent models, but it's generic enough to work with any class. We just need to extend it and tell it what to create:

     1final class PersonFactory extends Factory
     2{
     3    protected $model = PersonData::class;
     4
     5    private array $extraAttributes = [];
     6
     7    public function definition(): array
     8    {
     9        return [
    10            'id' => Str::uuid()->toString(),
    11            'email' => fake()->unique()->email(),
    12            'firstName' => fake()->firstName(),
    13            'lastName' => fake()->lastName(),
    14            'name' => fake()->name(),
    15            'city' => fake()->city(),
    16            'country' => fake()->country(),
    17            'postalCode' => fake()->postcode(),
    18            'birthdate' => CarbonImmutable::now(),
    19            'emailPermission' => fake()->boolean(),
    20            'smsPermission' => fake()->boolean(),
    21        ];
    22    }
    23
    24    public function make($attributes = [], ?Model $parent = null): PersonData|Collection
    25    {
    26        $this->extraAttributes = $attributes;
    27
    28        if ($this->count === null) {
    29            return $this->makeInstance($parent);
    30        }
    31        $instances = [];
    32
    33        for ($i = 0; $i < $this->count; $i++) {
    34            $instances[] = $this->makeInstance($parent);
    35        }
    36
    37        return collect($instances);
    38    }
    39
    40    protected function makeInstance(?Model $parent = null): PersonData
    41    {
    42        $attributes = array_merge(
    43            $this->getRawAttributes($parent),
    44            $this->extraAttributes
    45        );
    46
    47        return new PersonData(...$attributes);
    48    }
    49
    50    public function newModel(array $attributes = []): PersonData
    51    {
    52        $attributes = array_merge($this->definition(), $attributes);
    53
    54        return new PersonData(...$attributes);
    55    }
    56}
    

    The definition() method returns the default attributes. Faker gives us realistic, random data every time.

    But here's the crucial part: Laravel's factory expects Eloquent models, not Data Objects. We need to override make() to handle instantiation:

     1public function make($attributes = [], ?Model $parent = null): PersonData|Collection
     2{
     3    $this->extraAttributes = $attributes;
     4
     5    if ($this->count === null) {
     6        return $this->makeInstance($parent);
     7    }
     8    $instances = [];
     9
    10    for ($i = 0; $i < $this->count; $i++) {
    11        $instances[] = $this->makeInstance($parent);
    12    }
    13    return collect($instances);
    14}
    

    Instead of returning Eloquent models, this returns either a single PersonData or a Collection of them. The makeInstance() method handles the actual object creation:

     1protected function makeInstance(?Model $parent = null): PersonData
     2{
     3    $attributes = array_merge(
     4        $this->getRawAttributes($parent),
     5        $this->extraAttributes
     6    );
     7
     8    return new PersonData(...$attributes);
     9}
    

    The newModel() method is simpler but still important:

     1public function newModel(array $attributes = []): PersonData
     2{
     3    $attributes = array_merge($this->definition(), $attributes);
     4    return new PersonData(...$attributes);
     5}
    

    Laravel's factory calls this internally when it needs to create a fresh instance. It merges your custom attributes with the defaults from definition(), then uses argument unpacking to instantiate the Data Object. Without this, the factory wouldn't know how to construct your non-Eloquent object.

    Now we wire up the factory to the Data Object:

     1class PersonData implements Arrayable
     2{
     3    // ... constructor and toArray() ...
     4
     5    public static function factory(): PersonFactory
     6    {
     7        return PersonFactory::new();
     8    }
     9
    10    public function newCollection(array $items = []): Collection
    11    {
    12        return collect($items);
    13    }
    14}
    

    That's the setup. Now the magic happens in your tests.

    #Testing Gets Stupid Simple

    Here's a real test from the Ortto SDK. I need to verify that merging multiple people works correctly:

     1it('can merge multiple people at once', function () {
     2    Ortto::fake();
     3
     4    $people = PersonData::factory()->count(10)->make();
     5
     6    $response = $this->ortto->send(
     7        new MergePopleRequest(
     8            people: $people->toArray(),
     9            mergeBy: ['str::email'],
    10            mergeStrategy: MergeStrategy::OverwriteExisting->value,
    11            finsStartegy: FindStrategy::All->value,
    12        )
    13    );
    14
    15    expect($response->status())
    16        ->toBe(200)
    17        ->and($response->json())
    18        ->toHaveKey('people')
    19        ->and($response->json('people'))
    20        ->toBeArray()
    21        ->and($response->json('people'))
    22        ->toHaveCount(54);
    23});
    

    Ten realistic people, with valid emails, names, cities, postal codes, and external IDs. All different. All random. One line.

    Need edge cases? Override specific attributes:

     1$person = PersonData::factory()->make([
     2    'email' => 'invalid-email',
     3    'postalCode' => '',
     4]);
    

    Need a specific scenario? Make a factory state:

     1public function withMissingCity(): static
     2{
     3    return $this->state(fn (array $attributes) => [
     4        'city' => null,
     5    ]);
     6}
     7
     8// In your test
     9$person = PersonData::factory()->withMissingCity()->make();
    

    Need sequences? Got it:

     1$people = PersonData::factory()
     2    ->count(3)
     3    ->sequence(
     4        ['city' => 'Lisbon'],
     5        ['city' => 'Porto'],
     6        ['city' => 'Faro'],
     7    )
     8    ->make();
    

    #The DRY Payoff

    This pattern pays dividends immediately, but the real win shows up over time:

    One source of truth. When the API changes (Ortto adds a required phone field, for example), you update the factory definition. Every test that uses PersonData::factory() instantly gets the new structure.

    Readable tests. Compare these two:

     1// Before: What are we even testing here?
     2$payload = [
     3    'people' => [
     4        ['fields' => ['str::email' => '[email protected]', 'str::first' => 'Test', ...]],
     5        ['fields' => ['str::email' => '[email protected]', 'str::first' => 'Test2', ...]],
     6    ]
     7];
     8
     9// After: Ah, we're testing bulk merges
    10$people = PersonData::factory()->count(50)->make();
    

    Reusable across tests. Every test that needs person data uses the same factory. Integration tests, unit tests, feature tests. They all share the same realistic data generation.

    Type safety bonus. Because we're working with Data Objects instead of arrays, our IDE catches mistakes before the tests run. Try to set $person->emial and your IDE screams at you. Try to set $array['emial'] and you won't know until the test fails.

    #The toArray() Bridge

    The toArray() method is where we translate between our nice, typed PHP objects and whatever format the API expects. For Ortto, that means their type-prefixed notation (str::, geo::, dtz::, bol::), nested structures for geographic data, and complex datetime handling:

     1public function toArray(): array
     2{
     3    return [
     4        'fields' => [
     5            'str::ei' => (string) $this->id,
     6            'str::email' => $this->email,
     7            'str::first' => $this->firstName,
     8            'str::last' => $this->lastName,
     9            'geo::city' => [
    10                'name' => $this->city,
    11            ],
    12            'geo::country' => [
    13                'name' => $this->country,
    14            ],
    15            'str::postal' => $this->postalCode,
    16            'dtz::b' => [
    17                'year' => $this->birthdate?->year,
    18                'month' => $this->birthdate?->month,
    19                'day' => $this->birthdate?->day,
    20                'timezone' => $this->birthdate?->getTimezone()->getName(),
    21            ],
    22            'bol::p' => $this->emailPermission,
    23            'bol::sp' => $this->smsPermission,
    24        ]
    25    ];
    26}
    

    Look at that birthdate transformation. The API wants year, month, day, and timezone as separate fields. Without the Data Object, you'd be manually destructuring CarbonImmutable objects in every test. With it? One transformation, defined once, used everywhere.

    Your API probably expects something different. Maybe flat arrays, maybe different nesting, maybe camelCase instead of snake_case. Doesn't matter. You control the transformation in one place, and every factory-generated object transforms consistently.

    When working with collections, Laravel's map method makes the transformation trivial:

     1$people = PersonData::factory()->count(54)->make();
     2$arrayOfArrays = $people->toArray();
    

    That's calling toArray() on each PersonData object in the collection, then converting the collection itself to a plain PHP array. One line handles the entire transformation.

    #What's Next

    This pattern removes so much friction from testing that I kept finding ways to extend it. In the next article, we'll extract a DataFactory trait to clean up the repetitive factory setup even further. Then we'll tackle something trickier: managing relationships between Data Objects using factories.

    The Laravel Ortto SDK uses this pattern throughout its test suite. Check out the complete implementations:

    Stop writing arrays. Start using factories.

    profile image of Francisco Barrento

    Francisco Barrento

    Staff Software Engineer and Tech Lead at AXOGROUP working on uScore. I build Laravel applications, create open-source SDKs, and explore how AI can make development teams better. Also a father of two, dog owner, and occasional horse supervisor.

    More posts from Francisco Barrento