Skip to content

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:

StatusMeaning
PendingA priced plan was subscribed but its first invoice isn't paid yet — no access.
ActiveCurrently active and within its paid period.
OnTrialIn a trial — strictly: trial_ends_at must be in the future.
PastDueA renewal payment is late; in the dunning retry window.
PausedPaused by your app; the clock is frozen and access is revoked.
PendingCancellationGrace-cancelled — still valid until the period ends.
CancelledCancelled immediately; access revoked now.
SuspendedDunning retries exhausted; access cut.
ExpiredPast its access window.

The SubscriptionStatus enum holds these cases. Two helpers decide access:

  • isValid() is true for Active, OnTrial, PendingCancellation (during grace), and PastDue if dunning.keep_access_while_past_due is on.
  • subscribed() (the user-facing helper) mirrors that.

The lifecycle at a glance

subscribe
Pending / OnTrial / Active
renew
cancel
Expired

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:

withTrial + trial_days > 0OnTrialaccess granted now, billed at conversion
price > 0, requires_payment = truePendingno access; an initial invoice is issued
price = 0 or requires_payment = falseActiveaccess immediately, no invoice

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_at

Paying 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 lifecycle

Cancelling

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 preserved

Immediate cancel revokes access right away:

Tashil::subscription()->cancel($subscription, immediate: true, reason: 'User request');
// status: Cancelled, access ends now

Both 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 proration invoice (skipped if below billing.min_proration_amount). Fires SubscriptionPlanChanged.
  • 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_end

The 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 restored

Internally, 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 → Expired

The 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.

Tashil — Subscription management for Laravel. Released under the MIT license.