LarsaSub is launching soon. Purchases and account registration open shortly.

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:

Renewal Commands:

Notification Commands:

Credit Commands:

Mandate Commands:

Maintenance Commands:

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 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_minutes is 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:

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:

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:

  1. A subscription reaches its end_date and is deactivated (end_date < today, automatic_renewal = false).
  2. larsasub:execute-charges finally reaches that subscription's last charge. BillableSubscription::isInactive() now holds, so the charge is parked with needs_attention instead 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.enabled gates manual runs too. Every one of the twelve scheduled commands starts with the same guard, so with 'enabled' => false running one by hand prints LarsaSub 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-charges by hand while the scheduler is active. The scheduled run holds a withoutOverlapping lock, 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, raising execute_charges_frequency is 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:

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:

Execute Charges Command

Executes pending charges that are due for payment. This command:

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:

Announce Renewal Billable Subscriptions Command

Sends renewal announcements to users. This command:

Notify Charges Command

Sends upcoming charge notifications to users. This command:

Process Expired Credits Command

Processes expired credit transactions. This command:

Notify Expiring Credits Command

Sends credit expiration warning notifications. This command:

Prune Model Logs Command

Deletes old model-log entries (the per-model activity trail). This command:

Best Practices

  1. Monitor Scheduling: Set up logging and monitoring for scheduled commands
  2. Test Configuration: Use manual execution to test commands before enabling scheduling
  3. Adjust Limits: Configure appropriate limits based on your subscription volume
  4. Timezone Awareness: Set the correct timezone for your business operations
  5. Credit Expiration Timing: Ensure notify_credit_expiration_days_before is greater than the average time users need to use their credits

Troubleshooting

Commands Not Running

  1. Verify Laravel's scheduler is configured in crontab
  2. Check that larsasub.scheduling.enabled is true
  3. Ensure payment provider is configured
  4. Check Laravel logs for errors

Performance Issues

  1. Reduce command limits in configuration
  2. Adjust scheduling frequency
  3. Consider running commands during off-peak hours
  4. Monitor database performance

Payment Processing Errors

  1. Verify payment provider credentials
  2. Review payment provider logs
  3. Test manual command execution

Credit Notifications Not Sending

  1. Verify notify_credit_expiration_days_before is configured
  2. Check that credit transactions have expires_at dates set
  3. Verify notification channels are configured correctly
  4. Check mail configuration and logs