yuriitatur / subscriptions
subscription tracking
Requires
- php: >=8.4
- illuminate/collections: ^13.0
- psr/clock: ^1.0
- psr/log: ^3.0
- symfony/event-dispatcher: ^8.0
- symfony/event-dispatcher-contracts: ^3.0
- symfony/validator: ^8.0
- yuriitatur/exceptions: ^1.1
- yuriitatur/query-builder: dev-master
- yuriitatur/repository: dev-master
- yuriitatur/repository-laravel: dev-master
- yuriitatur/search: dev-master
Requires (Dev)
- dg/bypass-finals: ^1.11
- illuminate/console: ^13.0
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/http: ^13.0
- illuminate/routing: ^13.0
- illuminate/support: ^13.0
- kint-php/kint: ^6.0
- laravel/ai: ^1.3
- laravel/mcp: ^1.1
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^13.0
- symfony/lock: ^8.0
Suggests
- illuminate/support: For the optional Laravel integration (laravel/app, also needs illuminate/console, contracts, database, http and routing ^13.0)
- laravel/mcp: For the MCP server of the Laravel integration (`subscriptions.mcp`), whose tools laravel/ai agents can use too
- symfony/lock: To lock with Symfony\Component\Lock (SymfonyLock, Laravel `subscriptions.locking.driver` = symfony)
Provides
None
Conflicts
None
Replaces
None
README
Subscription tracking for PHP: plans, features, subscriptions and feature quotas, driven by commands and handlers, with an optional Laravel integration (models, migration, REST API, artisan commands).
- Framework-agnostic domain and services in
src/(YuriiTatur\Subscriptions) - Laravel integration in
laravel/(YuriiTatur\Subscriptions\Laravel) - Persistence through
yuriitatur/repositoryrepositories
Requires PHP 8.4+.
Concepts
| Entity | |
|---|---|
Plan | named plan with a periodicity (day, week, biweekly, month, quarter, half_year, year, eternity), a grace period, a list of features and a quota per period for its consumable features |
Feature | named capability, optionally consumable (has a quota) |
Subscription | a subscriber on a plan: startAt, expireAt, cancelAt, revokeAt, gracePeriodEndAt |
FeatureConsumption | a grant of quota of a consumable feature to a subscriber, optionally starting later and expiring; a subscriber may hold several (the plan quota of each period, top-ups) |
SubscriberInterface | whoever subscribes (users, teams...); implemented by your application. A subscriber is its type (getSubscriberType()) and its id together, so several kinds of subscribers may share ids |
A subscription is scheduled until it starts, then active while it is not revoked or cancelled and has not run
out of its paid period plus the grace period, and ended afterwards (ended subscriptions can not be changed). A
subscription of an eternity plan never expires. Month-based periods never overflow: a monthly subscription started on
Jan 31 runs to Feb 28 and is renewed to Mar 31.
Within those statuses a subscription goes through phases (SubscriptionPhase, Subscription::getPhase()):
scheduled, active, renewal_due (the paid period ends within the renewal notice and no cancellation is pending),
grace_period and ended. Commands announce what they change through their events; what time changes (a scheduled
subscription starts, a renewal comes due, the grace period begins, a subscription runs out) is announced by
AnnounceLifecycleCommand, see Lifecycle announcements.
Plans and features have an availability window (starts_at/ends_at, both optional) and an is_active switch.
Plans are never deleted, subscriptions refer to them for good; archive one instead (ArchivePlanCommand, which turns
is_active off). An archived plan can not be subscribed or switched to, nor can its subscriptions be renewed, whatever
the policy: running subscriptions keep running (with their features) until they end, but are not prolonged. With
require_active_plan (the default) the same applies to a plan outside its availability window when the subscription
or the renewed period starts.
Deleting a plan or a feature yourself (its row, or its item of a local catalog) or removing a subscriber model from
subscriber.models is a deliberate cleanup: delete (or move) the subscriptions and quota grants referring to it as
well. They can not be loaded without it, so the searches listing them fail (404) and subscriptions:tick stops at
them. A deleted subscriber is the exception: its subscriptions and grants stay readable (see
Personal data).
Commands
Every operation is a command object with a handler. The package does not ship a bus: map the commands to the handlers
in your framework, or call a handler directly ($handler($command)). Commands, handlers and events are grouped by
area: Commands\Subscriptions, Commands\Quotas, Commands\Plans and Commands\Features (the same under
Handlers\ and Events\).
| Command | Does |
|---|---|
SubscribeToPlanCommand | subscribe for one plan period, or between the given dates (a past start backdates, a future one schedules); conflicts when it would overlap a subscription to the same plan (any plan with allow_concurrent_plans off) |
SwitchPlanCommand | move to another plan right now (the old subscription is revoked) or at the end of the paid period (the old one is cancelled then, the new one is scheduled and remembers it in previous_subscription_id); in the grace period always right now |
CancelSubscriptionCommand | cancel at the end of the paid period, or immediately (always immediately for eternal, grace-period and not yet started subscriptions) |
ResumeSubscriptionCommand | withdraw a cancellation that has not taken effect yet |
RevokeSubscriptionCommand | terminate right now, ignoring the grace period |
AnnounceLifecycleCommand | announce the phases time moved subscriptions into since the last run (schedule it), see below |
RenewSubscriptionCommand | prolong by one plan period or by a given DateInterval, from the expiry date (from now in the grace period); conflicts when the plan is archived or not available then |
ExtendSubscriptionCommand | lengthen the current period by a DateInterval or to a later expiry date, e.g. to make up for an outage: not a renewal, no new period and no new quota (the plan quota of the period lasts until the new grace end), the plan is not checked, the grace period keeps its length; a pending cancellation at the period end moves along |
GrantFeatureQuotaCommand | add quota of a consumable feature, optionally expiring; tops up the grant with the same expiry |
ConsumeFeatureCommand | consume the feature from the grants that expire first, spilling over to the next ones; all or nothing |
ReleaseFeatureCommand | give a consumed amount back, to the grants that expire last |
ResetFeatureConsumptionCommand | zero the consumption of the unexpired grants, e.g. at the start of a billing period |
CreatePlanCommand | create a plan from PlanDetails (periodicity, grace period, texts, availability window, active switch) with its features and their quotas; conflicts when the id is taken |
UpdatePlanCommand | replace every attribute of a plan with PlanDetails, the features are kept; running subscriptions keep their dates |
ArchivePlanCommand | turn a plan off: no new subscriptions, switches or renewals, running subscriptions last until they end; bring it back by updating it as active |
CreateFeatureCommand | create a feature from FeatureDetails (consumable, texts, availability window, active switch); conflicts when the id is taken |
UpdateFeatureCommand | replace every attribute of a feature with FeatureDetails |
ArchiveFeatureCommand | turn a feature off: subscribers can not use it any more (even through running subscriptions), it can not be added to plans or granted, plans stop granting its quota; bring it back by updating it as active. Features are never deleted |
AttachPlanFeatureCommand / DetachPlanFeatureCommand | add a feature to a plan, optionally with a quota (attach it again to change the quota), or remove it; doing it twice changes nothing |
Plan quotas
A plan can give its consumable features a quota per period (api → 100 calls a month). Subscribing, switching and
renewing grant it automatically for the new period: one grant per feature, usable from the start of the period to the
end of its grace period (start_at, expire_at) and remembering its subscription (subscription_id). The quota stays
consumable in the grace period; a renewal ends what is left of it when the new period begins (right away in the grace
period, at the expiry when paid ahead) and grants fresh quota, so periods never add up. A scheduled switch grants the
new plan's quota when it starts. A plan grant is only
counted while its subscription is active: revoking, cancelling immediately or switching right away leaves the rest of
it unusable (the row stays as history). Changing a quota applies to the periods that begin afterwards. Manual grants
(GrantFeatureQuotaCommand, top-ups and bonuses) are separate and never merged with the plan grants. Plan quotas
are not carried over: what is left of a period expires with it. To roll unused quota over, grant the rest
(FeatureBalance::getAvailable()) as a manual grant when the period ends; the rules around it (caps, how long carried
quota lasts, overage) differ too much between products for the package to pick one. Extending a subscription
(ExtendSubscriptionCommand) lengthens the period, so its plan grants last until the new grace end.
A subscription switched at the end of its period can not be resumed, renewed or switched again until the scheduled subscription is revoked (which undoes the switch). Quota amounts are kept at 6 decimals, so fractions add up exactly.
$subscription = $subscribeHandler(new SubscribeToPlanCommand($user, 'pro'));
$cancelHandler(new CancelSubscriptionCommand($subscription->getId()));
Failures are YuriiTatur\Exceptions\AppExceptions carrying an error type and an HTTP code. They are not caught: let
your framework's error handler render them, or catch the typed ones (YuriiTatur\Subscriptions\Exceptions):
| Exception | Type | When |
|---|---|---|
ConflictException | conflict (409) | the operation is not allowed in the current state; the more specific ones below extend it |
SubscriptionConflictException | conflict (409) | the subscription is ended, never expires, is (not) cancelled, is being switched |
OverlappingSubscriptionException | conflict (409) | the subscription would overlap another one (getOverlapping(): their ids) |
UnavailableException | conflict (409) | the plan or feature is archived or out of its availability window |
AlreadyExistsException | conflict (409) | a plan or feature with the id exists |
QuotaExceededException | quota_exceeded (409) | the quota left can not cover the amount (getAvailable()) |
NotConsumableException | bad_request (400) | a quota for a feature that is not consumable |
LockBusyException | busy (409) | another request holds the subscriber's lock, or the operation outlasted it |
EntityNotFoundException | not_found_error (404) | from yuriitatur/repository |
InvalidArgumentException | invalid_argument (400) | from yuriitatur/exceptions |
try {
$user->consumeFeature('exports');
} catch (QuotaExceededException $e) {
return back()->withErrors("Only {$e->getAvailable()} exports left this month");
}
Lifecycle announcements
Every subscription remembers the phase last announced (announced_phase) and when its phase may change next
(next_phase_at: a start, the renewal notice, the expiry, the end of the grace period, a cancellation; null: never).
The commands set the phase they leave the subscription in, so their own events (AfterRenewSubscriptionEvent...) are
the announcement of what they did. AnnounceLifecycleCommand (Laravel: php artisan subscriptions:tick) only looks at
the subscriptions whose next_phase_at came, so a run costs what changed rather than every subscription; it records
their new phase and next moment and dispatches, under the subscriber's lock and in a transaction:
| Event | When |
|---|---|
SubscriptionStartedEvent | a scheduled subscription started (a switch at the period end, a subscription with a future start) |
SubscriptionRenewalDueEvent | the paid period ends within the renewal notice and no cancellation is pending: charge and renew |
SubscriptionGracePeriodStartedEvent | the paid period is over, the grace period began |
SubscriptionEndedEvent | the grace period ran out, or a pending cancellation took effect |
Each event carries the subscription and the previousPhase. A change is announced once; a run that misses a phase
(the grace period was over before the next run) announces only the phase the subscription is in, except that a start is
never skipped. A listener that throws rolls its subscription back, the run goes on with the others and reports the
failure, the next run announces it again: listeners get each event at least once. Subscriptions stored before the
announcements existed (no announced phase) are recorded silently by the first run. Renewals are not announced without
a renewal notice (SubscriptionLifecycleService's third argument, Laravel: lifecycle.renewal_notice). Subscriptions
written outside the commands (raw updates) are looked at when their next_phase_at comes: set it to now to have the
change announced by the next run (and once for the rows stored before the column existed). The moments are computed
with the renewal notice: after changing it, run AnnounceLifecycleCommand once with reschedule: true (Laravel:
php artisan subscriptions:tick --reschedule), which looks at every subscription that is not ended.
Atomicity
Every handler runs its operation through a CommandRunner: under a lock of the subscriber (LockInterface; plan
management locks the plan), so two concurrent requests can not both pass a quota or overlap check, and in a transaction
(TransactionRunnerInterface), so an operation that writes several rows (a switch, a consumption spread over grants) is
applied completely or not at all.
Lock drivers: NullLock (no locking, the default), SymfonyLock (the symfony/lock component on any of its stores,
composer require symfony/lock) and, in Laravel, CacheLock (an atomic lock of a cache store, Cache::lock()). They
are re-entrant within the process and fail with busy (409) when the lock is not released within the wait time. Before
the transaction commits, the runner asks the lock whether it is still held (LockInterface::assertHeld()): an
operation that outlasted the lock's ttl is rolled back with busy (409) instead of being committed while another
process may hold the lock. Implement assertHeld() in your own lock drivers.
The commands are not idempotent: a request that is retried (a timeout, a client retry) consumes, grants or renews
again. Deduplicate retries in your application when that matters, e.g. with an idempotency key checked by a
Before* listener or a middleware.
Logging
The CommandRunner, the services and the lifecycle handler take a PSR-3 LoggerInterface (last constructor
argument, NullLogger by default; Laravel: the application's logger, or the channel of logging.channel):
| Level | What |
|---|---|
info | an operation done: subscribed, switched, cancelled, renewed..., quota granted or consumed, catalog changed, a lifecycle phase announced, the summary of a lifecycle run |
notice | a command refused (a domain error: conflict, quota exceeded, not found, vetoed by a listener) |
warning | a busy or expired lock (retry), a lifecycle announcement failed (retried by the next run), a subscription whose phase moved back without a command |
error | a command failed on an exception, it is rolled back |
critical | a command failed on a PHP error (\Error: a bug) |
debug | locks, commits, dispatched events, no-op changes, grants drawn from, storages, entitlement builds and cache hits |
Messages are PSR-3 templates (Subscribed {subscriber} to {plan}) with the values in the context; the failures carry
the exception. Timings are left to the logger (e.g. a Monolog processor).
Events
Each handler dispatches events through a Symfony EventDispatcherInterface:
Before{Operation}Eventbefore the change, with the loaded subscription, plan or feature. Throw from a listener to veto the operation (eligibility checks).After{Operation}Eventonce it succeeded, carrying the outcome, for the rest of the application to react to.
For example BeforeSubscribeToPlanEvent / AfterSubscribeToPlanEvent. There are no error events. Both events are
dispatched inside the transaction: a listener that throws from an After event rolls the operation back. Every event
implements SubscriptionEvent and can be serialized (queued listeners): a plan loads its features first.
Every event is data as well, for webhooks, outboxes and logs: getName() (after_renew_subscription),
getOccurredAt() (stamped by the CommandRunner from its clock) and toArray() / json_encode($event):
{"event": ..., "occurred_at": 1767225600, "command": {...}, "subscription": {...}}. Entities and results appear as
their JSON, subscribers as {type, id} only, dates as Unix timestamps, durations as ISO 8601 (EventPayload).
The subscriber of an entity is the application's model, except when its record is gone: then it is a
SubscriberReference (type and id only), e.g. in the SubscriptionEndedEvent of a deleted user's subscription.
Listeners that need the model's attributes (an email address) check for it.
Entitlements
EntitlementService::getEntitlements($subscriber) returns what a subscriber is entitled to now, in a query each for the
active subscriptions, their plans' features and the grants: getPlanIds(), onPlan('pro', 'team'),
hasFeature('api'), getFeature(), getBalance('api') and getBalances(). It is a read model for the checks that
run on every request (gates, middleware, views); the commands never rely on it, they read the storage under the
subscriber's lock. validUntil is the moment they change by themselves (a subscription ends or starts, a grant or
a feature of the plans or of a grant opens or closes; null: never), isValidAt($at) tells whether they still hold.
A balance (FeatureBalance, also QuotaService::getBalance()) is usable when consuming the feature would be
accepted: the feature is active and, with consume_requires_plan, one of the active plans includes it; otherwise
nothing is available, whatever the grants hold (they still show their quota and consumption). hasFeature()
and consuming do not load the subscriptions at all: the storage checks for an
active subscription of a plan with the feature, and selects the grants of active subscriptions through the grants'
subscription relation.
Queries
The read side is a query handler per entity, built on yuriitatur/search:
SearchPlansHandler, SearchFeaturesHandler, SearchSubscriptionsHandler, SearchFeatureConsumptionsHandler. Pass the
raw query options (the query string) in the matching query object; the handler validates them and returns a paginated
result (iterate it for the entities, jsonSerialize()['meta'] has total, per_page, current_page, last_page).
$result = $searchSubscriptions(new SearchSubscriptionsQuery(['status' => 'active', 'subscriber' => 7, 'sort' => ['expire_at:asc']]));
foreach ($result as $subscription) { /* Subscription */ }
Common options: limit (100 by default, at most 500, see SearchLimits), pagination[page], pagination[offset],
sort[]=field:asc|desc (default id:desc). Invalid options throw a ValidationException (422). Each handler's filters() and its docblock list the rest:
| Handler | Options |
|---|---|
| plans | id, ids, name, periodicity_type, periodicity, active; sort: id, name, periodicity_type, periodicity |
| features | id, ids, name, consumable, active, plan, plans; sort: id, name, consumable |
| subscriptions | id, ids, subscriber, subscriber_type, plan, status (active, scheduled, ended), phase (scheduled, active, renewal_due, grace_period, ended), pending_cancellation (boolean), start_from, start_to, expire_from, expire_to; sort: id, start_at, expire_at |
| feature consumptions | id, ids, subscriber, subscriber_type, feature, subscription, expire_from, expire_to; sort: id, quota, consumption, expire_at |
The handlers take an EntityRepository, a Symfony ValidatorInterface (subscriptions also a clock) and optionally
SearchLimits.
Without Laravel
Construct the services with EntityRepository instances, a PSR-20 clock and optionally the policy provider, then the
handlers with a CommandRunner:
$options = new StaticSubscriptionOptionsProvider(new SubscriptionOptions(allowConcurrentPlans: false));
$subscriptions = new SubscriptionService($subscriptionRepository, $clock, $options, new PlanQuotaService($consumptionRepository));
$lock = new SymfonyLock(new LockFactory(new RedisStore($redis)));
$runner = new CommandRunner($eventDispatcher, $transactionRunner, $lock); // NullTransactionRunner / NullLock by default
$handler = new SubscribeToPlanHandler($subscriptions, new PlanService($planRepository), $runner);
The policy is asked per subscriber: implement SubscriptionOptionsProviderInterface::forSubscriber() to apply different
rules to different subscribers (StaticSubscriptionOptionsProvider applies the same to everyone). Without a
PlanQuotaService the subscription service grants no plan quotas.
FeatureService (the catalog) takes the feature repository; QuotaService (grants, balances, consumption) takes the
FeatureService, the consumption repository, the SubscriptionService, the clock and the options provider;
EntitlementService (what a subscriber may use) the same without the options. The repositories must filter on relations: features by planId, subscriptions by plan.features, grants by
subscription.<field> (the Laravel storages do, whatever the drivers); the plan repository must persist the features
and quotas of a saved plan (Plan::setFeatures()). Use a lock on a shared store
(SymfonyLock on Redis, a database...) when several processes serve the same subscribers.
Laravel
The service provider is auto-discovered. It needs yuriitatur/repository-laravel and the provider of
yuriitatur/exceptions, which renders the domain errors (both auto-discovered).
php artisan vendor:publish --tag=subscriptions-config
php artisan vendor:publish --tag=subscriptions-migrations # optional, migrations are loaded from the package
php artisan migrate
- Make your subscriber model implement
SubscriberInterface, e.g. with theIsSubscribertrait, and set it inconfig/subscriptions.php(subscriber.model, andsubscriber.key_typefor the migration). When several kinds of models subscribe (users and teams), list the others insubscriber.models. A subscriber's type is its model's morph class (aRelation::morphMap()alias, else the class name, stored insubscriber_type): name it in requests and commands (subscriber_type,--subscriber-type),subscriber.modelis the default. - Create features and plans: through the REST API or the Artisan commands (below), or with the
localdriver in the config.
Storage
Storage is chosen per entity in subscriptions.storage.{plan|feature|subscription|feature_consumption}:
'plan' => [
'driver' => 'local', // 'eloquent' (default) or 'local'
'eloquent' => ['model' => PlanModel::class, 'table' => null, 'connection' => null, 'pivot_table' => 'plan_feature'],
'local' => ['items' => [
['id' => 'pro', 'periodicity_type' => 'month', 'grace_period' => 'P2D', 'features' => ['api' => 100, 'sso']],
]],
],
'feature' => [
'driver' => 'local',
'local' => ['items' => [
['id' => 'api', 'is_consumable' => true],
['id' => 'sso'],
]],
],
eloquent: rows in the database through the given model (replace it with your subclass to add columns). The table and connection are the model's ($table,$connection);tableandconnectionoverride them for the package models, null keeps the model's, so a model of your own declares them once. The migration only creates the tables of entities using this driver, on the model's connection; the pivot table lives on the plans' connection. Subscriptions and quota grants take akey_type:id(auto-incremented, the default),uuid(UUIDv7) orulid; the package models make the key on insert and the migration types the columns referring to subscriptions the same.local: a PHP array served by the in-memory driver. Meant for static catalogs (plans, features) and for tests; changes made at runtime (plan management included) are not persisted. The accepted item keys are documented in the config file.
Keep plans and features on the same driver. Subscriptions and consumptions can live in the database while the catalog lives in the config. Entities on different connections are each covered by their own transaction (nested, without two-phase commit).
Octane
The package runs under Octane. What a request remembers (the plans, features and subscribers resolved while hydrating, the entitlements) lives in scoped services, created for each request and gone with it; the listeners and rate limiters the provider registers resolve them from the request's container. The singletons hold no request state, so they can be built once per worker:
// config/octane.php
'warm' => [...Octane::defaultServicesToWarm(), ...SubscriptionServiceProvider::WARM],
A custom policy.provider is warmed with them and must hold no request state either. Entities on the local driver
are kept by the worker: changes made at runtime differ from worker to worker.
Policy, locking, events, search
'policy' => [
'provider' => null, // a SubscriptionOptionsProviderInterface class for per-subscriber rules
'allow_concurrent_plans' => true, // false: one subscription at a time, plans change through switches
'require_active_plan' => true, // subscribing, switching and renewing need the plan within its window (archived plans: never)
'consume_requires_plan' => true, // false: granted quota (bonuses) is usable without a plan that includes the feature
'renew_from_now_in_grace' => true, // false: renewing in grace continues from the previous expiry
],
'locking' => [
'enabled' => true,
'driver' => 'cache', // cache (Cache::lock()) or symfony (symfony/lock)
'cache' => ['store' => null], // cache store, null: the default one
'symfony' => ['store' => 'flock'], // StoreFactory connection: flock, semaphore, redis://..., mysql:...
'prefix' => 'subscriptions:', 'ttl' => 10, 'wait' => 5,
],
'events' => ['forward_to_laravel' => true],
'lifecycle' => ['renewal_notice' => 'P3D'], // SubscriptionRenewalDueEvent this long before the expiry, null: never
'logging' => ['channel' => null], // a channel of config/logging.php, null: the default
'access' => [
'subscriber' => null, // who the subscriber of a request is, null: the authenticated user
'rate_limiters' => [], // rate limiters by feature: name => features, for throttle:{name}
'cache' => ['enabled' => false, 'store' => null, 'ttl' => 3600], // share the entitlements across requests
],
'search' => ['default_limit' => 100, 'max_limit' => 500],
Schedule the lifecycle announcements, e.g. in routes/console.php:
Schedule::command('subscriptions:tick')->everyMinute()->withoutOverlapping();
The lock must be shared by your servers: a cache store that supports locks (redis, memcached, database...) or a
symfony/lock store; bind your own Symfony\Component\Lock\LockFactory to configure the symfony driver in code. Set
policy.provider to your SubscriptionOptionsProviderInterface implementation (resolved from the container) to decide
the rules per subscriber, the policy values are then not used.
Events
The domain events are forwarded to Laravel's dispatcher, so regular listeners work:
Event::listen(BeforeSubscribeToPlanEvent::class, function ($event) {
if (! $event->command->subscriber->isVerified()) {
throw new AppException('Verify your email first', [], null, 'not_eligible', 403);
}
});
Event::listen(AfterCancelSubscriptionEvent::class, SendCancellationMail::class);
Listeners run inside the transaction of the command; implement ShouldHandleEventsAfterCommit (or queue them) for work
that must only happen once the change is committed. The lifecycle events are forwarded the same way:
Event::listen(SubscriptionRenewalDueEvent::class, ChargeAndRenew::class);
Event::listen(SubscriptionEndedEvent::class, RevokeAccess::class);
Set events.forward_to_laravel to false to keep them on the Symfony dispatcher only. There is no audit log: listen
to the After* events to keep one.
Gates, middleware and helpers
The checks read the subscriber's entitlements, built once per request (or job) and rebuilt after every
command that changes them. With subscriptions.access.cache.enabled they are shared by the requests through a cache
store (a shared one: redis, memcached, database...) until they change by themselves or for ttl seconds at most; a
command forgets its subscriber's (right away and once it commits), a catalog change everyone's. The keys carry a
generation of the subscriber (and a version of the catalog) read before the entitlements are built, which a forget
replaces: a request that built them from the state before a command stores them where nobody looks any more. The store holds them
without the subscriber model (its type and id only, SubscriberReference); the subscriber they are read for is
attached again.
// the subscriber model (IsSubscriber)
$user->hasFeature('sso');
$user->onPlan('pro', 'team');
$user->featureBalance('projects')?->getAvailable();
$user->consumeFeature('projects'); // runs ConsumeFeatureCommand: 409 quota_exceeded when none is left
$user->releaseFeature('projects');
$user->entitlements();
// gate abilities: the user, or the subscriber given as the second argument
Gate::allows('subscriptions.feature', 'sso');
Gate::allows('subscriptions.plan', ['pro', $team]);
@can('subscriptions.feature', 'export') ... @endcan
// route middleware: 401 without a subscriber, 403 without the entitlement
Route::get('/reports', ...)->middleware('subscriptions.feature:reports,export'); // every feature
Route::get('/admin', ...)->middleware('subscriptions.plan:pro,team'); // one of the plans
The middleware takes the authenticated user as the subscriber; set subscriptions.access.subscriber to a
RequestSubscriberResolverInterface class when teams or accounts subscribe.
Limits counted hundreds of times a minute, like API rate limits, should not be quotas (every request would consume and
write a grant): define Laravel rate limiters by feature and use the stock throttle middleware. The limits are the
rate_limit metadata of the feature, {"max": 1000, "per": "PT1M"} or a list of them (a feature without it is not
limited); the limiter takes the first listed feature one of the subscriber's plans includes (list the tiers best
first), 403 when none does, and counts each subscriber on its own.
// config/subscriptions.php: limiter name => features
'access' => ['rate_limiters' => ['api' => ['api-pro', 'api-basic']]],
Route::middleware('throttle:api')->get('/api/search', ...);
// or define the limiter yourself (FeatureRateLimiter::for() takes the subscriber)
RateLimiter::for('api', fn(Request $request) => app(FeatureRateLimiter::class)->forRequest($request, 'api-pro', 'api-basic'));
"Max 5 projects" is a quota: consume one per project created, release it when the project is deleted.
REST API
Routes are registered under the configured prefix (api by default) with the configured middleware. The package adds
no authentication, so put yours into subscriptions.routes.middleware; routes.search.middleware,
routes.commands.middleware and routes.catalog.middleware are added to the read routes, the subscription and quota
commands, and the management of plans and features (e.g. only administrators may change the catalog), and each group
can be disabled with its enabled key.
| Method and path | Body | |
|---|---|---|
POST /subscriptions | subscriber_id, subscriber_type?, plan_id, start_at?, expire_at? | subscribe |
POST /subscriptions/{id}/switch | plan_id, immediately? (default true), duration? | switch plan |
POST /subscriptions/{id}/cancel | immediately? | cancel |
POST /subscriptions/{id}/resume | resume | |
POST /subscriptions/{id}/revoke | revoke | |
POST /subscriptions/{id}/renew | duration? | renew |
POST /subscriptions/{id}/extend | duration or expire_at | extend the current period |
POST /subscribers/{id}/features/{feature}/quota | quota, expire_at? | grant quota, returns the grant |
POST /subscribers/{id}/features/{feature}/consume | amount | consume, returns the balance |
POST /subscribers/{id}/features/{feature}/release | amount | release, returns the balance |
DELETE /subscribers/{id}/features/{feature}/consumption | reset consumption, returns the balance |
The subscribers/{id} routes take the subscriber's type as subscriber_type (query string or body), the default
subscriber model's when omitted.
| POST /plans | id, periodicity_type, periodicity?, grace_period?, ui_name?, description?, starts_at?, ends_at?, is_active?, features? (ids), quotas? ({"api": 100}) | create a plan |
| PUT /plans/{plan} | the same without id and features | replace the attributes of a plan (omitted ones get their default) |
| POST /plans/{plan}/archive | | turn a plan off |
| PUT /plans/{plan}/features/{feature} | quota? | add a feature to a plan, or change its quota |
| DELETE /plans/{plan}/features/{feature} | | remove a feature from a plan |
| POST /features | id, is_consumable?, ui_name?, description?, starts_at?, ends_at?, is_active? | create a feature |
| PUT /features/{feature} | the same without id | replace the attributes of a feature (omitted ones get their default) |
| POST /features/{feature}/archive | | turn a feature off |
The endpoints are meant to be plug and play for small teams, they do not deduplicate retried requests: a retried
consume, quota or renew is applied twice (see Atomicity).
duration and grace_period are ISO 8601 durations (P1M, PT12H). Plans are returned with their features. Subscriptions are returned with their current status
(scheduled, active, ended) and in_grace_period; a balance is
{feature_id, subscriber_type, subscriber_id, quota, consumption, available, usable, grants: [...]} over the unexpired grants
(available is 0 when the feature can not be consumed, usable false). Domain errors are returned as
{"type": "conflict", "message": "...", "context": {...}} with their HTTP status. Set subscriptions.routes.enabled
to false to register your own routes and call the handlers yourself.
Querying
Read-only endpoints pass the query string to the query handlers and answer with {"data": [...], "meta": {...}}:
GET /api/plans
GET /api/plans/pro # the plan with its features
GET /api/features/api
GET /api/features?plan=pro&consumable=true
GET /api/subscriptions?status=active&subscriber=7&sort[]=expire_at:asc&limit=20&pagination[page]=2
GET /api/subscriptions?phase=grace_period # who is in the grace period; renewal_due: within lifecycle.renewal_notice
GET /api/subscriptions?pending_cancellation=1 # who is leaving at the end of the period
GET /api/feature-consumptions?subscriber=7&feature=api
GET /api/subscribers/7/features # features of the active plans
GET /api/subscribers/7/features/api # {"has_feature": true, ...balance}
Artisan commands
subscriptions:subscribe {subscriber} {plan} [--start=] [--expire=] [--subscriber-type=]
subscriptions:switch {subscription} {plan} [--at-period-end] [--duration=]
subscriptions:cancel {subscription} [--now]
subscriptions:resume {subscription}
subscriptions:revoke {subscription}
subscriptions:renew {subscription} [--duration=]
subscriptions:extend {subscription} [--duration=] [--expire=] # one of them
subscriptions:tick [--batch=100] [--reschedule] # announce the lifecycle changes, schedule it; --reschedule once after renewal_notice changed
subscriptions:grant-quota {subscriber} {feature} {quota} [--expire=] [--subscriber-type=]
subscriptions:consume {subscriber} {feature} {amount} [--subscriber-type=]
subscriptions:release {subscriber} {feature} {amount} [--subscriber-type=]
subscriptions:reset-consumption {subscriber} {feature} [--subscriber-type=]
subscriptions:plan-create {plan} {periodicity_type} [--periodicity=1] [--grace=] [--name=] [--description=] [--starts=] [--ends=] [--disabled] [--feature=api:100 ...]
subscriptions:plan-update {plan} {periodicity_type} [the same options without --feature]
subscriptions:plan-archive {plan}
subscriptions:plan-attach-feature {plan} {feature} [--quota=]
subscriptions:plan-detach-feature {plan} {feature}
subscriptions:feature-create {feature} [--consumable] [--name=] [--description=] [--starts=] [--ends=] [--disabled]
subscriptions:feature-update {feature} [the same options]
subscriptions:feature-archive {feature}
MCP server
With laravel/mcp installed (composer require laravel/mcp), the package serves an MCP server whose tools let AI
agents read and change the subscriptions, quotas and catalog. It is meant for administrators and support: the tools
act on any subscriber. It is disabled by default, enable it with subscriptions.mcp.enabled
(SUBSCRIPTIONS_MCP=true):
mcp.web: the HTTP endpoint,POST /mcp/subscriptions(path) behind['api', 'auth'](middleware). Put your authentication there (auth:sanctum, or Passport withMcp::oauthRoutes()).mcp.local: the stdio server,php artisan mcp:start subscriptions(handle), e.g. for a local agent. Whoever can run artisan can use it, there is no user.mcp.tools.{search|commands|catalog}: the reads, the subscription and quota commands, and the management of plans and features. Each group can be disabled (enabled) or require a Gateabilityof the user, checked when the tools are listed and when they are called; without a user (the local server) only a gate allowing guests passes.
| Group | Tools |
|---|---|
search | search-plans, get-plan, search-features, get-feature, search-subscriptions, search-feature-consumptions, get-subscriber-features, get-feature-balance |
commands | subscribe, switch-plan, cancel-subscription, resume-subscription, revoke-subscription, renew-subscription, extend-subscription, grant-quota, consume-feature, release-feature, reset-feature-consumption |
catalog | create-plan, update-plan, archive-plan, attach-plan-feature, detach-plan-feature, create-feature, update-feature, archive-feature |
The tools take the arguments of the REST endpoints (the search tools their query options, ids as a list and page
for pagination[page]), run the same handlers (under the lock, in a transaction, with the events) and answer with
the same JSON as structured content. A refused command answers an MCP error carrying
{"type": "conflict", "message": "...", "context": {...}}, so the agent learns why. The tools are annotated
(read-only, destructive, idempotent) for the clients that ask before running them. Like the REST endpoints, the
commands are not idempotent: an agent that retries a call that succeeded applies it twice, their descriptions tell
it to check the state first (see Atomicity).
The same tools work in laravel/ai agents, which accept MCP tools:
use YuriiTatur\Subscriptions\Laravel\Mcp\Servers\SubscriptionServer;
use YuriiTatur\Subscriptions\Laravel\Mcp\Tools\Search\GetFeatureBalanceTool;
public function tools(): iterable
{
return [app(GetFeatureBalanceTool::class)]; // or array_map(app(...), SubscriptionServer::TOOLS)
}
Scope and next steps
What the package leaves to the application, on purpose:
- Trials are subscriptions with an expiry of their own (
SubscribeToPlanCommandwithexpire_at); offering one trial per subscriber is a check in aBeforeSubscribeToPlanEventlistener. - Pausing has too many variants (from now or from the period end, for how long at most, what happens to the
quota) to pick one; extending a subscription (
ExtendSubscriptionCommand) makes up for a pause after the fact. - Plan versions: changing the features of a plan applies to its running subscriptions at once. Create a new plan
when the feature set changes and move subscribers with
SwitchPlanCommand. - Audit: listen to the
After*and lifecycle events. - Quota rollover and overage: grant the rest manually (see Plan quotas).
- Personal data: the subscriber is your model; subscriptions and quota grants only hold its id, which does not
identify a person by itself, so there is nothing to erase here when a subscriber is deleted. Their subscriptions
and grants stay readable: a deleted subscriber is a
SubscriberReference(its type and id), a soft-deleted one its trashed model. - Idempotency of retried requests (see Atomicity).
Next steps:
- Seat-based subscriptions: a subscription bought for a number of seats, assigned to members, with quotas per seat. It is a kind of subscription of its own, with an entity of its own, rather than a number on the plan or the subscription.
- Upgrading and downgrading plans without affecting the active subscriptions: moving the subscribers of a plan to its successor, at their next renewal or right away, with rules for added and removed features. Every organisation handles these cases differently, so this needs a design that leaves the decisions to the application.
Development
composer test
Unit tests live in tests/unit, the Laravel tests (Orchestra Testbench, in-memory SQLite) in tests/feature.
PHPUnit is strict and requires coverage attributes on every test class. See CLAUDE.md for the architecture notes.