Tashil (تسهيل) is a Laravel package for subscription and feature management with an immutable event store, atomic usage tracking, scheduled trial / quota / downgrade jobs, and a polymorphic subscriber trait.
It owns plan definitions, subscription state, feature gating, usage counters, trial lifecycle, scheduled transitions, and invoice issuance.
It does not charge — payment capture, dunning retries, refunds, and gateway reconciliation are delegated to a third-party integration in the host application.
- Installation
- Quick Start
- Configuration
- Subscriptions
- Feature System
- Trial System
- Invoices
- Scheduler
- Events
- Analytics & Reporting
- HasSubscriptions Trait
- Caching Architecture
- Documentation
- Testing
- License
- PHP 8.2 — 8.5
- Laravel 10.x, 11.x, 12.x, or 13.x
- Redis (optional — only when the caching layer is enabled)
Per-version compatibility:
| Laravel | Released | PHP | Manual scheduler wiring location |
|---|---|---|---|
| 10.x | Feb 2023 | 8.2 / 8.3 | app/Console/Kernel.php |
| 11.x | Mar 2024 | 8.2 / 8.3 / 8.4 | routes/console.php or bootstrap/app.php ->withSchedule() |
| 12.x | Feb 2025 | 8.2 – 8.5 | routes/console.php or bootstrap/app.php ->withSchedule() |
| 13.x | Mar 2026 | 8.3 / 8.4 / 8.5 | routes/console.php or bootstrap/app.php ->withSchedule() |
Auto-registration (tashil.schedule.enabled = true, default) is version-agnostic — the same provider wires correctly under L10's Kernel and L11+/L13's bootstrap/app.php flow. You only have to think about Laravel versions if you disable auto-registration and wire commands yourself; see docs/04-Scheduler-Jobs.md for L10 / L11+ examples side-by-side.
composer require foysal50x/tashilphp artisan vendor:publish --tag=tashil-config
php artisan vendor:publish --tag=tashil-migrations
php artisan migrateuse Foysal50x\Tashil\Facades\Tashil;
use Foysal50x\Tashil\Enums\ResetPeriod;
// 1. Define features (catalog)
$apiCalls = Tashil::feature('api-calls')->name('API Calls')->limit()->create();
$darkMode = Tashil::feature('dark-mode')->name('Dark Mode')->boolean()->create();
$storage = Tashil::feature('storage-gb')->name('Storage (GB)')->consumable()->create();
$apiCalls->update(['reset_period' => ResetPeriod::Monthly]); // resets each month
// 2. Define a package
$proPlan = Tashil::package('pro')
->name('Pro Plan')
->price(29.99)
->monthly()
->trialDays(14)
->feature($apiCalls, value: '10000')
->feature($darkMode, value: 'true')
->feature($storage, value: '50')
->create();
// 3. Subscribe a user — User uses the HasSubscriptions trait
$subscription = Tashil::subscription()->subscribe($user, $proPlan, withTrial: true);
// 4. Gate access + track usage
if ($user->hasFeature('api-calls')) {
$user->useFeature('api-calls'); // atomic increment, returns false if over limit
}
// 5. Report absolute usage for storage-style features
$user->reportStorage('storage-gb', 38.5); // 38.5 GB
// 6. Lifecycle ops
$user->cancelSubscription(); // grace cancel
$user->pauseSubscription(); $user->unpauseSubscription();
$user->scheduleDowngrade($basicPlan); // applied at period end
$user->switchPlan($enterprisePlan); // immediate
// 7. Analytics
$kpis = Tashil::analytics()->dashboardSummary();After publishing, edit config/tashil.php:
return [
'database' => [
'connection' => env('TASHIL_DB_CONNECTION', null),
'prefix' => 'tashil_',
'tables' => [
'packages' => 'packages',
'features' => 'features',
'package_feature' => 'package_feature',
'subscriptions' => 'subscriptions',
'subscription_features' => 'subscription_features',
'feature_usages' => 'feature_usages',
'usage_logs' => 'usage_logs',
'subscription_events' => 'subscription_events',
'invoices' => 'invoices',
'transactions' => 'transactions',
],
],
'invoice' => [
'prefix' => 'INV',
'format' => '#-YYMMDD-NNNNNN',
'generator' => Foysal50x\Tashil\Services\Generators\InvoiceNumberGenerator::class,
],
'currency' => env('TASHIL_CURRENCY', 'USD'),
'trial' => [
'warn_days' => env('TASHIL_TRIAL_WARN_DAYS', 3),
'grace_days' => env('TASHIL_TRIAL_GRACE_DAYS', 0),
],
'renewal' => [
'on_pending_invoice' => env('TASHIL_RENEWAL_ON_PENDING_INVOICE', 'cancel'),
'grace_days' => env('TASHIL_RENEWAL_GRACE_DAYS', 3),
],
'schedule' => [
'enabled' => env('TASHIL_SCHEDULE_ENABLED', true),
'overrides' => [],
],
'events' => [
'async' => env('TASHIL_EVENTS_ASYNC', true),
],
'redis' => [/* … */],
'cache' => [/* … */],
];| State | Enum case | Means |
|---|---|---|
| Pending | SubscriptionStatus::Pending |
Initial placeholder. |
| Active | SubscriptionStatus::Active |
Currently active. |
| OnTrial | SubscriptionStatus::OnTrial |
In trial — strict: trial_ends_at must be in the future. |
| PastDue | SubscriptionStatus::PastDue |
Host marks this when payment is late. |
| Paused | SubscriptionStatus::Paused |
Host paused; counter still exists but isValid() is false. |
| PendingCancellation | SubscriptionStatus::PendingCancellation |
Grace-cancelled — still valid until ends_at. |
| Cancelled | SubscriptionStatus::Cancelled |
Immediately cancelled, access revoked. |
| Expired | SubscriptionStatus::Expired |
Past access window. |
| Suspended | SubscriptionStatus::Suspended |
Admin-suspended. |
subscribe ─► Active ───────────────► PendingCancellation ─► Expired
│ │ ▲
│ ├─► Paused ─► Active │
│ ├─► switched (new sub) │
│ └─► Cancelled │
└─► OnTrial ─► (convert) ─► Active
└─► (expire) ─► Expired
use Foysal50x\Tashil\Facades\Tashil;
Tashil::subscription()->subscribe($user, $package, withTrial: true);
Tashil::subscription()->cancel($sub); // grace
Tashil::subscription()->cancel($sub, immediate: true, reason: 'x');
Tashil::subscription()->resume($sub); // only PendingCancellation
Tashil::subscription()->pause($sub); Tashil::subscription()->unpause($sub);
Tashil::subscription()->switchPlan($sub, $newPackage);
Tashil::subscription()->scheduleDowngrade($sub, $targetPackage);
Tashil::subscription()->cancelPendingChange($sub);
Tashil::subscription()->convertTrial($sub);
Tashil::subscription()->expireTrial($sub);
Tashil::subscription()->expire($sub);
Tashil::subscription()->advancePeriod($sub); // invoked by InvoiceObservercancel(immediate: false) does not revoke access. The subscription enters PendingCancellation, keeps ends_at, and stays in the Subscription::valid() scope. The tashil:expire-subscriptions job promotes it to Expired once cancellation_effective_at passes. $user->subscribed() returns true for the entire grace window.
Every transition appends to tashil_subscription_events with a monotonic per-subscription sequence_num (assigned under a SELECT … FOR UPDATE lock) and an optional idempotency_key. Read it back:
$sub->events()->orderBy('sequence_num')->get();
Tashil::events()->append($sub, 'host.custom.thing', payload: [...], idempotencyKey: 'op-42');See docs/05-Reporting-Data-Model.md.
Four feature types, all with consistent storage + atomic enforcement:
| Type | Behavior |
|---|---|
FeatureType::Boolean |
Pure on/off; value is "true" or "false". |
FeatureType::Limit |
Numeric quota with hard enforcement via atomic conditional UPDATE. |
FeatureType::Consumable |
Tracked usage without a hard ceiling (soft metering). |
FeatureType::Enum |
Named option / tier label. |
never / daily / weekly / monthly / yearly. The tashil:reset-quotas job advances period_start/period_end anchored to the previous period_end — a late cron doesn't drift the cadence.
On subscribe (or on plan switch), tahsil writes two rows per feature:
tashil_subscription_features— immutable snapshot of the feature config at that moment. Old snapshots are stampedsuperseded_atand kept forever.tashil_feature_usages— mutable counter with cachedlimit_value, period window, and the runningusagevalue.
$user->useFeature('api-calls', 1);becomes:
UPDATE tashil_feature_usages
SET usage = usage + :amount
WHERE id = :id
AND (limit_value IS NULL OR usage + :amount <= limit_value)Two concurrent callers cannot both succeed past the limit. The increment returns false on rejection without touching the row. Every successful change writes previous_usage + new_usage to tashil_usage_logs.
$user->reportStorage('storage-gb', 12.5);Use when the host knows the absolute value (storage bytes, AI compute hours). UsageLimitWarning fires only on crossings of 80%, not on every report.
See docs/02-Feature-System.md.
Trials are first-class. Four timestamps capture the entire lifecycle: trial_started_at, trial_ends_at, trial_converted_at, trial_expired_at.
Tashil::subscription()->subscribe($user, $package, withTrial: true);
Tashil::subscription()->convertTrial($sub);
Tashil::subscription()->expireTrial($sub);The tashil:mark-trials-ending job dispatches TrialEnding tashil.trial.warn_days (default 3) before expiry; tashil:expire-trials runs every 30 minutes and promotes overdue, unconverted trials to Expired.
isOnTrial() is strict: status must be OnTrial AND trial_ends_at in the future. A cancelled-mid-trial subscription never reports as on-trial.
Tahsil issues invoices on subscribe (non-trial) and renewal. The host charges and marks them paid.
$invoice = Tashil::billing()->generateInvoice($subscription);
// host charges via gateway, then:
$invoice->markAsPaid(); // InvoiceObserver advances current_period_end
// and dispatches SubscriptionRenewed + InvoicePaidInvoice statuses: draft, pending, paid, void, refunded.
Invoice numbers are generated by tashil.invoice.generator — default InvoiceNumberGenerator parses the format string #-YYMMDD-NNNNNN (e.g. INV-260522-849021).
Six idempotent commands. Auto-registered with ->onOneServer():
| Command | Default cron | Purpose |
|---|---|---|
tashil:renew-subscriptions |
daily 00:05 | Issue renewal invoices when current_period_end has elapsed. |
tashil:expire-subscriptions |
every 15 min | Promote out-of-window subs to Expired. |
tashil:expire-trials |
every 30 min | Promote overdue, unconverted trials. |
tashil:mark-trials-ending |
daily 07:55 | Dispatch TrialEnding. |
tashil:reset-quotas |
daily 00:00 | Zero counters whose period has elapsed; advance window. |
tashil:apply-pending-changes |
every 5 min | Apply scheduled package changes. |
Override per-command cron via tashil.schedule.overrides. Set tashil.schedule.enabled = false to disable auto-wiring entirely.
See docs/04-Scheduler-Jobs.md.
All events dispatch after DB::afterCommit() so listeners never see torn state (unless tashil.events.async = false).
| Event | When |
|---|---|
SubscriptionCreated |
New subscription persisted. |
SubscriptionCancelled |
cancel() — carries immediate flag + reason. |
SubscriptionResumed |
Resume from PendingCancellation. |
SubscriptionExpired |
expire() or the expire-subscriptions job. |
SubscriptionSwitched |
switchPlan() — carries old + new sub and package. |
SubscriptionPaused / SubscriptionUnpaused |
Pause / unpause. |
SubscriptionRenewed |
Triggered by InvoiceObserver when an invoice is marked paid. |
PendingChangeScheduled / PendingChangeApplied |
scheduleDowngrade() lifecycle. |
TrialEnding |
tashil:mark-trials-ending — carries daysRemaining. |
TrialConverted |
convertTrial(). |
TrialExpired |
expireTrial() or the expire-trials job. |
UsageReset |
Manual or scheduled reset. |
UsageLimitWarning |
80% threshold crossed (fired once per period). |
InvoiceIssued / InvoicePaid / InvoiceVoided / InvoiceOverdue |
Invoice lifecycle. |
Listen normally:
Event::listen(SubscriptionExpired::class, fn ($e) => /* … */);Tashil::analytics() provides live aggregates with cross-database queries (tpetry/laravel-query-expressions, no raw SQL):
Tashil::analytics()->dashboardSummary();
Tashil::analytics()->packageAnalytics();
Tashil::analytics()->calculateMRR();
Tashil::analytics()->churnRate(days: 30);
Tashil::analytics()->churnTrend(months: 12, windowDays: 30); // 2 queries
Tashil::analytics()->trialConversionRate();
Tashil::analytics()->totalRevenue();
Tashil::analytics()->revenueByPackage();
Tashil::analytics()->revenueByPeriod(months: 12);
Tashil::analytics()->getDailyUsage($sub, $featureId, days: 30);For audit / point-in-time questions, walk the event log + feature snapshots — see docs/05-Reporting-Data-Model.md.
use Foysal50x\Tashil\Traits\HasSubscriptions;
class User extends Authenticatable
{
use HasSubscriptions;
}Surface:
// Lifecycle
$user->subscribe($package, withTrial: false);
$user->cancelSubscription(immediate: false, reason: '…');
$user->resumeSubscription();
$user->switchPlan($newPackage);
$user->pauseSubscription(); $user->unpauseSubscription();
$user->scheduleDowngrade($targetPackage);
// State
$user->subscribed(); // true if Active / OnTrial / PendingCancellation (grace)
$user->subscribedTo($pkg); // by Package model or slug
$user->onPlan('pro');
$user->onTrial(); // strict
$user->paused();
$user->pendingChange(); // returns target Package or null
$user->subscription(); // hits DB
$user->loadSubscription(); // cached for request lifecycle
$user->clearSubscriptionCache();
// Features
$user->hasFeature('dark-mode');
$user->featureValue('api-calls');
$user->featureUsage('api-calls'); // float
$user->featureRemaining('api-calls'); // float|null (null = unlimited)
$user->useFeature('api-calls', 1);
$user->reportStorage('storage-gb', 12.5);
$user->dailyUsageFor('api-calls', days: 30);
// Invoices
$user->invoices(); // Collection of all invoices across all subsloadSubscription() caches the active subscription for the model instance — feature checks and usage operations all share the same row.
Repository decorator pattern. The cache layer wraps the Eloquent repositories for catalog + aggregate reads; hot-mutating tables (subscription_events, subscription_features, feature_usages) bypass the cache entirely.
Service Layer
└── CacheRepository (decorator, optional)
└── EloquentRepository
└── Database
Disable globally:
TASHIL_CACHE_ENABLED=falseA dedicated Redis store named tashil is auto-registered so cache traffic is isolated from the host app's main store. Configure via TASHIL_REDIS_* env vars.
Full design + reference:
- docs/01-DB-Schema.md — every table with the ER diagram.
- docs/02-Feature-System.md — feature types, snapshots, counters, reset cadence.
- docs/03-Trial-System.md — trial lifecycle, conversion, expiry.
- docs/04-Scheduler-Jobs.md — every command, cadence, idempotency.
- docs/05-Reporting-Data-Model.md — analytics + point-in-time + replay.
- docs/06-Developer-Guide.md — layout, conventions, extension points.
Cookbook-style examples:
- examples/01-Subscription-Management.md
- examples/02-Feature-Usage-Tracking.md
- examples/03-Billing-and-Invoicing.md
- examples/04-Analytics.md
- examples/05-Console-Commands.md
composer test
# or
./vendor/bin/pestThe suite covers subscription lifecycle, grace cancellation, EventStore monotonicity + idempotency + immutability, atomic usage with race rejection, trial transitions, scheduled jobs, analytics, and the trait surface. 211 tests / 550 assertions at the time of writing.
MIT
