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 → DatabaseA 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.asyncis 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_atto 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):
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:
'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:
$this->app->bind(
\Foysal50x\Tashil\Contracts\SubscriptionRepositoryInterface::class,
\App\Repositories\MyEventSourcedSubscriptionRepository::class,
);Custom scheduler cadence
Override any command's cron without disabling auto-registration:
'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
- Add a case to the
FeatureTypeenum. - If it needs a counter, the existing
FeatureUsagerow handles it — setlimit_valueandreset_periodappropriately on subscribe. - Extend
UsageService::check()andincrement()if its semantics differ from existing types. - 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.