Factory Relationships deep dive

post-thumb

In Laravel, Factories play a crucial role in generating data for your models.

Suppose we want to create a post with 20 comments. Typically, this would require two lines of code: one to generate the post, and another to create the comments, as shown below:

Here are the models for the user and posts:

 1class Post extends Model
 2{
 3    use HasFactory;
 4
 5    protected $fillable = [
 6        'title',
 7        'body',
 8        'views',
 9    ];
10
11    public function comments()
12    {
13        return $this->hasMany(Comment::class);
14    }
15}
16
17
18class Comment extends Model
19{
20    use HasFactory;
21}
1$post = Post::factory()->create();
2
3Comment::factory(20)->create([
4    'post_id' => $post->id,
5]);

However, Laravel provides a more efficient and streamlined method to achieve the same result, making the process significantly simpler.

1Post::factory()
2	->hasComments(20)
3	->create();

This approach will work seamlessly, creating the post and subsequently attaching the 20 comments to that post.

However, if you inspect the PostFactory class, you will notice that the hasComments method isn’t explicitly mentioned.

 1class PostFactory extends Factory
 2{
 3    public function definition(): array
 4    {
 5        return [
 6            'title' => $this->faker->sentence,
 7            'content' => $this->faker->paragraph,
 8            'user_id' => User::factory(),
 9        ];
10    }
11}

So, this naturally raises the question: how does this process actually function?

How does all has{relation}() work?

Before delving into how dynamic calls are made in Laravel, it would be beneficial to understand how they occur in PHP itself. (If you’re already familiar with the magic method __call in PHP, feel free to skip this section.)

Magic method __call

In php, any class can define the magic method __call

The __call method is automatically invoked when a non-existent or inaccessible method is called. For instance:

1class Person {
2	public function __call() {
3		return 'Person class';
4	}
5}
6
7
8$person = new Person();
9$person->badMethod() // returns: 'Person class'

A More Comprehensive Example

Instead of using a simple illustration, let’s design a ValueObject for a better understanding. The main purpose of a ValueObject, as the name suggests, is to hold values. It operates by receiving an array of data and using get{key} to fetch a specific value from the array.

1$vo = new ValueObject(['name' => 'Ahmed', 'language' => 'php']);
2
3echo $vo->getName(); // Ahmed
4echo $vo->getLanguage(); // php
5echo $vo->getAge(); // null

So, how can we implement this using the __call magic method? Let’s explore.

 1class ValueObject {
 2    private array $data = [];
 3
 4    public function __construct(array $data) {
 5        $this->data = $data;
 6    }
 7
 8    public function __call($name, $args) {
 9        if (str_starts_with($name, 'get')) {
10            $key = lcfirst(substr($name, 3));
11            return $this->data[$key] ?? null;
12        }
13
14        throw new \InvalidArgumentException("Method $name does not exist");
15    }
16}

Inside the __call method, we check if the $name starts with get. For instance, something like getName would be a match. If it doesn’t, we throw a ‘method not found’ exception.

In a similar vein, Laravel uses this technique to identify any has{relation} calls.

Factory class deep dive

When inspecting any of Laravel’s factories, you’ll see that they extend the Illuminate\Database\Eloquent\Factories\Factory class. Let’s delve into this file and search for the __call method.

 1public function __call($method, $parameters)
 2{
 3    // .... some code ....
 4
 5    if (! Str::startsWith($method, ['for', 'has'])) {
 6        static::throwBadMethodCallException($method);
 7    }
 8
 9    $relationship = Str::camel(Str::substr($method, 3));
10
11    $relatedModel = get_class($this->newModel()->{$relationship}()->getRelated());
12
13    if (method_exists($relatedModel, 'newFactory')) {
14        $factory = $relatedModel::newFactory() ?? static::factoryForModel($relatedModel);
15    } else {
16        $factory = static::factoryForModel($relatedModel);
17    }
18
19    if (str_starts_with($method, 'for')) {
20        return $this->for($factory->state($parameters[0] ?? []), $relationship);
21    } elseif (str_starts_with($method, 'has')) {
22        return $this->has(
23            $factory
24                ->count(is_numeric($parameters[0] ?? null) ? $parameters[0] : 1)
25                ->state((is_callable($parameters[0] ?? null) || is_array($parameters[0] ?? null)) ? $parameters[0] : ($parameters[1] ?? [])),
26            $relationship
27        );
28    }
29}

By examining the first few lines of the __call method, you can understand how Laravel identifies the relation and creates a factory from it - which, in our case, is the CommentFactory. Further, if you look at the elseif statement at line 21, Laravel checks if the string starts with has. If it does, a new Factory object from PostFactory is initiated, containing a has collection that refers to the CommentFactory.

1public function has(self $factory, $relationship = null)
2{
3    return $this->newInstance([
4        'has' => $this->has->concat([new Relationship(
5            $factory, $relationship ?? $this->guessRelationship($factory->modelName())
6        )]),
7    ]);
8}

What Happens Next?

Now that we understand what the has{relation}() function does (it initiates a new Factory with a properly defined relationship based on the has{relation} call), let’s return to the original call chain we discussed earlier.

1Post::factory()
2	->hasComments(20)
3	->create();

The hasComments function returns the PostFactory with a defined comments relation, and then the create method is invoked.

Let’s take a closer look at the create method.

 1public function create($attributes = [], ?Model $parent = null)
 2{
 3    // .... some code ....
 4
 5    if ($results instanceof Model) {
 6        $this->store(collect([$results]));
 7
 8        $this->callAfterCreating(collect([$results]), $parent);
 9    } else {
10        $this->store($results);
11
12        $this->callAfterCreating($results, $parent);
13    }
14
15    return $results;
16}

The key element we’re interested in here is the store method. Let’s investigate what it does.

 1protected function store(Collection $results)
 2{
 3    $results->each(function ($model) {
 4        if (! isset($this->connection)) {
 5            $model->setConnection($model->newQueryWithoutScopes()->getConnection()->getName());
 6        }
 7
 8        $model->save();
 9
10        foreach ($model->getRelations() as $name => $items) {
11            if ($items instanceof Enumerable && $items->isEmpty()) {
12                $model->unsetRelation($name);
13            }
14        }
15
16        $this->createChildren($model);
17    });
18}

The last line of this method holds the key to our inquiry. The createChildren method is responsible for creating all defined ‘has’ relations.

Let’s examine this method more closely.

1protected function createChildren(Model $model)
2{
3    Model::unguarded(function () use ($model) {
4        $this->has->each(function ($has) use ($model) {
5            $has->recycle($this->recycle)->createFor($model);
6        });
7    });
8}

Alright, there are some complex calls in here, specifically the lack of type hints that would tell us what $has and $has->recycle refer to. For now, let’s set these aside and focus on createFor($model). To clarify, the $model here is an instance of the Post class. This means that $has is undoubtedly our CommentFactory instance.

The variable $has is an instance of Illuminate\Database\Eloquent\Factories\Relationship, which wraps our CommentFactory. If we examine the createFor method within the Relationship class, we’ll see the following:

 1public function createFor(Model $parent)
 2{
 3    $relationship = $parent->{$this->relationship}();
 4
 5    if ($relationship instanceof MorphOneOrMany) {
 6        $this->factory->state([
 7            $relationship->getMorphType() => $relationship->getMorphClass(),
 8            $relationship->getForeignKeyName() => $relationship->getParentKey(),
 9        ])->create([], $parent);
10    } elseif ($relationship instanceof HasOneOrMany) {
11        $this->factory->state([
12            $relationship->getForeignKeyName() => $relationship->getParentKey(),
13        ])->create([], $parent);
14    } elseif ($relationship instanceof BelongsToMany) {
15        $relationship->attach($this->factory->create([], $parent));
16    }
17}

For our specific case, we are primarily interested in the first else if clause. This is because a Post hasMany Comments, which eventually translates to the following:

1Comment::factory()->create(['post_id' => $post->id]);

And that completes the comprehensive overview of how Factory relations function in Laravel!

comments powered by Disqus

You May Also Like