PHPackages                             onaonbir/oo-subscription - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Payment Processing](/categories/payments)
4. /
5. onaonbir/oo-subscription

ActiveLibrary[Payment Processing](/categories/payments)

onaonbir/oo-subscription
========================

Backend-only subscription management package for Laravel. Supports polymorphic subscribables, immutable subscription rows, multi-language content, multi-currency pricing, feature usage tracking with overage billing, and a pluggable payment gateway contract.

1.0.0(3mo ago)033MITPHPPHP ^8.2CI passing

Since Mar 31Pushed 3mo agoCompare

[ Source](https://github.com/onaonbir/OO-Subscription)[ Packagist](https://packagist.org/packages/onaonbir/oo-subscription)[ Docs](https://onaonbir.com)[ RSS](/packages/onaonbir-oo-subscription/feed)WikiDiscussions main Synced 4w ago

READMEChangelogDependencies (4)Versions (2)Used By (0)

OO-Subscription
===============

[](#oo-subscription)

A backend-only subscription management package for Laravel. Built by [OnaOnbir](https://onaonbir.com).

Supports polymorphic subscribables, immutable subscription rows, multi-language content, multi-currency pricing, feature usage tracking with overage billing, and a pluggable payment gateway contract.

- **PHP** 8.2+
- **Laravel** 11 / 12
- **Primary Keys**: ULIDs on all models
- **Polymorphic**: Works with any model via `morphTo` (User, Team, etc.)
- **Immutable Rows**: Renewals and plan changes create new subscription rows
- **Multi-language**: JSON fields for slug, name, and description
- **Multi-currency**: JSON `prices` field on plans, per-currency overage pricing

---

Table of Contents
-----------------

[](#table-of-contents)

- [Installation](#installation)
- [Configuration](#configuration)
- [Models](#models)
- [Enums](#enums)
- [HasSubscriptions Trait](#hassubscriptions-trait)
- [Actions](#actions)
- [State Guards](#state-guards)
- [Events](#events)
- [Feature Types](#feature-types)
- [Plan Snapshots](#plan-snapshots)
- [Overage Pricing](#overage-pricing)
- [Direct Feature Assignments](#direct-feature-assignments)
- [Usage Cycle Reset](#usage-cycle-reset)
- [Payment Gateway Integration](#payment-gateway-integration)
- [Model Customization](#model-customization)
- [Table Customization](#table-customization)
- [Scheduled Commands](#scheduled-commands)
- [Authorization](#authorization)
- [Testing](#testing)
- [Quick Start](#quick-start)
- [License](#license)

---

Installation
------------

[](#installation)

```
composer require onaonbir/oo-subscription
```

Publish the configuration and migrations:

```
php artisan vendor:publish --tag=subscription-config
php artisan vendor:publish --tag=subscription-migrations
php artisan migrate
```

The service provider (`OnaOnbir\Subscription\SubscriptionServiceProvider`) is auto-discovered.

---

Configuration
-------------

[](#configuration)

The config file is published to `config/subscription.php`.

KeyDescriptionDefault`default_currency`Default currency code for pricing`'TRY'``grace_period_days`Default grace period after expiration`3``models`Override any of the 7 model classesBase model classes`tables`Override any of the 7 table namesDefault table names`gateway.driver`Active gateway driver name`null``gateway.handler`PaymentGateway implementation class`null`---

Models
------

[](#models)

The package provides 7 models. All use ULIDs as primary keys.

### Plan

[](#plan)

Represents a subscription plan with multi-language fields and multi-currency pricing.

FieldTypeDescription`slug`jsonLocalized slugs (`{"en": "pro", "tr": "pro"}`)`name`jsonLocalized names`description`jsonLocalized descriptions`prices`jsonCurrency-keyed prices in cents (`{"USD": 1490, "TRY": 14900}`)`billing_interval`enum`monthly`, `yearly`, or `lifetime``trial_days`intNumber of trial days (0 for none)`grace_period_days`intGrace period after expiration`sort_order`intDisplay order`is_active`boolWhether the plan is available`metadata`jsonArbitrary metadata**Relationships:** `features()` (BelongsToMany), `planFeatures()` (HasMany), `subscriptions()` (HasMany)

Uses `SoftDeletes`.

### Feature

[](#feature)

Represents a capability that can be attached to plans or assigned directly.

FieldTypeDescription`code`stringUnique identifier (e.g., `api-requests`)`slug`jsonLocalized slugs`name`jsonLocalized names`description`jsonLocalized descriptions`type`enum`boolean`, `quantity`, or `metered``resettable`boolWhether usage resets each billing cycle`metadata`jsonArbitrary metadataUses `SoftDeletes`.

### Subscription

[](#subscription)

An immutable record of a subscribable's subscription to a plan.

FieldTypeDescription`subscribable`morphPolymorphic owner (User, Team, etc.)`plan_id`foreign keyAssociated plan`plan_snapshot`jsonImmutable snapshot of plan at creation`gateway`stringPayment gateway identifier`gateway_subscription_id`stringExternal subscription ID`status`enum`active`, `trialing`, `past_due`, `canceled`, `expired``trial_ends_at`datetimeTrial end date`starts_at`datetimeSubscription start`ends_at`datetimeSubscription end / next renewal`cancels_at`datetimeScheduled cancellation date`canceled_at`datetimeActual cancellation timestamp`canceled_reason`stringReason for cancellation`grace_ends_at`datetimeGrace period end`metadata`jsonArbitrary metadata**Methods:** `isActive()`, `isTrialing()`, `isPastDue()`, `isCanceled()`, `isExpired()`, `isValid()`, `onTrial()`, `onGracePeriod()`, `hasCancelScheduled()`, `isLifetime()`, `resolveCurrency(?string $override): string`

### PlanFeature (Pivot)

[](#planfeature-pivot)

Pivot model connecting plans to features with plan-specific values.

FieldTypeDescription`value`stringLimit value (e.g., `'1000'`, `'true'`, `null` for unlimited)`overage_prices`jsonPer-currency overage rates`metadata`jsonArbitrary metadata### SubscribableFeature

[](#subscribablefeature)

Direct feature assignment to a subscribable, independent of any plan.

FieldTypeDescription`subscribable`morphPolymorphic owner`feature_id`foreign keyFeature`value`stringLimit value`overage_prices`jsonPer-currency overage rates`valid_from`datetimeStart of validity`valid_until`datetimeEnd of validity (null = indefinite)**Scopes:** `scopeCurrentlyValid()` -- filters to features within their validity window.

### FeatureUsage

[](#featureusage)

Tracks current usage of a feature within a billing cycle.

### UsageRecord

[](#usagerecord)

Immutable audit trail of individual usage events.

---

Enums
-----

[](#enums)

### BillingInterval

[](#billinginterval)

`Monthly`, `Yearly`, `Lifetime`

**Methods:** `addToDate(Carbon $date): ?Carbon` -- calculates the next period end date.

### SubscriptionStatus

[](#subscriptionstatus)

`Active`, `Trialing`, `PastDue`, `Canceled`, `Expired`

**Methods:** `activeStatuses(): array` -- returns `[Active, Trialing, PastDue]`.

### FeatureType

[](#featuretype)

`Boolean`, `Quantity`, `Metered`

---

HasSubscriptions Trait
----------------------

[](#hassubscriptions-trait)

Add to any Eloquent model to make it subscribable:

```
use OnaOnbir\Subscription\Concerns\HasSubscriptions;

class User extends Authenticatable
{
    use HasSubscriptions;
}
```

### Subscription Methods

[](#subscription-methods)

MethodReturnsDescription`subscribe(Plan, ?currency, ?gateway, ?gatewayId)``Subscription`Create a new subscription`subscriptions()``MorphMany`All subscriptions`activeSubscriptions()``Collection`Active + Trialing + PastDue (cached per request)`clearSubscriptionCache()``void`Clear the cached active subscriptions`subscription(?Plan)``?Subscription`Latest active subscription`subscribed()``bool`Has any active subscription`subscribedTo(Plan)``bool`Subscribed to a specific plan`onTrial()``bool`Currently on a trial`onGracePeriod()``bool`Currently in a grace period`subscriptionHistory()``Collection`All subscriptions ordered by date### Feature Methods

[](#feature-methods)

MethodReturnsDescription`hasFeature(string $code)``bool`Has feature via plan or direct assignment`canUseFeature(string $code)``bool`Has feature and has remaining quota`remainingUsage(string $code)``?int`Remaining usage (null = unlimited)`recordUsage(string $code, int $amount, ?array $metadata)``FeatureUsage`Record feature usage`subscribableFeatures()``MorphMany`Direct feature assignments`featureUsages()``MorphMany`All feature usage records---

Actions
-------

[](#actions)

All actions are resolved from the container and follow an immutable pattern.

### CreateSubscription

[](#createsubscription)

```
use OnaOnbir\Subscription\Actions\CreateSubscription;

app(CreateSubscription::class)->handle($user, $plan, 'USD', 'stripe', 'sub_xxx');
```

- Creates a subscription with an immutable plan snapshot.
- Sets status to `Trialing` if `plan.trial_days > 0`, otherwise `Active`.
- **Guard:** Throws `DuplicateSubscriptionException` if the subscribable already has an active subscription for the same plan.
- Dispatches `SubscriptionCreated` and `SubscriptionActivated` (if no trial).

### CancelSubscription

[](#cancelsubscription)

```
use OnaOnbir\Subscription\Actions\CancelSubscription;

// Immediate
app(CancelSubscription::class)->handle($subscription, immediately: true, reason: 'user_request');

// Schedule at period end
app(CancelSubscription::class)->handle($subscription, immediately: false);

// Resume
app(CancelSubscription::class)->resume($subscription);
```

- **Guard:** Throws `InvalidSubscriptionStateException` if subscription is already canceled or expired.
- Dispatches `SubscriptionCanceled`.

### RenewSubscription

[](#renewsubscription)

```
use OnaOnbir\Subscription\Actions\RenewSubscription;

$newSubscription = app(RenewSubscription::class)->handle($subscription, 'USD');
```

- Creates a **new** subscription row (immutable pattern).
- **Guard:** Throws `InvalidSubscriptionStateException` if subscription is not valid (canceled/expired).
- Dispatches `SubscriptionExpired` and `SubscriptionRenewed`.

### ChangePlan

[](#changeplan)

```
use OnaOnbir\Subscription\Actions\ChangePlan;

$newSubscription = app(ChangePlan::class)->handle($subscription, $newPlan, 'USD');
```

- **Guard:** Throws `InvalidSubscriptionStateException` if subscription is not valid.
- Dispatches `SubscriptionCanceled` and `PlanChanged`.

### RecordFeatureUsage

[](#recordfeatureusage)

```
use OnaOnbir\Subscription\Actions\RecordFeatureUsage;

$usage = app(RecordFeatureUsage::class)->handle($user, 'api-requests', 5, ['endpoint' => '/api/users']);

// Or via the trait:
$user->recordUsage('api-requests', 5);
```

- **Validation:** Amount must be &gt;= 1, feature must exist, feature must not be boolean type.
- **Atomicity:** Uses `DB::transaction()` with `lockForUpdate()` to prevent race conditions.
- **With overage pricing**: allows exceeding the limit.
- **Without overage pricing**: throws `FeatureLimitExceededException`.
- Dispatches `UsageRecorded`, `FeatureLimitReached`, and `BillingCycleCompleted` when applicable.

---

State Guards
------------

[](#state-guards)

Actions validate subscription state before executing. Invalid operations throw typed exceptions:

ActionAllowed StatesException`CreateSubscription`(new)`DuplicateSubscriptionException` if same plan already active`CancelSubscription`Active, Trialing, PastDue`InvalidSubscriptionStateException``RenewSubscription`Active, Trialing, PastDue`InvalidSubscriptionStateException``ChangePlan`Active, Trialing, PastDue`InvalidSubscriptionStateException``RecordFeatureUsage`(any)`InvalidArgumentException` for bad input, `FeatureLimitExceededException` for limits---

Events
------

[](#events)

EventDispatched When`SubscriptionCreated`New subscription created`SubscriptionActivated`Subscription becomes active`SubscriptionCanceled`Subscription canceled`SubscriptionExpired`Subscription expired`SubscriptionRenewed`Subscription renewed`PlanChanged`Plan changed`FeatureLimitReached`Usage limit reached`UsageRecorded`Usage recorded`BillingCycleCompleted`Billing cycle reset---

Feature Types
-------------

[](#feature-types)

### Boolean

[](#boolean)

The subscribable either has the feature or does not. No usage tracking.

- **Value**: `'true'` or `'false'`
- **Example**: "Priority Support", "Code Editor"

### Quantity

[](#quantity)

A limited number of items per billing cycle.

- **Value**: Numeric string (e.g., `'100'`). `null` = unlimited.
- **Example**: "10 S3 Connections", "500 Monthly Transfers"

### Metered

[](#metered)

Pay-as-you-go with an optional included amount.

- **Value**: Numeric string (included amount). `null` = unlimited included.
- **Overage**: When `overage_prices` is set, usage beyond the included amount is billed per unit.

### Modeling Protocols (Boolean + Quantity Pattern)

[](#modeling-protocols-boolean--quantity-pattern)

For capabilities with both an on/off toggle and a numeric limit, use two features:

```
// Boolean: is the protocol enabled?
Feature::create(['code' => 'protocol-sftp', 'type' => FeatureType::Boolean]);

// Quantity: how many connections?
Feature::create(['code' => 'protocol-sftp-limit', 'type' => FeatureType::Quantity]);
```

Then in your plan:

```
// Free plan: SFTP disabled
$freePlan->features()->attach([
    $sftp->id => ['value' => 'false'],
    $sftpLimit->id => ['value' => '0'],
]);

// Pro plan: SFTP enabled, 10 connections
$proPlan->features()->attach([
    $sftp->id => ['value' => 'true'],
    $sftpLimit->id => ['value' => '10'],
]);

// Unlimited plan: SFTP enabled, unlimited connections
$unlimitedPlan->features()->attach([
    $sftp->id => ['value' => 'true'],
    $sftpLimit->id => ['value' => null],  // null = unlimited
]);
```

Check in your application:

```
$user->hasFeature('protocol-sftp');           // true or false
$user->remainingUsage('protocol-sftp-limit'); // 10, 0, or null (unlimited)
$user->canUseFeature('protocol-sftp-limit');  // true or false
```

---

Plan Snapshots
--------------

[](#plan-snapshots)

Each subscription stores an immutable snapshot of the plan at creation time. Plan changes never retroactively affect existing subscriptions.

```
{
    "plan": {
        "id": "01abc...",
        "slug": {"en": "pro"},
        "name": {"en": "Pro"},
        "billing_interval": "monthly"
    },
    "price": {"amount": 1490, "currency": "USD"},
    "features": [
        {
            "code": "api-requests",
            "type": "quantity",
            "value": "10000",
            "resettable": true,
            "overage_prices": {"TRY": 10, "USD": 1}
        }
    ],
    "captured_at": "2026-03-05T12:00:00+00:00"
}
```

---

Overage Pricing
---------------

[](#overage-pricing)

```
$plan->features()->attach($feature->id, [
    'value' => '1000',
    'overage_prices' => ['TRY' => 10, 'USD' => 1],
]);
```

- **With overage pricing**: Usage beyond the limit is allowed.
- **Without overage pricing**: `FeatureLimitExceededException` is thrown.
- Plan limits and direct feature limits are **additive**.

---

Direct Feature Assignments
--------------------------

[](#direct-feature-assignments)

Assign features directly to a subscribable without requiring a plan:

```
$user->subscribableFeatures()->create([
    'feature_id' => $feature->id,
    'value' => '50',
    'overage_prices' => ['TRY' => 5, 'USD' => 1],
    'valid_from' => now(),
    'valid_until' => now()->addMonth(),
]);
```

Direct feature limits are **additive** with plan limits.

---

Usage Cycle Reset
-----------------

[](#usage-cycle-reset)

For resettable features, the `used` counter resets to 0 when the billing period expires. A `BillingCycleCompleted` event is dispatched on reset.

---

Payment Gateway Integration
---------------------------

[](#payment-gateway-integration)

This package manages subscription state -- it does **not** process payments. Implement the `PaymentGateway` contract in your application:

```
use OnaOnbir\Subscription\Contracts\PaymentGateway;

class StripeGateway implements PaymentGateway
{
    public function create(Subscription $subscription): array { /* ... */ }
    public function cancel(Subscription $subscription): bool { /* ... */ }
    public function renew(Subscription $subscription): array { /* ... */ }
    public function changePlan(Subscription $subscription, array $newPlanData): array { /* ... */ }
}
```

Register in `config/subscription.php`:

```
'gateway' => [
    'driver' => 'stripe',
    'handler' => App\Gateways\StripeGateway::class,
],
```

Webhook handling is **your responsibility**. Create your own webhook controller and call the package's Actions directly.

---

Model Customization
-------------------

[](#model-customization)

Swap any model by extending the base class and updating the config:

```
use OnaOnbir\Subscription\Models\Plan as BasePlan;

class CustomPlan extends BasePlan
{
    // Add custom methods, scopes, relationships...
}
```

```
// config/subscription.php
'models' => ['plan' => App\Models\CustomPlan::class],
```

---

Table Customization
-------------------

[](#table-customization)

Override table names in the config:

```
'tables' => ['plans' => 'sub_plans', 'subscriptions' => 'sub_subscriptions'],
```

---

Scheduled Commands
------------------

[](#scheduled-commands)

### `subscription:process`

[](#subscriptionprocess)

Processes all pending lifecycle transitions:

```
// routes/console.php
Schedule::command('subscription:process')->everyFiveMinutes();
```

OperationResultEventExpire activestatus -&gt; `expired``SubscriptionExpired`Activate trialsstatus -&gt; `active``SubscriptionActivated`Expire gracestatus -&gt; `expired``SubscriptionExpired`Execute cancelsstatus -&gt; `canceled``SubscriptionCanceled`Reset usage`used = 0``BillingCycleCompleted`Supports `--expired`, `--trials`, `--grace`, `--cancellations`, `--usage-reset`, and `--dry-run` flags.

### `subscription:status`

[](#subscriptionstatus-1)

Displays a status report with subscription counts and pending warnings.

---

Authorization
-------------

[](#authorization)

This package does **not** include authorization policies. You are responsible for implementing gates, policies, or middleware to control who can subscribe, cancel, change plans, etc.

---

Testing
-------

[](#testing)

```
php artisan test --compact --testsuite=Subscription
```

---

Quick Start
-----------

[](#quick-start)

```
use OnaOnbir\Subscription\Models\Plan;
use OnaOnbir\Subscription\Models\Feature;
use OnaOnbir\Subscription\Enums\BillingInterval;
use OnaOnbir\Subscription\Enums\FeatureType;

// 1. Create a plan
$plan = Plan::create([
    'slug' => ['en' => 'pro'],
    'name' => ['en' => 'Pro'],
    'prices' => ['USD' => 1490, 'TRY' => 14900],
    'billing_interval' => BillingInterval::Monthly,
    'trial_days' => 14,
    'is_active' => true,
]);

// 2. Create features
$apiRequests = Feature::create([
    'code' => 'api-requests',
    'slug' => ['en' => 'api-requests'],
    'name' => ['en' => 'API Requests'],
    'type' => FeatureType::Quantity,
    'resettable' => true,
]);

// 3. Attach feature with limit and overage pricing
$plan->features()->attach($apiRequests->id, [
    'value' => '10000',
    'overage_prices' => ['USD' => 1],
]);

// 4. Subscribe a user
$subscription = $user->subscribe($plan, 'USD');

// 5. Check features
$user->hasFeature('api-requests');     // true
$user->canUseFeature('api-requests');  // true
$user->remainingUsage('api-requests'); // 10000

// 6. Record usage
$user->recordUsage('api-requests', 100);
$user->remainingUsage('api-requests'); // 9900

// 7. Cancel
app(CancelSubscription::class)->handle($subscription, immediately: false);

// 8. Renew
$newSubscription = app(RenewSubscription::class)->handle($subscription);

// 9. Change plan
$newSubscription = app(ChangePlan::class)->handle($subscription, $enterprisePlan, 'USD');
```

---

License
-------

[](#license)

MIT - See [LICENSE](LICENSE) for details.

Built with care by [OnaOnbir](https://onaonbir.com).

###  Health Score

39

—

LowBetter than 85% of packages

Maintenance82

Actively maintained with recent releases

Popularity11

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity46

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

91d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/2fb05958be043fd78fa4bd2047f2eb39e79d70aa581b88f78b5485115779add9?d=identicon)[onaonbir](/maintainers/onaonbir)

---

Top Contributors

[![bariskanberkay](https://avatars.githubusercontent.com/u/48731845?v=4)](https://github.com/bariskanberkay "bariskanberkay (3 commits)")

---

Tags

laravelbillingsubscriptionsaasfeature-flagsmulti-currencyusage-trackingoverage-billing

###  Code Quality

TestsPest

### Embed Badge

![Health badge](/badges/onaonbir-oo-subscription/health.svg)

```
[![Health](https://phpackages.com/badges/onaonbir-oo-subscription/health.svg)](https://phpackages.com/packages/onaonbir-oo-subscription)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3345.1M337](/packages/psalm-plugin-laravel)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
