Search by

yuriitatur / subscriptions

yuriitatur

subscription tracking

Package info

bitbucket.org/yurii_tatur/subscriptions

pkg:composer/yuriitatur/subscriptions

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

v0.8.1 2026-10-30 13:42 UTC

This package is auto-updated.

Last update: 2026-10-30 13:43:18 UTC


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/repository repositories

Requires PHP 8.4+.

Concepts

Entity
Plannamed 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
Featurenamed capability, optionally consumable (has a quota)
Subscriptiona subscriber on a plan: startAt, expireAt, cancelAt, revokeAt, gracePeriodEndAt
FeatureConsumptiona 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)
SubscriberInterfacewhoever 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\).

CommandDoes
SubscribeToPlanCommandsubscribe 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)
SwitchPlanCommandmove 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
CancelSubscriptionCommandcancel at the end of the paid period, or immediately (always immediately for eternal, grace-period and not yet started subscriptions)
ResumeSubscriptionCommandwithdraw a cancellation that has not taken effect yet
RevokeSubscriptionCommandterminate right now, ignoring the grace period
AnnounceLifecycleCommandannounce the phases time moved subscriptions into since the last run (schedule it), see below
RenewSubscriptionCommandprolong 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
ExtendSubscriptionCommandlengthen 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
GrantFeatureQuotaCommandadd quota of a consumable feature, optionally expiring; tops up the grant with the same expiry
ConsumeFeatureCommandconsume the feature from the grants that expire first, spilling over to the next ones; all or nothing
ReleaseFeatureCommandgive a consumed amount back, to the grants that expire last
ResetFeatureConsumptionCommandzero the consumption of the unexpired grants, e.g. at the start of a billing period
CreatePlanCommandcreate a plan from PlanDetails (periodicity, grace period, texts, availability window, active switch) with its features and their quotas; conflicts when the id is taken
UpdatePlanCommandreplace every attribute of a plan with PlanDetails, the features are kept; running subscriptions keep their dates
ArchivePlanCommandturn a plan off: no new subscriptions, switches or renewals, running subscriptions last until they end; bring it back by updating it as active
CreateFeatureCommandcreate a feature from FeatureDetails (consumable, texts, availability window, active switch); conflicts when the id is taken
UpdateFeatureCommandreplace every attribute of a feature with FeatureDetails
ArchiveFeatureCommandturn 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 / DetachPlanFeatureCommandadd 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):

ExceptionTypeWhen
ConflictExceptionconflict (409)the operation is not allowed in the current state; the more specific ones below extend it
SubscriptionConflictExceptionconflict (409)the subscription is ended, never expires, is (not) cancelled, is being switched
OverlappingSubscriptionExceptionconflict (409)the subscription would overlap another one (getOverlapping(): their ids)
UnavailableExceptionconflict (409)the plan or feature is archived or out of its availability window
AlreadyExistsExceptionconflict (409)a plan or feature with the id exists
QuotaExceededExceptionquota_exceeded (409)the quota left can not cover the amount (getAvailable())
NotConsumableExceptionbad_request (400)a quota for a feature that is not consumable
LockBusyExceptionbusy (409)another request holds the subscriber's lock, or the operation outlasted it
EntityNotFoundExceptionnot_found_error (404)from yuriitatur/repository
InvalidArgumentExceptioninvalid_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:

EventWhen
SubscriptionStartedEventa scheduled subscription started (a switch at the period end, a subscription with a future start)
SubscriptionRenewalDueEventthe paid period ends within the renewal notice and no cancellation is pending: charge and renew
SubscriptionGracePeriodStartedEventthe paid period is over, the grace period began
SubscriptionEndedEventthe 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):

LevelWhat
infoan operation done: subscribed, switched, cancelled, renewed..., quota granted or consumed, catalog changed, a lifecycle phase announced, the summary of a lifecycle run
noticea command refused (a domain error: conflict, quota exceeded, not found, vetoed by a listener)
warninga busy or expired lock (retry), a lifecycle announcement failed (retried by the next run), a subscription whose phase moved back without a command
errora command failed on an exception, it is rolled back
criticala command failed on a PHP error (\Error: a bug)
debuglocks, 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:

  1. Before{Operation}Event before the change, with the loaded subscription, plan or feature. Throw from a listener to veto the operation (eligibility checks).
  2. After{Operation}Event once 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:

HandlerOptions
plansid, ids, name, periodicity_type, periodicity, active; sort: id, name, periodicity_type, periodicity
featuresid, ids, name, consumable, active, plan, plans; sort: id, name, consumable
subscriptionsid, 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 consumptionsid, 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
  1. Make your subscriber model implement SubscriberInterface, e.g. with the IsSubscriber trait, and set it in config/subscriptions.php (subscriber.model, and subscriber.key_type for the migration). When several kinds of models subscribe (users and teams), list the others in subscriber.models. A subscriber's type is its model's morph class (a Relation::morphMap() alias, else the class name, stored in subscriber_type): name it in requests and commands (subscriber_type, --subscriber-type), subscriber.model is the default.
  2. Create features and plans: through the REST API or the Artisan commands (below), or with the local driver 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); table and connection override 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 a key_type: id (auto-incremented, the default), uuid (UUIDv7) or ulid; 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 pathBody
POST /subscriptionssubscriber_id, subscriber_type?, plan_id, start_at?, expire_at?subscribe
POST /subscriptions/{id}/switchplan_id, immediately? (default true), duration?switch plan
POST /subscriptions/{id}/cancelimmediately?cancel
POST /subscriptions/{id}/resumeresume
POST /subscriptions/{id}/revokerevoke
POST /subscriptions/{id}/renewduration?renew
POST /subscriptions/{id}/extendduration or expire_atextend the current period
POST /subscribers/{id}/features/{feature}/quotaquota, expire_at?grant quota, returns the grant
POST /subscribers/{id}/features/{feature}/consumeamountconsume, returns the balance
POST /subscribers/{id}/features/{feature}/releaseamountrelease, returns the balance
DELETE /subscribers/{id}/features/{feature}/consumptionreset 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 with Mcp::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 Gate ability of 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.
GroupTools
searchsearch-plans, get-plan, search-features, get-feature, search-subscriptions, search-feature-consumptions, get-subscriber-features, get-feature-balance
commandssubscribe, switch-plan, cancel-subscription, resume-subscription, revoke-subscription, renew-subscription, extend-subscription, grant-quota, consume-feature, release-feature, reset-feature-consumption
catalogcreate-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 (SubscribeToPlanCommand with expire_at); offering one trial per subscriber is a check in a BeforeSubscribeToPlanEvent listener.
  • 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.