• testing
  • From Laravel Factories to Framework-Agnostic: Building the Data Factory Package

    Last updated on Nov 11, 2025 by Francisco Barrento

    After my last article on using Laravel factories with Data Objects, I kept thinking: why should only Laravel developers get this elegant API?

    If you're building framework-agnostic PHP packages, you face a problem. You need realistic test data, but you can't depend on Laravel's factory system. So you end up writing repetitive array construction code in every test, violating DRY and making maintenance a nightmare.

    I built Data Factory to solve this.

    #The Framework-Agnostic Challenge

    When you're building a PHP SDK or package that works with any framework (or no framework), test data becomes painful:

     1// Every test looks like this
     2it('processes a deployment', function () {
     3    $deployment = [
     4        'id' => '123e4567-e89b-12d3-a456-426614174000',
     5        'status' => 'deployment.succeeded',
     6        'branch_name' => 'main',
     7        'commit_hash' => 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0',
     8        'commit_message' => 'Deploy feature X to production',
     9        'failure_reason' => null,
    10        'php_major_version' => '8.4',
    11        'uses_octane' => true,
    12        'started_at' => '2024-01-15 10:00:00',
    13        'finished_at' => '2024-01-15 10:05:00',
    14    ];
    15    
    16    // Your actual test...
    17});
    

    Multiply this by dozens of tests, and you've got a maintenance problem. When the API structure changes, you're updating arrays scattered across your entire test suite.

    Laravel developers don't have this problem. They use factories:

     1$deployment = Deployment::factory()->succeeded()->make();
    

    But that's tied to Eloquent. If you're building a package, you can't bring in the entire Laravel framework as a dev dependency just for test factories.

    #The Solution: Extract the Pattern

    Data Factory brings Laravel's elegant factory API to any PHP project. No framework required. No Eloquent dependency.

    Here's what that same test looks like with Data Factory:

     1it('processes a deployment', function () {
     2    $deployment = Deployment::factory()->succeeded()->make();
     3    
     4    // Your actual test logic - clean and focused!
     5});
    

    One line. Clear intent. Type safe. And when the structure changes, you update one factory definition, not 50 tests.

    #Building Your First Factory

    Installation is simple:

     1composer require fbarrento/data-factory --dev
    

    Then create a factory for your Data Object:

     1use FBarrento\DataFactory\Factory;
     2
     3class DeploymentFactory extends Factory
     4{
     5    protected string $dataObject = Deployment::class;
     6    
     7    protected function definition(): array
     8    {
     9        return [
    10            'id' => $this->fake->uuid(),
    11            'status' => 'pending',
    12            'branch_name' => 'main',
    13            'commit_hash' => $this->fake->sha1(),
    14            'commit_message' => $this->fake->sentence(),
    15            'php_major_version' => '8.4',
    16            'uses_octane' => false,
    17        ];
    18    }
    19}
    

    The definition() method returns default attributes. Notice $this->fake - that's FakerPHP integrated out of the box for realistic test data.

    The $dataObject property tells the factory which class to instantiate. The factory automatically uses argument unpacking to construct your object.

    Wire it up to your Data Object:

     1use FBarrento\DataFactory\Concerns\HasDataFactory;
     2
     3readonly class Deployment
     4{
     5    use HasDataFactory;
     6    
     7    public function __construct(
     8        public string $id,
     9        public string $status,
    10        public string $branch_name,
    11        public string $commit_hash,
    12        public string $commit_message,
    13        public string $php_major_version,
    14        public bool $uses_octane,
    15    ) {}
    16    
    17    public static function newFactory(): DeploymentFactory
    18    {
    19        return new DeploymentFactory();
    20    }
    21}
    

    The HasDataFactory trait provides the factory() method, and newFactory() tells it which factory class to instantiate.

    Now you can use it anywhere:

     1// Single object
     2$deployment = Deployment::factory()->make();
     3
     4// Collection of objects
     5$deployments = Deployment::factory()->count(50)->make();
     6
     7// Override specific attributes
     8$deployment = Deployment::factory()->make([
     9    'status' => 'failed',
    10    'branch_name' => 'feature/new-api',
    11]);
    

    #States: Named Variations

    States let you define common variations without repeating yourself:

     1class DeploymentFactory extends Factory
     2{
     3    // ... definition() ...
     4    
     5    public function succeeded(): static
     6    {
     7        return $this->state([
     8            'status' => 'deployment.succeeded',
     9            'finished_at' => now(),
    10        ]);
    11    }
    12    
    13    public function failed(): static
    14    {
    15        return $this->state([
    16            'status' => 'deployment.failed',
    17            'failure_reason' => $this->fake->sentence(),
    18        ]);
    19    }
    20    
    21    public function withOctane(): static
    22    {
    23        return $this->state(['uses_octane' => true]);
    24    }
    25}
    

    Then use them in your tests:

     1it('handles successful deployments', function () {
     2    $deployment = Deployment::factory()->succeeded()->make();
     3    expect($deployment->status)->toBe('deployment.succeeded');
     4});
     5
     6it('handles failed Octane deployments', function () {
     7    $deployment = Deployment::factory()
     8        ->failed()
     9        ->withOctane()
    10        ->make();
    11        
    12    expect($deployment->status)->toBe('deployment.failed');
    13    expect($deployment->uses_octane)->toBeTrue();
    14});
    

    Compare this to manually building arrays with different statuses in every test. States make your intent clear and your tests maintainable.

    #Sequences: Cycling Through Values

    Sometimes you need different values for each item in a collection. Sequences handle this elegantly:

     1$deployments = Deployment::factory()
     2    ->count(3)
     3    ->sequence(
     4        ['branch_name' => 'main'],
     5        ['branch_name' => 'staging'],
     6        ['branch_name' => 'feature/new-api'],
     7    )
     8    ->make();
     9
    10// First deployment has branch_name = 'main'
    11// Second has 'staging'
    12// Third has 'feature/new-api'
    

    This is the exact same API Laravel uses for Eloquent factories. If you know Laravel, you already know Data Factory.

    #Nested Factories: Complex Object Graphs

    Here's where it gets powerful. Real-world data isn't flat - it's nested. A deployment might have a user, a repository, commit details, and more.

    Without factories, building nested test data is painful:

     1$deployment = [
     2    'id' => '...',
     3    'repository' => [
     4        'name' => '...',
     5        'owner' => [
     6            'name' => '...',
     7            'email' => '...',
     8        ],
     9    ],
    10    'commit' => [
    11        'hash' => '...',
    12        'author' => [
    13            'name' => '...',
    14            'email' => '...',
    15        ],
    16    ],
    17];
    

    With Data Factory, you compose factories:

     1class DeploymentFactory extends Factory
     2{
     3    protected function definition(): array
     4    {
     5        return [
     6            'id' => $this->fake->uuid(),
     7            'repository' => Repository::factory(),
     8            'commit' => Commit::factory(),
     9        ];
    10    }
    11}
    12
    13// In your test
    14$deployment = Deployment::factory()->make();
    15// Automatically creates nested Repository and Commit objects
    

    You can override nested factories too:

     1$deployment = Deployment::factory()
     2    ->make([
     3        'repository' => Repository::factory()->private()->make(),
     4        'commit' => Commit::factory()->make(['hash' => 'abc123']),
     5    ]);
    

    This composition pattern keeps your test setup clean while handling arbitrarily complex object graphs.

    #Array Factories: When You Don't Need Objects

    Sometimes you need arrays, not objects. Maybe you're testing JSON serialization, API responses, or working with dynamic data where objects would be overkill.

    Data Factory supports this through dedicated array factories:

     1use FBarrento\DataFactory\ArrayFactory;
     2
     3class DeploymentArrayFactory extends ArrayFactory
     4{
     5    protected function definition(): array
     6    {
     7        return [
     8            'id' => $this->fake->uuid(),
     9            'status' => 'pending',
    10            'branch_name' => 'main',
    11            'commit_hash' => $this->fake->sha1(),
    12        ];
    13    }
    14    
    15    public function succeeded(): static
    16    {
    17        return $this->state(['status' => 'deployment.succeeded']);
    18    }
    19}
    20
    21// Single array
    22$deploymentArray = DeploymentArrayFactory::new()->make();
    23// Returns: ['id' => '...', 'status' => '...', ...]
    24
    25// Collection of arrays
    26$deploymentsArray = DeploymentArrayFactory::new()->count(5)->make();
    27// Returns: [['id' => '...'], ['id' => '...'], ...]
    28
    29// With states
    30$succeeded = DeploymentArrayFactory::new()->succeeded()->make();
    

    Perfect for testing API endpoints, JSON serialization, or when you're working with unstructured data that doesn't warrant a Data Object.

    #Why Framework-Agnostic Matters

    When I built the Laravel Ortto SDK, I needed factories for testing. But I'm also building other SDKs and packages that aren't Laravel-specific.

    Data Factory works with:

    • ✅ Laravel projects (alongside Eloquent factories)
    • ✅ Symfony projects
    • ✅ Framework-agnostic PHP packages
    • ✅ Plain PHP projects with PEST or PHPUnit

    The API is familiar to Laravel developers, but you don't need Laravel. You get the elegant factory pattern everywhere.

    #Real-World Usage

    In the Laravel Cloud SDK I'm building, I use Data Factory extensively:

     1class ServerFactory extends Factory
     2{
     3    protected function definition(): array
     4    {
     5        return [
     6            'id' => $this->fake->uuid(),
     7            'name' => $this->fake->word(),
     8            'region' => $this->fake->randomElement(['us-east-1', 'eu-west-1']),
     9            'size' => 'small',
    10            'status' => 'active',
    11            'ipAddress' => $this->fake->ipv4(),
    12        ];
    13    }
    14    
    15    public function provisioning(): static
    16    {
    17        return $this->state(['status' => 'provisioning']);
    18    }
    19    
    20    public function large(): static
    21    {
    22        return $this->state(['size' => 'large']);
    23    }
    24}
    25
    26// In tests
    27it('lists active servers', function () {
    28    $servers = Server::factory()->count(10)->make();
    29    // Test logic...
    30});
    31
    32it('handles server provisioning', function () {
    33    $server = Server::factory()->provisioning()->large()->make();
    34    expect($server->status)->toBe('provisioning');
    35    expect($server->size)->toBe('large');
    36});
    

    Clean. Readable. Maintainable. And when the Cloud API changes their server structure, I update one factory, not dozens of tests.

    #What's Next

    Data Factory is on Packagist and actively maintained. The package has:

    • ✅ 100% test coverage
    • ✅ 100% type coverage (PHPStan level 9)
    • ✅ Comprehensive documentation
    • ✅ PHP 8.2+ with modern patterns

    I'm considering these features for future releases:

    • raw() method for returning attribute arrays without instantiating objects
    • afterMaking() callbacks for post-processing
    • Export to JSON fixtures
    • Streaming/chunking for massive datasets

    In the next article, we'll dive into advanced patterns: managing relationships between factories, custom faker providers, and testing strategies with complex object graphs.

    Check out Data Factory on GitHub and give it a try. If you're building PHP packages or SDKs, it'll change how you write tests.

    Stop writing arrays. Start using factories. Everywhere.

    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