Speeding Up Laravel Tests with tmpfs (MySQL in RAM)
If your Laravel test suite is painfully slow and does a lot of database work (multi-tenancy, migrations, seeding), the bottleneck is almost…
I'll show you how to test Spark billing components without making actual API calls to Paddle.
When testing Laravel Spark with Paddle, you'll likely encounter these challenges:
First, create a factory that handles subscription creation for testing:
// TeamFactory.php
public function withSubscription(string $planType = 'basic'): static
{
return $this->afterCreating(function (Team $team) use ($planType) {
// Create a local Paddle Customer record associated with the team
$customer = PaddleCustomer::create([
'billable_id' => $team->id,
'billable_type' => Team::class,
'paddle_id' => 'cus_' . fake()->unique()->lexify('????????????'),
'name' => $team->paddleName(),
'email' => $team->paddleEmail(),
'trial_ends_at' => null,
]);
// Set the customer relationship
$team->setRelation('customer', $customer);
// Create a Subscription record if needed
if (!$team->subscribed('default')) {
$subscription = $team->subscriptions()->create([
'type' => 'default',
'paddle_id' => 'sub_' . fake()->unique()->lexify('????????????'),
'status' => 'active',
'trial_ends_at' => null,
'paused_at' => null,
'ends_at' => null,
]);
// Generate mock Paddle Price ID
$paddlePriceId = 'pri_' . fake()->unique()->lexify('??????????????????????????');
// Create the SubscriptionItem
$subscription->items()->create([
'subscription_id' => $subscription->id,
'product_id' => 'prod_' . fake()->unique()->lexify('????????????'),
'price_id' => $paddlePriceId,
'status' => 'active',
'quantity' => 1,
]);
}
// Update team credits based on the plan
$credits = match ($planType) {
'basic' => config('plans.defaults.basic.credits_per_month', 1000),
'pro' => config('plans.defaults.pro.credits_per_month', 5000),
default => 0,
};
$team->update(['credits' => $credits]);
// Refresh the team instance
$team->refresh();
});
}
Modify your Team model to skip actual API calls during testing:
// Team.php
public function createOrFetchExistingPaddleCustomer()
{
// If this team already has a local Paddle customer record, just return it
if ($this->customer) {
return $this->customer;
}
// If running in test environment, create a mock customer without API calls
if (app()->environment('testing')) {
$customer = $this->customer()->make([
'paddle_id' => 'cus_' . fake()->unique()->lexify('????????????'),
'name' => $this->paddleName(),
'email' => $this->paddleEmail(),
]);
$customer->save();
return $customer;
}
// Normal production code follows...
}
/**
* Override this method for tests to skip actual billing portal access
*/
public function redirectToBillingPortal(array $options = [])
{
if (app()->environment('testing')) {
return redirect('/billing');
}
return parent::redirectToBillingPortal($options);
}
/**
* Get the owner of the team - alias for the user relationship
* expected by some billing logic.
*/
public function user()
{
return $this->owner();
}
Use Laravel's HTTP facade to mock all requests to Paddle:
// In test setup
Http::fake([
'paddle.com/*' => Http::response(['data' => [
['id' => 'test-customer-id', 'email' => 'test@example.com']
]], 200),
'api.paddle.com/*' => Http::response(['data' => [
['id' => 'test-customer-id', 'email' => 'test@example.com']
]], 200),
'*/prices-preview' => Http::response(['data' => []], 200),
'*' => Http::response(['data' => []], 200),
]);
public function team_can_subscribe_to_basic_plan()
{
// Create a user with a team and a basic subscription
$user = User::factory()->create();
$team = Team::factory()
->for($user)
->withSubscription('basic')
->create(['personal_team' => true]);
$user->current_team_id = $team->id;
$user->save();
// Assert - Team should have an active subscription
$this->assertTrue($team->subscribed('default'));
// Check for a valid Paddle price ID format
$subscription = $team->subscription('default');
$priceId = $subscription->items->first()->price_id;
$this->assertStringStartsWith('pri_', $priceId);
}
public function team_can_cancel_subscription()
{
$user = User::factory()->create();
$team = Team::factory()
->for($user)
->withSubscription('basic')
->create(['personal_team' => true]);
// Get the subscription and cancel it
$subscription = $team->subscription('default');
$subscription->update([
'ends_at' => now()->addDays(7)
]);
// Assert it's on grace period but still active
$this->assertTrue($team->subscription('default')->onGracePeriod());
$this->assertFalse($team->subscription('default')->canceled());
$this->assertTrue($team->subscription('default')->active());
}
public function team_can_resume_subscription_during_grace_period()
{
$user = User::factory()->create();
$team = Team::factory()
->for($user)
->withSubscription('basic')
->create(['personal_team' => true]);
// Put it in grace period
$subscription = $team->subscription('default');
$subscription->update([
'ends_at' => now()->addDays(7)
]);
// Should be in grace period
$this->assertTrue($team->subscription('default')->onGracePeriod());
// Act - Resume subscription
$subscription->update([
'ends_at' => null
]);
// No longer in grace period
$team->refresh();
$this->assertFalse($team->subscription('default')->onGracePeriod());
$this->assertTrue($team->subscription('default')->active());
}
For testing grace period notifications, use a Carbon instance rather than a Subscription object:
public function grace_period_notification_is_sent_at_appropriate_intervals()
{
Notification::fake();
$user = User::factory()->create();
$team = Team::factory()
->for($user)
->withSubscription('basic')
->create(['personal_team' => true]);
// Set up a grace period
$subscription = $team->subscription('default');
$endsAt = now()->addDays(7);
$subscription->update([
'ends_at' => $endsAt
]);
// Fire the notification with a Carbon instance
$user->notify(new SubscriptionGracePeriodWarning($endsAt));
// Verify notification was sent
Notification::assertSentTo(
User::find($user->id),
SubscriptionGracePeriodWarning::class
);
}
For testing billing routes without actually loading them:
public function localized_routes_are_registered_for_billing()
{
$routes = app('router')->getRoutes();
// Check that the main portal route exists
$this->assertTrue($routes->hasNamedRoute('spark.portal'));
// Test that we can generate a URL
$this->assertNotEmpty(route('spark.portal'));
}
Don't use class aliasing for mocking: Rather than trying to mock Cashier::previewPrices() with Mockery::mock('alias:Laravel\Paddle\Cashier'), implement test-specific overrides in your models.
Handle missing method issues: Some methods like invoices() or paymentMethods() that are available in Stripe may not exist in Paddle's implementation. Skip these tests or verify alternative functionality.
Locale-aware testing: If your app has multiple locales, ensure your User model's preferredLocale() method handles null values:
public function preferredLocale(): string
{
return $this->locale ?? config('app.locale', 'en');
}
Test real behavior: Focus on testing the actual behavior of your application with subscriptions rather than implementation details of Spark/Paddle.
Avoid testing the billing portal directly: Testing the actual billing portal page is challenging due to external API dependencies. Instead, test that the routes exist and focus on the subscription lifecycle logic.
Give Vroni a GitHub issue, bug report, spec, or rough idea. It reads the repo, plans the change, writes code, runs checks, and works toward a review-ready pull request.
Take a look at vroni.com