Documentation for larsasub v0.12.0.
On an older version? Your release notes ship with the package (CHANGELOG.md).
LarsaSub Scheduled Commands
This document describes the scheduled command functionality in LarsaSub for automated subscription management and charge processing.
Overview
LarsaSub provides scheduled commands to automate subscription billing operations:
Core Commands:
larsasub:plan-charges- Plans future recurring charges for active subscriptionslarsasub:execute-charges- Executes pending charges that are duelarsasub:end-subscriptions- Deactivates expired base subscriptionslarsasub:end-billable-subscriptions- Deactivates expired billable subscriptionslarsasub:cleanup-unused-billable-subscriptions- Cleans up subscriptions with failed first payments
Renewal Commands:
larsasub:renew-billable-subscriptions- Handles automatic subscription renewalslarsasub:announce-renewal-billable-subscriptions- Sends renewal announcements to users
Notification Commands:
larsasub:notify-charges- Sends upcoming charge notifications to users
Credit Commands:
larsasub:process-expired-credits- Processes and marks expired credit transactionslarsasub:notify-expiring-credits- Sends credit expiration warning notifications
Mandate Commands:
larsasub:validate-mandates- Validates usable mandates against the payment provider and marks revoked ones unusable
Maintenance Commands:
larsasub:prune-model-logs- Deletes model-log entries older than the configured retention period (retention.model_logs_days, default 730 days)
Configuration
All scheduling configuration is located in config/larsasub.php under the scheduling section:
'scheduling' => [
// Enable/disable automatic command scheduling
'enabled' => env('LARSASUB_SCHEDULING_ENABLED', true),
// Auto-register commands with Laravel scheduler
'auto_register' => env('LARSASUB_SCHEDULING_AUTO_REGISTER', true),
// Core command frequencies
'plan_charges_frequency' => env('LARSASUB_PLAN_CHARGES_FREQUENCY', 'daily'),
'execute_charges_frequency' => env('LARSASUB_EXECUTE_CHARGES_FREQUENCY', 'daily'),
'end_subscriptions_frequency' => env('LARSASUB_END_SUBSCRIPTIONS_FREQUENCY', 'daily'),
'end_billable_subscriptions_frequency' => env('LARSASUB_END_BILLABLE_SUBSCRIPTIONS_FREQUENCY', 'daily'),
'cleanup_unused_billable_subscriptions_frequency' => env('LARSASUB_CLEANUP_UNUSED_BILLABLE_SUBSCRIPTIONS_FREQUENCY', 'twiceDaily'),
'cleanup_unused_billable_subscriptions_hours' => env('LARSASUB_CLEANUP_UNUSED_BILLABLE_SUBSCRIPTIONS_HOURS', 12),
// Renewal command frequencies
'renew_billable_subscriptions_frequency' => env('LARSASUB_RENEW_BILLABLE_SUBSCRIPTIONS_FREQUENCY', 'daily'),
'announce_renewal_billable_subscriptions_frequency' => env('LARSASUB_ANNOUNCE_RENEWAL_BILLABLE_SUBSCRIPTIONS_FREQUENCY', 'daily'),
// Charge notification settings
'notify_charges_frequency' => env('LARSASUB_NOTIFY_CHARGES_FREQUENCY', 'daily'),
'notify_charges_days_before' => env('LARSASUB_NOTIFY_CHARGES_DAYS_BEFORE', 10),
// Credit expiration settings
'process_expired_credits_frequency' => env('LARSASUB_PROCESS_EXPIRED_CREDITS_FREQUENCY', 'daily'),
'notify_expiring_credits_frequency' => env('LARSASUB_NOTIFY_EXPIRING_CREDITS_FREQUENCY', 'daily'),
'notify_credit_expiration_days_before' => env('LARSASUB_NOTIFY_CREDIT_EXPIRATION_DAYS_BEFORE', 14),
// Mandate validation
'validate_mandates_frequency' => env('LARSASUB_VALIDATE_MANDATES_FREQUENCY', 'weekly'),
'validate_mandates_delay_ms' => env('LARSASUB_VALIDATE_MANDATES_DELAY_MS', 100),
// Model-log pruning. The retention itself lives outside this section:
// larsasub.retention.model_logs_days (default 730; null/0 disables pruning)
'prune_model_logs_frequency' => env('LARSASUB_PRUNE_MODEL_LOGS_FREQUENCY', 'daily'),
// Timezone for scheduled commands (null = app timezone)
'timezone' => env('LARSASUB_SCHEDULING_TIMEZONE', null),
// Max charges per execute-charges run (null = no limit)
'execute_charges_limit' => env('LARSASUB_EXECUTE_CHARGES_LIMIT'),
// How long a claimed-but-unresolved charge may sit before the next run
// asks the provider what happened to it. Keep under 60 — see below.
'reconcile_unresolved_after_minutes' => env('LARSASUB_RECONCILE_UNRESOLVED_AFTER_MINUTES', 30),
// How long a command's overlap mutex is held before it is considered
// stale (minutes). An expiry, not a guarantee — see "Overlap protection".
'mutex_expires_after_minutes' => env('LARSASUB_SCHEDULING_MUTEX_EXPIRES_AFTER_MINUTES', 60),
]
Overlap protection
Every scheduled command is registered with withoutOverlapping(), so a tick
does not start while the previous run of the same command is still going. Two
properties of that mutex are easy to assume and wrong to rely on:
The expiry is a stale-lock cleanup, not a promise. The number
(mutex_expires_after_minutes, default 60) is how long Laravel holds the
mutex before considering it abandoned. A run that takes longer than that
loses its slot and the next tick starts alongside it. That overlap is not a
double-billing risk — execute-charges claims each charge under a row lock
before anything goes to Mollie, so the second run skips what the first one
took — but it is two processes fighting over the same rows and doing work the
other already did. If your runs legitimately outlive the default (large
volume, slow provider), raise the expiry instead of accepting the contention.
Non-positive values fall back to 60: a mutex that expires instantly is no
mutex at all.
The mutex lives in the default cache store. Laravel's
withoutOverlapping() uses cache.default — there is no way to point it at
another store per command. That makes the driver load-bearing:
- The
arraydriver provides no overlap protection at all, even on one server: it is process-local, so everyschedule:runinvocation starts with an empty store and acquires the mutex while the previous run still holds it. - The
filedriver works on a single server, but is per-server. - Multiple app servers running the scheduler need a shared, persistent store (redis, memcached, database) — with a per-server driver each server sees only its own locks, both start, and the mutex protects nothing between them.
The same claim that makes an expired mutex safe makes this safe too — no double billing — but a host with the wrong driver is running without the protection it thinks it has. Run the scheduler on one designated server with a persistent store, or give all servers a shared one.
Charges whose outcome was never learned
Sending a payment is two steps that cannot be made one: a write in your
database, then an HTTP call to Mollie. larsasub:execute-charges claims a
charge first — that is what stops two runs from collecting it twice — and only
afterward does the provider answer come back and get stored.
A run that is killed in between (a deploy, the OOM killer, a request timeout) leaves a charge behind that is marked as taken up but has no payment against it. Nothing would pick it up again: the executor filters on "not yet charged", and no webhook can be matched to a payment id that was never stored. Whether the customer was actually charged is not readable from your database at all.
It is readable at Mollie. Payments created for a RecurringCharge carry
recurring_charge_id in their metadata (a one-off payment carries
single_payment_id instead), so each run starts by asking about charges that
have been sitting unresolved for longer than
reconcile_unresolved_after_minutes:
| what Mollie says | what happens |
|---|---|
| a payment exists for this charge | its id and status are recorded, so the charge is reachable by its webhook again |
| no payment exists | the claim is released and the next run collects the charge normally |
| the lookup itself failed | the claim stays and the charge is parked with needs_attention — releasing on a failed lookup is how you charge someone twice |
This works in Connect mode as well, where a webhook for an unknown payment cannot be resolved at all: the tenant is derived from the local charge instead of from the remote payment.
execute_charges_limit bounds these lookups too, at the same number — each one
costs a provider call, so a run that caps its charges should cap these as well.
They do not come out of the charge budget: an unresolved charge is precisely one
this run cannot execute. Whatever is left waits for the next run.
reconcile_unresolved_after_minutesis clamped to 59. Releasing a charge hands it back to the executor, and that retry is only safe while Mollie still holds the idempotency key from the first attempt — the package sends a deterministic key with every payment, and Mollie keeps keys for one hour. Past the hour a release that turns out to be wrong becomes a second payment, so a configured 60 or more is lowered to 59 rather than honoured. The default of 30 minutes leaves room for a slow run to finish while staying inside the window.
A customer with a long payment history is not walked through indefinitely:
reconcile_lookup_ceiling (500) bounds one lookup. Reaching it parks the charge
with needs_attention instead of releasing it — "we stopped looking" is not the
same as "there is nothing there", and treating it as such is how you charge
someone twice.
What this does and does not cover
Reconciliation covers every flow that claims before the provider call:
- executed charges —
larsasub:execute-chargesclaimscharged_atunder a row lock before collecting; - the first payment of a new subscription (
createSubscription, including the retry flow) — the controllers claimcharged_atbefore the checkout payment is created, and release the claim only when Mollie provably rejected the request (a 4xx, or a failure before anything was sent); - mandate setup (
createMandateSetupPayment) — the bookkeeping charge is born claimed and flaggedmandate_setup. When Mollie turns out to have nothing for it, the leftover is removed rather than released: there is no executor to hand it back to, and releasing it would put a 0,01-charge in front of the standalone loop; - single payments — the start flows claim by setting
payable = falsebefore the call, soreconcileUnresolvedSinglePayments()finds a claimed start without a (new) payment id, adopts the payment Mollie made, or hands the pay button back when Mollie has nothing. A claimed retry still carries the id of its previous failed payment; finding exactly that payment back counts as "nothing new". Unlike charges, whose window is measured fromcharged_at, the single-payment window is measured fromupdated_at(the claim is the last write in the normal flow) — an unrelated write to the row restarts it.reconcile_lookup_ceilingapplies here too: hitting it parks the payment withneeds_attentioninstead of releasing it.
What still falls outside it: a manual payment for an existing recurring
charge (startManualChargePayment) that is truncated mid-call. Its claim is
payable = false, but the charge keeps the charged_at and provider id of the
original collection attempt, so a lost answer is not distinguishable from the
recorded failed payment by row state alone. The customer loses the pay button
until an operator intervenes — rare, visible, and tracked as a known gap.
Limiting one execution run
execute_charges_limit caps how many charges larsasub:execute-charges takes
up in a single run. It bounds the work per run — provider API calls and
wall-clock — and nothing else: whatever is left over stays planned and is
picked up by the next run, so lowering it spreads billing over more runs
instead of dropping charges.
Two things to know:
- It is one budget for the whole run, shared by the subscription loop and the standalone loop. A limit of 50 means 50 charges in total, not 50 per loop.
- It counts every charge the run takes up, including the ones it skips (a charge whose subscription is inactive, or whose tenant context cannot be activated). Those still cost queries and writes, so they still cost budget. The number of payments created in a run is therefore at most the limit, usually fewer.
null, an empty value, or any non-positive number means no limit. A configured
0 reads as "don't limit this", never as "process nothing".
Sizing the limit
A leftover charge is not touched: it keeps its past planned_on, so it matches
plannedOnDatePassed() again on the next run and the backlog drains by itself.
No queue, no extra state, no manual step. But it drains at the rate of your
schedule, and that is the whole risk.
Your capacity is limit × runs per day. With the default
execute_charges_frequency of daily and a limit of 50, that is 50 charges a
day — 600 due charges then take twelve days.
You do not add schedulers for this; there is one schedule:run cron entry and
it drives everything. You raise execute_charges_frequency:
'execute_charges_frequency' => 'hourly', // 24 × 50 = 1200/day
'execute_charges_limit' => 50,
Size capacity against your peak day, not your average — charges cluster on
the days most of your customers signed up. And note that the command runs with
withoutOverlapping(60): if a run outlives its interval the next tick is
skipped, so at aggressive frequencies your real ceiling is how long a run
takes, not the interval.
That 60 is the lock's expiry in minutes, not a guarantee. It exists so a
crashed run does not block the schedule forever, which means a run that is
still going after an hour loses its lock and the next tick starts alongside it
— two runs at once, over the same due charges. Keeping runs short is therefore
not only about throughput: a limit sized so that a run finishes well inside an
hour is itself the mitigation.
When the cap bites, the run says so — Run limit of N charges reached on
stdout and in the log. Seeing that on every run means your capacity is too low,
not that the limit is doing its job.
Raise the frequency rather than draining a backlog by hand: a manual run
alongside the scheduled one is not covered by withoutOverlapping, and two
concurrent runs can charge the same customer twice. See
Manual command execution.
⚠️ A lagging run can cost you the charge
larsasub:end-billable-subscriptions is not limited, runs daily, and knows
nothing about charges still waiting in the queue. That produces a race:
- A subscription reaches its
end_dateand is deactivated (end_date < today,automatic_renewal = false). larsasub:execute-chargesfinally reaches that subscription's last charge.BillableSubscription::isInactive()now holds, so the charge is parked withneeds_attentioninstead of collected.
The charge is not recoverable by the customer either:
RecurringCharge::getIsPayableAttribute() returns false for a charge whose
subscription is inactive — that state needs an admin, not a self-service
payment. So a limit set too low turns revenue into a support ticket.
This only affects fixed-term subscriptions with automatic renewal off.
Continuous subscriptions have no end_date and are never ended by that
command, and subscriptions with automatic_renewal = true are converted to
continuous by larsasub:renew-billable-subscriptions rather than deactivated.
Practical rule: if you sell fixed terms, your execution capacity must stay
comfortably ahead of your peak day. Treat a recurring Run limit reached line
as an incident, not as noise.
Usage Options
Option 1: Automatic Scheduling (Default)
By default, LarsaSub automatically registers all commands with Laravel's scheduler using sensible defaults:
// config/larsasub.php
'scheduling' => [
'enabled' => true,
'auto_register' => true,
// ... other settings
]
Commands will run automatically when you have Laravel's scheduler running:
# In your Laravel app's crontab:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
Option 2: Manual Scheduling
For full control over when commands run, disable auto-registration and manually add them to your app/Console/Kernel.php:
// config/larsasub.php
'scheduling' => [
'enabled' => true,
'auto_register' => false,
// ... other settings
]
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
// Core billing operations
$schedule->command('larsasub:plan-charges')
->dailyAt('06:00');
$schedule->command('larsasub:execute-charges')
->hourly()
->between('8:00', '18:00');
// Subscription lifecycle
$schedule->command('larsasub:end-subscriptions')
->dailyAt('00:30');
$schedule->command('larsasub:end-billable-subscriptions')
->dailyAt('01:00');
$schedule->command('larsasub:cleanup-unused-billable-subscriptions')
->twiceDaily(1, 13);
// Renewals
$schedule->command('larsasub:renew-billable-subscriptions')
->dailyAt('02:00');
$schedule->command('larsasub:announce-renewal-billable-subscriptions')
->dailyAt('09:00');
// Notifications
$schedule->command('larsasub:notify-charges')
->dailyAt('10:00');
// Credit management
$schedule->command('larsasub:process-expired-credits')
->dailyAt('00:15');
$schedule->command('larsasub:notify-expiring-credits')
->dailyAt('11:00');
// Mandate validation
$schedule->command('larsasub:validate-mandates')
->weeklyOn(1, '03:00');
// Maintenance
$schedule->command('larsasub:prune-model-logs')
->dailyAt('04:00');
}
Option 3: Disable Scheduling
To completely disable all scheduled functionality:
// config/larsasub.php
'scheduling' => [
'enabled' => false,
// ... other settings
]
Manual Command Execution
All commands below can be run manually for testing or one-off operations.
scheduling.enabledgates manual runs too. Every one of the twelve scheduled commands starts with the same guard, so with'enabled' => falserunning one by hand printsLarsaSub scheduling is disabled.and exits 0 without doing any work — a success code, not a silent failure, but nothing happens either. Option 3 above disables the commands themselves, not just their registration with the scheduler. The one-off commands in the next section are not gated.
⚠️ Do not run
larsasub:execute-chargesby hand while the scheduler is active. The scheduled run holds awithoutOverlappinglock, but that only covers other scheduled runs — and only for its 60-minute expiry. A manual run is not covered at all. Two runs executing at the same time can both pick up the same charge before either has marked it, and both will then create a payment at the provider: the customer is charged twice.This matters most in exactly the situation that tempts you into it: a backlog after a run limit was hit. If you have to drain one by hand, pause the cron entry — comment out
schedule:run, or stop your scheduler process — run the command, then put it back. Do not reach for'enabled' => false: that flag gates manual runs too (see above), so your manual run would do nothing at all. For a structural backlog, raisingexecute_charges_frequencyis the better answer.
# Core billing commands
php artisan larsasub:plan-charges
php artisan larsasub:execute-charges
# Subscription lifecycle
php artisan larsasub:end-subscriptions
php artisan larsasub:end-billable-subscriptions
php artisan larsasub:cleanup-unused-billable-subscriptions
# Renewal commands
php artisan larsasub:renew-billable-subscriptions
php artisan larsasub:announce-renewal-billable-subscriptions
# Notification commands
php artisan larsasub:notify-charges
# Credit commands
php artisan larsasub:process-expired-credits
php artisan larsasub:notify-expiring-credits
# Mandate commands
php artisan larsasub:validate-mandates --limit=200
# Maintenance commands
php artisan larsasub:prune-model-logs
One-Off Commands
These are not scheduled, but ship with the package:
# Backfill invoices for paid charges that have none (migration aid)
php artisan larsasub:generate-missing-invoices --dry-run
php artisan larsasub:generate-missing-invoices --limit=500
# Send every notification with test data, to check your theming
php artisan larsasub:test-notifications --list
php artisan larsasub:test-notifications --all --to=you@company.com
Available Frequencies
When using auto-registration, you can configure these frequencies:
everyMinuteeveryTwoMinuteseveryThreeMinuteseveryFourMinuteseveryFiveMinuteseveryTenMinuteseveryFifteenMinuteseveryThirtyMinuteshourlyeveryTwoHourseveryThreeHourseveryFourHourseverySixHoursdailytwiceDailyweeklymonthly
Environment Variables
You can configure scheduling via environment variables:
# Enable/disable scheduling
LARSASUB_SCHEDULING_ENABLED=true
LARSASUB_SCHEDULING_AUTO_REGISTER=true
# Timezone (optional)
LARSASUB_SCHEDULING_TIMEZONE=Europe/Amsterdam
# Core command frequencies
LARSASUB_PLAN_CHARGES_FREQUENCY=daily
LARSASUB_EXECUTE_CHARGES_FREQUENCY=daily
LARSASUB_EXECUTE_CHARGES_LIMIT=50
LARSASUB_END_SUBSCRIPTIONS_FREQUENCY=daily
LARSASUB_END_BILLABLE_SUBSCRIPTIONS_FREQUENCY=daily
LARSASUB_CLEANUP_UNUSED_BILLABLE_SUBSCRIPTIONS_FREQUENCY=twiceDaily
LARSASUB_CLEANUP_UNUSED_BILLABLE_SUBSCRIPTIONS_HOURS=12
# Renewal command frequencies
LARSASUB_RENEW_BILLABLE_SUBSCRIPTIONS_FREQUENCY=daily
LARSASUB_ANNOUNCE_RENEWAL_BILLABLE_SUBSCRIPTIONS_FREQUENCY=daily
# Charge notification settings
LARSASUB_NOTIFY_CHARGES_FREQUENCY=daily
LARSASUB_NOTIFY_CHARGES_DAYS_BEFORE=10
# Credit expiration settings
LARSASUB_PROCESS_EXPIRED_CREDITS_FREQUENCY=daily
LARSASUB_NOTIFY_EXPIRING_CREDITS_FREQUENCY=daily
LARSASUB_NOTIFY_CREDIT_EXPIRATION_DAYS_BEFORE=14
# Model-log pruning
LARSASUB_PRUNE_MODEL_LOGS_FREQUENCY=daily
LARSASUB_MODEL_LOGS_RETENTION_DAYS=730
Command Details
Plan Charges Command
Plans future recurring charges for active subscriptions. This command:
- Finds all active, chargeable subscriptions
- Generates charges for the current billing period
- Respects subscription start/end dates
- Applies credit amounts automatically
- Logs all operations
Execute Charges Command
Executes pending charges that are due for payment. This command:
- Finds charges that are due (planned_on <= today)
- Creates payments with your payment provider
- Updates charge status based on provider response
- Handles errors gracefully and continues processing
- Respects the configured limit to prevent overwhelming the system
End Subscriptions Command
Deactivates base subscription plans that have passed their end date.
End Billable Subscriptions Command
Deactivates billable subscriptions (user subscriptions) that have passed their end date without automatic renewal enabled.
Cleanup Unused Billable Subscriptions Command
Cleans up subscriptions where the first payment has failed. Runs twice daily by default to remove subscriptions that were never activated.
Renew Billable Subscriptions Command
Handles automatic subscription renewals. This command:
- Finds active subscriptions approaching their end date with automatic renewal enabled
- Creates renewal subscriptions with new billing periods
- Updates subscription records and plans new charges
- Logs all renewal operations
Announce Renewal Billable Subscriptions Command
Sends renewal announcements to users. This command:
- Finds subscriptions where the announcement date has been reached
- Calculates announcement timing:
end_date - cancellation_notice_period - 2 weeks - Sends
SubscriptionRenewalAnnouncedNotificationto users - Marks subscriptions as announced to prevent duplicate notifications
Notify Charges Command
Sends upcoming charge notifications to users. This command:
- Finds charges with
notify = truethat haven't been notified - Filters charges planned within the configured days before (
notify_charges_days_before) - Sends
SubscriptionChargeAnnouncedNotificationfor subscription charges - Sends
StandaloneChargeAnnouncedNotificationfor standalone charges - Marks charges as notified after sending
Process Expired Credits Command
Processes expired credit transactions. This command:
- Finds credit transactions with passed
expires_atdate - Marks them as expired
- Creates offsetting transactions to deduct from balance
- Updates the billable credit account balance
- Sends
CreditExpiredNotificationto users (optional)
Notify Expiring Credits Command
Sends credit expiration warning notifications. This command:
- Finds credit transactions expiring within configured days (
notify_credit_expiration_days_before) - Filters transactions that haven't been notified yet
- Sends
CreditExpiringNotificationto users - Marks transactions as notified to prevent duplicate notifications
Prune Model Logs Command
Deletes old model-log entries (the per-model activity trail). This command:
- Reads the retention period from
retention.model_logs_days(default 730 days — comfortably beyond the 13-month SEPA chargeback window a disputed direct debit needs the trail for) - Deletes matching entries in bounded chunks of 1,000 rows, so one run never holds a long transaction over a hot table
- Does nothing when the retention is
nullor0(pruning disabled; logs grow forever)
Best Practices
- Monitor Scheduling: Set up logging and monitoring for scheduled commands
- Test Configuration: Use manual execution to test commands before enabling scheduling
- Adjust Limits: Configure appropriate limits based on your subscription volume
- Timezone Awareness: Set the correct timezone for your business operations
- Credit Expiration Timing: Ensure
notify_credit_expiration_days_beforeis greater than the average time users need to use their credits
Troubleshooting
Commands Not Running
- Verify Laravel's scheduler is configured in crontab
- Check that
larsasub.scheduling.enabledistrue - Ensure payment provider is configured
- Check Laravel logs for errors
Performance Issues
- Reduce command limits in configuration
- Adjust scheduling frequency
- Consider running commands during off-peak hours
- Monitor database performance
Payment Processing Errors
- Verify payment provider credentials
- Review payment provider logs
- Test manual command execution
Credit Notifications Not Sending
- Verify
notify_credit_expiration_days_beforeis configured - Check that credit transactions have
expires_atdates set - Verify notification channels are configured correctly
- Check mail configuration and logs