Skip to content

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

CommandDefault cronPurpose
tashil:renew-subscriptionsdaily 00:05Issue a renewal invoice when an active sub's period has elapsed (never trials).
tashil:process-dunningevery 30 minEscalate unpaid overdue invoices: Active → PastDue → Suspended → Expired.
tashil:expire-subscriptionsevery 15 minPromote subs past their access window to Expired.
tashil:expire-trialsevery 30 minMark trials past trial_ends_at without conversion as Expired.
tashil:mark-trials-endingdaily 07:55Dispatch TrialEnding for trials approaching expiry.
tashil:reset-quotasdaily 00:00Reset usage counters whose period has elapsed; advance the window.
tashil:apply-pending-changesevery 5 minApply 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:list

You 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_invoice policy 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_end has passed, anchored to the previous period_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:

Active
PastDue
Suspended
Expired

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_start to the previous period_end,
  • advances period_end by exactly one window (anchored — not relative to now()),
  • writes a usage_logs row with operation = reset,
  • appends usage.reset with 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

config/tashil.php
'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.

config/tashil.php
'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.

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