Skip to content

Trials

Trials are first-class state in Tashil. The package tracks four distinct timestamps so the entire trial lifecycle is reconstructable at any point:

TimestampSet when
trial_started_atsubscribe(..., withTrial: true) and the plan has trial_days > 0.
trial_ends_atThe same moment — equals trial_started_at + trial_days.
trial_converted_atAn explicit convertTrial() call.
trial_expired_atThe tashil:expire-trials job, when a trial ends unconverted.

A trial's behavior is entirely orthogonal to billing — your app still handles charging. Tashil owns only the trial state and the related events.

The lifecycle

subscribe(withTrial)
OnTrial
convertTrial()
Active

And the unhappy path:

OnTrial
tashil:expire-trials
Expired

tashil:mark-trials-ending sits in the middle — it dispatches a warning event but does not change status; the subscription stays OnTrial.

Starting a trial

Tashil::subscription()->subscribe($user, $package, withTrial: true);

For this to actually start a trial, the plan must define trial_days > 0. Tashil then sets:

  • status = OnTrial
  • trial_started_at = now()
  • trial_ends_at = now() + trial_days
  • current_period_start = now(), current_period_end = the computed billing-period end

If the trial outlasts the first billing window, ends_at is set to trial_ends_at so access stays valid until the trial genuinely expires. The event store records subscription.created with with_trial: true in the payload.

Strict on-trial semantics

isOnTrial() returns true only when all three hold:

  • status is OnTrial, and
  • trial_ends_at is not null, and
  • trial_ends_at is in the future.
$subscription->isOnTrial();
$user->onTrial();

Why so strict?

A subscription that was on trial and is now cancelled or expired will not report as on-trial, even if trial_ends_at is still in the future. This prevents a cancelled-mid-trial subscriber from appearing as an active trial — a subtle bug the strict check exists to prevent (and a regression test guards it).

Warning before expiry

The tashil:mark-trials-ending job (default daily at 07:55) finds trials approaching expiry — status = OnTrial, trial_ends_at within trial.warn_days (default 3), and not yet converted. For each, it dispatches the TrialEnding event carrying daysRemaining, using an idempotency key so a same-day re-run sends nothing twice.

Listen for it to nudge the customer:

use Foysal50x\Tashil\Events\TrialEnding;
 
Event::listen(TrialEnding::class, function (TrialEnding $event) {
    Mail::to($event->subscription->subscriber)
        ->send(new TrialEndingMail($event->daysRemaining));
});

Converting a trial

When your product decides a trial has converted, call convertTrial():

Tashil::subscription()->convertTrial($subscription);

This sets status = Active and trial_converted_at = now(), then:

  • Anchors the first paid period to the conversion momentcurrent_period_start = now, current_period_end = now + billing period — and re-anchors the feature counters with it. The customer does not get the unused remainder of the trial-subscribe period for free.
  • Issues the first initial invoice for priced plans (skipped for free plans). Paying it records the payment without advancing the just-anchored period.
  • Appends trial.converted and dispatches TrialConverted (plus SubscriptionActivated).

Conversion is host-triggered — never inferred from payment

Tashil does not automatically convert a trial when an invoice is paid, because conversion semantics vary by product. Your app calls convertTrial() when it decides the trial has converted. The next renewal is billed one full period after conversion — so there's no free period after a trial.

The renewal cron (tashil:renew-subscriptions) selects Active subscriptions only — it never bills a subscription that is still OnTrial, even if the trial outlives the first billing window.

Expiring a trial

The tashil:expire-trials job (default every 30 minutes) finds trials where status = OnTrial, trial_ends_at has passed, and trial_converted_at is null. For each it calls expireTrial(), which sets status = Expired and trial_expired_at = now(), appends trial.expired, and dispatches TrialExpired. The job is idempotent — converted trials and already-expired ones are filtered out.

You can also expire one explicitly:

Tashil::subscription()->expireTrial($subscription);

Grace after expiry

There's no built-in "grace state" — expired is expired from the plan's point of view. If you want a limited-access window after a trial expires, implement that policy yourself: Tashil sets trial_expired_at so you can compute the grace window, and trial.grace_days is available in config as a hint for your own logic.

Trial-aware plan switching

switchPlan() carries a trial forward only when both are true: the old subscription isOnTrial() returns true, and the new plan has trial_days > 0. In that case the new subscription starts OnTrial with a fresh trial_started_at / trial_ends_at based on the new plan — switching mid-trial grants the new plan's full trial, not the remaining days. If you need to preserve remaining days instead, call the lower-level subscribe() with your own dates.

Trial events reference

EventDispatched byPayload
TrialEndingtashil:mark-trials-ending$subscription, $daysRemaining
TrialConvertedconvertTrial()$subscription
TrialExpiredtashil:expire-trials / expireTrial()$subscription

All three dispatch after the database commit (when events.async is true, the default), so listeners never observe a torn state.

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