Subscriptions
A subscription links a subscriber (your User, Team, etc.) to a plan and tracks its state over
time. This page covers the entire lifecycle: how to create one, the statuses it can be in, and every
operation you can perform on it.
Subscription statuses
At any moment a subscription is in exactly one status:
| Status | Meaning |
|---|---|
Pending | A priced plan was subscribed but its first invoice isn't paid yet — no access. |
Active | Currently active and within its paid period. |
OnTrial | In a trial — strictly: trial_ends_at must be in the future. |
PastDue | A renewal payment is late; in the dunning retry window. |
Paused | Paused by your app; the clock is frozen and access is revoked. |
PendingCancellation | Grace-cancelled — still valid until the period ends. |
Cancelled | Cancelled immediately; access revoked now. |
Suspended | Dunning retries exhausted; access cut. |
Expired | Past its access window. |
The SubscriptionStatus enum holds these cases. Two helpers decide access:
isValid()is true forActive,OnTrial,PendingCancellation(during grace), andPastDueifdunning.keep_access_while_past_dueis on.subscribed()(the user-facing helper) mirrors that.
The lifecycle at a glance
A fuller map, including the money-related transitions, is on the Billing Lifecycle page. The rest of this page is the practical API.
Subscribing
use Foysal50x\Tashil\Facades\Tashil;
use Foysal50x\Tashil\Models\Package;
$user = User::find(1);
$package = Package::where('slug', 'pro')->first();
$subscription = Tashil::subscription()->subscribe($user, $package);
// Or start a trial (uses the package's trial_days):
$subscription = Tashil::subscription()->subscribe($user, $package, withTrial: true);Subscribing dispatches SubscriptionCreated and appends a subscription.created row to the
immutable event log.
What status do you get?
subscribe() does not always return an Active subscription. The result depends on the plan:
Subscribing twice throws
Calling subscribe() for a subscriber that already has a live subscription throws
Foysal50x\Tashil\Exceptions\SubscriptionException (via the alreadySubscribed named constructor).
To move someone between plans, use changePlan() or switchPlan() instead — or cancel the current
subscription first.
Activating a gated (Pending) subscription
A Pending subscription has no access and no period yet. It becomes Active when its initial
invoice is paid. Tashil never moves money — your gateway charges, then you record the payment:
use Foysal50x\Tashil\Facades\Tashil;
// The outstanding invoice for this subscription, via the billing API (no direct query):
$invoice = Tashil::billing()->pendingInvoice($subscription);
// Records the transaction + marks the invoice paid → InvoiceObserver → activate()
Tashil::billing()->recordPayment($invoice, gateway: 'stripe', transactionId: 'ch_…');
$subscription->refresh(); // Active; period anchored to invoice.paid_atPaying anchors current_period_start, current_period_end, and activated_at to the payment
moment — never to subscribe time — so the customer always gets the full period they paid for. A
SubscriptionActivated event fires.
Checking state
$user->subscribed(); // Active, OnTrial, or grace (PendingCancellation)
$user->onPlan('pro'); // subscribed to this slug?
$user->onTrial(); // strict trial check
$user->paused(); // Paused?
$user->pendingChange(); // the queued target Package, or null
$user->subscription(); // the resolved Subscription model (hits DB)
$user->loadSubscription(); // same, cached for the request lifecycleCancelling
There are two flavors. Grace cancel (the default) keeps access until the period ends; the
subscription moves to PendingCancellation and the tashil:expire-subscriptions job promotes it to
Expired when the window passes:
Tashil::subscription()->cancel($subscription);
// status: PendingCancellation, auto_renew: false, ends_at preservedImmediate cancel revokes access right away:
Tashil::subscription()->cancel($subscription, immediate: true, reason: 'User request');
// status: Cancelled, access ends nowBoth dispatch SubscriptionCancelled, carrying the immediate flag and reason.
Resuming
Only a PendingCancellation subscription whose period is still in the future can be resumed:
$user->resumeSubscription(); // back to Active
// or: Tashil::subscription()->resume($subscription);Changing plans
There are two distinct operations. Choose based on whether you want to keep the same subscription row (and its usage and period) or start fresh.
changePlan() — in-place, with proration
Keeps the same subscription, period, and usage counters. Tashil classifies the move by normalized monthly price:
- Upgrade / lateral → applied immediately. Feature snapshots are re-written from the new plan
while usage carries forward, and the prorated price difference for the rest of the current
period is billed on a
prorationinvoice (skipped if belowbilling.min_proration_amount). FiresSubscriptionPlanChanged. - Downgrade → deferred to period end (the current period is already paid).
$enterprise = Package::where('slug', 'enterprise')->first();
// prorate defaults to true.
$subscription = Tashil::subscription()->changePlan($subscription, $enterprise);
// trait shortcut:
$user->changePlan($enterprise);Single-currency only
Proration across currencies throws SubscriptionException (cannotProrateAcrossCurrencies). To
move someone to a plan priced in a different currency, cancel and resubscribe instead.
switchPlan() — cancel + recreate
Cancels the old subscription and creates a brand-new one with the target plan (fresh counters).
Access continues with no re-gating or double-billing, and SubscriptionSwitched fires. If the old
subscription is mid-trial and the new plan offers a trial, the trial carries forward as the new
plan's full trial.
$newSub = Tashil::subscription()->switchPlan($subscription, $enterprise);Which should I use?
Reach for changePlan() for ordinary upgrades and downgrades on a live plan — it keeps history and
usage continuous. Use switchPlan() for cross-product moves or when you genuinely want a clean new
subscription row.
Scheduling a downgrade
Queue a plan change to take effect at the end of the current period:
$user->scheduleDowngrade($basicPackage);
// stores pending_package_id + pending_change_at = current_period_endThe tashil:apply-pending-changes job applies it when the time arrives, dispatching
PendingChangeApplied. Cancel a queued change with:
Tashil::subscription()->cancelPendingChange($subscription);Pausing and resuming
Pausing freezes the clock and banks the remaining paid time, so a pause never burns the customer's days:
$user->pauseSubscription(); // status: Paused, access revoked
$user->unpauseSubscription(); // status: Active, remaining time restoredInternally, pause() stores metadata.paused_remaining_seconds and fires SubscriptionPaused;
unpause() adds that remainder back from the resume moment and fires SubscriptionUnpaused. A
Paused subscription is not isValid(), so feature checks and the subscribed middleware deny
access while paused. Lifetime / open-ended subscriptions bank nothing.
Trial transitions
Tashil::subscription()->convertTrial($subscription); // OnTrial → Active
Tashil::subscription()->expireTrial($subscription); // OnTrial → ExpiredThe scheduled jobs tashil:expire-trials and tashil:mark-trials-ending handle the unattended
path. Trials are covered in full on the Trials page.
Handling lifecycle errors
Lifecycle problems throw a typed exception — catch the dedicated type, never message text:
use Foysal50x\Tashil\Exceptions\SubscriptionException;
try {
Tashil::subscription()->subscribe($user, $package);
} catch (SubscriptionException $e) {
// e.g. alreadySubscribed, subscriberNotSubscribable, cannotProrateAcrossCurrencies
}SubscriptionException extends RuntimeException and is raised via named constructors:
alreadySubscribed (duplicate live subscription), subscriberNotSubscribable (a switchPlan target
whose model doesn't implement Subscribable), and cannotProrateAcrossCurrencies.
Replaying history
Every transition is appended to a per-subscription event log with a strictly monotonic
sequence_num:
$subscription->events()
->orderBy('sequence_num')
->get()
->each(fn ($e) => print "{$e->sequence_num}: {$e->event_type}\n");The log is the source of truth for audit and point-in-time questions — see Events and Analytics & Reporting.