goldnead/statamic-invoices
A Statamic addon for creating and managing invoices in your control panel. Define invoice data and line items, render branded templates, and keep billing documents alongside your content. A lightweight option for Laravel and Statamic sites needing simple invoicing without an external billing platfor...
attempts and last_error to invoice_deliveries; if you're jumping from anything older, run php artisan migrate before deploying code that relies on retries. There is no schema change in v2.8.0, but the columns are still the prerequisite for a failed send to be recordable at all.invoices:pending, invoices:retry, invoices:recheck-vat-ids and invoices:export no longer read the package config when running from the console — on a multi-brand host they run once per brand, on that brand's rows, with that brand's saved settings (the settings page). Single-brand installs behave as before. This is the single biggest day-to-day change: the settings page is now the source of truth for scheduled behaviour.invoices:retry every five minutes. Nothing is retried unless php artisan schedule:run fires every minute on the host (cron or schedule:work). On a multi-brand host one scheduled entry now fans out across brands.invoice_deliveries (state, attempts, last_error) is the source of truth; invoices:held is the quick human-readable view and is deliberately cross-brand, so it's still your first stop before narrowing with --brand.delivery.retry.max_attempts (default 3) with a doubling gap starting at delivery.retry.after_minutes (default 10 → 10, 20, 40 minutes). After the last attempt the row becomes failed_final and is logged at error.php artisan migrate # only if coming from < 2.7.0
php artisan invoices:held # cross-brand: what is stuck / exhausted
php artisan invoices:retry --brand=acme # narrow a run to one brand
php artisan invoices:release {invoice} # manual re-send, under the invoice's own brand
Let the package own retries. Don't wrap send() in your own retry() helper or queue-level retry loop. Rows are claimed atomically and only failed rows are retried, which is what preserves the exactly-one-mail-per-invoice guarantee. Duplicating retry logic outside the package is how you send the same invoice twice.
Tune the backoff deliberately — and in the right place. With defaults, the three attempts land at roughly +10, +30 and +70 minutes, and the five-minute schedule adds up to five minutes of granularity to each. For transactional invoices that's fine; if you need a longer window, raise delivery.retry.max_attempts rather than shortening after_minutes — the gap doubles per attempt. On multi-brand hosts the retry command reads each brand's saved delivery settings, so tune it on the settings page per brand; the config file remains the default/fallback and is what single-brand installs use.
// config/invoices.php (defaults; per-brand saved settings win on multi-brand hosts)
'delivery' => [
'retry' => [
'max_attempts' => env('INVOICES_RETRY_MAX_ATTEMPTS', 3),
'after_minutes' => 10,
'schedule' => env('INVOICES_RETRY_SCHEDULE', true),
],
'release_sending_after_minutes' => 10,
],
Self-scheduling sites: if you already drive commands from routes/console.php or the console kernel, set INVOICES_RETRY_SCHEDULE=false so the addon doesn't register a second entry, then schedule it yourself. Your own entry still gets the per-brand fan-out — that behaviour lives in the command, not the schedule registration.
Schedule::command('invoices:retry')->everyFiveMinutes();
// or, to keep the scheduled run predictable and cheap:
Schedule::command('invoices:retry --brand=acme')->everyFiveMinutes();
Treat --brand as a triage tool, not the default. The fan-out is what makes multi-brand hosts correct; using --brand everywhere silently drops a brand from the nightly export or the retry sweep. Use it for manual intervention, backfills, and re-running after fixing one brand.
Build a failed_final runbook. failed_final rows are never retried automatically; invoices:release is the only path back. Alert on the log line invoices: delivery failed for good, out of attempts (grep-able, stable) or on invoice_deliveries.state = 'failed_final', and have support run invoices:release <invoice> after fixing the cause. Since v2.8.0 the release sends under the invoice's own brand (subject, file name), and an invoice with brand_id 0 goes out with the default brand — verify that's the branding you actually want before the first manual run.
InvoiceDelivery::where('state', 'failed_final')
->where('updated_at', '>', now()->subDay())
->get(['invoice_id', 'brand_id', 'attempts', 'last_error']);
Debug with attempts / last_error. Before escalating, read the stored error on the row — it distinguishes a renderer failure (template/blade/data problem, retries won't help) from a transport failure (mail server, retries will help). Fix the renderer issue, then release manually instead of burning attempts.
Export with brands in mind. On a multi-brand host, invoices:export without --brand produces one export per brand; with --output each brand gets its own file (belege.csv → belege_marke-2.csv); CSV to standard output requires --brand. If your accounting pipeline consumes the export, decide up front between one file per brand or one explicit --brand run per brand, and update the consumer's filename expectations in the same deploy.
php artisan invoices:export --output=storage/exports/belege.csv # one file per brand, suffixed
php artisan invoices:export --brand=acme > belege.csv # single-brand to stdout
Queued exports carry the brand. invoices:export pdf --queue enqueues jobs that carry the brand and see the saved settings in the worker. That means the worker must be able to resolve the same brands and settings — if your queue workers run from a different config or database connection, you'll get package-default output rather than the brand's.
Greeting data. buyer.name is the stored name, falling back to the local part of the address; buyer.email is always the full address. There is no user/customer lookup — an invoice stores no link to an account. If you want a real salutation, populate the stored name at invoice creation time; don't try to reverse-engineer it from the email.
Tests. Keep Mail::fake() assertions to one mail per invoice and add a case that forces a transport exception, then asserts the row is failed with attempts = 1 and a populated last_error (not deleted, not sent). Add multi-brand coverage: a command run that iterates two brands, a --brand run that touches only its own rows, a brand_id = 0 invoice, and the export filename suffix.
config/invoices.php, check the settings page — config:clear won't help, because the value lives in the database. Conversely, if you expected config to win on a multi-brand host, you've just lost an env-based override.belege.csv becomes belege_marke-2.csv (one per brand). Anything downstream that assumed a single fixed filename — ingestion scripts, SFTP pushes, dashboards — breaks in a way that looks like "no data" rather than an error.--brand. Scripts that piped invoices:export to stdout will start failing on multi-brand hosts. Adding --brand is the fix; the fan-out alternative is only available with --output.brand_id. Documents whose brand_id names no brand are reported and the exit code is 1. They will never export until you either restore the brand or reassign the rows — schedule a periodic query for them rather than discovering it during month-end.--brand for the one that failed.invoices:brand-check and invoices:held stay cross-brand on purpose. --brand doesn't narrow them. Don't build dashboards that assume otherwise, and don't be surprised when held shows other brands' rows.invoices:release now brands the mail. Subject and file name come from the invoice's brand; brand_id 0 falls back to the default brand. Changing the default brand changes the appearance of previously-stuck invoices on release — expect a diff in support screenshots.attempts / last_error, a failed send can't be recorded and sits in sending forever, untouched by invoices:retry, until someone runs invoices:release. Deploy migration and code together.schedule:run isn't firing; rows simply stay failed. Verify with schedule:list after deploy, especially if you also flipped INVOICES_RETRY_SCHEDULE.invoices:release will refuse a sending row younger than 10 minutes (delivery.release_sending_after_minutes). That's a guard against double-sending an in-flight mail, not a bug. If you know the send is dead, wait it out; if you routinely hit this, your sender timeouts are longer than 10 minutes — raise the config to match.failed (it used to remove the claim). Anything that counted deliveries by row absence, or treated "no row" as "not attempted", needs revisiting.send() after a failure still sends. A second explicit call behaves as before, so idempotency across a manual call plus an automatic retry is only protected by the state machine — don't call send() from a job that also retries.buyer.name is never the full address; assertions expecting [email protected] in the salutation will fail. Expect jane when no name is stored. Cosmetic in production, noisy in CI.INVOICES_RETRY_* env values run php artisan config:clear (or re-cache) — but on multi-brand hosts also re-check the settings page, since that's what the commands actually consume.--brand run — but don't schedule invoices:retry more frequently than the documented five minutes: extra runs add log noise and lock contention without improving latency.error; if your production log level is critical or you ship logs elsewhere, add an explicit alert on failed_final rows instead of relying on log scraping.How can I help you explore Laravel packages today?