Database Schema
All tables are prefixed (tashil_ by default) and fully configurable via
config/tashil.php under database.prefix and database.tables. A single migration creates them
all. BaseModel resolves each table name and connection from config at runtime, so renaming a table
is a config change, not a code change.
Relationships
packages ──┬─< package_feature >── features
├─< subscriptions ──┬─< subscription_features >── features
│ ├─< feature_usages >──────── features
│ ├─< usage_logs >──────────── features
│ ├─< subscription_events
│ └─< invoices ──< transactions
└ (pending_package_id) ─ subscriptionsThe aggregate root is subscriptions. Around it sit the immutable snapshot, the mutable
counter, the audit log, the event store, and the invoices — each with a clear, single
responsibility.
tashil_packages
The catalog of subscribable plans — pricing, trial config, and billing cadence.
| Column | Type | Notes |
|---|---|---|
id | BIGINT PK | |
slug | VARCHAR UNIQUE | Stable identifier used by builders and lookups. |
name / description | VARCHAR / TEXT | Display fields. |
price / original_price | DECIMAL(10,2) | original_price is an optional strike-through. |
currency | CHAR(3) | ISO 4217; defaults to tashil.currency. |
billing_period | VARCHAR | Period enum: day, week, month, year, lifetime. |
billing_interval | INT | Multiplier (e.g. month × 3 = quarterly). |
trial_days | INT | Trial length; 0 disables. |
requires_payment | BOOL | When true (and price > 0), the plan gates behind its first paid invoice. Authoritative at runtime; seeded from billing.activate_on_payment at creation. |
is_active / is_featured | BOOL | |
sort_order | INT | |
metadata | JSON | Your custom data. |
created_at / updated_at / deleted_at | TIMESTAMP | Soft-deletes enabled. |
tashil_features
Feature definitions, independent of any plan.
| Column | Type | Notes |
|---|---|---|
id | BIGINT PK | |
slug | VARCHAR UNIQUE | Stable identifier used in code. |
name / description | VARCHAR / TEXT | |
type | VARCHAR | FeatureType enum: boolean, limit, consumable, enum, metered. |
reset_period | VARCHAR | ResetPeriod enum: never, daily, weekly, monthly, yearly. |
is_active | BOOL | Global kill-switch — when false, access checks refuse globally. |
sort_order | INT | |
metadata | JSON | |
created_at / updated_at / deleted_at | TIMESTAMP | Soft-deletes enabled. |
tashil_package_feature (pivot)
Per-plan feature configuration.
| Column | Type | Notes |
|---|---|---|
package_id / feature_id | BIGINT FK | Cascade on delete. Unique together. |
value | VARCHAR | Limit amount, boolean/enum string, or — for metered — the unit price. |
is_available | BOOL | If false, the feature isn't synced on subscribe. |
sort_order | INT |
tashil_subscriptions (aggregate root)
The current state of one subscription. Updated on every transition; the immutable history lives in the event store.
| Column | Type | Notes |
|---|---|---|
id | BIGINT PK | |
subscriber_type / subscriber_id | morphs | Polymorphic — any HasSubscriptions model. |
package_id | BIGINT FK | Current plan. |
pending_package_id / pending_change_at | BIGINT FK / TIMESTAMP | A scheduled change (e.g. downgrade at period end). |
status | VARCHAR | SubscriptionStatus enum. |
starts_at / ends_at | TIMESTAMP | First activation; lifetime cutoff (null for open-ended). |
current_period_start / current_period_end | TIMESTAMP | The billing window — drives renewal. |
trial_started_at / trial_ends_at / trial_converted_at / trial_expired_at | TIMESTAMP | Trial lifecycle. |
cancelled_at / cancellation_effective_at / cancellation_reason | TIMESTAMP / VARCHAR | Cancellation. |
auto_renew | BOOL | |
activated_at | TIMESTAMP | First pending → active. |
dunning_attempts / last_dunning_at / suspended_at | INT / TIMESTAMP | Dunning bookkeeping. |
last_event_seq | BIGINT | Cursor for the event store. |
metadata | JSON | |
created_at / updated_at / deleted_at | TIMESTAMP | Soft-deletes enabled. |
Key indexes drive the scheduler and lookups: (subscriber_type, subscriber_id, status),
(status, current_period_end), (status, ends_at), (status, trial_ends_at), and
(pending_change_at).
tashil_subscription_features (immutable snapshot)
A frozen view of a feature on a subscription, written on subscribe and on every plan change. Old rows
are stamped superseded_at and never touched again.
| Column | Type | Notes |
|---|---|---|
id | BIGINT PK | |
subscription_id / feature_id | BIGINT FK | Cascade on delete. |
feature_slug / feature_type | VARCHAR | Denormalized so catalog renames don't affect history. |
value | VARCHAR | The pivot value at subscribe time. |
reset_period | VARCHAR | Snapshotted. |
added_at / superseded_at | TIMESTAMP | superseded_at is null for current rows. |
Enforced immutability
SubscriptionFeature::booted() throws on any update to a column other than superseded_at /
updated_at, and on any delete. The snapshot is a trustworthy audit trail by construction.
tashil_feature_usages (mutable counter)
The current usage value per (subscription, feature). Every change is also written to the usage log.
| Column | Type | Notes |
|---|---|---|
id | BIGINT PK | |
subscription_id / feature_id | BIGINT FK | Cascade. Unique together. |
usage | DECIMAL(20,4) | Current counter; fractional values supported. |
limit_value | DECIMAL(20,4) | Cached cap (null = unlimited). Populated only for limit features. |
reset_period | VARCHAR | Used by the reset job. |
period_start / period_end | TIMESTAMP | The reset job zeroes counters whose period_end has passed, anchored to the previous period_end. |
tashil_usage_logs (append-only audit)
One row per mutation of a counter. Never updated or deleted in the public API.
| Column | Type | Notes |
|---|---|---|
id | BIGINT PK | |
subscription_id / feature_id | BIGINT FK | Cascade. |
operation | VARCHAR | UsageOperation enum: consume, reset, adjust, report. |
amount | DECIMAL(20,4) | |
previous_usage / new_usage | DECIMAL(20,4) | Before/after snapshot — enables full replay. |
description / metadata | VARCHAR / JSON |
tashil_subscription_events (immutable event store)
The append-only log of every transition. The Eloquent layer rejects updates and deletes.
| Column | Type | Notes |
|---|---|---|
id | BIGINT PK | |
event_id | UUID UNIQUE | Stable external identifier. |
subscription_id | BIGINT FK | Cascade. |
event_type | VARCHAR(64) | e.g. subscription.created, trial.expired, usage.reset. |
sequence_num | BIGINT | Strictly monotonic per subscription (assigned under a FOR UPDATE lock). |
payload / metadata | JSON | Domain data / caller context. |
idempotency_key | UUID | When set, a repeated append returns the existing row. |
occurred_at / recorded_at | TIMESTAMP | Logical vs storage time. |
Unique: (subscription_id, sequence_num) and (subscription_id, idempotency_key).
tashil_invoices
Bills Tashil issues — initial when a priced plan is subscribed (or a trial converts), renewal
at period end, and proration on an in-place upgrade.
| Column | Type | Notes |
|---|---|---|
id | BIGINT PK | |
subscription_id | BIGINT FK | Cascade. |
invoice_number | VARCHAR UNIQUE | Generated by the observer via tashil.invoice.generator. |
kind | VARCHAR | InvoiceKind enum: initial, renewal, proration, usage — drives routing on payment. |
amount / currency | DECIMAL(10,2) / CHAR(3) | |
status | VARCHAR | InvoiceStatus: draft, pending, paid, void, refunded. |
period_start / period_end | TIMESTAMP | The window the invoice covers. |
issued_at / due_date / paid_at | TIMESTAMP | |
attempts / last_attempt_at | INT / TIMESTAMP | Dunning bookkeeping. |
notes | TEXT | |
created_at / updated_at / deleted_at | TIMESTAMP | Soft-deletes enabled. |
When a paid invoice is observed, it routes by kind + subscription status:
initial+Pending→activate()(first access, period anchored topaid_at).renewal+Active/OnTrial→advancePeriod(), firesSubscriptionRenewed.- any kind + lapsed (
PastDue/Suspended/Expired) →reactivate(). proration(orinitialon an active sub) → records payment, no period change.
tashil_transactions
An optional ledger of gateway settlements linked to invoices. Tashil never writes here — your app records gateway responses.
| Column | Type | Notes |
|---|---|---|
id | BIGINT PK | |
invoice_id | BIGINT FK | Cascade. |
gateway | VARCHAR | Defaults to manual (e.g. stripe, paddle, bkash). |
transaction_id | VARCHAR | Gateway-supplied, or auto-generated when blank (unique within its gateway). |
amount / currency / status | ||
gateway_response / metadata | JSON | |
refunded_amount / refunded_at / refund_reason |
Index: UNIQUE(gateway, transaction_id) — a reconciliation guard that makes duplicate webhook
deliveries safe. transaction_id stays nullable so pre-gateway-response rows aren't blocked.
Soft deletes
packages, features, subscriptions, and invoices use Laravel soft-deletes. The other tables
(snapshot, counter, logs, events, transactions) intentionally don't — they're either append-only
audit or short-lived counters managed by their parent's cascade.
Connection & table-name overrides
Every table name and the connection come from config('tashil.database.*') at runtime. BaseModel
resolves the table name from class_basename → snake_plural, looks up
tashil.database.tables.<key>, and prefixes with tashil.database.prefix — so renaming a table or
moving Tashil to its own database connection is pure configuration. See
Configuration.