Under the hood: How states work in Laravel factories

Since Laravel 8 we can use the new and reworked model factories. They allow us to create multiple interconnected model instances using a simple and easy to read syntax. As usual Laravel has a great documentation about how to create and write these factories. In this article I don’t describe the usage of the factories, if you are interested in that check out the documentation. Instead we will go a little deeper and understand how the states work in factories. Also we will show an interesting example we run into lately, where the states worked differently as we expected.

What are the states?

From the documentation: “State manipulation methods allow you to define discrete modifications that can be applied to your model factories in any combination.” Basically it allows us to define for example a status of the model you create, or basically predefine any of the model attributes.

According to the documentation you should pass a closure to the state method, however you can also pass a simple array, because it will wrap it into a closure:

public function state($state)
{
return $this->newInstance([
'states' => $this->states->concat([
is_callable($state) ? $state : function () use ($state) {
return $state;
},
]),
]);
}

If you define a sequence it basically also add a state on the factory:

public function sequence(...$sequence)
{
return $this->state(new Sequence(...$sequence));
}

When the factory creates a new model instance it will call the state callback, to apply the changes to the model attributes:

protected function getRawAttributes(?Model $parent)
{
    return $this->states->pipe(function ($states) {
        return $this->for->isEmpty() ? $states : new Collection(array_merge([function () {
            return $this->parentResolvers();
        }], $states->all()));
    })->reduce(function ($carry, $state) use ($parent) {
        if ($state instanceof Closure) {
            $state = $state->bindTo($this);
        }

        return array_merge($carry, $state($carry, $parent));
    }, $this->definition());
}

What has tricked us?

As I mentioned earlier is is also possible to pass a simple array to the state, and it works fine with static values, like:

->state(['status' => 'draft'])

In our case we wanted to create multiple instances of a model, and pass a state which is generated by faker with some special rules like this:

->count(3)->state(['value' => $this->faker->numberBetween(0, 10)])

This is obviously a simplified example in the real case we did multiple instances with the same faker with different conditions. And here comes the trick. It generated all the instances with the same value. Why this has happened? As we saw earlier when you pass a non callable argument to the state it will wrap it into a closure. And basically the faker will be called only once, when you pass the array, and the generated closure for the state will look like this:

function () {
    return ['value' => 4];
}

And this state closure will be executed over and over again so it will return the same value for all models. But when we realised this, and passed a closure our closure looked like this:

function () {
    return ['value' => $this->faker->numberBetween(0, 10)];
}

and in this case the faker is executed for all generated models separately, and generated different random values as expected.

What we could have done differently?

In this case we could use sequences. Those are basically intended for these purposes.

->state(new Sequence(
    fn ($sequence) => ['value' => $this->faker->numberBetween(0, 10)],
))

I wanted to show one interesting thing for the end of this article. As we already saw the sequence instance is passed to the state. The sequence is not wrapped into a closure because it has an __invoke() method and the php built in is_callable($state) method will return true for those invokable classes.

Hope this article helps you understanding the mechanics behind the factory states and to avoid the same mistake we did.


  Follow me on on Twitter for more software development tips.