Wave is a package designed to streamline subscription billing for your application. It handles the entire lifecycle of a subscription, from plan creation and trial management to recurring invoicing, tax calculation, and payment collection via multiple gateways.
- Recurring Subscription Management: Support for multiple plans, intervals (daily, weekly, monthly, yearly), trial periods, and quantity scaling.
- Hybrid Payment System: Seamlessly integrates with Wallet for balance-based billing and Pay for external payment collection (cards, bank transfers, etc.).
- Smart Invoicing: Automated invoice generation with PDF support, email dispatch, and tax calculation.
- Coupon & Discount Engine: Apply fixed or percentage-based discounts to subscriptions with usage limits and expiration handling.
- Advanced Lifecycle Handling: Built-in support for grace periods, subscription swaps, cancellations, and renewals.
- Global Tax Support: Automated tax logic based on user location or global configuration.
Wave is a package that requires installation before use.
php dock package:install Wave --packagesThis command will:
- Publish the
wave.phpconfiguration file. - Run the migration for Wave tables.
- Register the
WaveServiceProvider. - Enable global helper function (
wave())
Edit App/Config/wave.php to match your business logic:
return [
'currency' => 'USD', // Default currency for plans and invoices
/*
* Payment Strategy
* 'direct': Payments go directly to the invoice (Standard SaaS).
* 'wallet': Payments fund a digital wallet, then the wallet pays the invoice (Marketplaces/Credits).
*/
'payment_strategy' => 'direct',
'trial_days' => 14, // Default trial period for new subscriptions
'grace_period_days' => 5, // Days to keep subscription active after payment failure
/*
* Tax Configuration
* 'enabled': Toggle global tax calculation
* 'rate': Default flat tax rate (if no region-specific rate found)
* 'inclusive': Whether prices include tax (VAT style) or exclude it (US Sales Tax style)
*/
'tax' => [
'enabled' => true,
'rate' => 15, // 15% VAT
'inclusive' => false,
'default_country' => 'US', // Fallback for tax calculation
],
// Dynamic URLs for notifications
'invoice' => [
'prefix' => 'INV-',
'url' => '/billing/invoices', // {refid} appended automatically
'subscription_url' => '/billing/subscription',
'payment_method_url' => '/billing/payment-method',
'footer' => 'Thank you for your business!',
],
'affiliate' => [
'commission_rate' => 10, // 10% commission on referrals
],
];To understand how Wave orchestrates your business, view it as an ecosystem where each component has a specific role.
Everything starts with Plans (Recurring) and Products (One-Time). These define the base price and interval. TaxRates are configured here to define the geography of your billing rules.
When a user subscribes, the SubscriptionBuilder creates the "Contract". If they arrived via a referral link, the AffiliateManager tags them. No money moves yet; it is just a record.
The InvoiceManager handles "Billing events." It looks at the Agreement (Subscription) and creates a snapshot of the debt. It hands this to the TaxManager, which appends regional taxes based on the user's location.
When collecting payment, Wave uses a hierarchical strategy:
- Wallet: Checks internal balance first. If sufficient, the invoice is paid instantly.
- Pay Service: If the wallet is empty, Wave initializes an external checkout (Stripe, PayPal, etc.).
Once payment is confirmed, the AffiliateManager calculates commission for the partner, and the AnalyticsManager updates key performance metrics:
- MRR (Monthly Recurring Revenue): The total predictable revenue your SaaS generates each month. Wave automatically "normalizes" yearly or weekly plans into a monthly value for this calculation.
- Churn Rate: The percentage of subscribers who canceled in the last 30 days. It measures customer retention and is the most critical indicator of "product-market fit."
- LTV (Lifetime Value): The total revenue collected from a single customer over their entire relationship with your app. It helps you determine how much you can afford to spend on customer acquisition.
Wave distinguishes between two types of billing:
- Subscriptions (Recurring): Plans that auto-renew (e.g., "Monthly Pro"). Managed via
PlanManagerandSubscriptionManager. - Products (One-Time): Individual items or services charged once (e.g., "Setup Fee", "Consultation"). Managed via direct invoicing or
Productmodels (if using inventory).
Plans define what your users are subscribing to.
use Wave\Wave;
$plan = Wave::plan()->make()
->name('Pro Plan')
->slug('pro-monthly')
->price(29.00) // Amount in dollars ($29.00)
->currency('USD')
->monthly() // or ->interval('month')
->description('Perfect for professionals.')
->save();The Wave package provides a convenient global helper for accessing the manager service.
// Get WaveManagerService instance
$wave = wave();
// Alias for accessing sub-services
$plans = wave()->plan();
$subscriptions = wave()->subscriptions();To subscribe a user (or any model) to a plan:
use Wave\Wave;
$subscription = Wave::newSubscription()
->for($user) // Automatically sets owner_id and owner_type
->plan('pro-monthly')
->trialDays(14)
->quantity(1)
->start();Check if a user has an active subscription:
if (Wave::subscriptions()->hasActiveSubscription($user->id, 'user')) {
// Grant access
}You can define reusable products for one-time purchases, though explicit Product creation is optional if you just need ad-hoc invoicing.
Wave::product()->make()
->name('Setup Fee')
->description('One-time server configuration')
->price(500.00) // $500.00
->currency('USD')
->create();For non-recurring charges, you generate an invoice directly. You can link it to a conceptual "Product" or just charge an ad-hoc amount.
use Wave\Wave;
use Helpers\DateTimeHelper;
// Create an invoice for a one-time service
$invoice = Wave::invoices()->make()
->for($user)
->amount(50.00) // $50.00
->currency('USD')
->description('Lifetime Access Fee')
->discount('BLACKFRIDAY25')
->dueInDays(7) // Optional: Set due date to 7 days from now
// ->dueNow() // Or due immediately
->create();
// Attempt payment immediately
$paid = Wave::invoices()->attemptPayment($invoice);
if ($paid) {
// Check if a redirect is required
$invoice->refresh(); // Refresh to get latest metadata
$checkoutUrl = $invoice->metadata['checkout_url'] ?? null;
if ($checkoutUrl) {
// Redirect user to payment gateway (Stripe/PayPal/etc)
return redirect($checkoutUrl, [], false);
}In some cases (e.g., bank transfers or check payments), you may need to mark an invoice as paid manually.
$invoice = Wave::invoices()->find('INV-2024-0042');
// Mark as paid with optional driver and transaction ID
Wave::invoices()->markAsPaid($invoice, driver: 'manual_wire', transactionId: 'TXN_998877');Wave includes built-in commands for the Dock CLI to handle recurring tasks like renewal reminders and processing overdue payments.
| Command | Description |
|---|---|
wave:remind |
Send email reminders for upcoming renewals. |
wave:renew |
Create invoices and process payments for renewals. |
Notify users whose subscriptions are about to renew.
# Send reminders for subscriptions renewing in 3 days (default)
php dock wave:remind
# Send reminders for renewals in 7 days
php dock wave:remind --days=7Automatically process renewals that have reached their period end. This command finds active subscriptions where the current_period_end has passed, generates a new invoice, and attempts payment.
# Process all pending renewals
php dock wave:renewProduction Tip: Set up a CRON job to run these commands daily.
0 0 * * * php /path/to/project/dock wave:renew
0 0 * * * php /path/to/project/dock wave:remindYou can control exactly when a subscription should end.
// 1. Cancel at period end (Default - Graceful)
// Set status to active but sets ends_at = current_period_end
Wave::subscriptions()->cancel($subId, atPeriodEnd: true);
// 2. Cancel immediately (Nuclear)
// Sets status to 'canceled' and ends_at = now
Wave::subscriptions()->cancel($subId, atPeriodEnd: false);Upgrade or downgrade a user's subscription instantly. Wave handles the proration automatically and updates the billing cycle.
// Swap to Enterprise plan
Wave::subscriptions()->swap($subscription->id, 'enterprise-yearly');Useful for "per-seat" billing models.
// Add 2 more seats
Wave::subscriptions()->updateQuantity($subscription->id, 5);Before applying coupons, you must create them. Wave supports percentage-based and fixed-amount coupons.
use Wave\Models\Coupon;
use Wave\Enums\CouponType;
use Wave\Enums\CouponDuration;
// Create a 20% off forever coupon
Wave::coupons()->make()
->code('FOREVER20')
->name('20% Off Forever')
->percent(20) // 20% off (applies to any currency)
->forever()
->create();
// Create a $10 off once coupon
Wave::coupons()->make()
->code('WELCOME10')
->name('$10 Welcome Bonus')
->fixed(10.00, 'USD') // $10.00 in USD (default is USD if omitted)
->once()
->maxRedemptions(100)
->create();Apply a discount code to an active subscription.
use Wave\Models\Coupon;
use Wave\Exceptions\CouponExpiredException;
try {
Wave::coupons()->applyToSubscription($subscription, 'BLACKFRIDAY25');
} catch (CouponExpiredException $e) {
// Handle invalid coupon
}While Wave handles recurring invoices automatically, you can trigger one manually:
$invoice = Wave::invoices()->createFromSubscription(
$subscription,
description: 'Ad-hoc charge for extra resources'
);
// Attempt to collect payment immediately
$paid = Wave::invoices()->attemptPayment($invoice);
if ($paid) {
// Payment successful via Wallet or Pay
}The Wave\Wave facade provides a unified entry point for all services.
| Method | Description |
|---|---|
subscribe(...) |
Quick alias to start a new subscription. |
newSubscription() |
Returns a fluent SubscriptionBuilder. |
plan() |
Returns the PlanManager. |
product() |
Returns the ProductManager. |
subscriptions() |
Returns the SubscriptionManager. |
invoices() |
Returns the InvoiceManager. |
coupons() |
Returns the CouponManager. |
taxes() |
Returns the TaxManager. |
analytics() |
Returns the AnalyticsManager. |
affiliates() |
Returns the AffiliateManager. |
findPlan(id) |
Helper to find plan by ID/Slug. |
findProduct(id) |
Helper to find product by ID/RefID. |
findSubscription() |
Helper to find subscription by ID/RefID. |
Access via Wave::subscriptions().
| Method | Description |
|---|---|
find(id) |
Find a subscription by ID. |
swap(id, planId) |
Change the plan for a subscription. |
cancel(id, atPeriodEnd) |
Cancel subscription (immediate or deferred). |
updateQuantity(id, qty) |
Update the quantity of subscribed items. |
hasActiveSubscription(...) |
Check if a user is subscribed. |
calculatePeriodEnd(...) |
Helper to determine next billing date. |
sendRenewalReminders(days) |
Send email reminders for expiring subs. |
Access via Wave::invoices().
| Method | Description |
|---|---|
createFromSubscription(...) |
Generate a new invoice from subscription data. |
find(id) |
Retrieve an invoice by its ID. |
attemptPayment(invoice) |
Critical: Attempts payment via Wallet first, then Pay. |
markAsPaid(invoice...) |
Manually mark an invoice as paid with driver details. |
calculateTax(invoice) |
Applies configured tax rules to the invoice. |
Access via Wave::taxes().
| Method | Description |
|---|---|
make() |
Start a fluent tax rate builder. |
find(id) |
Find a tax rate by ID or RefID. |
Access via Wave::coupons().
| Method | Description |
|---|---|
findByCode(code) |
Retrieve a coupon by its unique code. |
applyToSubscription(...) |
Link a coupon to a subscription for future invoices. |
Access via resolve(AffiliateManager::class) (or helper if available).
| Method | Description |
|---|---|
recordReferral(...) |
Link a new user to an affiliate code. |
onConversion(...) |
Calculate commission after a successful payment. |
getReferrals(code) |
Get all referrals for a specific affiliate code. |
To implement an affiliate program:
-
Capture the Referral: When a user registers with an
?ref=CODEquery parameter. -
Record It: Call
recordReferralduring registration. -
Process Commission: The system automatically handles
onConversionwhenProcessPaymentSuccessfires (ensure your listener is set up if customizing).
// In your RegisterController
use Wave\Services\AffiliateManager;
public function register(Request $request, AffiliateManager $affiliates)
{
// ... create user ...
if ($code = $request->get('ref')) {
try {
$affiliates->recordReferral($code, $user->id, 'user');
} catch (\Exception $e) {
// Invalid code, ignore or log
}
}
}You can retrieve all referrals associated with a specific affiliate code for reporting.
use Wave\Wave;
// Get referrals via the Wave facade
$referrals = Wave::affiliates()->getReferrals('PARTNER2024');
foreach ($referrals as $referral) {
echo "Referred User: {$referral->referred_owner_id} - Status: {$referral->status}";
}Wave allows you to choose how payments are routed via the payment_strategy config.
- Direct (
'direct'): The standard approach. When a user pays an invoice, the money is marked against that invoice immediately. Best for simple SaaS apps. - Wallet (
'wallet'): The "Centralized Ledger" approach. When a user pays, the money funds their internalWalletbalance first. Then, the system attempts to pay the invoice using that balance. Best for platforms where users maintain a balance or credits.
To verify the strategy in code:
$strategy = config('wave.payment_strategy', 'direct');Wave's tax system is hierarchical:
- Region Specific: The system looks for a
Wave\Models\TaxRatematching the user'sbilling_country(and optionalbilling_state). - Global Fallback: If no specific rate is found, it uses
wave.tax.ratefrom the config.
To populate tax rates:
use Wave\Models\TaxRate;
Wave::taxes()->make()
->country('DE')
->rate(19.0) // 19% VAT
->name('VAT (Germany)')
->exclusive() // or ->inclusive()
->create();When creating tax rates, you can specify whether the tax is Exclusive (default) or Inclusive.
Tax is added on top of the product price. This is common in the US and for B2B transactions.
- Example: Product is $100. Tax is 10%.
- Calculation: $100 + ($100 * 0.10) = $110 Total.
- Usage: Use
->exclusive().
Tax is already included in the display price. The system extracts the tax amount from the total. This is standard in the EU/UK (VAT) and for B2C transactions where the customer expects to pay exactly the displayed price.
- Example: Product is $110. Tax is 10% (inclusive).
- Calculation: Tax = $110 - ($110 / 1.10) = $10. Base Price = $100.
- Usage: Use
->inclusive().
- Trial Days: Defined globally in config (
trial_days) or overridden per-subscription using the fluent builder.- Usage:
->trialDays(14)sets the trial period to 14 days, overriding the plan default.
- Usage:
- Grace Period: If a renewal payment fails, the subscription enters
past_due. The user retains access for a duration defined in the global configgrace_period_days. This is not set per-subscription but is a system-wide policy for failed payments.
// Subscription with explicit 30-day trial
$subscription = Wave::newSubscription()
->for($user)
->plan($plan->id)
->trialDays(30) // Overrides plan's default trial
->start();Wave's payment logic is robust and fail-safe. When attemptPayment() is called:
-
Wallet Check: It checks the user's internal
Walletbalance.- Success: Balance is deducted, invoice marked PAID.
- Failure: Proceed to step 2.
-
Pay Fallback: It initializes a payment via the
Paypackage.- This generates a secure payment link (e.g., Stripe Checkout, Paystack Page).
- User pays via the link -> Webhook confirms payment -> Invoice marked PAID.
Wave automatically sends email notifications for key events using the Mail package.
InvoiceGeneratedNotification: Sent when a new invoice is created. Contains a direct link to pay/view.PaymentSuccessNotification: Receipt sent after successful payment.PaymentFailedNotification: Alert sent when wallet and fallback methods both fail.RenewalReminderNotification: Sentwave.reminder_daysbefore a subscription renews.
Since Subscription and Invoice are standard Eloquent models, you can query them directly for reporting.
use Wave\Models\Subscription;
// Fetch all active subscriptions
$active = Subscription::active()->get();
// Get subscriptions on trial
$trialing = Subscription::trialing()->get();
// Get subscriptions that have been canceled
$canceled = Subscription::canceled()->get();
// Get subscriptions with failed payments
$pastDue = Subscription::pastDue()->get();
// Find subscriptions renewable in the next 3 days
$renewingSoon = Subscription::isRenewableInDays(3)->get();The Subscription model includes convenient helper methods to check status and remaining time.
// Check if subscription grants access (Active, Trialing, or Canceled but in grace period)
if ($subscription->isCurrentlyActive()) {
// User has access
}
// Get remaining days of access (Current cycle or grace period)
// Returns 0 if expired
$daysLeft = $subscription->daysLeft();
echo "You have {$daysLeft} days remaining.";
// Get a human-readable status label
// Returns: 'Active', 'Trialing', 'Past Due', 'Canceled', or 'Canceled (Grace Period)'
echo $subscription->displayStatus();use Wave\Models\Invoice;
// Get all open (unpaid) invoices
$unpaid = Invoice::open()->get();
// Get paid invoices
$paid = Invoice::paid()->get();
// Get overdue invoices
$overdue = Invoice::overdue()->get();Most Wave models (Plan, Product, Coupon, Subscription) support fluent status checks.
// Plans
// 1. Subscription Scopes
// -----------------------
// Get all active subscriptions
$active = Subscription::isActive()->get();
// Get subscriptions on trial
$trialing = Subscription::isOnTrial()->get();
// Get subscriptions belonging to a specific owner
$userSubs = Subscription::owner($user->id)->get();
// Get subscriptions renewable in the next X days
$renewingSoon = Subscription::isRenewableInDays(3)->get();
// 2. Plan Scopes
// --------------
// Get all active plans
$activePlans = Plan::isActive()->get();
// Get inactive plans
$inactivePlans = Plan::isInactive()->get();
// Get monthly/yearly plans
$daily = Plan::isDaily()->get();
$weekly = Plan::isWeekly()->get();
$monthly = Plan::isMonthly()->get(); // 1 month
$quarterly = Plan::isQuarterly()->get(); // 3 months
$biannual = Plan::isBiannual()->get(); // 6 months
$yearly = Plan::isYearly()->get(); // 1 year
// 3. Product Scopes
// -----------------
// Get all active products
$activeProducts = Product::isActive()->get();
// Get inactive products
$inactiveProducts = Product::isInactive()->get();
// 4. Coupon Scopes
// ----------------
// Get all active coupons
$activeCoupons = Coupon::isActive()->get();
// Get all expired coupons
$expiredCoupons = Coupon::isExpired()->get();The Wave::analytics() service provides a comprehensive suite of methods for retrieving SaaS metrics and reporting data. This service is essential for building admin dashboards, user reports, and monitoring business health.
Calculates the total monthly normalized revenue from all active subscriptions.
- Use Case: Displaying the "Headline" MRR figure on an admin dashboard.
- Method:
mrr() - Returns:
Money\Money(Immutable Money Object)
$mrr = Wave::analytics()->mrr();
// Returns Money object
echo $mrr->formatSimple(); // Outputs: $5,000.00
echo $mrr->getAmount(); // Outputs: 500000Calculates the total revenue collected from paid invoices within an optional date range.
- Use Case: Generating financial reports for a specific month or year.
- Method:
revenue(?string $start, ?string $end) - Returns:
Money\Money
// Total all-time revenue
$total = Wave::analytics()->revenue();
echo $total->formatSimple(); // $15,400.00
// Revenue for January 2024
$january = Wave::analytics()->revenue('2024-01-01', '2024-01-31');Counts the total number of users with an active or trialing subscription status.
- Use Case: Showing user growth metrics.
- Method:
activeSubscribers() - Returns:
int
// Count total active seats (Active + Trialing)
$count = Wave::analytics()->activeSubscribers();
// Count only subscribers currently on trial
$trialingCount = Wave::analytics()->trialingSubscribers();Calculates the percentage of subscribers who canceled in the last X days (default 30) relative to the active count at the start of that period.
- Use Case: Monitoring retention health over different timeframes.
- Method:
churnRate(int $days = 30) - Returns:
float(Percentage)
// Monthly Churn (Default)
$monthlyChurn = Wave::analytics()->churnRate();
// 90-Day Churn
$quarterlyChurn = Wave::analytics()->churnRate(90);Retrieves time-series data for charting libraries (like Chart.js or ApexCharts). Supports revenue, new_subscriptions, and cancellations.
- Use Case: Populating line or bar charts for trends over time.
- Method:
getHistory(string $metric, string|array $range = '30d') - Metrics:
'revenue','new_subscriptions','cancellations' - Ranges:
'7d','30d','90d','year', or['YYYY-MM-DD', 'YYYY-MM-DD'] - Returns:
array
// Get revenue history
$revenue = Wave::analytics()->getHistory()->revenue('7d');
// Get new subscriptions history
$newSubs = Wave::analytics()->getHistory()->newSubscriptions('30d');
// Get cancellations history
$churn = Wave::analytics()->getHistory()->cancellations('year');$data = Wave::analytics()->getHistory('revenue', '7d');
/* Returns:
[
'labels' => ['Jan 01', 'Jan 02', 'Jan 03', ...],
'values' => [15000, 20000, 0, ...] // values in cents or count
]
*/Retrieves Life-Time Value (LTV) and active subscription count for a specific owner (User/Team).
- Use Case: Showing a user's billing summary on their profile.
- Method:
subscriberStats(int|string $ownerId) - Returns:
array
$stats = Wave::analytics()->subscriberStats($user->id);
/* Returns:
[
'ltv' => Money object, // Use $stats['ltv']->formatSimple() -> $1,250.00
'active_subscriptions' => 1
]
*/Retrieves the top-performing plans based on active subscription counts.
- Use Case: Identifying most popular plans.
- Method:
productStats() - Returns:
array
$stats = Wave::analytics()->productStats();
/* Returns:
[
'top_plans' => [
['name' => 'Pro Monthly', 'count' => 150],
['name' => 'Basic Yearly', 'count' => 45],
...
]
]
*/Retrieves usage metrics for coupons.
- Use Case: Analyzing marketing campaign performance.
- Method:
couponStats() - Returns:
array
$stats = Wave::analytics()->couponStats();
/* Returns:
[
'total_redemptions' => 342,
'active_coupons' => 5
]
*/Retrieves a high-level overview of invoice statuses.
- Use Case: Admin overview of billing operations.
- Method:
invoiceStats() - Returns:
array
$stats = Wave::analytics()->invoiceStats();
/* Returns:
[
'paid_count' => 1205,
'unpaid_count' => 15,
'overdue_count' => 3,
'total_collected' => Money object // Use ->formatSimple() -> $15,400.00
]
*/Retrieves performance metrics for the affiliate program.
- Use Case: Monitoring partner contributions and commission payouts.
- Method:
affiliateStats(?string $affiliateCode = null) - Returns:
array
// Global Affiliate Stats
$globalStats = Wave::analytics()->affiliateStats();
/* Returns:
[
'active_affiliates' => 12,
'total_conversions' => 450,
'total_commission' => Money object
]
*/
// Stats for a Specific Partner
$partnerStats = Wave::analytics()->affiliateStats('PARTNER2024');
/* Returns:
[
'total_conversions' => 45,
'total_commission' => Money object
]
*/The Wave package uses automated scheduling to handle subscription renewals and send reminders. These tasks are automatically registered in the framework scheduler:
// packages/Wave/Schedules/SubscriptionRenewalSchedule.php
namespace Wave\Schedules;
use Cron\Interfaces\Schedulable;
use Cron\Schedule;
class SubscriptionRenewalSchedule implements Schedulable
{
public function schedule(Schedule $schedule): void
{
$schedule->task()
->signature('wave:renew')
->hourly();
$schedule->task()
->signature('wave:renewal-reminders')
->daily();
}
}- Idempotency: Wave generates a unique
refidfor every invoice. This is used as theIdempotency-KeyorReferencein all payment gateways to prevent double-charging. - Atomic Operations: All plan swaps and renewals are wrapped in database transactions.
- Public URLs: Never expose internal IDs (integers). Use the
refid(UUID-like string) for all public-facing routes (e.g.,/invoices/inv_x9s8d7f).