Events
Tashil has two complementary event systems:
- Laravel domain events — dispatched on every meaningful transition (
SubscriptionCreated,InvoicePaid,TrialEnding, …) so your app can react. - The immutable event store — an append-only log row written for every transition, with a strictly monotonic sequence number per subscription, for audit and point-in-time replay.
This page covers both.
The immutable event store
Every state transition appends one row to tashil_subscription_events. The table is genuinely
immutable: the Eloquent model throws on any update or delete.
Key guarantees:
- Monotonic
sequence_numper subscription — assigned under aSELECT … FOR UPDATElock byEventStore::append, so it's strictly increasing with no gaps from races. - Idempotency — when you pass an idempotency key, a repeated
append()with the same key returns the existing row instead of writing a duplicate. - Logical vs storage time — both
occurred_at(when it happened) andrecorded_at(when it was written) are kept.
Reading the log
The per-subscription relation is the sequence-ordered reader — use it for replay and audit:
$subscription->events()->orderBy('sequence_num')->get();
$subscription->events()->ofType('trial.expired')->get();
$subscription->events()->upTo(now()->subWeek())->get();Cross-cutting history readers
For UI timelines that span many subscriptions, the EventStore exposes paginated, newest-first
readers. Each returns a LengthAwarePaginator<SubscriptionEvent> (ordered by occurred_at desc):
use Foysal50x\Tashil\Facades\Tashil;
// One subscription:
Tashil::events()->historyFor($subscription, perPage: 20);
// Every subscription a subscriber has ever held:
Tashil::events()->historyForSubscriber($tenant, perPage: 20);
// A plan-wide lifecycle timeline:
Tashil::events()->historyForPackage($plan,
perPage: 20,
with: ['subscription.subscriber'],
pageName: 'events',
);These are reads — the log stays append-only.
Common event types
The store records these event_types (payload fields in parentheses):
subscription.created (status, requires_payment, with_trial)
subscription.activated (invoice_id) — pending → active on first payment
subscription.cancelled (immediate, reason)
subscription.resumed
subscription.expired
subscription.past_due (invoice_id, attempt) — entered/advanced dunning
subscription.suspended — dunning retries exhausted
subscription.reactivated (invoice_id) — recovered a lapse by payment
subscription.switched (new_subscription_id, new_package_id)
subscription.plan_changed (old_package_id, new_package_id, proration_amount)
subscription.paused (remaining_seconds) / .unpaused
subscription.renewed (new_period_end)
subscription.pending_change_scheduled / .pending_change_cancelled
trial.ending (days_remaining)
trial.converted
trial.expired
usage.reset (feature_id, previous_usage)
usage.metered_charged (feature_id, units, unit_price, amount, currency)Domain events
These are regular Laravel events you can listen for. They dispatch after the database commit (via
DB::afterCommit() when events.async is true, the default), so a listener never sees a half-written
state.
Subscription lifecycle
| Event | Fired when |
|---|---|
SubscriptionCreated | A new subscription is persisted. |
SubscriptionActivated | Pending → Active on first payment, or a free plan created active. |
SubscriptionRenewed | A renewal invoice is paid and the period advances. |
SubscriptionCancelled | cancel() — carries the immediate flag and reason. |
SubscriptionResumed | Resumed from PendingCancellation. |
SubscriptionExpired | expire() or the expire-subscriptions job. |
SubscriptionSwitched | switchPlan() — carries old + new subscription and plan. |
SubscriptionPlanChanged | In-place changePlan() — carries the proration amount + invoice. |
SubscriptionPaused / SubscriptionUnpaused | Pause / unpause. |
SubscriptionPastDue | A renewal goes unpaid past due — carries the invoice + attempt. |
SubscriptionSuspended | Dunning retries exhausted. |
SubscriptionReactivated | A lapsed subscription recovered by payment. |
PendingChangeScheduled / PendingChangeApplied | scheduleDowngrade() lifecycle. |
Trial
| Event | Fired when |
|---|---|
TrialEnding | tashil:mark-trials-ending — carries daysRemaining. |
TrialConverted | convertTrial(). |
TrialExpired | expireTrial() or the expire-trials job. |
Usage & metered
| Event | Fired when |
|---|---|
UsageReset | A manual or scheduled counter reset. |
UsageLimitWarning | The 80% threshold crossed (once per period). |
MeteredCharged | A metered charge accepted — units, unit price, amount, currency. |
MeteredChargeRejected | A metered charge declined (insufficient balance / refusal). |
Invoice
| Event | Fired when |
|---|---|
InvoiceIssued | An invoice is created. |
InvoicePaid | An invoice is marked paid. |
InvoiceVoided | An invoice is voided. |
InvoiceOverdue | Each dunning attempt on an overdue invoice. |
Transaction ledger
| Event | Fired when |
|---|---|
PaymentRecorded | Tashil::billing()->recordPayment() wrote a successful transaction — carries the transaction + invoice. |
PaymentFailed | recordFailedPayment() wrote a failed transaction — carries the transaction + invoice. |
PaymentRefunded | recordRefund() recorded a refund — carries the transaction (cumulative refunded_amount) + invoice. |
Listening to events
Listen the standard Laravel way — for example, in your EventServiceProvider:
protected $listen = [
\Foysal50x\Tashil\Events\SubscriptionCreated::class => [SendWelcomeEmail::class],
\Foysal50x\Tashil\Events\InvoiceIssued::class => [ChargeViaGateway::class],
\Foysal50x\Tashil\Events\SubscriptionPastDue::class => [RetryCharge::class],
\Foysal50x\Tashil\Events\TrialEnding::class => [SendTrialNudge::class],
\Foysal50x\Tashil\Events\UsageLimitWarning::class => [NotifyApproachingLimit::class],
];Or inline:
use Foysal50x\Tashil\Events\SubscriptionExpired;
Event::listen(SubscriptionExpired::class, fn (SubscriptionExpired $e) => /* … */);This is how you wire payments
InvoiceIssued, SubscriptionPastDue, and InvoiceOverdue are the hooks where your app charges the
card. Tashil issues the bill and fires the event; your listener moves the money and calls
Tashil::billing()->recordPayment($invoice, …) — which records the transaction and settles the
invoice in one idempotent call. See Billing Lifecycle.
Appending your own events
The event store is part of the public API. Append host-specific events to a subscription's timeline:
Tashil::events()->append($subscription, 'host.custom.thing',
payload: ['foo' => 'bar'],
idempotencyKey: 'host-custom-' . $request->id,
);Pass an idempotency key whenever the operation can be retried by an outer system (a queued job, an HTTP retry) so a re-run doesn't write a duplicate row.