Skip to content

Extending Tashil

Tashil is built to be extended without forking. Every persistence concern sits behind an interface, every observer-stamped id is pluggable, and the event store is part of the public API. This page covers the seams designed for you to hook into.

Architecture in brief

HasSubscriptions trait
  → Tashil facade → SubscriptionService / UsageService / BillingService /
                    AnalyticsService / EventStore
      → Repository interfaces (Contracts/)
          → Eloquent or Cache-decorated implementations
              → Eloquent models → Database

A few conventions worth knowing before you extend:

  • Strict typing everywhere. Constructor property promotion, explicit return types.
  • Every mutating service method runs in a transaction, and the closure is idempotent on retry.
  • Domain events fire after the DB commit (when events.async is true), so listeners never see a torn state.
  • Append-only models throw on update/delete — the event store rejects everything, the snapshot allows only superseded_at to change.

Custom event listeners

The most common extension: react to Tashil's domain events. Register them as you would any Laravel event (see Events for the full list):

app/Providers/EventServiceProvider.php
protected $listen = [
    \Foysal50x\Tashil\Events\SubscriptionCreated::class   => [SendWelcomeEmail::class],
    \Foysal50x\Tashil\Events\SubscriptionRenewed::class   => [PushRenewalToCRM::class],
    \Foysal50x\Tashil\Events\SubscriptionCancelled::class => [TriggerExitSurvey::class],
    \Foysal50x\Tashil\Events\InvoiceIssued::class         => [ChargeViaGateway::class],
    \Foysal50x\Tashil\Events\UsageLimitWarning::class     => [NotifyApproachingLimit::class],
];

Custom id generation

Both invoice_number and transaction_id are stamped by an observer when the column is left blank, using a generator resolved from config. The two built-in generators share TokenizedIdGenerator, an abstract base that owns the token grammar (#, YY, MM, DD, N, S, A).

To change just the prefix or format, subclass and declare the config keys:

namespace App\Billing;
 
use Foysal50x\Tashil\Services\Generators\TokenizedIdGenerator;
 
class MyInvoiceNumberGenerator extends TokenizedIdGenerator
{
    protected function prefix(): string
    {
        return (string) config('tashil.invoice.prefix', 'INV');
    }
 
    protected function format(): string
    {
        return (string) config('tashil.invoice.format', '#-YYMMDD-NNNNNN');
    }
}

For an entirely different scheme (say, a Stripe-style in_…), skip the base class — the observer only requires a generate(): string method:

class StripeStyleGenerator
{
    public function generate(): string
    {
        return 'in_' . bin2hex(random_bytes(12));
    }
}

Point the config at your class:

config/tashil.php
'invoice' => ['generator' => \App\Billing\MyInvoiceNumberGenerator::class],

Guaranteed-unique ids

Rather than rely on entropy plus the database constraint to catch a collision, a generator can verify uniqueness up front by implementing ShouldBeUnique. TokenizedIdGenerator::generate() then re-renders until isUnique() accepts the id (or the attempt budget is exhausted).

Both built-in generators already do this: InvoiceNumberGenerator checks invoice_number globally, and TransactionIdGenerator checks the composite (gateway, transaction_id) — it receives the row's gateway through its constructor (passed by TransactionObserver), so the same id under a different gateway (say a manual id that matches a Stripe charge id) is correctly treated as free. Override only to change the scope of the check — for example, to include soft-deleted rows:

namespace App\Billing;
 
use Foysal50x\Tashil\Contracts\ShouldBeUnique;
use Foysal50x\Tashil\Models\Invoice;
use Foysal50x\Tashil\Services\Generators\TokenizedIdGenerator;
 
class UniqueInvoiceNumberGenerator extends TokenizedIdGenerator implements ShouldBeUnique
{
    protected function prefix(): string { return (string) config('tashil.invoice.prefix', 'INV'); }
    protected function format(): string { return (string) config('tashil.invoice.format', '#-YYMMDD-NNNNNN'); }
 
    public function isUnique(string $id): bool
    {
        return ! Invoice::withTrashed()->where('invoice_number', $id)->exists();
    }
 
    // Optional: widen the retry budget for tight formats (default 10).
    protected function maxGenerationAttempts(): int
    {
        return 25;
    }
}

Keep isUnique() cheap, and widen the format if it throws

isUnique() runs on the row-creating path for every id, so make it an indexed lookup. If the format space is too small to find a free id within the attempt budget, generate() throws UniqueIdGenerationException — a signal to widen the format, not to retry. The database UNIQUE constraint remains the real guarantee under concurrency; the pre-check only narrows the window.

Custom repository implementations

Every persistence concern lives behind an interface in Contracts/. Swap one by binding your implementation — Tashil's own bindings happen in register(), so a host override in a service provider's register() or boot() wins:

app/Providers/AppServiceProvider.php
$this->app->bind(
    \Foysal50x\Tashil\Contracts\SubscriptionRepositoryInterface::class,
    \App\Repositories\MyEventSourcedSubscriptionRepository::class,
);

Custom scheduler cadence

Override any command's cron without disabling auto-registration:

config/tashil.php
'schedule' => [
    'enabled'   => true,
    'overrides' => [
        'tashil:expire-trials' => '*/10 * * * *',
        'tashil:reset-quotas'  => '0 1 * * *',
    ],
],

To take full control, set schedule.enabled = false and wire the commands yourself — see Scheduler & Jobs.

Adding a new feature type

  1. Add a case to the FeatureType enum.
  2. If it needs a counter, the existing FeatureUsage row handles it — set limit_value and reset_period appropriately on subscribe.
  3. Extend UsageService::check() and increment() if its semantics differ from existing types.
  4. Update the snapshot logic in EloquentSubscriptionRepository::syncFeatures() if it needs extra fields.

Triggering custom events

The EventStore is public — append your own domain events to a subscription's immutable timeline:

use Foysal50x\Tashil\Facades\Tashil;
 
Tashil::events()->append($subscription, 'host.custom.thing',
    payload: ['foo' => 'bar'],
    idempotencyKey: 'host-custom-' . $request->id,
);

Use the idempotency key whenever an outer system (a queued job, an HTTP retry) might repeat the call.

Testing your integration

Tashil's own suite uses Pest with a real database and real time travel. Mirror that pattern in your app:

use Foysal50x\Tashil\Facades\Tashil;
use Foysal50x\Tashil\Events\TrialExpired;
 
it('does the host thing on trial expiry', function () {
    Event::fake([TrialExpired::class]);
 
    $package = Tashil::package('pro')->name('Pro')->price(10)->monthly()
        ->trialDays(7)->create();
 
    $user = User::create(['name' => 'A', 'email' => 'a@a.com']);
    $sub  = Tashil::subscription()->subscribe($user, $package, withTrial: true);
 
    $this->travel(8)->days();
    $this->artisan('tashil:expire-trials')->assertSuccessful();
 
    Event::assertDispatched(TrialExpired::class);
});

Database & cache isolation

Keep Tashil's tables on their own connection by setting TASHIL_DB_CONNECTION and defining a matching connection in config/database.php — every model, repository, and the migration honor it.

A dedicated Redis store named tashil is auto-registered so cache traffic is isolated from your app's main store; tune it with the TASHIL_REDIS_* and TASHIL_CACHE_* variables. Disabling the cache (TASHIL_CACHE_ENABLED=false) sends every repository read to the database — useful for local development. See Configuration.

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