Skip to content

Foysal50x/tashil

Repository files navigation

Tahsil — Subscription management for Laravel

Tashil – Subscription Management for Laravel

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.


Table of Contents


Installation

Requirements

  • 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.

Install via Composer

composer require foysal50x/tashil

Publish configuration and run migrations

php artisan vendor:publish --tag=tashil-config
php artisan vendor:publish --tag=tashil-migrations
php artisan migrate

Quick Start

use 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();

Configuration

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' => [/* … */],
];

Subscriptions

Subscription states

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.

Lifecycle

subscribe ─► Active ───────────────► PendingCancellation ─► Expired
       │      │                        ▲
       │      ├─► Paused ─► Active     │
       │      ├─► switched (new sub)   │
       │      └─► Cancelled            │
       └─► OnTrial ─► (convert) ─► Active
                  └─► (expire) ─► Expired

Service API

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 InvoiceObserver

Grace cancellation semantics

cancel(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.

Immutable event log

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.


Feature System

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.

Reset cadence (ResetPeriod)

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.

Snapshot + counter

On subscribe (or on plan switch), tahsil writes two rows per feature:

  • tashil_subscription_featuresimmutable snapshot of the feature config at that moment. Old snapshots are stamped superseded_at and kept forever.
  • tashil_feature_usagesmutable counter with cached limit_value, period window, and the running usage value.

Atomic increment

$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.

Absolute reporting

$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.


Trial System

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.

See docs/03-Trial-System.md.


Invoices

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 + InvoicePaid

Invoice 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).


Scheduler

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.


Events

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) => /* … */);

Analytics & Reporting

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.


HasSubscriptions Trait

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 subs

loadSubscription() caches the active subscription for the model instance — feature checks and usage operations all share the same row.


Caching Architecture

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=false

A 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.


Documentation

Full design + reference:

Cookbook-style examples:


Testing

composer test
# or
./vendor/bin/pest

The 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.


License

MIT

About

A comprehensive subscription management package for Laravel with built-in Redis caching, feature-gated access, usage tracking, billing, and analytics

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages