glueful/subscriptions
最新稳定版本:v1.0.0
Composer 安装命令:
composer require glueful/subscriptions
包简介
Tenant subscriptions, plans, entitlements, overrides, trials, and lifecycle sync for Glueful SaaS apps.
README 文档
README
Tenant subscriptions, plans, stateless entitlements with numeric limits, trials, overrides, and lifecycle sync for Glueful SaaS apps.
Subscriptions is a config-driven subscription lifecycle and entitlement resolution layer. A tenant's effective entitlement map is resolved from a plan catalog (config) plus per-tenant overrides (DB), 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 for v1 and on the roadmap (v1.1+).
Install
composer require glueful/subscriptions php glueful extensions:enable subscriptions php glueful migrate:run
Requires glueful/framework ^1.54.0 (the release that ships the
Glueful\Entitlements seam and the container-precedence fix).
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).reconcileis 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_entitlementmiddleware, the rate-tier bridge) degrade: the middleware fails closed with 403 by default (opt out viasubscriptions.permissive_middleware), the tier bridge delegates to the framework's default resolver.
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
The effective plan is derived from the subscription status before the entitlement map is built:
| Subscription state | Effective plan |
|---|---|
| none | default_plan |
active |
the subscription's |
trialing |
the trialed plan's |
past_due, grace_ends_at in the future |
the 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
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->middleware bridge for extension attributes (plan blocker B1), so the attribute form is deferred. Use therequire_entitlement:<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 (config/subscriptions.php)
return [ 'default_plan' => 'free', 'plans' => [ 'free' => [ 'entitlements' => [ 'reports.export' => false, 'projects.limit' => 3, 'team.limit' => 1, ], ], 'pro' => [ 'payvia_priced_plan' => null, // optional payvia billing-plan uuid '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
use Glueful\Extensions\Subscriptions\SubscriptionService; $service = app($context, SubscriptionService::class); // Free/comp/trial -- no payvia object needed, all payvia_* 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 payvia is installed
Every transition appends a subscription_events row (created,
plan_changed, canceled, reconciled, or provider event types) with
from_status / to_status / source (manual, payvia_event,
reconcile).
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)
Subscriptions consumes payvia; payvia stays tenancy-agnostic:
- Priced plans: a catalog plan may point at a payvia billing plan via
payvia_priced_plan. - Provider events: when
Glueful\Extensions\Payvia\Events\PaymentProviderEventexists, a listener self-registers inboot()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:reconcilepulls authoritative provider state through payvia'sGatewaySubscriptionService::reconcile($gateway, $gatewaySubscriptionId)and applies drift, recording areconciledevent.
Provider-event projection maps:
| Provider event | Projection |
|---|---|
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 -> active, clear grace |
invoice.paid |
same settle path |
Idempotency is claim-first: the subscription_events insert (unique per
(payvia_gateway, payvia_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.
CLI
php glueful subscriptions:show --tenant=<uuid> php glueful subscriptions:set-plan --tenant=<uuid> --plan=pro php glueful subscriptions:reconcile [--tenant=<uuid>]
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
| Installed | Checker | Middleware | Tier bridge | Lifecycle | Provider events | Reconcile |
|---|---|---|---|---|---|---|
| neither | works (explicit uuid) | 403 unless permissive | delegates to default | works | none registered | no-op |
| tenancy only | works | full (current tenant resolves) | active | works | none registered | no-op |
| payvia only | works (explicit uuid) | 403 unless permissive | delegates to default | works | projected | pulls provider |
| tenancy + payvia | works | full | active | works | projected | pulls provider |
"Works" for the checker always means: catalog + overrides + status gating; no payment object is ever consulted at check time.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 2
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-06-10