Events in Plain Sight is a Laravel app and uses Cashier for its integration with Stripe for subscription management.

Pretty much every page in it checks whether there is an active subscription, so pretty much every test needs to also have an active subscription. Which is kinda a pain. Here is a snippet from the docs on testing with Cashier.

Now, whenever you interact with Cashier while testing, it will send actual API requests to your Stripe testing environment. For convenience, you should pre-fill your Stripe testing account with subscriptions / prices that you may use during testing.

Hitting Stripe constantly a) causes things to be slower, b) offends my [antiquated?] notion of being able to do things without an active network connection.

Cashier does come with some undocumented factories letting you do something like this

$user = User::factory()->create();
$niche = Niche::factory()
    ->for($user, 'owner')
    ->has(Subscription::factory(), 'subscriptions')
    ->create();

which will then answer true to $niche->subscribed(). And if that’s all you care about, then you are done. Huzzah!

However, EiPS has 2 tiers (with a 3rd planned) – with ads and without ads. Those are configured in Stripe as separate Products, with monthly and annual Prices configured in each. And because the behaviour is different between them, the simple subscribed check isn’t enough. I need to know what Product is active.

> $niche->subscribedToProduct(config('cashier.products.ad-free'), 'default')
= false

Ugh.

Now, the reason this fails is because what is being checked is the SubscriptionItems that are attached to that Subscription. But Cashier includes a factory for that too.

$user = User::factory()->create();
$niche = Niche::factory()
    ->for($user, 'owner')
    ->has(
        Subscription::factory()
            ->state(function (array $attributes) {
                return [
                    'stripe_price' => config('cashier.prices.annual.ad-free'),
                    'quantity' => 1,
                ];
            })
            ->has(
                SubscriptionItem::factory()
                    ->state(function (array $attributes, Subscription $subscription) {
                        return [
                            'stripe_price' => $subscription->stripe_price,
                            'stripe_product' => config('cashier.products.ad-free'),
                            'quantity' => 1,
                        ];
                    }), 'items'), 'subscriptions'
    )
    ->create()

Which means this now works.

> $niche->subscribedToProduct(config('cashier.products.ad-free'), 'default')
= true

So now we have the ability to check whether they are subscribed, and what they are subscribed to. But what if you want to display to the user when their subscription is going to renew. Unfortunately that information isn’t available in Cashier so you have to actually interact with Stripe to get their object.

> $niche->subscriptions()->first()->asStripeSubscription()

   Stripe\Exception\InvalidRequestException  No such subscription: 'sub_Xi6hzfLc2AyxmICZPkn9BAXLCoKDzwN6EiW4qdiV'.

This error makes sense since we’re letting the factories make up ids. Fine. If you re-read the quote from the Cashier docs they hint at a strategy of seeding your Stripe test account with things. So let’s do that. I created a Customer and gave them a Subscription. We then take those ids and put them in so the factory will use them instead of malking things up.

$user = User::factory()->create();
$niche = Niche::factory()
    ->for($user, 'owner')
    ->has(
        Subscription::factory()
            ->state(function (array $attributes) {
                return [
                    'stripe_id' => 'sub_1SmkmZB71lHx7iTUdOlNoBaU',
                    'stripe_price' => config('cashier.prices.annual.ad-free'),
                    'quantity' => 1,
                ];
            })
            ->has(
                SubscriptionItem::factory()
                    ->state(function (array $attributes, Subscription $subscription) {
                        return [
                            'stripe_id' => 'si_TkFHkV7XWTyhM2',
                            'stripe_price' => $subscription->stripe_price,
                            'stripe_product' => config('cashier.products.ad-free'),
                            'quantity' => 1,
                        ];
                    }), 'items'), 'subscriptions'
    )
    ->create()

Now we can get display this information to the user. And everything worked until this point with the wifi turned off of my laptop. This is the first time we’ve actually reached out.

> Carbon::createFromTimestamp($niche->subscriptions()->first()->asStripeSubscription()->current_period_end)
= Carbon\Carbon @1799282367 {#8457
    date: 2027-01-07 00:39:27.0 +00:00,
  }

And we’re done. Ha! Of course not. Because I don’t want anything to do with storing payment information, I’m using Stripe’s billing portal as described in the Cashier docs.

> $niche->redirectToBillingPortal(route('niche.billing.index', ['niche' => $niche]))->headers->get('location')

   Laravel\Cashier\Exceptions\InvalidCustomer  Niche is not a Stripe customer yet. See the createAsStripeCustomer method.

Which is because the Niche model (which has the Billable trait) isn’t being factory-ed with the Customer id from Stripe.

$user = User::factory()->create();
$niche = Niche::factory()
    ->for($user, 'owner')
    ->has(
        Subscription::factory()
            ->state(function (array $attributes) {
                return [
                    'stripe_id' => 'sub_1SmkmZB71lHx7iTUdOlNoBaU',
                    'stripe_price' => config('cashier.prices.annual.ad-free'),
                    'quantity' => 1,
                ];
            })
            ->has(
                SubscriptionItem::factory()
                    ->state(function (array $attributes, Subscription $subscription) {
                        return [
                            'stripe_id' => 'si_TkFHkV7XWTyhM2',
                            'stripe_price' => $subscription->stripe_price,
                            'stripe_product' => config('cashier.products.ad-free'),
                            'quantity' => 1,
                        ];
                    }), 'items'), 'subscriptions'
    )
    ->create(['stripe_id' => 'cus_TL0d3T9lhEwlwv'])

And since this is a real customer, this now works and produces a URL that doesn’t error.

> $niche->redirectToBillingPortal(route('niche.billing.index', ['niche' => $niche]))->headers->get('location')
= "https://billing.stripe.com/p/session/test_YWNjdF8xUEdQbDlCNzFsSHg3aVRVLF9Ua1VvYmxHTkxmS3lSWGdCMUVIa3pkblZqUkRHb3FM010054cf5AnO"

*Phew.* Now we’re done and can seed Subscriptions in every test without sacrificing speed of Cashier functionality.

Except, this looks terrible to put. In. Every. Test. Function.

So let’s wrap this all up as pseudo-state and bury it in the NicheFactory class. Afterall, this looks not too too horible.

$user = User::factory()->create();
$niche = Niche::factory()
    ->for($user, 'owner')
    ->subscribedToPrice(config('cashier.prices.annual.ad-free'))
    ->create();

In order to do this burying, I’ve added a few more layers of abstraction. As seen above, I have the Products and Prices configured via the environment so I don’t need to hard code things with environment checks. The testing specific stuff I am perfectly okay with hard coding as it is in the right context.

I refer to this as a pseudo-state as it acts like one but doesn’t have the return $this->state( ... ) syntax. But since it is in the class it can be used like one so long as it has the expected behaviour.

The afterCreating function also checks if I want to override the Stripe Customer id right in the test and not override my overide.

public function configure(): static
{
    return $this->afterCreating(function (Niche $niche) {
        if ($niche->subscribed() && ! $niche->stripe_id) {
            $niche->stripe_id = 'cus_TL0d3T9lhEwlwv';
            $niche->saveQuietly();
        }
    });
}

public function subscribedToPrice($price): Factory
{
    $subscription = match ($price) {
        config('cashier.prices.annual.ad-free') => 'sub_1SmkmZB71lHx7iTUdOlNoBaU',
        config('cashier.prices.monthly.ad-free') => 'sub_fixme',
        config('cashier.prices.annual.ad') => 'sub_fixme',
        config('cashier.prices.monthly.ad') => 'sub_fixme'
    };

    $item = match ($price) {
        config('cashier.prices.annual.ad-free') => 'si_TkFHkV7XWTyhM2',
        config('cashier.prices.monthly.ad-free') => 'si_fixme',
        config('cashier.prices.annual.ad') => 'si_fixme',
        config('cashier.prices.monthly.ad') => 'si_fixme'
    };

    $product = match ($price) {
        config('cashier.prices.annual.ad-free') => config('cashier.products.ad-free'),
        config('cashier.prices.monthly.ad-free') => config('cashier.products.ad-free'),
        config('cashier.prices.annual.ad') => config('cashier.products.ad'),
        config('cashier.prices.monthly.ad') => config('cashier.products.ad')
    };

    return $this->has(
        Subscription::factory()
            ->state(function (array $attributes) use ($subscription, $price) {
                return [
                    'stripe_id' => $subscription,
                    'stripe_price' => $price,
                    'quantity' => 1,
                ];
            })
            ->has(
                SubscriptionItem::factory()
                    ->state(function (array $attributes, Subscription $subscription) use ($item, $product) {
                        return [
                            'stripe_id' => $item,
                            'stripe_price' => $subscription->price,
                            'stripe_product' => $product,
                            'quantity' => 1,
                        ];
                    }), 'items'), 'subscriptions'
    );
}

There might be a more clever way to do this, but I’m pretty happy with how it turned out. I can treat Cashier as if everything was created in Stripe (because it was, just not in real-time) and don’t have to pay the penalty of going over the wire to Stripe except in the very specific instances where I have to.