The Feature System
Tashil keeps four concerns cleanly separated, each in its own table:
- What features exist — the catalog (
tashil_features). - What a plan offers — per-plan configuration (
tashil_package_feature). - What a subscription was given — an immutable snapshot (
tashil_subscription_features). - What a subscription has used — a mutable counter (
tashil_feature_usages).
This separation is what makes Tashil's gating reliable: enforcement always reads the snapshot, so renaming or repricing a feature in the catalog never silently changes an existing subscriber's contract.
The five feature types
| Type | Builder | Behavior |
|---|---|---|
| Boolean | ->boolean() | Pure on/off. The value is "true" or "false". No counter. |
| Limit | ->limit() | A numeric quota with hard enforcement. Over-limit increments are rejected atomically. |
| Consumable | ->consumable() | Tracked usage without a hard ceiling — soft metering (e.g. storage). |
| Enum | ->enum() | A named option or tier label, read via featureValue(). No counter semantics. |
| Metered | ->metered() | Charged per unit consumed against a balance your app owns. The pivot value is the unit price. |
Metered features have their own page — see Metered Billing.
Reset cadence
Counters can reset on a schedule via the ResetPeriod enum: never, daily, weekly, monthly,
or yearly.
No cron drift
The reset job computes the next window from the previous period_end, not from now(). So if the
scheduled job runs an hour late, your monthly quota still resets on the same day each month — the
cadence never drifts.
Defining features
Features live in a catalog, defined once and reused across plans:
use Foysal50x\Tashil\Facades\Tashil;
use Foysal50x\Tashil\Enums\ResetPeriod;
Tashil::feature('api-requests')
->name('API Requests')
->limit()
->metadata(['warn_at_pct' => 80])
->create();
Tashil::feature('dark-mode')->name('Dark Mode')->boolean()->create();
Tashil::feature('storage-gb')->name('Storage (GB)')->consumable()->create();
Tashil::feature('ai-tokens')->name('AI Tokens')->metered()->create();Set the reset cadence on the feature — it's snapshotted onto each subscription at subscribe time:
$feature = Tashil::feature('api-requests')->limit()->create();
$feature->update(['reset_period' => ResetPeriod::Monthly]);Attaching features to a plan
The tashil_package_feature pivot carries three things: the value, an is_available flag, and a
sort_order. Setting is_available to false lets you stage a feature on a plan without rolling it
out yet — it won't be synced on subscribe.
Tashil::package('pro')
->name('Pro')
->price(29)->monthly()
->feature($api, value: '10000') // numeric cap for a limit feature
->feature($dark, value: 'true') // string for a boolean feature
->feature($storage, value: '50') // 50 GB
->create();What happens on subscribe
subscribe() runs in one database transaction. For each available feature on the plan, it writes
two rows:
- An immutable snapshot in
tashil_subscription_features— the feature config frozen at this moment (feature_slug,feature_type,value,reset_period). Kept forever for audit. - A mutable counter in
tashil_feature_usages—usage = 0, the period window, and a cachedlimit_value(populated only for limit features; all other types carryNULL).
It also appends subscription.created to the event store.
Snapshots are append-only
When a plan changes, Tashil never edits old snapshot rows — it stamps them superseded_at = now()
and inserts new rows. Both sets stay in the table forever, giving you a complete answer to "what
features did this subscription have on date X?".
Checking access
$user->hasFeature('dark-mode');
// Or against a specific subscription:
Tashil::usage()->check($subscription, 'api-requests');A check denies access when any of these is true:
- The subscription isn't valid (
isValid()is false — coversPending, expired, cancelled, paused, suspended;PastDuedepends on your dunning config). - The catalog
Feature.is_activeflag is false — a global kill-switch. - For a limit feature, the requested amount would exceed the cap.
Consuming a limit
$user->useFeature('api-requests', 1);
// or
Tashil::usage()->increment($subscription, 'api-requests', 1);Under the hood this is a single conditional UPDATE:
UPDATE tashil_feature_usages
SET usage = usage + :amount
WHERE id = :id
AND (limit_value IS NULL OR usage + :amount <= limit_value)If no row is affected, the increment was rejected — the counter is untouched and increment()
returns false.
Atomic by design
Because the cap is checked inside the same UPDATE that increments, two concurrent callers can never both slip past the limit. There's no read-then-write window to race on. Always rely on the boolean return value rather than checking-then-incrementing in PHP.
On success, tashil_usage_logs records previous_usage and new_usage, and UsageLimitWarning
fires exactly once per period the first time the counter crosses 80% of the limit.
Absolute reporting (storage-style)
When your app already knows the absolute value — say, the measured size of a storage bucket — use
reportStorage instead of incrementing:
$user->reportStorage('storage-gb', 38.5); // the counter is now exactly 38.5This sets the counter directly (no delta), logs the change with operation = report, and fires
UsageLimitWarning only when the new value crosses 80% — not on every report. Reporting is rejected
for metered features (their delta-charge model is incompatible with an absolute set).
Reading the current state
| You want | Call |
|---|---|
| The snapshot value | $user->featureValue('api-requests') |
| Current usage (float) | $user->featureUsage('api-requests') |
Remaining (null = unlimited) | $user->featureRemaining('api-requests') |
| Daily usage history | $user->dailyUsageFor('api-requests', 30) |
| Current snapshot rows | $subscription->currentFeatures |
| All snapshot rows (incl. history) | $subscription->subscriptionFeatures |
Resetting usage
Manually:
Tashil::usage()->resetUsage($subscription, 'api-requests');
Tashil::usage()->resetAllUsage($subscription);In practice you'll let the scheduler do it — tashil:reset-quotas zeroes counters whose period has
elapsed and advances the window. See Scheduler & Jobs.
Plan changes and usage
How usage behaves on a plan change depends on which operation you use:
changePlan()(in-place upgrade) re-snapshots from the new plan and carries the usage value forward, updating the cap and reset cadence to the new plan. A mid-period upgrade keeps what's already been consumed.switchPlan()(cancel + recreate) starts fresh counters on the new subscription.
Point-in-time queries
Because snapshots are immutable, you can ask what a subscription looked like at any past moment:
app(\Foysal50x\Tashil\Contracts\SubscriptionFeatureRepositoryInterface::class)
->asOf($subscription, now()->subDays(30));It returns the snapshot rows that were in effect then —
added_at <= moment AND (superseded_at IS NULL OR superseded_at > moment). This is the foundation
for the reporting features described in Analytics & Reporting.