develupers / laravel-plan-usage
A Laravel package for managing subscription plans, features, quotas, and usage tracking across multiple billing providers
Fund package maintenance!
Requires
- php: ^8.3
- illuminate/contracts: ^11.0||^12.0||^13.0
- illuminate/database: ^11.0||^12.0||^13.0
- illuminate/support: ^11.0||^12.0||^13.0
- laravel/cashier: ^15.0||^16.0
- spatie/laravel-package-tools: ^1.18
Requires (Dev)
- danestves/laravel-polar: ^2.13
- larastan/larastan: ^3.0
- laravel/cashier-paddle: ^2.8
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^11.0.0||^10.0.0||^9.0.0
- pestphp/pest: ^3.0||^4.0
- pestphp/pest-plugin-arch: ^3.0||^4.0
- pestphp/pest-plugin-laravel: ^3.0||^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- spatie/laravel-ray: ^1.35
Suggests
- danestves/laravel-polar: Required for Polar billing provider integration
- laravel/cashier-paddle: Required for Paddle billing provider integration
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-10 03:32:37 UTC
README
A powerful Laravel package for managing subscription plans, features, quotas, and usage tracking. Perfect for SaaS applications that need flexible plan management with feature access control and usage monitoring. Supports Stripe, Paddle, and Polar billing integrations.
β¨ Features
- π Flexible Plan Management - Create and manage subscription tiers with customizable pricing
- π― Feature Access Control - Define boolean, limit, and quota-based features
- π Usage Tracking - Monitor and track feature consumption in real-time
- π¦ Quota Enforcement - Automatic quota limits with configurable warning thresholds
- π³ Multi-Provider Billing - Support for Stripe, Paddle, and Polar billing providers
- π MoR Support - Use Paddle or Polar as Merchant of Record for simplified tax/VAT handling
- π± Multi-Currency Support - Support for different currencies per pricing option
- π Flexible Pricing Intervals - Daily, weekly, monthly, yearly, or lifetime pricing
- π Periodic Reset Options - Daily, weekly, monthly, or yearly quota resets
- πͺ Event-Driven Architecture - React to usage events and quota warnings
- π‘οΈ Middleware Protection - Route-level feature and quota enforcement
- π·οΈ Plan Types - Support for public, legacy, and private plans
- βΎοΈ Lifetime Plans - Mark plans as lifetime to exempt them from subscription enforcement
- β° Quota Reset Scheduling - Built-in command and job for resetting expired quotas
- π Usage Analytics - Built-in statistics and reporting capabilities
π Requirements
- PHP 8.3+
- Laravel 11.x, 12.x, or 13.x
- One of the following billing packages:
- Laravel Cashier 15.x or 16.x (for Stripe; installed automatically)
- Laravel Cashier Paddle 2.8+ (for Paddle Billing; install separately)
- Laravel Polar 2.13+ within 2.x (for Polar; install separately)
π Installation
Install the package from Packagist by running this command in your Laravel application's root directory:
composer require develupers/laravel-plan-usage
Laravel automatically discovers the service provider and facade aliases. No custom Composer repository or manual provider registration is required.
Select Your Billing Provider
Stripe's laravel/cashier package is installed as a dependency. For Paddle or Polar, also install the matching integration:
# Paddle composer require laravel/cashier-paddle:"^2.8" # Polar composer require danestves/laravel-polar:"^2.13"
Set your chosen provider in .env before publishing migrations, because the package publishes different migrations for each provider:
BILLING_PROVIDER=stripe
Use paddle or polar instead when appropriate. Clear any cached configuration:
php artisan config:clear
Publish and Configure
For a fresh installation, publish the configuration and the selected provider's migrations:
php artisan plan-usage:install
This writes config/plan-usage.php and migration files in database/migrations; it does not run the migrations. The installer overwrites the published configuration, so use the individual vendor:publish commands when updating an existing installation:
php artisan vendor:publish --tag="plan-usage-config" php artisan vendor:publish --tag="plan-usage-migrations"
Before migrating, configure models.billable and tables.billable in config/plan-usage.php for your billable model and table. Add the package's HasPlanFeatures trait and your provider's Billable trait to that model, and complete the provider's database and webhook setup. See the Installation Guide for model examples and provider configuration.
Then create the tables:
php artisan migrate
βοΈ Configuration
After installation, configure the package in config/plan-usage.php:
return [ 'tables' => [ 'billable' => 'accounts', // Your billable model's table 'plans' => 'plans', 'plan_prices' => 'plan_prices', 'features' => 'features', 'plan_features' => 'plan_features', 'usages' => 'usages', 'quotas' => 'quotas', 'subscription_plan_changes' => 'subscription_plan_changes', 'billing_webhook_events' => 'billing_webhook_events', ], 'cache' => [ 'enabled' => true, 'store' => 'redis', 'ttl' => 3600, ], 'quota' => [ 'throw_exception' => true, 'soft_limit' => false, 'grace_percentage' => 0, 'warning_thresholds' => [80, 100], 'trigger_once' => false, // Only fire each threshold event once per billing period ], // Billing provider configuration 'billing' => [ // 'auto' = detect from installed package // 'stripe' = force Stripe provider // 'paddle' = force Paddle provider // 'polar' = force Polar provider 'provider' => env('BILLING_PROVIDER', 'auto'), ], // Subscription settings 'subscription' => [ // Default plan ID for new billables and cancelled subscriptions. // Set this to your free plan ID so users always have a plan. 'default_plan_id' => env('DEFAULT_PLAN_ID', null), ], // Paddle-specific configuration (only needed if using Paddle) 'paddle' => [ 'sandbox' => env('PADDLE_SANDBOX', true), 'seller_id' => env('PADDLE_SELLER_ID'), 'api_key' => env('PADDLE_API_KEY'), 'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'), 'client_side_token' => env('PADDLE_CLIENT_SIDE_TOKEN'), ], // Polar-specific configuration (only needed if using Polar) // Webhook signature verification is handled by danestves/laravel-polar via // POLAR_WEBHOOK_SECRET in its own config, so there is no key for it here. 'polar' => [ 'access_token' => env('POLAR_ACCESS_TOKEN'), 'organization_id' => env('POLAR_ORGANIZATION_ID'), 'server' => env('POLAR_SERVER', 'sandbox'), ], ];
Default Plan for New Billables
When default_plan_id is configured, the package automatically assigns this plan to new billable models when they're created. This is useful for:
- Free tier assignment: New users automatically get the free plan
- Subscription cancellation fallback: When a paid subscription is cancelled, users fall back to the default plan instead of having no plan
// In config/plan-usage.php 'subscription' => [ 'default_plan_id' => 1, // Your Free plan ID ], // Or via .env DEFAULT_PLAN_ID=1
With this configured, you don't need to manually assign plans when creating billables:
// The free plan is automatically assigned! $account = Account::create([ 'name' => 'New Account', 'owner_id' => $user->id, ]); echo $account->plan->name; // "Free"
π³ Billing Provider Setup
This package supports Stripe, Paddle, and Polar as billing providers. You can only use one provider at a time.
Option 1: Stripe (Default)
Install Laravel Cashier for Stripe:
composer require laravel/cashier
Add to your .env:
BILLING_PROVIDER=stripe STRIPE_KEY=your-stripe-key STRIPE_SECRET=your-stripe-secret STRIPE_WEBHOOK_SECRET=your-webhook-secret
Option 2: Paddle (Merchant of Record)
Paddle acts as Merchant of Record, handling all tax/VAT compliance for you. This is ideal if you want to avoid dealing with tax regulations across different countries.
Install Laravel Cashier for Paddle:
composer require laravel/cashier-paddle
Add to your .env:
BILLING_PROVIDER=paddle PADDLE_SANDBOX=true PADDLE_SELLER_ID=your-seller-id PADDLE_API_KEY=your-api-key PADDLE_WEBHOOK_SECRET=your-webhook-secret PADDLE_CLIENT_SIDE_TOKEN=your-client-token
Option 3: Polar (Merchant of Record)
Install Laravel Polar and run its installer:
composer require danestves/laravel-polar php artisan polar:install
Add to your .env:
BILLING_PROVIDER=polar POLAR_ACCESS_TOKEN=your-access-token POLAR_ORGANIZATION_ID=your-organization-id POLAR_SERVER=sandbox POLAR_WEBHOOK_SECRET=your-webhook-secret
Each PlanPrice maps to a distinct Polar product through
polar_product_id. Monthly and yearly subscriptions are separate Polar
products even when they grant the same application plan.
Lifetime plans are fully supported on Polar: a PlanPrice with the
lifetime interval is pushed as a one-time Polar product, the plan and
quotas are assigned when the order.paid webhook arrives (no subscription is
created), and a fully refunded order revokes them again. Mark the plan
is_lifetime = true so subscription enforcement never touches its holders.
Auto-Detection
If BILLING_PROVIDER is set to auto (or not set), the package will automatically detect which billing package is installed and use the appropriate provider. Detection priority: Paddle > Polar > Stripe.
Automatic Migration Selection
The package automatically publishes the correct migration for your billable table based on the detected billing provider:
- Stripe: Adds
stripe_id,pm_type,pm_last_four,trial_ends_atcolumns - Paddle: Adds
paddle_id,trial_ends_at,billing_emailcolumns - Polar: Uses Polar's polymorphic customer/subscription tables and adds only the plan tracking columns
All migrations also add plan tracking columns: plan_id, plan_price_id, plan_changed_at.
The plan_prices table likewise only receives the selected provider's price/product identifier column (stripe_price_id, paddle_price_id, or polar_product_id). The managed subscription-change table (subscription_plan_changes) is published for providers that support managed plan changes (Stripe, Paddle, and Polar); the durable webhook-event table (billing_webhook_events) is Polar-only.
Switching billing providers? Migrations are selected from the provider detected at publish time, so after installing the new provider package and updating
BILLING_PROVIDER, re-runphp artisan vendor:publish --tag="plan-usage-migrations"andphp artisan migrate. Only the new provider's migrations are added (already-published ones are skipped), and every provider-specific migration is guarded withhasColumn/hasTablechecks so re-running is safe. Skipping this step leaves the new provider's column/tables missing β e.g. Polar webhooks will fail without thebilling_webhook_eventstable.
The Paddle stub also adds a
billing_emailoverride (falls back to the owner/user email). Paddle allows only one customer per email, so each billable needs its own billing identity to subscribe independently;PaddleProvider::updateCustomerEmail()pushes changes to Paddle.
Important: You still need to publish and run the billing provider's own migrations separately. The package only adds the billable columns to your model's table.
# For Stripe php artisan vendor:publish --tag=cashier-migrations # For Paddle php artisan vendor:publish --provider="Laravel\Paddle\CashierServiceProvider" # For Polar php artisan polar:install # Then run migrations php artisan migrate
π Quick Start
1. Add Traits to Your Billable Model
For Stripe:
use Develupers\PlanUsage\Traits\HasPlanFeatures; use Laravel\Cashier\Billable; class Account extends Model { use Billable, HasPlanFeatures; }
For Paddle:
use Develupers\PlanUsage\Traits\HasPlanFeatures; use Laravel\Paddle\Billable; class Account extends Model { use Billable, HasPlanFeatures; }
For Polar:
use Danestves\LaravelPolar\Billable; use Develupers\PlanUsage\Traits\HasPlanFeatures; class Account extends Model { use Billable, HasPlanFeatures; }
2. Create Plans and Features
use Develupers\PlanUsage\Models\Plan; use Develupers\PlanUsage\Models\PlanPrice; use Develupers\PlanUsage\Models\Feature; use Develupers\PlanUsage\Models\PlanFeature; // Create a plan $plan = Plan::create([ 'name' => 'Professional', 'slug' => 'professional', 'display_name' => 'Professional Plan', 'description' => 'Perfect for growing businesses', 'stripe_product_id' => 'prod_123456', // Stripe Product ID (if using Stripe) 'paddle_product_id' => 'pro_01abc123', // Paddle Product ID (if using Paddle) 'trial_days' => 14, 'type' => 'public', ]); // Create pricing options for the plan $monthlyPrice = PlanPrice::create([ 'plan_id' => $plan->id, 'stripe_price_id' => 'price_monthly123', // Stripe Price ID 'paddle_price_id' => 'pri_01xyz789', // Paddle Price ID 'polar_product_id' => 'prod_polar_monthly123', // Polar Product ID 'price' => 49.00, 'currency' => 'USD', 'interval' => 'month', 'is_default' => true, // Default pricing option 'is_active' => true, ]); $yearlyPrice = PlanPrice::create([ 'plan_id' => $plan->id, 'stripe_price_id' => 'price_yearly456', 'paddle_price_id' => 'pri_01abc456', 'polar_product_id' => 'prod_polar_yearly456', 'price' => 490.00, // Discounted yearly price 'currency' => 'USD', 'interval' => 'year', 'is_active' => true, ]); // Create features $apiFeature = Feature::create([ 'name' => 'API Calls', 'slug' => 'api-calls', 'type' => 'quota', 'reset_period' => 'month', ]); // Link feature to plan with a value PlanFeature::create([ 'plan_id' => $plan->id, 'feature_id' => $apiFeature->id, 'value' => '5000', // 5000 API calls per month ]);
3. Assign Plan to Billable
$account = Account::find(1); $account->plan_id = $plan->id; $account->save();
4. Use Features in Your Application
There are two approaches -- pick whichever fits your use case:
Middleware approach (automatic, on routes):
// CheckQuota gates the request, ConsumeQuota enforces + logs on success Route::middleware(['check-quota:api-calls,1', 'consume-quota:api-calls,1']) ->post('/api/generate', 'ApiController@generate');
Manual approach (in your code):
// consume() does everything: checks quota, increments, and logs usage if ($account->consume('api-calls', 1, ['endpoint' => '/api/generate'])) { // Success -- quota was available } else { // Quota exceeded }
Other useful methods:
// Check if feature is in the plan $account->hasFeature('api-calls'); // Read-only quota check (no side effects) $account->checkQuota('api-calls', 10); // Log usage without quota enforcement $account->logUsage('api-calls', 1, ['source' => 'import']); // Check remaining quota $remaining = $account->getRemainingQuota('api-calls'); // Get detailed usage information $usage = $account->getFeatureUsage('api-calls'); // Returns: ['limit' => 5000, 'used' => 1250, 'remaining' => 3750] // Returns null when there's nothing to meter β see "getFeatureUsage() can // return null" under Usage Tracking & Analytics.
π Understanding Feature Checks
The package provides two main methods for checking features:
| Method | Purpose | Mutates quota? |
|---|---|---|
hasFeature('api-calls') |
Check if feature is included in the plan | No |
checkQuota('api-calls', 10) |
Check if quota is available for amount | No |
consume('api-calls', 10) |
Enforce + increment + log (the full operation) | Yes |
logUsage('api-calls', 10) |
Log usage only (no enforcement) | No |
Key difference: hasFeature() checks plan inclusion, checkQuota() checks current quota availability, and consume() actually uses the quota.
π·οΈ Plan Types
Plans can be categorized by visibility and distribution type:
| Type | Description | Purchasable | Visible to |
|---|---|---|---|
| public | Current plans on pricing page | Yes, self-service checkout | Everyone |
| private | Gated plans requiring access code, invite, or membership | Yes, with access | Invited/authorized users |
| legacy | Discontinued plans, grandfathered for existing subscribers | No, existing subscribers only | Current holders only |
| hidden | Internal/admin-only plans (lifetime deals, staff plans) | No, manually assigned | Admin only |
// Only show public plans on pricing page $availablePlans = Plan::availableForPurchase()->get(); // active + public // Get legacy plans for existing customers $legacyPlans = Plan::legacy()->get(); // Get hidden plans (admin use only) $hiddenPlans = Plan::hidden()->get(); // Check plan type if ($plan->isAvailableForPurchase()) { // Show "Subscribe" button } if ($plan->isHidden()) { // Only show in admin panel } // Plan type lifecycle: public β legacy (when retired) // Private plans are for gated access (access codes, invitations) // Hidden plans are never exposed to users (lifetime deals, internal use)
βΎοΈ Lifetime Plans
Lifetime plans are plans that don't require an active billing subscription. They're ideal for one-time purchase deals, promotional offers, or manually assigned plans that should never be revoked by subscription enforcement.
Creating a Lifetime Plan
$plan = Plan::create([ 'name' => 'Growth Lifetime', 'slug' => 'growth-lifetime', 'display_name' => 'Growth Lifetime', 'description' => 'Growth plan with lifetime access', 'type' => 'hidden', // Not shown on public pricing pages 'is_lifetime' => true, // Exempt from subscription enforcement 'is_active' => true, ]);
Querying Lifetime Plans
// Get all lifetime plans $lifetimePlans = Plan::lifetime()->get(); // Get plans that require an active subscription $subscriptionPlans = Plan::requiresSubscription()->get(); // Check if a specific plan is lifetime if ($plan->isLifetime()) { // Skip subscription enforcement }
How Lifetime Plans Work
| Aspect | Regular Plan | Lifetime Plan |
|---|---|---|
| Requires subscription | Yes | No |
| Quotas reset periodically | Yes | Yes |
| Subject to plan enforcement | Yes | No |
| Shown on pricing page | If type = public |
Typically type = hidden |
| Assigned via | Stripe/Paddle/Polar checkout | Admin panel, seeder, or manual assignment |
Lifetime plans still benefit from quota resets β a lifetime plan with 8,000 monthly credits will reset to 0 used credits each month. The only difference is that the plan is never removed due to a missing subscription.
Lifetime Purchases on Polar
Polar is the only provider with true one-time purchases: a LIFETIME-interval PlanPrice maps to a one-time Polar product. The order.paid webhook assigns the plan and quotas (orders belonging to a subscription are ignored), and a fully refunded order is the only thing that revokes a lifetime plan β subscription lifecycle events, reconciliation, and enforcement all skip lifetime holders, so a historical (cancelled) subscription can never wipe a lifetime plan bought afterwards.
Checkout blocks buying the same lifetime plan price twice (one-time purchases create no subscription row, so the usual subscribed() guard cannot catch a repeat purchase). Known limitation: purchases made outside the package's checkout (e.g. Polar dashboard payment links) are not deduplicated β sell lifetime products through CreateCheckoutSessionAction to keep refund semantics safe.
Migration
If upgrading from a previous version, publish and run the migration:
php artisan vendor:publish --tag=plan-usage-migrations php artisan migrate
This adds the is_lifetime boolean column (default: false) to the plans table.
π° Pricing Structure
Plans support multiple pricing options with different intervals and currencies:
Creating Multiple Prices
// Plans can have multiple pricing options $plan = Plan::find(1); // Monthly pricing $plan->prices()->create([ 'stripe_price_id' => 'price_monthly', 'price' => 29.00, 'currency' => 'USD', 'interval' => 'month', 'is_default' => true, ]); // Annual pricing with discount $plan->prices()->create([ 'stripe_price_id' => 'price_yearly', 'price' => 290.00, // Save $58! 'currency' => 'USD', 'interval' => 'year', ]); // Lifetime deal $plan->prices()->create([ 'stripe_price_id' => 'price_lifetime', 'price' => 999.00, 'currency' => 'USD', 'interval' => 'lifetime', ]);
Working with Prices
// Get default price $defaultPrice = $plan->defaultPrice; // Get price by interval $monthlyPrice = $plan->getMonthlyPrice(); $yearlyPrice = $plan->getYearlyPrice(); $customPrice = $plan->getPriceByInterval('week'); // Get all active prices $activePrices = $plan->activePrices; // Find plan by any provider price ID (auto-detects current provider) $plan = Plan::findByProviderPriceId('price_monthly123'); // Calculate savings $yearlyPrice = $plan->getYearlyPrice(); $monthlyPrice = $plan->getMonthlyPrice(); $savings = $yearlyPrice->calculateSavings($monthlyPrice); echo "Save {$savings}% with yearly billing!";
Supported Intervals
| Interval | Description |
|---|---|
day |
Daily billing |
week |
Weekly billing |
month |
Monthly billing |
year |
Annual billing |
lifetime |
One-time payment |
π― Feature Types
The package supports three types of features:
| Type | Description | Example |
|---|---|---|
| Boolean | On/off features | Advanced Analytics, Priority Support |
| Limit | Maximum allowed quantity | Max Projects, Team Members |
| Quota | Usage-based with periodic reset | API Calls, Storage, Bandwidth |
π‘οΈ Middleware
Protect your routes with built-in middleware:
// In bootstrap/app.php ->withMiddleware(function (Middleware $middleware) { $middleware->alias([ 'check-feature' => \Develupers\PlanUsage\Http\Middleware\CheckFeature::class, 'check-quota' => \Develupers\PlanUsage\Http\Middleware\CheckQuota::class, 'consume-quota' => \Develupers\PlanUsage\Http\Middleware\ConsumeQuota::class, ]); }) // In routes/web.php Route::get('/analytics', function () { // Only accessible if user has 'advanced-analytics' feature })->middleware('check-feature:advanced-analytics'); Route::post('/api/generate', function () { // Enforces quota, increments, and logs usage on success })->middleware('consume-quota:api-calls,1'); // Gate + consume (check first, consume after success) Route::middleware(['check-quota:api-calls,5', 'consume-quota:api-calls,5']) ->post('/api/bulk', 'ApiController@bulk');
π Usage Tracking & Analytics
Feature Usage Details
// Get comprehensive usage details for a feature $usage = $account->getFeatureUsage('api-calls'); // Returns: ['limit' => 5000, 'used' => 1250, 'remaining' => 3750] // Get usage percentage $percentage = $account->getFeatureUsagePercentage('api-calls'); echo "You've used {$percentage}% of your API calls"; // Get all features status $featuresStatus = $account->getFeaturesStatus(); foreach ($featuresStatus as $status) { echo "{$status['name']}: {$status['used']}/{$status['limit']}\n"; }
β οΈ getFeatureUsage() can return null β always guard for it
getFeatureUsage() returns ?array. It gives back null whenever there is
nothing to meter, which is deliberately different from a feature that is
metered but exhausted (['limit' => 0, ...]). Treat null as "not applicable"
rather than rendering it as a maxed-out progress bar.
$usage = $account->getFeatureUsage('api-calls'); if ($usage === null) { // One of: // - the feature slug doesn't exist, or it's a boolean feature // (boolean features have no usage to report) // - the billable has no plan, or its plan doesn't include this feature return; // nothing to show } // Granted but UNLIMITED β limit/remaining are null (never coerced to 0) if ($usage['limit'] === null) { echo "{$usage['used']} used (unlimited)"; } else { echo "{$usage['used']} / {$usage['limit']} used, {$usage['remaining']} left"; }
| Scenario | Return value |
|---|---|
| Feature not found, or boolean type | null |
| Billable has no plan | null |
| Plan doesn't include the feature | null |
| Granted, unlimited | ['limit' => null, 'used' => N, 'remaining' => null] |
| Granted with a limit | ['limit' => 5000, 'used' => 1250, 'remaining' => 3750] |
Track Usage
use Develupers\PlanUsage\Facades\PlanUsage; // Consume: enforce quota + increment + log (returns false if exceeded) PlanUsage::consume($account, 'api-calls', 10, [ 'endpoint' => '/api/generate', 'ip' => $request->ip(), ]); // Get usage history $history = PlanUsage::usage()->getHistory($account, 'api-calls'); // Get usage statistics $stats = PlanUsage::usage()->getStatistics( $account, 'api-calls', now()->subMonth(), now(), 'day' );
Quota Management
// Get current usage $used = PlanUsage::quotas()->getUsed($account, 'api-calls'); // Get remaining quota $remaining = PlanUsage::quotas()->getRemaining($account, 'api-calls'); // Get usage percentage $percentage = PlanUsage::quotas()->getUsagePercentage($account, 'api-calls'); // Enforce quota (returns false if would exceed) $canProceed = PlanUsage::quotas()->enforce($account, 'api-calls', 10);
πͺ Events
The package dispatches events you can listen to:
UsageRecorded- When usage is recordedQuotaWarning- When usage reaches warning threshold (80%, 100%)QuotaExceeded- When quota limit is exceededPlanRevoked- When a plan is revoked due to no active subscription (see Enforcing Plan Subscriptions)SubscriptionPlanChangeScheduled- When a future plan change is accepted by the providerSubscriptionPlanChanged- When a confirmed plan change is applied locallySubscriptionPlanChangeCancelled- When a pending plan change is cancelled
// In your EventServiceProvider or via Laravel auto-discovery protected $listen = [ \Develupers\PlanUsage\Events\QuotaWarning::class => [ \App\Listeners\SendQuotaWarningNotification::class, ], \Develupers\PlanUsage\Events\QuotaExceeded::class => [ \App\Listeners\HandleQuotaExceeded::class, ], \Develupers\PlanUsage\Events\PlanRevoked::class => [ \App\Listeners\NotifyPlanRevoked::class, ], ];
Event Deduplication (trigger_once)
By default, QuotaWarning and QuotaExceeded events fire on every consume() call where usage exceeds a threshold. This can result in duplicate notifications (e.g., an email on every API request after 80% usage).
Set trigger_once to true to fire each event only once per billing period:
// config/plan-usage.php 'quota' => [ 'warning_thresholds' => [80, 100], 'trigger_once' => true, ],
When enabled, the package uses a cache key scoped to the billable, feature, and threshold. The key expires at the quota's reset_at timestamp (or 24 hours if no reset period is configured). Each threshold fires independently β crossing 80% sends one event, and later crossing 100% sends another.
This is handled at the package level, so your listeners don't need any deduplication logic.
π³ Billing Provider Integration
The package integrates with Cashier Stripe, Cashier Paddle, and Laravel Polar through one billing provider contract.
Design boundary: the provider libraries own the money β payments, invoices, taxes, payment methods, and the billing clock. This package owns the entitlements β what a customer may use inside your application, and how much of it is left. It only reaches into a provider where entitlement correctness requires owning the call path (plan changes, cancellation), and it never replicates provider machinery it can consume instead.
Webhook Behavior & Entitlement Policy
Providers do not guarantee webhook delivery order, so payloads are treated only as triggers: listeners re-fetch the subscription's current state from the provider API inside a per-billable lock and converge the local plan to that authoritative response β out-of-order deliveries are harmless. Failed deliveries return a non-2xx response (and release their dedupe key) so the provider redelivers.
One shared status policy governs listeners, subscriptions:reconcile, and subscription enforcement:
| Remote status | Outcome |
|---|---|
active, trialing |
Plan granted / synced |
past_due |
Kept by default; set plan-usage.{stripe,paddle,polar}.past_due_keeps_entitlements to false to revoke |
canceled (Polar) |
Grace period β kept until the effective end passes |
Everything else (incomplete, unpaid, paused, canceled, unknown) |
Plan revoked |
Revocation is fail-closed (the plan-clear commits before quota cleanup, so a cleanup failure still denies access) and idempotent (a billable already on the configured subscription.default_plan_id is never re-processed, preserving free-tier usage).
Provider-Agnostic Methods
// Get the current provider's price ID for a plan price $priceId = $planPrice->getProviderPriceId(); // Find a plan by its provider's price/product ID $plan = Plan::findByProviderPriceId($priceId); // Get/set the provider's product ID $productId = $plan->getProviderProductId(); $plan->setProviderProductId('prod_ABC123'); // Create a provider checkout for a plan price $checkout = app(\Develupers\PlanUsage\Actions\Subscription\CreateCheckoutSessionAction::class) ->executeForPlanPrice($billable, $planPrice, [ 'success_url' => route('billing.success'), 'cancel_url' => route('billing.index'), ]); return $checkout->redirect();
Managed Plan Changes
Use the managed plan-change API instead of calling the provider's subscription model directly. This keeps the remote subscription, local plan, quotas, and pending-change audit record consistent.
Provider support:
| Timing | Stripe | Paddle | Polar |
|---|---|---|---|
Immediate (swap + prorate now) |
β | β | β |
NextPeriod (scheduled change at renewal) |
β | β | β |
Only the configured default-type subscription (subscription.default_name, 'default' by default) controls the billable's plan: changePlan() rejects other subscription names, webhook events for add-on subscriptions are ignored, and immediate cancellation only revokes the plan when the default subscription itself was cancelled.
A timing is exposed only where the provider supports it natively β the package never emulates provider-side scheduling with local timers, because the provider owns the billing clock and only it can bill the new price correctly at renewal. (Stripe could gain NextPeriod later via its native Subscription Schedules; Paddle has no native equivalent.) Feature-detect instead of catching exceptions:
use Develupers\PlanUsage\Contracts\BillingProvider; use Develupers\PlanUsage\Contracts\SubscriptionPlanChangeProvider; use Develupers\PlanUsage\Enums\SubscriptionChangeTiming; $provider = app(BillingProvider::class); if ($provider instanceof SubscriptionPlanChangeProvider && $provider->supportsTiming(SubscriptionChangeTiming::NextPeriod)) { // Offer "downgrade at renewal" in the UI }
use Develupers\PlanUsage\Actions\Subscription\CancelSubscriptionAction; use Develupers\PlanUsage\Enums\SubscriptionChangeTiming; // Upgrade now. The provider invoices the prorated price difference immediately, // then the package grants the prorated quota difference after confirmation. $change = $account->changePlan($growthMonthlyPrice); // Downgrade on renewal (Polar only). Current plan limits and quotas remain // unchanged until the provider reports that the pending product is now current. $change = $account->changePlan($starterMonthlyPrice, SubscriptionChangeTiming::NextPeriod); // Inspect or remove a scheduled downgrade before it takes effect. $pending = $account->pendingPlanChange(); $account->cancelPendingPlanChange(); // The same operations are available through the facade: // PlanUsage::changePlan($account, $growthMonthlyPrice); // PlanUsage::cancelPendingPlanChange($account); // Cancel at period end, resume during the grace period, or revoke now. app(CancelSubscriptionAction::class)->execute($account); app(CancelSubscriptionAction::class)->resume($account); app(CancelSubscriptionAction::class)->execute($account, immediately: true);
The package applies these entitlement rules:
- Immediate quota upgrades add only the prorated difference for the remaining entitlement period.
- Scheduled changes do not alter current entitlements early.
- When a scheduled change becomes effective, quota usage resets and the target allowance is applied.
- Period-end cancellation keeps access through Polar's paid grace period; revocation removes the plan and quotas.
- Webhook deliveries are durably deduplicated and ordered per subscription.
subscriptions:reconcilerepairs state after missed webhooks.
For asset limits such as active projects, this package updates the numeric plan limit but does not delete or lock application records. Your application should enforce the new limit after the scheduled change is appliedβfor example, by asking the user which projects remain active and making excess projects read-only.
Syncing Plans to Billing Provider
Use the unified plans:push command to sync your local plans to the billing provider:
# Sync to configured provider (auto-detected) php artisan plans:push # Sync to specific provider php artisan plans:push --provider=stripe php artisan plans:push --provider=paddle php artisan plans:push --provider=polar # Preview what would be synced (no changes made) php artisan plans:push --dry-run # Force update existing products php artisan plans:push --force
Resetting Expired Quotas
Quotas with a reset period (daily, weekly, monthly, yearly) need to be periodically reset. The package provides a command and a queued job for this.
Command:
# Run synchronously php artisan plan-usage:reset-quotas # Dispatch as a queued job php artisan plan-usage:reset-quotas --dispatch
Schedule it in your routes/console.php:
use Illuminate\Support\Facades\Schedule; // Reset expired quotas every hour Schedule::command('plan-usage:reset-quotas --dispatch')->hourly();
Dispatch the job directly from your code:
use Develupers\PlanUsage\Jobs\ResetExpiredQuotasJob; // Queued ResetExpiredQuotasJob::dispatch(); // Synchronous ResetExpiredQuotasJob::dispatchSync();
The job finds all quotas where reset_at has passed and used > 0, resets the usage to 0, and sets the next reset_at date based on the feature's reset period.
Enforcing Plan Subscriptions
Plan enforcement ensures that billables with a paid plan but no active subscription get their plan revoked. Lifetime plans are automatically exempt.
Command:
# Run synchronously php artisan plan-usage:enforce-subscriptions # Dispatch as a queued job php artisan plan-usage:enforce-subscriptions --dispatch
Schedule it in your routes/console.php:
use Illuminate\Support\Facades\Schedule; // Enforce plan subscriptions daily Schedule::command('plan-usage:enforce-subscriptions --dispatch')->daily();
What it does:
- Finds all billables with a
plan_idwhere the plan is NOT lifetime (is_lifetime = false) - Checks if they have an active subscription or are on a grace period
- If not, clears
plan_id(or sets todefault_plan_idfrom config) - Dispatches a
PlanRevokedevent for each revoked plan
Listen for revocations:
use Develupers\PlanUsage\Events\PlanRevoked; // In your EventServiceProvider or via Laravel auto-discovery protected $listen = [ PlanRevoked::class => [ \App\Listeners\NotifyPlanRevoked::class, ], ];
The PlanRevoked event contains:
$event->billableβ the account/user that lost their plan$event->previousPlanβ the plan that was removed$event->reasonβ why it was revoked (e.g.no_active_subscription)
Reconciling Subscriptions
If webhooks are missed, you can reconcile local subscription status with the billing provider. Every billable with subscription rows is checked against the provider's current state: expired subscriptions are revoked, active subscriptions have plan/price drift corrected, and a billable whose initial checkout webhook was lost gets its plan recovered. Billables with a plan but no subscription rows (lifetime purchases, manually granted plans) are never touched. Only the configured default-type subscription controls the plan β custom-typed subscriptions are deliberately ignored.
# Reconcile with configured provider php artisan subscriptions:reconcile # Reconcile with specific provider php artisan subscriptions:reconcile --provider=stripe php artisan subscriptions:reconcile --provider=paddle php artisan subscriptions:reconcile --provider=polar # Preview changes php artisan subscriptions:reconcile --dry-run
Stripe-Specific
// Plans connect to Stripe Products $plan->stripe_product_id = 'prod_ABC123'; // Each price connects to Stripe Prices $price->stripe_price_id = 'price_XYZ789';
Paddle-Specific
// Plans connect to Paddle Products $plan->paddle_product_id = 'pro_01ABC123'; // Each price connects to Paddle Prices $price->paddle_price_id = 'pri_01XYZ789';
Polar-Specific
Polar sells products rather than a separate product/price pair. Each local PlanPrice therefore maps to one Polar product:
$monthlyPrice->polar_product_id = 'prod_01MONTHLY'; $yearlyPrice->polar_product_id = 'prod_01YEARLY';
Use plans:push --provider=polar to create those products automatically. Laravel Polar owns customer, checkout, portal, order, subscription, and webhook transport; this package owns the mapping from the confirmed Polar subscription to plans, features, quotas, and usage.
β° Recommended Schedule
Add both commands to your routes/console.php to keep quotas and plans in sync:
use Illuminate\Support\Facades\Schedule; // Reset expired quotas (e.g. monthly credit resets) Schedule::command('plan-usage:reset-quotas --dispatch')->hourly(); // Revoke plans from accounts without active subscriptions (lifetime plans exempt) Schedule::command('plan-usage:enforce-subscriptions --dispatch')->daily(); // Reconcile local subscriptions with billing provider to catch missed webhooks Schedule::command('subscriptions:reconcile')->daily();
π Plan Comparison
Compare plans to show upgrade benefits:
$comparison = PlanUsage::plans()->comparePlans($currentPlanId, $newPlanId); foreach ($comparison as $featureSlug => $data) { echo "{$data['feature']}: "; echo "{$data['plan1']} β {$data['plan2']}"; if ($data['difference'] > 0) { echo " (+{$data['difference']})"; } }
π§ͺ Testing
Run the test suite:
composer test
Run tests with coverage:
composer test-coverage
Format code:
composer format
Static analysis:
composer analyse
π Documentation
For detailed documentation, see:
- Installation Guide - Complete setup instructions
- User Guide - Comprehensive usage documentation
- Quick Reference - Common operations cheatsheet
π€ Contributing
Please see CONTRIBUTING for details on how to contribute to this project.
Development Setup
- Clone the repository
- Install dependencies:
composer install - Run tests:
composer test - Check code style:
composer format
π Security
If you discover any security-related issues, please email security@develupers.com instead of using the issue tracker.
π Changelog
Please see CHANGELOG for more information on what has changed recently.
π₯ Credits
π License
The MIT License (MIT). Please see License File for more information.
πͺ Support
Need help or have questions? Feel free to:
- π Check the documentation
- π Report bugs
- π‘ Request features
- β Star the repository if you find it useful!
Built with β€οΈ by Develupers