PHPackages                             glueful/subscriptions - 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. glueful/subscriptions

ActiveGlueful-extension[Payment Processing](/categories/payments)

glueful/subscriptions
=====================

Tenant subscriptions, plans, entitlements, overrides, trials, and lifecycle sync for Glueful SaaS apps.

v1.3.1(1mo ago)05MITPHPPHP ^8.3

Since Jun 10Pushed 1mo agoCompare

[ Source](https://github.com/glueful/subscriptions)[ Packagist](https://packagist.org/packages/glueful/subscriptions)[ Docs](https://github.com/glueful/subscriptions)[ RSS](/packages/glueful-subscriptions/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (6)Dependencies (8)Versions (8)Used By (0)

Glueful Subscriptions
=====================

[](#glueful-subscriptions)

Tenant subscriptions, plans, and stateless entitlements with numeric limits, trials, overrides, and lifecycle sync for Glueful SaaS apps.

Subscriptions is a subscription lifecycle and entitlement resolution layer. A tenant's effective entitlement map is resolved from a plan catalog plus per-tenant overrides, gated by the subscription's status -- never from a live payment object. Entitlement checks are stateless reads (allow/deny plus an optional numeric limit); usage metering and quota *consumption* are out of scope and on the roadmap.

Install
-------

[](#install)

```
composer require glueful/subscriptions
php glueful extensions:enable subscriptions
php glueful migrate:run
```

Requires `glueful/framework ^1.55.0`. (The `Glueful\Entitlements` seam and the container-precedence fix this extension relies on shipped in 1.54.0; 1.55.0 is required as the security-hardened baseline.)

The decoupling invariant
------------------------

[](#the-decoupling-invariant)

This package works fully with **no `glueful/payvia` and no `glueful/tenancy`installed**. Both are soft dependencies, probed at runtime via `class_exists`:

- **No payvia:** free / trial / comp subscriptions work end to end through `SubscriptionService` (start, change plan, cancel). `reconcile` is a safe no-op. No provider listener is registered.
- **No tenancy:** the entitlement checker still works anywhere you can supply a tenant uuid explicitly (jobs, CLI, webhooks). Only the conveniences that need a *current* tenant (the `require_entitlement` middleware, the rate-tier bridge) degrade: the middleware fails closed with 403 by default (opt out via `subscriptions.permissive_middleware`), the tier bridge delegates to the framework's default resolver.

Checking entitlements
---------------------

[](#checking-entitlements)

This package binds a DB+config-backed `DefaultEntitlementChecker` over the framework-core contract `Glueful\Entitlements\Contracts\EntitlementCheckerInterface`(overriding core's allow-all `NullEntitlementChecker` default):

```
use Glueful\Entitlements\Contracts\EntitlementCheckerInterface;

$checker = app($context, EntitlementCheckerInterface::class);

if ($checker->allows($tenantUuid, 'reports.export')) {
    // gated feature
}

$limit = $checker->limit($tenantUuid, 'projects.limit'); // ?int -- null = unlimited
```

Entitlement values come from the plan catalog merged with active per-tenant overrides. Overrides win per key; expired overrides are ignored.

Configured value`allows()``limit()`key absent (typo too)`false``0``false``false``0``true``true``null` (unlimited)`null` (explicit)`true``null` (unlimited)int `n > 0``true``n`int `0``false``0`Absent-key-denies is deliberate: a typo in an entitlement name fails closed instead of silently allowing.

### Status gating

[](#status-gating)

The effective plan is derived from the subscription status before the entitlement map is built:

Subscription stateEffective plannone`default_plan``active`the subscription's`trialing`the trialed plan's`past_due`, `grace_ends_at` in the futurethe subscription's`past_due`, grace passed or absent`default_plan``incomplete``default_plan``paused``default_plan``canceled``default_plan``paused` is accepted from payvia's provider-status vocabulary (via `subscription.updated` projection or reconcile drift) and resolves to the default plan: a paused tenant is treated as not entitled to paid features until the provider resumes the subscription.

Route middleware
----------------

[](#route-middleware)

The supported API is the middleware-string form:

```
$router->get('/reports/export', [ReportController::class, 'export'])
    ->middleware(['require_entitlement:reports.export']);
```

> NOTE: a `#[RequireEntitlement]` route **attribute is NOT shipped** in v1. The framework's attribute routing offers no generic attribute-&gt;middleware bridge for extension attributes (plan blocker B1), so the attribute form is deferred. Use the `require_entitlement:` middleware string.

The gate fails closed: no resolvable tenant means 403 unless `subscriptions.permissive_middleware` is `true`. A denied entitlement returns 403 with an `entitlement` error code so clients can prompt an upgrade.

Plan catalog
------------

[](#plan-catalog)

The catalog has two sources:

- Managed DB plans in `subscription_plans`.
- Config plans in `config/subscriptions.php` as seed/fallback.

Resolution prefers DB rows with status `active` or `archived`. If a DB row is `draft`, it does not resolve and config is used when a config plan with the same key exists. If neither source resolves, the entitlement map is empty and every key denies.

An empty `subscription_plans` table is safe: config plans keep working. If a DB is wiped, tenants on config-backed keys continue resolving from config; DB-only plan keys resolve to an empty map until restored. If migration 004 has not run yet, catalog reads catch the missing table and behave as config-only.

Plan assignment is stricter than plan resolution:

Plan source/statusResolves for existing tenantsAssignable to new tenantsDB `active`yesyesDB `archived`yesnoDB `draft`nonoconfig onlyyesyesArchived is never delete: tenants already on an archived plan keep resolving it. Draft is pre-publish only; active and archived plans cannot transition back to draft. An empty entitlement map `{}` is valid and means "deny every entitlement key."

### Config seed (config/subscriptions.php)

[](#config-seed-configsubscriptionsphp)

```
return [
    'default_plan' => 'free',
    'plans' => [
        'free' => [
            'entitlements' => [
                'reports.export' => false,
                'projects.limit' => 3,
                'team.limit'     => 1,
            ],
        ],
        'pro' => [
            'provider_price_id' => null, // optional provider price/plan id
            'entitlements' => [
                'reports.export' => true,
                'projects.limit' => 50,
                'team.limit'     => 20,
                'api.monthly'    => 100000,
            ],
        ],
    ],
    'rate_tiers' => ['enterprise', 'pro'], // highest-first (rate-limit bridge)
    'grace_days' => 3,                     // dunning grace before downgrade
    'cache' => ['enabled' => true, 'ttl' => 300],
    'permissive_middleware' => false,
    'reconcile' => ['schedule_enabled' => false],
];
```

A lapsed tenant (canceled / incomplete / past\_due beyond grace) downgrades to `default_plan` -- it is never locked out; paid entitlements simply fall away. Per-tenant overrides (the `subscription_overrides` table) win per key and may carry an expiry.

Lifecycle via SubscriptionService
---------------------------------

[](#lifecycle-via-subscriptionservice)

```
use Glueful\Extensions\Subscriptions\SubscriptionService;

$service = app($context, SubscriptionService::class);

// Free/comp/trial -- no provider object needed, all provider_* columns stay NULL.
$service->start($tenantUuid, 'free');
$service->start($tenantUuid, 'pro', [
    'status' => 'trialing',
    'trial_ends_at' => '2026-07-01 00:00:00',
]);

$service->current($tenantUuid);          // ?array (the subscriptions row)
$service->changePlan($tenantUuid, 'pro');
$service->cancel($tenantUuid);                       // at period end (metadata flag)
$service->cancel($tenantUuid, atPeriodEnd: false);   // immediate: status=canceled
$service->reconcile($tenantUuid);        // pull provider truth, when a provider puller is bound
```

Every transition appends a `subscription_events` row (`created`, `plan_changed`, `canceled`, `reconciled`, or provider event types) with `from_status` / `to_status` / `source` (`manual`, `provider_event`, `reconcile`).

Rate-limit tier bridge
----------------------

[](#rate-limit-tier-bridge)

`EntitlementTierResolver` implements the framework's `TierResolverInterface`over the default resolver: plans grant boolean `rate.tier.{tier}` entitlement flags for the tiers listed in `subscriptions.rate_tiers` (highest-first); the first granted tier wins, and `TierManager` config owns the numbers. No tenant or no granted flag delegates to the default resolver -- the bridge is inert without tenancy.

Consumes Payvia (when installed)
--------------------------------

[](#consumes-payvia-when-installed)

Payvia is the **first-party default** and needs zero wiring. It is just one provider, though: any payment package (or your own app) can drive subscription state through the same generic seam — see [docs/BRING\_YOUR\_OWN\_PROVIDER.md](docs/BRING_YOUR_OWN_PROVIDER.md). Subscriptions *consumes* payvia; payvia stays tenancy-agnostic:

- **Priced plans:** a catalog plan may point at a provider price/plan via `provider_price_id`.
- **Provider events:** when `Glueful\Extensions\Payvia\Events\PaymentProviderEvent`exists, a thin bridge self-registers as a listener in `boot()` and projects normalized provider events (`subscription.created/updated/past_due/canceled`, `payment.succeeded`, `invoice.paid`) onto subscription status -- claim-first in one transaction with per-gateway logical-event-key dedupe DB-enforced by a unique index, so a duplicate or concurrent delivery never re-projects (grace is never extended twice).
- **Reconcile:** `subscriptions:reconcile` pulls authoritative provider state through payvia's `GatewaySubscriptionService::reconcile($gateway, $gatewaySubscriptionId)`and applies drift, recording a `reconciled` event.

Provider-event projection maps:

Provider eventProjection`subscription.created`link provider sub, status `active`/`trialing``subscription.updated`status/period drift; settling to active clears grace`subscription.past_due`status `past_due`, `grace_ends_at = now + grace_days``subscription.canceled`status `canceled`, `canceled_at``payment.succeeded`if `trialing`/`past_due` -&gt; `active`, clear grace`invoice.paid`same settle pathIdempotency is claim-first: the `subscription_events` insert (unique per `(provider_gateway, provider_logical_event_key)`) and the projection run in one transaction, so a duplicate or concurrent delivery rolls back and never re-projects -- grace can never be extended twice. The tenant mapping is `(gateway, gateway_subscription_id)`; on `subscription.created` an unlinked row can be recovered via provider metadata `tenant_uuid`.

Managed plan API
----------------

[](#managed-plan-api)

Plan management routes are permission gated with `auth` plus `subscriptions_plans_manage`, which calls `PermissionManager::can()` directly for `subscriptions.plans.manage` on `subscriptions.plans` and fails closed.

```
GET    /subscriptions/plans
POST   /subscriptions/plans
POST   /subscriptions/plans/import-config
GET    /subscriptions/plans/{key}
PATCH  /subscriptions/plans/{key}
POST   /subscriptions/plans/{key}/archive

```

`{key}` accepts lowercase letters, numbers, dot, underscore, and hyphen. The reserved key `import-config` is rejected for plans so the collection import route cannot collide with a plan key.

CLI
---

[](#cli)

```
php glueful subscriptions:show --tenant=
php glueful subscriptions:set-plan --tenant= --plan=pro
php glueful subscriptions:reconcile [--tenant=]
php glueful subscriptions:plans:create --key=pro --name="Pro" --entitlements='{"reports.export":true}'
php glueful subscriptions:plans:update --key=pro --status=archived
php glueful subscriptions:plans:archive --key=pro
php glueful subscriptions:plans:import-config [--force]
php glueful subscriptions:plans:list
```

`subscriptions:plans:import-config` seeds the DB catalog from `config/subscriptions.php`. Without `--force`, existing DB rows are left alone. With `--force`, config entitlements and provider price-id links overwrite the existing DB row.

Reconcile pulls the authoritative provider state through payvia's `GatewaySubscriptionService::reconcile($gateway, $gatewaySubscriptionId)` and applies status/period drift, appending a `reconciled` event (source `reconcile`, NULL logical key). No drift means no write and no event.

Reconcile grants the **same dunning grace as the provider-event path**: drifting into `past_due` sets `grace_ends_at = now + grace_days`, so a tenant discovered late (for example, a missed webhook) is never downgraded instantly. An already-`past_due` subscription never has its grace re-extended, and settling back to `active` clears any grace.

Scheduling is **opt-in and default-off**: `subscriptions.reconcile.schedule_enabled` is `false`. When you enable it, wire `subscriptions:reconcile` into your scheduler (cron or the framework scheduler) at the cadence you want; the package does not self-schedule.

Soft-dependency behavior matrix
-------------------------------

[](#soft-dependency-behavior-matrix)

InstalledCheckerMiddlewareTier bridgeLifecycleProvider eventsReconcileneitherworks (explicit uuid)403 unless permissivedelegates to defaultworksnone registeredno-optenancy onlyworksfull (current tenant resolves)activeworksnone registeredno-oppayvia onlyworks (explicit uuid)403 unless permissivedelegates to defaultworksprojectedpulls providertenancy + payviaworksfullactiveworksprojectedpulls provider"Works" for the checker always means: catalog + overrides + status gating; no payment object is ever consulted at check time.

###  Health Score

41

—

FairBetter than 87% of packages

Maintenance92

Actively maintained with recent releases

Popularity4

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity53

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

Every ~1 days

Total

6

Last Release

40d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/95650726?v=4)[msowah](/maintainers/msowah)[@msowah](https://github.com/msowah)

---

Top Contributors

[![MichaelSowah](https://avatars.githubusercontent.com/u/24699905?v=4)](https://github.com/MichaelSowah "MichaelSowah (74 commits)")

---

Tags

billingsubscriptionssaasplansentitlementsglueful

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP\_CodeSniffer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/glueful-subscriptions/health.svg)

```
[![Health](https://phpackages.com/badges/glueful-subscriptions/health.svg)](https://phpackages.com/packages/glueful-subscriptions)
```

###  Alternatives

[mollie/laravel-cashier-mollie

Laravel Cashier provides an expressive, fluent interface to Mollie's subscription billing services.

178204.3k1](/packages/mollie-laravel-cashier-mollie)[revoltify/subscriptionify

Feature-based subscription management for Laravel

1572.3k](/packages/revoltify-subscriptionify)[ptuchik/billing

Billing package for Laravel 5.5 supporting packages, plans, coupons, addons, payments and subscriptions

3021.2k](/packages/ptuchik-billing)[chargebee/cashier

Laravel Cashier for Chargebee provides an expressive, fluent interface to Chargebee's subscription billing services.

649.9k1](/packages/chargebee-cashier)[enupal/stripe

Allows customers sign up for recurring and one-time payments with Stripe, perfect for orders, donations, subscriptions, and events. Create simple payment forms in seconds easily without coding. For Craft CMS 3.x

3416.6k1](/packages/enupal-stripe)

PHPackages © 2026

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