Trials
Trials are first-class state in Tashil. The package tracks four distinct timestamps so the entire trial lifecycle is reconstructable at any point:
| Timestamp | Set when |
|---|---|
trial_started_at | subscribe(..., withTrial: true) and the plan has trial_days > 0. |
trial_ends_at | The same moment — equals trial_started_at + trial_days. |
trial_converted_at | An explicit convertTrial() call. |
trial_expired_at | The 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
And the unhappy path:
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 = OnTrialtrial_started_at = now()trial_ends_at = now() + trial_dayscurrent_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:
statusisOnTrial, andtrial_ends_atis not null, andtrial_ends_atis 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 moment —
current_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
initialinvoice for priced plans (skipped for free plans). Paying it records the payment without advancing the just-anchored period. - Appends
trial.convertedand dispatchesTrialConverted(plusSubscriptionActivated).
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
| Event | Dispatched by | Payload |
|---|---|---|
TrialEnding | tashil:mark-trials-ending | $subscription, $daysRemaining |
TrialConverted | convertTrial() | $subscription |
TrialExpired | tashil: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.