Overview
Product Plans define the pricing structure for your products. Each product can have multiple plans (e.g., monthly and yearly), each with its own price, billing interval, trial period, and currency.
use Eufaturo\Billing\Billing;
Quick Start
use Eufaturo\Billing\ProductPlans\Enums\Interval;
use Eufaturo\Billing\ProductPlans\Enums\ProductPlanType;
// Get product and currency
$product = Billing::products()->find('pro');
$currency = Billing::currencies()->find('USD');
// Create a monthly plan
$monthlyPlan = Billing::plans()->create(
product: $product,
currency: $currency,
type: ProductPlanType::FlatRate,
name: 'Pro Monthly',
slug: 'pro-monthly',
isActive: true,
price: 2900, // $29.00 in cents
interval: Interval::Monthly,
intervalCount: 1,
);
// Create a yearly plan with trial
$yearlyPlan = Billing::plans()->create(
product: $product,
currency: $currency,
type: ProductPlanType::FlatRate,
name: 'Pro Yearly',
slug: 'pro-yearly',
isActive: true,
price: 29000, // $290.00 in cents
interval: Interval::Yearly,
intervalCount: 1,
trialInterval: Interval::Daily,
trialIntervalCount: 14, // 14-day trial
);
Product Plan Manager
Access the product plan manager through the Billing facade:
$manager = Billing::plans();
Methods
create()
Create a new product plan.
public function create(
Product $product,
Currency $currency,
ProductPlanType $type,
string $name,
string $slug,
bool $isActive,
int $price,
Interval $interval,
int $intervalCount,
?string $description = null,
?int $level = null,
?int $pricePerUnit = null,
?Interval $trialInterval = null,
?int $trialIntervalCount = null,
): ProductPlan
Parameters:
$product (Product): The product this plan belongs to
$currency (Currency): Currency for pricing
$type (ProductPlanType): FlatRate or UsageBased
$name (string): Plan name (e.g., “Pro Monthly”)
$slug (string): URL-friendly identifier
$isActive (bool): Whether plan is available for new subscriptions
$price (int): Price in minor units (cents)
$interval (Interval): Billing interval (Daily, Weekly, Monthly, Yearly)
$intervalCount (int): Number of intervals (e.g., 3 for “every 3 months”)
$description (string|null): Plan description
$level (int|null): Plan level within product hierarchy
$pricePerUnit (int|null): Per-unit price for usage-based plans
$trialInterval (Interval|null): Trial period interval
$trialIntervalCount (int|null): Number of trial intervals
Returns: ProductPlan
Example:
use Eufaturo\Billing\Billing;
use Eufaturo\Billing\ProductPlans\Enums\Interval;
use Eufaturo\Billing\ProductPlans\Enums\ProductPlanType;
$product = Billing::products()->find('pro');
$usd = Billing::currencies()->find('USD');
// Monthly plan
$monthly = Billing::plans()->create(
product: $product,
currency: $usd,
type: ProductPlanType::FlatRate,
name: 'Pro Monthly',
slug: 'pro-monthly',
isActive: true,
price: 2900, // $29.00
interval: Interval::Monthly,
intervalCount: 1,
description: 'Billed monthly',
);
// Yearly plan (save 20%)
$yearly = Billing::plans()->create(
product: $product,
currency: $usd,
type: ProductPlanType::FlatRate,
name: 'Pro Yearly',
slug: 'pro-yearly',
isActive: true,
price: 29000, // $290.00 ($24.17/month)
interval: Interval::Yearly,
intervalCount: 1,
description: 'Billed annually - Save 20%',
trialInterval: Interval::Daily,
trialIntervalCount: 14, // 14-day trial
);
// Quarterly plan
$quarterly = Billing::plans()->create(
product: $product,
currency: $usd,
type: ProductPlanType::FlatRate,
name: 'Pro Quarterly',
slug: 'pro-quarterly',
isActive: true,
price: 8400, // $84.00 ($28/month)
interval: Interval::Monthly,
intervalCount: 3, // Every 3 months
description: 'Billed every 3 months',
);
// Usage-based plan
$usageBased = Billing::plans()->create(
product: $product,
currency: $usd,
type: ProductPlanType::UsageBased,
name: 'Pro Pay-as-you-go',
slug: 'pro-usage',
isActive: true,
price: 0, // Base price
pricePerUnit: 10, // $0.10 per unit
interval: Interval::Monthly,
intervalCount: 1,
description: 'Pay only for what you use',
);
update()
Update an existing product plan.
public function update(
ProductPlan $plan,
Product $product,
Currency $currency,
ProductPlanType $type,
string $name,
string $slug,
bool $isActive,
int $price,
Interval $interval,
int $intervalCount,
?string $description = null,
?int $level = null,
?int $pricePerUnit = null,
?Interval $trialInterval = null,
?int $trialIntervalCount = null,
): ProductPlan
Parameters: Same as create(), plus:
$plan (ProductPlan): The plan instance to update
Returns: ProductPlan - Updated plan instance
Example:
$plan = Billing::plans()->find('pro-monthly');
$updated = Billing::plans()->update(
plan: $plan,
product: $plan->product,
currency: $plan->currency,
type: $plan->type,
name: 'Pro Monthly (Updated)',
slug: $plan->slug,
isActive: true,
price: 3500, // Price increase to $35.00
interval: $plan->interval,
intervalCount: $plan->interval_count,
description: 'New price effective next billing cycle',
);
Updating a plan doesn’t affect existing subscriptions. They continue with their original pricing. Only new subscriptions will use the updated plan.
delete()
Soft delete a product plan.
public function delete(ProductPlan $plan): ProductPlan
Parameters:
$plan (ProductPlan): Plan to delete
Returns: ProductPlan - Soft-deleted plan instance
Example:
$plan = Billing::plans()->find('old-plan');
$deleted = Billing::plans()->delete($plan);
expect($deleted->trashed())->toBeTrue();
restore()
Restore a soft-deleted product plan.
public function restore(ProductPlan $plan): ProductPlan
Parameters:
$plan (ProductPlan): Soft-deleted plan to restore
Returns: ProductPlan - Restored plan instance
Example:
use Eufaturo\Billing\ProductPlans\Models\ProductPlan;
// Find including soft-deleted
$plan = ProductPlan::withTrashed()->where('slug', 'old-plan')->first();
// Restore
$restored = Billing::plans()->restore($plan);
expect($restored->trashed())->toBeFalse();
find()
Find a product plan by slug.
public function find(string $slug): ?ProductPlan
Parameters:
$slug (string): Plan slug
Returns: ProductPlan|null - Plan instance or null if not found
Example:
$plan = Billing::plans()->find('pro-monthly');
if ($plan) {
echo "Price: $" . ($plan->price / 100);
}
active()
Get all active product plans.
public function active(): Collection
Returns: Collection<int, ProductPlan> - Active plans
Example:
// Get all active plans
$activePlans = Billing::plans()->active();
// Filter by product
$proPlans = $activePlans->filter(
fn($plan) => $plan->product->slug === 'pro'
);
all()
Get all product plans (active and inactive).
public function all(): Collection
Returns: Collection<int, ProductPlan> - All non-deleted plans
Example:
$allPlans = Billing::plans()->all();
withTrashed()
Get all product plans including soft-deleted ones.
public function withTrashed(): Collection
Returns: Collection<int, ProductPlan> - All plans including soft-deleted
Example:
$allPlans = Billing::plans()->withTrashed();
$deletedPlans = $allPlans->filter(fn($p) => $p->trashed());
Product Plan Model
Properties
/** @var int */
public int $id;
/** @var int */
public int $product_id;
/** @var int */
public int $currency_id;
/** @var ProductPlanType - FlatRate or UsageBased */
public ProductPlanType $type;
/** @var string */
public string $name;
/** @var string */
public string $slug;
/** @var string|null */
public ?string $description;
/** @var bool - Available for new subscriptions */
public bool $is_default;
/** @var bool - Visible on pricing page */
public bool $is_active;
/** @var int - Plan level within product */
public int $level;
/** @var int - Price in minor units (cents) */
public int $price;
/** @var int|null - Per-unit price for usage-based */
public ?int $price_per_unit;
/** @var Interval - Billing interval */
public Interval $interval;
/** @var int - Number of intervals */
public int $interval_count;
/** @var Interval|null - Trial interval */
public ?Interval $trial_interval;
/** @var int|null - Number of trial intervals */
public ?int $trial_interval_count;
/** @var CarbonInterface */
public CarbonInterface $created_at;
/** @var CarbonInterface */
public CarbonInterface $updated_at;
/** @var CarbonInterface|null */
public ?CarbonInterface $deleted_at;
Relationships
// Get the product
$plan->product; // Product
// Get the currency
$plan->currency; // Currency
// Get subscriptions using this plan
$plan->subscriptions; // Collection<Subscription>
Methods
hasTrial()
Check if the plan has a trial period.
public function hasTrial(): bool
Example:
if ($plan->hasTrial()) {
echo "Trial: {$plan->trial_interval_count} {$plan->trial_interval->value}";
// Output: "Trial: 14 daily"
}
isChangeable()
Check if the plan allows subscription changes.
public function isChangeable(): bool
Returns: bool - false for usage-based plans, true for flat-rate
Example:
if ($plan->isChangeable()) {
// Allow plan changes
} else {
// Disable plan change button for usage-based plans
}
Usage-based plans cannot be changed mid-cycle to prevent abuse. Users must wait until the billing cycle ends.
Enums
Interval
Billing interval options:
namespace Eufaturo\Billing\ProductPlans\Enums;
enum Interval: string
{
case Daily = 'daily';
case Weekly = 'weekly';
case Monthly = 'monthly';
case Yearly = 'yearly';
}
Methods:
// Get date identifier for Carbon
$interval = Interval::Monthly;
$interval->dateIdentifier(); // "month"
// Get approximate days
$interval->days(); // 30
Example:
use Eufaturo\Billing\ProductPlans\Enums\Interval;
$interval = Interval::Monthly;
// Use in date calculations
$nextBilling = now()->add($interval->dateIdentifier(), 1);
ProductPlanType
Plan type options:
namespace Eufaturo\Billing\ProductPlans\Enums;
enum ProductPlanType: string
{
case FlatRate = 'flat_rate';
case UsageBased = 'usage_based';
}
FlatRate:
- Fixed price per billing cycle
- Most common subscription model
- Can have trials
- Supports plan changes
UsageBased:
- Pay per unit consumed
- Billed at end of cycle
- Cannot be changed mid-cycle
- Requires usage tracking
Price Storage
Prices are stored as integers in minor units (cents):
// ✅ Correct: Store as cents
$plan->price = 2900; // $29.00
$plan->price = 9999; // $99.99
$plan->price = 100; // $1.00
// ❌ Wrong: Never use floats
$plan->price = 29.00; // NO!
Display prices:
// In cents
$priceInCents = $plan->price; // 2900
// Convert to dollars
$priceInDollars = $plan->price / 100; // 29.00
// Format for display
$formatted = '$' . number_format($plan->price / 100, 2); // "$29.00"
// Using money library (recommended)
use Brick\Money\Money;
$money = Money::ofMinor($plan->price, $plan->currency->code);
echo $money->formatTo('en_US'); // "$29.00"
Complete Example
use Eufaturo\Billing\Billing;
use Eufaturo\Billing\ProductPlans\Enums\Interval;
use Eufaturo\Billing\ProductPlans\Enums\ProductPlanType;
// Setup
$pro = Billing::products()->find('pro');
$usd = Billing::currencies()->find('USD');
// Monthly plan (standard)
$monthly = Billing::plans()->create(
product: $pro,
currency: $usd,
type: ProductPlanType::FlatRate,
name: 'Pro Monthly',
slug: 'pro-monthly',
isActive: true,
price: 2900,
interval: Interval::Monthly,
intervalCount: 1,
description: 'Billed monthly',
);
// Yearly plan (discounted)
$yearly = Billing::plans()->create(
product: $pro,
currency: $usd,
type: ProductPlanType::FlatRate,
name: 'Pro Yearly',
slug: 'pro-yearly',
isActive: true,
price: 29000,
interval: Interval::Yearly,
intervalCount: 1,
description: 'Billed annually - save 17%',
trialInterval: Interval::Daily,
trialIntervalCount: 14,
);
// Display in pricing table
foreach ($pro->plans as $plan) {
$monthlyEquivalent = ($plan->price / 100) /
($plan->interval_count * $plan->interval->days() / 30);
echo "{$plan->name}\n";
echo "$" . number_format($plan->price / 100, 2) . " / ";
echo $plan->interval->value . "\n";
echo "~$" . number_format($monthlyEquivalent, 2) . "/month\n";
if ($plan->hasTrial()) {
echo "🎁 {$plan->trial_interval_count}-day free trial\n";
}
echo "---\n";
}
// Output:
// Pro Monthly
// $29.00 / monthly
// ~$29.00/month
// ---
// Pro Yearly
// $290.00 / yearly
// ~$24.17/month
// 🎁 14-day free trial
// ---
Working with Multiple Currencies
$pro = Billing::products()->find('pro');
// Create USD plans
$usd = Billing::currencies()->find('USD');
$usdMonthly = Billing::plans()->create(
product: $pro,
currency: $usd,
type: ProductPlanType::FlatRate,
name: 'Pro Monthly (USD)',
slug: 'pro-monthly-usd',
isActive: true,
price: 2900, // $29.00
interval: Interval::Monthly,
intervalCount: 1,
);
// Create EUR plans
$eur = Billing::currencies()->find('EUR');
$eurMonthly = Billing::plans()->create(
product: $pro,
currency: $eur,
type: ProductPlanType::FlatRate,
name: 'Pro Monthly (EUR)',
slug: 'pro-monthly-eur',
isActive: true,
price: 2700, // €27.00
interval: Interval::Monthly,
intervalCount: 1,
);
// Filter plans by currency
$usdPlans = $pro->plans()->where('currency_id', $usd->id)->get();
Best Practices
1. Slug Naming
Use consistent, descriptive slugs:
// ✅ Good: Clear pattern
'pro-monthly'
'pro-yearly'
'pro-quarterly'
// ❌ Avoid: Inconsistent or confusing
'monthly-pro'
'pro_year'
'proQ'
2. Trial Periods
Only add trials to annual plans or higher-tier plans:
// ✅ Good: Yearly plan with trial
Billing::plans()->create(
// ...
interval: Interval::Yearly,
intervalCount: 1,
trialInterval: Interval::Daily,
trialIntervalCount: 14,
);
// ⚠️ Consider: Monthly plan might not need trial
Billing::plans()->create(
// ...
interval: Interval::Monthly,
intervalCount: 1,
// No trial - users can cancel anytime
);
3. Plan Levels
Keep plan levels consistent within a product:
// ✅ Good: Clear hierarchy
$monthly = Billing::plans()->create(/*...*/ level: 1);
$yearly = Billing::plans()->create(/*...*/ level: 2);
// Use levels for ordering
$plans = $product->plans()->orderBy('level')->get();
4. Inactive vs Deleted
Use inactive for temporary disable, deleted for retirement:
// Temporarily disable (seasonal pricing)
$summerPlan->is_active = false;
$summerPlan->save();
// Permanently retire (old pricing)
Billing::plans()->delete($oldPlan);
Testing
use Eufaturo\Billing\Billing;
use Eufaturo\Billing\ProductPlans\Enums\Interval;
use Eufaturo\Billing\ProductPlans\Enums\ProductPlanType;
use Eufaturo\Billing\Database\Factories\CurrencyFactory;
use Eufaturo\Billing\Database\Factories\ProductFactory;
test('can create a monthly plan', function () {
$product = ProductFactory::new()->create();
$currency = CurrencyFactory::new()->create();
$plan = Billing::plans()->create(
product: $product,
currency: $currency,
type: ProductPlanType::FlatRate,
name: 'Monthly Plan',
slug: 'monthly',
isActive: true,
price: 2900,
interval: Interval::Monthly,
intervalCount: 1,
);
expect($plan->price)->toBe(2900);
expect($plan->interval)->toBe(Interval::Monthly);
});
test('plan with trial period', function () {
$product = ProductFactory::new()->create();
$currency = CurrencyFactory::new()->create();
$plan = Billing::plans()->create(
product: $product,
currency: $currency,
type: ProductPlanType::FlatRate,
name: 'Yearly with Trial',
slug: 'yearly-trial',
isActive: true,
price: 29000,
interval: Interval::Yearly,
intervalCount: 1,
trialInterval: Interval::Daily,
trialIntervalCount: 14,
);
expect($plan->hasTrial())->toBeTrue();
expect($plan->trial_interval_count)->toBe(14);
});
See Also