Invoice Lifecycle Hooks
Eight hooks from an invoice being raised to it being deleted: the creation gate, two separate creation events, status changes and deletion.
Overview
The first thing to watch on the invoice hooks is that there are two separate creation events. One runs on renewal invoices alone, the other on every invoice. Their names look alike and they are often mixed up.
The second is where the money sits: raising an invoice is not taking payment. Payment arrives on its own hooks and the status settles when it turns paid.
Reference
Skipping a renewal invoice
Runs before a renewal invoice is raised. This gate sits in a scheduled task, so stopping it raises no error and skips the job.
service or addon.Hook::add('gate:invoice.create', 10, function ($target_type, $target_id, $duedate) {
// Raising a renewal while a cancellation is open only annoys the customer.
if ($target_type === 'service' && Acme::cancelPending($target_id))
return 'A cancellation is open; no renewal invoice was raised.';
return null;
});Changing the invoice record before it is written
Runs before the invoice row is written to the database.
user_id, user_data, currency, status, the totals, pmethod. The JSON fields are already encoded here; do not expect a raw array.Hook::add('filter:invoice.create_payload', 10, function (&$data) {
// Add your own reference; the JSON fields arrive encoded already.
$data['notes'] = trim(($data['notes'] ?? '') . ' ' . Acme::stamp());
});Catching every invoice
Runs after every invoice is written: renewals, manual ones, order invoices, all of them.
0, so check before using it.Hook::add('action:invoice.created.any', 10, function ($invoice_id, $data) {
if ($invoice_id === 0) return; // the write failed
Accounting::mirror($invoice_id, $data);
});Catching a renewal invoice
Runs after a renewal invoice was raised. Not for every invoice: manual and order invoices do not arrive here.
Hook::add('action:invoice.created', 10, function ($invoice_id) {
// These are renewals ONLY; for all invoices use the .any hook.
Dunning::scheduleReminder((int) $invoice_id);
});Catching a manually raised invoice
Runs where an operator raised an invoice from the panel.
Hook::add('action:invoice.created_manually', 10, function ($invoice_id, $data) {
// A manual invoice is reported apart from the automatic ones.
Accounting::tagManual((int) $invoice_id);
});Stopping an invoice status change
Runs before an invoice status changes.
paid, unpaid, cancelled, refund, waiting.Hook::add('gate:invoice.status_change', 10,
function ($invoice, $status, $old_status, $options) {
// Taking a paid invoice back breaks the books.
if ($old_status === 'paid' && $status === 'unpaid')
return 'A paid invoice cannot go back to unpaid.';
return null;
});Following an invoice status
Runs after the status was written.
Hook::add('action:invoice.status_changed', 10,
function ($invoice, $status, $old_status, $options) {
// Did money REALLY arrive? React to the move into paid alone.
if ($status === 'paid' && $old_status !== 'paid')
Accounting::settled((int) ($invoice['id'] ?? 0), $options['pmethod'] ?? '');
});Stopping an invoice delete
Runs before an invoice is deleted.
status, taxed, total. Deleting a formalised invoice leaves no legal trail; check these fields.Hook::add('gate:invoice.delete', 10, function ($id, $invoice) {
// A formalised invoice is not deleted; it is cancelled.
if (!empty($invoice['taxed'])) return 'A formalised invoice cannot be deleted.';
return null;
});Following an invoice deletion
Runs after an invoice is deleted. The record is gone, so the snapshot you hold is the last copy of it.
Hook::add('action:invoice.deleted', 10, function ($id, $invoice) {
// The record is gone: take what you need from the snapshot.
Acme::voidInAccounting($id, $invoice['number'] ?? '');
});Following lines being split off
Runs after lines are moved from one invoice into a new one. There are two invoices now.
Hook::add('action:invoice.items_split', 10,
function ($source_invoice_id, $new_invoice_id, $item_ids) {
// Both invoices need to reach accounting separately.
Acme::resync($source_invoice_id);
Acme::resync($new_invoice_id);
});Following a renewal invoice being made
Runs after a renewal invoice is made, or after a line joins one that already exists. Both services and add-ons land here.
service or addon.Hook::add('action:invoice.renewal_generated', 10,
function ($invoice_id, $target_type, $target) {
// The same invoice can arrive more than once: make this safe to repeat.
Acme::noteRenewal($invoice_id, $target_type, (int) ($target['id'] ?? 0));
});Following a payment reminder
Runs after a payment reminder goes out. It can fire more than once for the same invoice, because reminders repeat.
Hook::add('action:invoice.reminder_sent', 10, function ($invoice) {
// Nudge over a second channel as well.
Acme::smsReminder((int) ($invoice['user_id'] ?? 0), $invoice['number'] ?? '');
});Following a customer opening an invoice
Runs when a customer views an invoice. Knowing an invoice was opened is a useful signal for reminder and collection flows.
Hook::add('action:invoice.viewed_by_client', 10, function ($invoice, $id, $ctx) {
// Knowing it was read softens the collection flow.
Acme::markSeen($id, (int) ($invoice['user_id'] ?? 0));
});Stopping a status change
Runs while an administrator changes an invoice status by hand. Marking paid, cancelling and refunding all pass through this gate.
paid, unpaid, refund or cancelled.Hook::add('gate:invoice.update_status', 10,
function ($id, $status, $pmethod, $refund_method) {
// No manual refunds once the period is closed.
if ($status === 'refund' && Acme::periodClosed($id))
return 'This period is closed, so the refund belongs in accounting.';
return null;
});Stopping a share link
Runs when an invoice is opened through a share link. Whoever opens it may not be logged in: the only thing standing behind that access is the secret in the address.
Hook::add('gate:invoice.share_view', 10,
function ($invoice, $id, $owner, $token, $isOwner) {
// Hold access without a session to your own address list.
if (!$isOwner && !Acme::ipAllowed()) return 'access denied';
return null;
});Changing the customer details on an invoice
Runs once the customer and address details are prepared for an invoice, before they are saved. What you write here becomes a permanent part of the invoice.
Hook::add('filter:invoice.client_details', 10, function (&$merged_data, $invoice) {
// Match the company title to the official name in accounting.
$official = Acme::officialName($merged_data['company_tax_number'] ?? '');
if ($official !== '') $merged_data['company'] = $official;
});Pitfalls
The plainly named event runs on renewals only, while the one ending in .any runs on every invoice. An accounting mirror, an outside sync or anything wanting a full count belongs on .any — otherwise manual and order invoices slip past quietly.
The .any event runs even where the database write failed, with the id arriving as 0. Using it unchecked means writing against an invoice that does not exist.
On the creation hooks the invoice is unpaid. Opening a service, switching an add-on on or saying "thank you" does not belong here. Money arrives when the status turns paid.
The status event can run even where the new and old statuses are the same. You see it where a payment page is called twice or a job runs again. A listener reacting without comparing the two does its work twice.
Related Articles
- Invoice and Payment Hooks
- Service Renewal Hooks
- Order Flow Hooks
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.