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 objectsafterMaking()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.