Trials are first-class state on the subscription. Tahsil tracks four distinct timestamps so the entire lifecycle is reconstructable:
| Column | Set when |
|---|---|
trial_started_at |
subscribe(..., withTrial: true) and the package has trial_days > 0. |
trial_ends_at |
Same moment, equals trial_started_at + trial_days. |
trial_converted_at |
Explicit convertTrial() call, or when the host marks an invoice paid during the trial window. |
trial_expired_at |
tashil:expire-trials runs when trial_ends_at <= now() AND trial_converted_at IS NULL. |
A trial's behaviour is entirely orthogonal to billing — the host third-party handles charging. Tahsil only owns the trial state and the related events.
stateDiagram-v2
[*] --> OnTrial: subscribe(withTrial=true)
OnTrial --> Active: convertTrial()
OnTrial --> Expired: tashil:expire-trials
OnTrial --> OnTrial: tashil:mark-trials-ending (event only)
Active --> [*]
Expired --> [*]
The "mark-trials-ending" arrow is a no-op state-wise — it dispatches TrialEnding so the host can email/notify, but the subscription remains OnTrial.
app('tashil')->subscription()->subscribe($user, $package, withTrial: true);For this to start a trial, the package must define trial_days > 0. Tahsil sets:
status = OnTrialtrial_started_at = now()trial_ends_at = now() + package.trial_days dayscurrent_period_start = now(),current_period_end = computed billing period end
If trial_ends_at > current_period_end, ends_at is set to trial_ends_at so the subscription stays valid until the trial actually expires.
The event store gets subscription.created with with_trial: true in the payload.
Subscription::isOnTrial() returns true only when:
status === SubscriptionStatus::OnTrial, andtrial_ends_at !== null, andtrial_ends_atis in the future.
A subscription that was on trial and is now cancelled or expired will not return true from isOnTrial() even if trial_ends_at is still future. This was a notable bug in earlier versions; the regression test lives in tests/Unit/Models/SubscriptionStatusHelpersTest.php.
The scheduled job tashil:mark-trials-ending (default daily 07:55) finds:
status = OnTrialtrial_ends_atbetweennow()andnow() + tashil.trial.warn_days(default 3)trial_converted_at IS NULL
For each, it appends trial.ending to the event store with an idempotency key "trial-ending:{sub_id}:{date}" and dispatches the TrialEnding event carrying the number of daysRemaining. The idempotency key guarantees that running the job twice on the same day re-sends nothing.
Host apps listen to TrialEnding to send notifications:
Event::listen(TrialEnding::class, function ($event) {
Mail::to($event->subscription->subscriber)->send(new TrialEndingMail($event->daysRemaining));
});Two ways:
- Explicit — host calls
SubscriptionService::convertTrial($sub). Setsstatus = Active,trial_converted_at = now(), appendstrial.converted, dispatchesTrialConverted. - Invoice-paid — host marks an invoice as paid during the trial. Tahsil's
InvoiceObserveradvancescurrent_period_endand firesSubscriptionRenewed. The host can also callconvertTrial()from a listener onInvoicePaidto settrial_converted_at. Tahsil does not automatically infer conversion from payment; the host owns that policy because conversion semantics vary (some products treat any payment as conversion; others require an explicit upgrade).
tashil:expire-trials (default every 30 min) finds:
status = OnTrialtrial_ends_at <= now()trial_converted_at IS NULL
For each, it calls SubscriptionService::expireTrial() which sets:
status = Expiredtrial_expired_at = now()
Appends trial.expired and dispatches TrialExpired. Idempotent — running the job again finds nothing because converted trials are filtered out by trial_converted_at IS NULL and expired trials by status != OnTrial.
Hosts that want to keep trials in a limited-access window after expiry can implement that policy themselves — tahsil sets trial_expired_at so the host can compute the grace window. There's no built-in "grace state"; expired is expired from the package's point of view.
switchPlan() carries trial forward iff:
- The old subscription
isOnTrial()returnstrue, and - The new package has
trial_days > 0.
In that case the new subscription starts in OnTrial with a fresh trial_started_at and a fresh trial_ends_at based on the new package's trial_days. This is intentional — switching plans mid-trial doesn't preserve the remaining days; it grants the new plan's full trial. Hosts that need the alternative ("preserve days") can call the lower-level subscribe directly with their own dates.
$sub->isOnTrial();
$sub->trial_ends_at;
$sub->trial_converted_at;
$sub->trial_expired_at;
// User-facing
$user->onTrial();| Event | Dispatched by | Payload |
|---|---|---|
TrialEnding |
tashil:mark-trials-ending |
$subscription, $daysRemaining |
TrialConverted |
convertTrial() |
$subscription |
TrialExpired |
tashil:expire-trials / expireTrial() |
$subscription |
All three are dispatched after the DB commit via DB::afterCommit() when tashil.events.async is true (default), so listeners never see a torn state.