Skip to content

Events

Tashil has two complementary event systems:

  1. Laravel domain events — dispatched on every meaningful transition (SubscriptionCreated, InvoicePaid, TrialEnding, …) so your app can react.
  2. 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_num per subscription — assigned under a SELECT … FOR UPDATE lock by EventStore::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) and recorded_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

EventFired when
SubscriptionCreatedA new subscription is persisted.
SubscriptionActivatedPending → Active on first payment, or a free plan created active.
SubscriptionRenewedA renewal invoice is paid and the period advances.
SubscriptionCancelledcancel() — carries the immediate flag and reason.
SubscriptionResumedResumed from PendingCancellation.
SubscriptionExpiredexpire() or the expire-subscriptions job.
SubscriptionSwitchedswitchPlan() — carries old + new subscription and plan.
SubscriptionPlanChangedIn-place changePlan() — carries the proration amount + invoice.
SubscriptionPaused / SubscriptionUnpausedPause / unpause.
SubscriptionPastDueA renewal goes unpaid past due — carries the invoice + attempt.
SubscriptionSuspendedDunning retries exhausted.
SubscriptionReactivatedA lapsed subscription recovered by payment.
PendingChangeScheduled / PendingChangeAppliedscheduleDowngrade() lifecycle.

Trial

EventFired when
TrialEndingtashil:mark-trials-ending — carries daysRemaining.
TrialConvertedconvertTrial().
TrialExpiredexpireTrial() or the expire-trials job.

Usage & metered

EventFired when
UsageResetA manual or scheduled counter reset.
UsageLimitWarningThe 80% threshold crossed (once per period).
MeteredChargedA metered charge accepted — units, unit price, amount, currency.
MeteredChargeRejectedA metered charge declined (insufficient balance / refusal).

Invoice

EventFired when
InvoiceIssuedAn invoice is created.
InvoicePaidAn invoice is marked paid.
InvoiceVoidedAn invoice is voided.
InvoiceOverdueEach dunning attempt on an overdue invoice.

Transaction ledger

EventFired when
PaymentRecordedTashil::billing()->recordPayment() wrote a successful transaction — carries the transaction + invoice.
PaymentFailedrecordFailedPayment() wrote a failed transaction — carries the transaction + invoice.
PaymentRefundedrecordRefund() recorded a refund — carries the transaction (cumulative refunded_amount) + invoice.

Listening to events

Listen the standard Laravel way — for example, in your EventServiceProvider:

app/Providers/EventServiceProvider.php
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.

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