Releases: glueful/subscriptions
Release list
v2.3.0
[2.3.0] - 2026-08-17
Added
- Declares the Glueful schema manifest (migration descriptors, requires.extensions, structural
verifier); requires framework >=1.79.0 for schema-on-enable participation. Migrations are now
registered by the manifest, not by provider boot.
v2.2.0 — Checkout reservation and purchasability
Host-integration seams for self-serve provider checkout, building on 2.1's administrative seams. SubscriptionService::reserveCheckoutFor() creates idempotent, non-entitling, origination-bound reservations before provider I/O, with guarded replacement and exact-origination release. A new non_renewing status projects stop-renewal cancellations while preserving entitlements exactly until the paid period's end, with a never-resurrect guard for terminally canceled rows. The projector gains an additive outcome-returning entry point with deterministic rejection codes (including origination_mismatch), and the strict Payvia bridge writes durable accepted/rejected acknowledgements to payvia ^2.5's checkout-origination ledger after receipt commit. Plans gain per-gateway provider_identifiers (migration 008) — the sole checkout-purchasability authority, read through the typed PlanPurchasability::forGateway() projection; the legacy scalar provider_price_id remains correlation-compatibility only, and existing plans require explicit operator configuration to become purchasable. On PATCH, provider_identifiers is a full-map replacement. All additive; existing 2.0/2.1 callers unchanged.
v2.1.0 - Host integration seams
Four additive seams for host platforms embedding the subscriptions engine. SubscriptionService::currentForTenants() gives platform-authority callers a bounded, single-query bulk projection of tenant subscriptions. OverrideRepository::listForSubject() exposes full override rows including expiry and reason metadata for administrative UIs. SubscriptionSubjectDataPurger::countSubjectRows() provides non-mutating per-table counts that share the purge predicates exactly, for purge prepare/verify flows. SubscriptionSchemaReadiness::isReady() is the extension-owned schema readiness authority — a never-fatal probe of the complete 2.x shape for host degraded modes. No behavior changes for existing callers. Also amends the v2 spec's resolver wording: binding a host resolver enables memberships only when it positively resolves and validates user subjects.
v2.0.0 - Subject model: workspace subscriptions + user memberships
Subscriptions 2.0 generalizes the workspace-only 1.x lifecycle engine into two coexisting, never-crossing products on one payment/lifecycle engine: workspace subscriptions (tenant billing, unchanged for existing callers) and user memberships (a user's paid relationship to a single workspace — paywalls, member benefits, recurring content access).
⚠️ Upgrading from 1.x — the bridge is mandatory
A populated install cannot migrate directly from 1.3.1. Migration 006 refuses to run without the 1.4.0 preparation marker:
- Put subscription writes into a maintenance window.
- Install 1.4.0 and run
php glueful subscriptions:prepare-v2(idempotent). - Install 2.0.0 and run
php glueful migrate:run.
Fresh installs: subscriptions:plans:import-config is now a required install step — with an empty subscription_plans table every entitlement resolves empty (a loud default_plan_unresolvable diagnostic tells you exactly this).
Breaking changes
- Subject-model schema (migration
006). Every domain row carries the(tenant_uuid, subject_type, subject_uuid)triple;plan_uuidbecomes a NOT NULL reference; uniqueness is now one subscription per subject. A newsubscription_provider_event_receiptstable is the first claim authority for inbound provider events. - Direct
subscription_overridesinserts must supplysubject_type/subject_uuid— a 1.x-shaped insert silently matches nothing (a hand-written deny override would stop denying). Use the new supported writer:OverrideRepository::upsertForSubject()/deleteForSubject(). - DB-authoritative catalog. Config plans are seeds only — no runtime overlay; new config plans require
subscriptions:plans:import-config. Plan keys are immutable and scoped by(audience, owner_tenant_uuid); subscriptions reference immutableplan_uuids. - Projector rejection semantics. Deterministic rejections (
missing_subject,invalid_subject,plan_scope_mismatch,subject_mismatch) commit an auditable receipt;UnmappedProviderSubscriptionExceptionis retryable — webhook endpoints MUST let it surface as a retry-inducing response. Provider payloads are sanitized (closed allowlist) in both receipts and events, including historical rows during006. - Stricter read-path validation.
current('')and the…For()methods throwinvalid subjecton empty/incoherent identities where 1.x returnednull.
New
- Subject-aware core (
currentFor/startFor/changePlanFor/cancelFor/reconcileFor(Subject …)) with the full 1.x tenant API preserved as a supported facade — existing integrations keep working. - Memberships are enabled by binding a
SubjectResolverInterface— no new config keys. IncludesMemberEntitlementResolver+require_member_entitlementmiddleware (fail-closed; rate tiers remain tenant-only). - Owner-fenced, receipts-first provider projection with deterministic race handling (same-plan idempotent, different-plan conflict) proven against real PostgreSQL savepoints (env-gated
PostgresSavepointTest,SUBSCRIPTIONS_TEST_PG_*). - Strict payvia delivery lane (requires
glueful/payvia >= 2.4): subscription events ride payvia's strict tagged lane with at-least-once delivery and genuine retry on the checkout race. With payvia ≤ 2.3 delivery degrades to the fault-isolated bus — unmapped events are then permanently lost, not retried (upgrade payvia to get the guarantee). - Tenant lifecycle mechanics: tenant-table registration, system-context discipline, and a host-neutral
SubscriptionSubjectDataPurgerfor user- and workspace-scoped purges. - Console subject options (
--subject-type/--subject-uuid) acrosssubscriptions:show/set-plan/reconcile; scopedplans:*commands (--audience/--owner).
Compatibility
- PHP ^8.3 · glueful/framework ^1.57 · payvia and tenancy remain optional (
suggest); payvia ≥ 2.4 required only for the strict delivery guarantee. - The 1.x tenant service facade is not deprecated — it is the supported API for workspace billing.
Two notes before you publish: the body assumes payvia 2.4.0 is released first (the strict-lane bullet references it, and this repo's require-dev resolves ^2.4 from Packagist) — publish that release before or together with this one. And make sure the pushed v2.0.0 tag points at 26f6c52 (the local tag moved when we dropped the CI workflow; if you pushed an earlier tag, force-push the tag or re-tag remotely first).
v1.3.1
1.3.1 -- 2026-06-16
Fixed
- Register migration paths during provider boot so
migrate:runsees the
subscriptions schema through the same CLI lifecycle used by other extension
migrations.
v1.3.0
1.3.0 -- 2026-06-14
Changed
- Migrated OpenAPI documentation to the framework 1.57.0 reflect generator. Route
documentation (summaries, query parameters, request-body fields and response codes)
is now expressed as typed#[ApiOperation],#[QueryParam]and#[ApiResponse]
attributes on the controller methods; the now-inert route-file docblocks were removed.
Docs-only — no runtime behaviour changes. - Raised the minimum framework requirement to
^1.57.0.
v1.2.0
1.2.0 -- 2026-06-13
Added
- Provider-agnostic subscription event projection. A new
SubscriptionEventProjectorInterface+ProviderSubscriptionEventDTO own all
projection rules (claim-first idempotency, tenant relink, the status state
machine, period/grace), and aProviderStatePullerInterfacedrives reconcile.
Third-party payment providers can now project subscription state and reconcile
drift by mapping their events into the DTO and (optionally) binding their own
puller -- with no payvia present and no subscriptions internals touched. See
docs/BRING_YOUR_OWN_PROVIDER.md.
Fixed
- Harden boot/registration against partial failures. Each independent
registration step (migrations, command discovery, route loading, and the
optional payvia event listener) is now wrapped in its own try/catch that logs
a[Subscriptions] ...message and re-throws outside production -- so a single
failing step degrades gracefully in production instead of aborting app boot,
while still failing fast during development. The existingregisterMetaguard
is unchanged. - Cap plan
descriptionat 255 characters in the payload validator (matching the
subscription_plans.descriptionVARCHAR(255)column) on both the create and
patch paths. An over-long description now raises a clean validation error (HTTP
422) instead of a confusing 500 (strict MySQL) or silent truncation. Only
descriptionis capped; othernullableStringfields are unaffected. - Fail closed when an entitlement value has an unrecognized type. Plan values are
validated (bool|int >= 0|null) but override values are JSON-decoded
and unvalidated, so a malformed value could reach the checker.allows()now
denies (andlimit()returns0) for any non-bool/non-numeric/non-null type --
previously the JSON string"false"(bool)-coerced to a grant and an
unrecognized type read as an unlimited (null) limit. The intentional
null = unlimited/allowsemantics are unchanged. - Derive the entitlement cache key from the resolved content rather than from
updated_attimestamps. The key now folds in a stable hash of the
resolved-plan inputs (status,plan_key,grace_ends_at) and of the active
override map, so a status/plan downgrade or an override edit invalidates the
cache immediately even when a writer fails to bumpupdated_at-- closing a
window (up to the cache TTL) in which a downgraded tenant kept elevated
entitlements. The active override map is now read once and reused for both the
key and the merge (no extra query). - Only relink unlinked tenant subscriptions on provider
subscription.created
events. The provider-echoedmetadata.tenant_uuidis now treated as a recovery
HINT used solely to attach an as-yet-unlinked subscription -- never to move an
existing link. Asubscription.creatednaming a tenant whose row is already
linked to a different provider subscription is logged as an anomaly
(subscriptions.relink_conflict_skipped, no payload) and no-ops instead of
silently stealing the link. - Do not resurrect a canceled subscription on a late or replayed
subscription.createdevent. When the stored status is alreadycanceled, the
event is still recorded/claimed but no status change is projected, so a
delayed creation event can no longer flip a terminal subscription back to
active.
Changed
- Read plan write payloads (
store/update) from the JSON body and POST form
only -- query-string params are no longer merged into the body. Validation
already gated every field, so this is a logging-hygiene change: plan fields
(e.g.entitlements/status) passed via the query string for a write are no
longer copied into the request body (and thus access logs).importConfig
reads itsforce/statusquery params explicitly and is unaffected. Callers
that relied on passing plan write fields via the query string must move them
into the request body. - Payvia is now a thin optional integration rather than the projection owner. The
formerPaymentProviderEventListener(which both adapted payvia's event shape
AND owned the projection rules) is replaced by a generic
SubscriptionEventProjectorplus a thinPayviaSubscriptionEventBridge
(event adapter) andPayviaProviderStatePuller(reconcile adapter), each
registered only when payvia is installed. All subscriptions-ownedpayvia_*
storage/options/config/CLI vocabulary is renamed toprovider_*:
provider_gateway,provider_customer_id,provider_subscription_id,
provider_price_id(generalized from the 12-charpayvia_priced_plan_uuidto
aVARCHAR(191)provider price/plan identifier), and
provider_logical_event_key; the eventsourcevaluepayvia_eventbecomes
provider_event, the unique indexuniq_subscriptions_payvia_subbecomes
uniq_subscriptions_provider_sub, and the plan CLI flag--payvia-priced-plan
becomes--provider-price-id. No backward-compat shim or dual-read (the
extension is pre-release); the columns are renamed directly in the base
migrations. - An unknown provider event type that maps to an existing subscription is now
recorded (the idempotency claim is taken) with no projected status change,
instead of being dropped before the claim. This keeps every delivered event
auditable and idempotent even when its type is not in the handled set.
v1.1.1
1.1.1 -- 2026-06-11
Fixed
- Load plan catalog, entitlement resolver, and subscription service factories
through the framework extension service DSL so the provider boots through the
realDefaultServicesLoaderin production. (Also keeps the provider compatible
with the framework 1.55.0 load-time non-instantiable-binding guard.) - Return denied entitlement and plan-management permission checks through the
frameworkResponseerror envelope instead of raw/manual JSON responses.
Changed
- Require
glueful/framework ^1.55.0(was^1.54.0) as the security-hardened
baseline. The entitlement seam and container-precedence fix this extension
relies on shipped in 1.54.0; 1.55.0 adds the security/correctness hardening
pass (permission-attribute enforcement, signed-URL fail-closed, fail-loud
extension loading, etc.).
1.1.0 - Managed subscription plan catalog
1.1.0 -- 2026-06-10
Added
- Managed subscription plan catalog with DB-backed plans, config fallback, HTTP
management API, and CLI commands.
v1.0.0 - Initial release
1.0.0 -- 2026-06-10
Initial release.
Added
- Entitlement checker over the core seam:
DefaultEntitlementChecker
implements framework-coreGlueful\Entitlements\Contracts\EntitlementCheckerInterface
and is bound over core's allow-allNullEntitlementCheckerdefault (relies on
the framework container-precedence fix; requiresglueful/framework ^1.54.0).
S3 value semantics: absent key denies;false/0deny;true/explicit
nullallow unlimited; positive int is the limit; non-positive ints deny
withlimit() === 0(allows()andlimit()always agree). - Schema (3 tables at DEPENDENT priority):
subscriptions(one current
subscription per tenant, uniquetenant_uuid, nullablepayvia_*link
columns, unique(payvia_gateway, payvia_subscription_id)),
subscription_overrides(per-tenant entitlement overrides with optional
expiry, unique(tenant_uuid, entitlement)), andsubscription_events
(audit log with DB-enforced per-gateway logical-event-key dedupe via unique
(payvia_gateway, payvia_logical_event_key)-- multiple all-NULL rows allowed
for manual/reconcile events). - Config plan catalog (
config/subscriptions.php):default_plan, plans
with entitlement maps, optionalpayvia_priced_planlinks,grace_days,
resolver cache settings,permissive_middleware,rate_tiers, and the
opt-in reconcile scheduler flag. - Status-gated resolution with cache:
EffectivePlanResolver(lapsed /
incomplete / paused / expired-grace tenants downgrade todefault_plan;
past_duekeeps paid access only whilegrace_ends_atis in the future;
trials resolve plan-as-trialed;pausedis accepted from payvia's
provider-status vocabulary and treated as not entitled to paid features)EntitlementResolver(catalog + overrides merge) with a
naturally-keyed cache (tenant + catalog fingerprint + row timestamps -- any
change invalidates by key).CacheStoreis optional; zero-infra installs
resolve uncached.
RequireEntitlementroute middleware, fail-closed 403 with an
entitlementerror code;permissive_middlewareopt-in allows requests with
no tenant context. Registered under therequire_entitlementalias
(middleware-string formrequire_entitlement:<entitlement>).
The#[RequireEntitlement]route attribute is NOT shipped in v1 -- the
framework has no generic attribute->middleware bridge for extension
attributes (B1); the attribute form is deferred until a sanctioned hook
exists.EntitlementTierResolverrate-limit bridge over the framework's default
TierResolver(tier-flag mapping: booleanrate.tier.{tier}entitlements
pick the bucket,TierManagerconfig owns the numbers). Inert without
tenancy; lookup failures degrade to the default resolver.SubscriptionServicelifecycle:current/start/changePlan/
cancel(at period end via metadata flag, or immediate) /reconcile--
works fully with NO payvia installed (free/trial/comp). Every transition
appends asubscription_eventsrow.- Conditional payvia listener (S7): when payvia's
PaymentProviderEvent
exists,PaymentProviderEventListenerself-registers (lazy@serviceId)
and projects normalized provider events onto subscription state --
claim-first in ONE transaction (the event-row insert is the atomic gate), so
duplicate/concurrent deliveries never re-project andpast_duegrace is set
exactly once. A swallowed duplicate claim emits a debug-level log line
(logger resolved defensively -- never a hard dependency) so a misclassified
integrity error stays observable. Unmapped provider subscriptions no-op;
subscription.createdcan recover the tenant link from provider metadata
tenant_uuid. - Reconcile (soft payvia seam): pulls authoritative state through payvia's
GatewaySubscriptionService::reconcile()only when the class exists
(injectable puller seam for tests); applies status/period drift and appends a
reconciledevent with a NULL logical key. Drifting intopast_duegrants
the same dunning grace as the event path (grace_ends_at = now + grace_days;
an already-past_due row is never re-extended), and settling toactive
clears grace. - CLI:
subscriptions:reconcile [--tenant=],subscriptions:show --tenant=,subscriptions:set-plan --tenant= --plan=(validates the plan
against the catalog).
Tooling
- PHPStan at level 6 via a committed
phpstan.neon(composer analyzeis
config-driven); all array docblocks carry value types. PHPUnit suite and
PSR-12 (phpcs) gates ship green.
Guarantees
- Soft dependencies only: no payvia and no tenancy class is referenced without
aclass_existsguard; the package installs, boots, and passes its suite
with neither package present. tenant_uuidis an opaque external id (no FK) -- works with any tenant
source, not justglueful/tenancy.- Entitlement checks are stateless reads (allow/deny + optional numeric
limit); usage metering / quota consumption is a non-goal for v1 (roadmap:
v1.1+).