Scheduler & Jobs
Tashil ships seven console commands that handle everything that happens without a user present:
renewing subscriptions, expiring trials, resetting quotas, processing dunning, and applying queued
plan changes. When schedule.enabled is true (the default), they auto-register with Laravel's
scheduler — so as long as your app runs the scheduler, you're done.
The commands
| Command | Default cron | Purpose |
|---|---|---|
tashil:renew-subscriptions | daily 00:05 | Issue a renewal invoice when an active sub's period has elapsed (never trials). |
tashil:process-dunning | every 30 min | Escalate unpaid overdue invoices: Active → PastDue → Suspended → Expired. |
tashil:expire-subscriptions | every 15 min | Promote subs past their access window to Expired. |
tashil:expire-trials | every 30 min | Mark trials past trial_ends_at without conversion as Expired. |
tashil:mark-trials-ending | daily 07:55 | Dispatch TrialEnding for trials approaching expiry. |
tashil:reset-quotas | daily 00:00 | Reset usage counters whose period has elapsed; advance the window. |
tashil:apply-pending-changes | every 5 min | Apply scheduled plan changes (e.g. queued downgrades). |
Every command:
- Accepts
--date="Y-m-d H:i:s"to run as of a specific moment — handy for tests and backfills. - Runs with
->onOneServer()so multi-server deploys never double-process. - Is idempotent — safe to re-run.
Verifying registration
php artisan schedule:listYou should see all seven tashil:* commands — regardless of your Laravel version.
Idempotency
Re-running any command is safe. Concretely:
- renew-subscriptions checks for an existing pending invoice before issuing a new one; the
on_pending_invoicepolicy decides what to do when one exists. - expire-subscriptions is a no-op for rows already
Expired. - expire-trials filters by
trial_converted_at IS NULL— converted trials are never expired. - mark-trials-ending appends an event with the idempotency key
"trial-ending:{sub}:{date}", so a same-day re-run sends no duplicate. - reset-quotas advances the window only when
period_endhas passed, anchored to the previousperiod_end— a delayed run never accumulates skipped windows. - apply-pending-changes clears the pending fields once applied; re-runs find nothing.
How renewal works (it does not advance the period)
tashil:renew-subscriptions selects auto-renewing subscriptions whose current_period_end has
elapsed and issues a renewal invoice. It does not advance the period — that happens when your
app marks the invoice paid, via the InvoiceObserver.
A common misconception
Running tashil:renew-subscriptions alone never advances current_period_end or charges anyone. It
only creates the invoice. The period advances when the invoice is paid. If a test expects a period
advance from the renewal command alone, the test is wrong. See
Billing Lifecycle.
When a pending invoice already exists at renewal time, the renewal.on_pending_invoice policy
applies (cancel, skip, or extend_grace) — see Configuration.
How dunning escalates
tashil:process-dunning walks every overdue, still-pending renewal invoice and advances its
subscription through the recovery lifecycle, anchored to due_date:
Each milestone increments dunning_attempts, fires SubscriptionPastDue and InvoiceOverdue (so
your app can re-charge), and — at the thresholds — SubscriptionSuspended / SubscriptionExpired.
Paying the overdue invoice before expiry reactivates automatically. Each invoice is processed under a
per-subscription lock in its own transaction; the command exits non-zero if any invoice failed (so a
stuck one is surfaced to your cron monitor) while the rest still commit. Full details in
Billing Lifecycle.
How quota resets stay drift-free
tashil:reset-quotas zeroes every counter whose period_end has passed and reset_period isn't
never. It then:
- sets
period_startto the previousperiod_end, - advances
period_endby exactly one window (anchored — not relative tonow()), - writes a
usage_logsrow withoperation = reset, - appends
usage.resetwith the idempotency key"usage-reset:{usage_id}:YYYY-MM-DD-HH", - dispatches
UsageReset.
Anchoring means a one-hour-late cron never pushes the next reset an hour later.
Configuration
'schedule' => [
'enabled' => env('TASHIL_SCHEDULE_ENABLED', true),
'overrides' => [
// Override the cadence per command:
'tashil:expire-trials' => '*/15 * * * *',
'tashil:reset-quotas' => '0 1 * * *',
],
],Disabling auto-registration
Set schedule.enabled to false and wire the commands yourself. The wiring location differs by
Laravel version — the package code itself stays version-agnostic.
'schedule' => ['enabled' => false],Laravel 10 — app/Console/Kernel.php
use Illuminate\Console\Scheduling\Schedule;
protected function schedule(Schedule $schedule): void
{
$schedule->command('tashil:renew-subscriptions')->dailyAt('00:05')->onOneServer();
$schedule->command('tashil:process-dunning')->everyThirtyMinutes()->onOneServer();
$schedule->command('tashil:expire-subscriptions')->everyFifteenMinutes()->onOneServer();
$schedule->command('tashil:expire-trials')->everyThirtyMinutes()->onOneServer();
$schedule->command('tashil:reset-quotas')->dailyAt('00:00')->onOneServer();
$schedule->command('tashil:apply-pending-changes')->everyFiveMinutes()->onOneServer();
$schedule->command('tashil:mark-trials-ending')->dailyAt('07:55')->onOneServer();
}Laravel 11 / 12 / 13 — routes/console.php
app/Console/Kernel.php was removed in Laravel 11. The idiomatic place is routes/console.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('tashil:renew-subscriptions')->dailyAt('00:05')->onOneServer();
Schedule::command('tashil:process-dunning')->everyThirtyMinutes()->onOneServer();
Schedule::command('tashil:expire-subscriptions')->everyFifteenMinutes()->onOneServer();
Schedule::command('tashil:expire-trials')->everyThirtyMinutes()->onOneServer();
Schedule::command('tashil:reset-quotas')->dailyAt('00:00')->onOneServer();
Schedule::command('tashil:apply-pending-changes')->everyFiveMinutes()->onOneServer();
Schedule::command('tashil:mark-trials-ending')->dailyAt('07:55')->onOneServer();Auto-registration is version-agnostic
Tashil resolves the scheduler via $this->app->booted(...), an API stable since Laravel 5. The same
provider code wires correctly under Laravel 10's Kernel and Laravel 11+'s bootstrap/app.php flow —
no per-version branching inside the package.