Scheduled commands
Off by default — they move money and subscription state, so a fresh install runs nothing until you opt in:
BILLING_SCHEDULE_ENABLED=trueThe commands are then registered with Laravel’s scheduler, which still needs the standard cron entry (* * * * * php artisan schedule:run).
| Command | Cadence | What it does |
|---|---|---|
billing:process-recurring-charges | every minute, withoutOverlapping | Finalizes due cancellations, charges due package-managed subscriptions, see Renewals |
billing:reconcile-pending-payments | every 15 minutes, withoutOverlapping | Polls payments stuck pending, see below |
billing:expire-trials | hourly | TrialWillEnd reminders, then trialing → ended with TrialEnded, see Trials |
billing:send-period-notices | hourly | SubscriptionPeriodEnding before a paid period ends (only if period_ending_notices is set) |
billing:expire-pauses | hourly | Resumes paused subscriptions whose pause_ends_at passed (SubscriptionResumed) |
billing:reset-usage-quotas | hourly | Resets quotas with their own cycle, see Usage and quotas |
model:prune for BillingWebhookCall | daily | Deletes webhook calls older than webhook.prune_after_days |
None of the housekeeping commands gates access — isActive() reads the row’s own dates. A lagging run delays only the status write and the event. The exception is the quota reset: until it runs, the customer keeps hitting “quota exhausted”.
Every command skips provider-managed subscriptions, except reset-usage-quotas and the trial reminders for providers that don’t send their own (Paddle).
billing:reconcile-pending-payments
Section titled “billing:reconcile-pending-payments”The fallback for a lost webhook, and for gateway statuses that never get one (an expired checkout). Looks at pending payments not updated for more than reconcile_after_minutes (60). The age runs from updated_at, so a checkout re-issued through the pay link counts as fresh:
gatewaynull — skipped (manual payment);- gateway no longer registered — left alone;
- a subscription payment with neither
external_idnorpayment_url— a renewal whose initiation never got a reference — written off ascanceled; - the driver implements
ChecksPaymentStatus— the gateway is polled (a Monobank, Stripe or Paddle payment withoutexternal_id, never charged yet, is skipped) and the outcome goes through the same dedup as webhooks (paid,failed,canceled; non-terminal states stay pending). Paddle also cancels an open transaction whose link TTL has passed, and polls pending Paddle refunds; - no status polling (the
fakegateway, custom drivers) — written off ascanceled(PaymentCanceled), unless itspayment_url_expires_atis still in the future.
One failing payment is reported and skipped, never blocks the rest.
Custom cadence
Section titled “Custom cadence”The built-in schedule is a default. Turn it off and register the commands yourself — they are idempotent (pending-renewal guard, next_retry_at, shared dedup):
// BILLING_SCHEDULE_ENABLED=false, then in routes/console.php:Schedule::command('billing:process-recurring-charges')->dailyAt('03:00')->withoutOverlapping()->onOneServer();Schedule::command('billing:reconcile-pending-payments')->everyFiveMinutes()->withoutOverlapping()->onOneServer();Schedule::command('billing:expire-trials')->everyMinute();Schedule::command('billing:send-period-notices')->hourly();Schedule::command('billing:expire-pauses')->everyMinute();Schedule::command('billing:reset-usage-quotas')->hourly();Schedule::command('model:prune', ['--model' => [\Fomvasss\Billing\Webhooks\BillingWebhookCall::class]])->daily();Keep withoutOverlapping() on the money-moving commands, and add onOneServer() when the scheduler runs on several servers. Renewals are additionally serialized by a row lock per subscription, so even a manual run next to the scheduled one can’t charge twice.
Command options — Artisan commands.