Nested States in Laravel 8 Database Factories

• 4 min read

While upgrading the new screeenly.com to Laravel 8, I hit a bit of a wall. At first, it wasn't clear to me how I can get my previous setup of database factories to work with the new and improved Factory Classes.

See, in screeenly I'm using laravel/cashier-paddle. Cashier comes with 3 models: Customer, Subscription and Receipt. In my Laravel 7 testsuite, I've created factories for all of them with different states. subscribed, onTrial, trialExpired.

To make writing test easier, I've put those states on the User Factory. Here's how the code look liked for the subscribed state.

$factory->afterCreating(User::class, function ($user, $faker) {
    $user->customer()->create();
});

$factory->afterCreatingState(User::class, 'subscribed', function ($user) {
    $customer = $user->customer()->update([
        'trial_ends_at' => null,
    ]);

    factory(Subscription::class)->states(['status_active'])->create([
        'billable_id' => $user->id,
        'paddle_plan' => app(TestPlan::class)->paddleId()
    ]);

    factory(Receipt::class)->create([
        'billable_id' => $user->id,
    ]);
});

When a User is created, a new "empty" Customer model is attached to it. When the subscribed state has been defined on the call to the User-factory, the trial_ends_at value is reset to null, a new Subscription is created and attached to the user. We also create a new Receipt.

In my tests I could create this whole structure by using the following line.

$user = factory(User::class)->states(['subscribed'])->create();

I don't have to remember to create a Customer or a Subscription.
Super convenient. 👌

I've used Laravel Shift to automate the upgrade to Laravel 8. It migrated my existing factories to the new class versions, but couldn't convert my exessive use of afterCreatingState to the new format.

Getting the above code to work in Laravel 8 took me a couple of nights, as it wasn't clear to me from the docs, that I could put anything in the new states-methods. (Now, it's so obious to me)

In those methods I can combine $this->state() with $this->afterCreating(). 🤯

Here's how my User-, Customer- and Subscription-Factories now look like to create the subscribed-state:

namespace Database\Factories;

use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

class UserFactory extends Factory
{
    protected $model = User::class;

    public function definition()
    {
        return [
            'name' => $this->faker->name,
            'email' => $this->faker->unique()->safeEmail,
            'email_verified_at' => now(),
            'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
            'remember_token' => Str::random(10),
            'finished_onboarding_at' => null,
        ];
    }

    public function subscribed()
    {
        return $this
            ->afterCreating(function (User $user) {
                CustomerFactory::new()->subscribed()->create([
                    'billable_id' => $user->id,
                ]);
            });
    }
}

The subscribed-method doesn't update the User itself with $this->state, but calls a $this->afterCreating callback where we create a new subscribed Customer.

namespace Database\Factories;

use App\Domain\PaddleSubscription\Plans\TestPlan;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Laravel\Paddle\Customer;

class CustomerFactory extends Factory
{
    protected $model = Customer::class;

    public function definition()
    {
        return [
            'billable_id' => User::factory(),
            'billable_type' => User::class,
            'trial_ends_at' => now()
        ];
    }

    public function subscribed()
    {
        return $this
            ->state(fn () => ['trial_ends_at' => null])
            ->afterCreating(function (Customer $customer) {
                SubscriptionFactory::new()->statusActive()->create([
                    'billable_id' => $customer->id,
                    'paddle_plan' => app(TestPlan::class)->paddleId()
                ]);
            });
    }
}

The CustomerFactory is similar to the UserFactory. But instead of just using a afterCreating-callback again, we combine it with $this->state and reset the trial_ends_at timestamp.

namespace Database\Factories;

use App\Domain\PaddleSubscription\Plans\TestPlan;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Laravel\Paddle\Subscription;

class SubscriptionFactory extends Factory
{
    protected $model = Subscription::class;

    public function definition()
    {
        return [
            'billable_id' => User::factory(),
            'billable_type' => User::class,
            'name' => 'default',
            'paddle_id' => $this->faker->unique()->randomNumber,
            'paddle_status' => Subscription::STATUS_ACTIVE,
            'paddle_plan' => app(TestPlan::class)->paddleId(),
            'quantity' => 1,
            'trial_ends_at' => null,
            'paused_from' => null,
            'ends_at' => null,
        ];
    }

    public function statusActive()
    {
        return $this->state(function () {
            return [
                'paddle_status' => Subscription::STATUS_ACTIVE
            ];
        });
    }
}

To finish things of, the Subscription-factory creates a new active subscription.

The tests itself have been updated too. To create 5 new subscribed User I can use this one-liner.

$users = User::factory()->times(5)->subscribed()->create();

Much easier to write and read than.

$users = User::factory()
    ->times(5)
    ->has(
        CustomerFactory::new()
            ->has(SubscriptionFactory::new()->statusActive())
    )
    ->create();

Maybe you think I write my tests in a wrong way. It's not "professional" enough. Or I hide too much information in the factory and that I should create the Customer and the Subscription in every – single – test.

In the example of User, Customer and Subscription I think it's acceptable to hide the relationship in those state-methods. It's central to how the app works and the majority of the tests rely on that constellation. I don't want to repeat myself in every tests. Maintainable tests are as important as maintainable application code.

I hope this short posts helped you and makes your tests a bit more readable. When I find the time, I will contribute an example to the official docs.