thirtybittech/invoicematic
Invoicematic is a Laravel package for creating, managing, and exporting invoices. It offers configurable templates, PDF generation, and straightforward Eloquent integration, helping keep billing logic out of your application code.
Invoicematic is a Statamic addon, so it assumes a Statamic site running on Laravel — install it there rather than into a bare Laravel app.
composer require --dev # no — install as a normal dependency
composer require <vendor>/invoicematic
php artisan vendor:publish --tag=invoicematic-config
Open config/invoicematic.php first. This is the single place that tells you almost everything about how the package behaves: the storage disk, the Blade template path, the entry field mapping per collection, and the mail settings.
Add a field to the collection that should store the generated PDF path (a plain text field or an asset field both work — check which the config expects before wiring it up). Keep the handle short and stable, e.g. invoice_pdf.
Create your first template under resources/views/vendor/invoicematic/ (or wherever the config points). Start by copying the package's default template and replacing only the parts you care about — customer block, line items, totals.
First use case: on publish of an orders entry, generate a PDF and write its path to the entry. The quickest way to validate the install is a single php artisan tinker call against a real entry, then check the file appears on the configured disk. Once that works, move on to events and email.
If the PDF isn't produced on the first try, the problem is almost always one of: wrong disk, mismatched field handle, or a template that references a variable the entry doesn't have.
Per-collection field mapping. The core abstraction is the mapping between a collection and the fields the template needs. Treat the config as the contract:
'collections' => [
'orders' => [
'template' => 'invoicematic.invoice',
'path_field' => 'invoice_pdf',
'mapping' => [
'number' => 'order_number',
'issued_at' => 'order_date',
'customer' => 'customer_name',
'line_items' => 'items',
],
],
],
Map only what the template actually uses. If a collection has no mapping, skip generation entirely rather than falling back to a generic template — half-rendered invoices are worse than none.
Event-driven generation. Hook into the entry lifecycle rather than calling the generator from controllers:
use Statamic\Events\EntrySaved;
Event::listen(EntrySaved::class, function (EntrySaved $event) {
if ($event->entry->collectionHandle() !== 'orders') {
return;
}
if (! $event->entry->published()) {
return;
}
GenerateInvoice::dispatch($event->entry);
});
Wrap generation in a job when templates are non-trivial or the entry has many relations — PDF rendering is the slowest part of the publish request.
Storing the path. Write the returned path back to the mapped field and save quietly, otherwise you re-trigger the save event:
$path = Invoicematic::generate($entry);
$entry->set('invoice_pdf', $path)->saveQuietly();
Queued email delivery. Email is optional and should always be queued. Build the mailable with the generated PDF attached from the disk, not from a freshly rendered string:
Mail::to($entry->customer_email)
->queue(new InvoiceMail($entry, Storage::disk(config('invoicematic.disk'))->path($path)));
Keep delivery as a separate listener from generation so you can regenerate a PDF without re-sending mail, and re-send mail without regenerating.
Regeneration flows. For corrections and credit notes, expose a utility action or Artisan command that re-runs generation for a single entry, overwriting the stored path. This is also the cleanest way to bulk-backfill invoices for entries created before the addon was installed.
Environment separation. Use a local disk with local / public visibility in development and S3 (or similar) in production. Because the path is stored on the entry, switching disks invalidates stored paths — decide early and document it, or generate a URL via the disk rather than hardcoding the path in the template.
The save loop. Setting the path field and calling save() inside an EntrySaved listener is the classic recursion trap. Always saveQuietly(), and consider a if ($entry->invoice_pdf && ! $force) guard so republishing doesn't churn through PDF generation.
Config caching. Statamic and Laravel both cache config in production. After installing or editing the mapping, run php artisan config:clear and php artisan cache:clear, then re-check the addon is listed in the Statamic addon manifest. A mapping that "isn't being picked up" is nearly always a stale cache.
Field handles vs. display names. The mapping keys are handles. Renaming a field in the blueprint silently breaks generation — the template renders empty values instead of erroring. Add a smoke test that generates one PDF per configured collection; it catches handle drift in CI.
Missing assets in PDFs. Dompdf-style renderers don't resolve relative URLs. Reference images and CSS with absolute URLs or public_path(), otherwise the invoice renders with a broken logo that looks fine in the browser preview.
Memory and timeout on bulk runs. Rendering hundreds of PDFs in one job will blow PHP's memory limit. Chunk the collection (->chunk(50)) and let each chunk be its own job; PDF rendering doesn't release memory well within a single process.
Queue worker required. Email delivery is queued, so a running worker is a hard dependency. If invoices stop going out but PDFs still appear, check the queue before touching the addon config.
Disk permissions and visibility. A PDF written to a private disk can't be linked directly. Either serve it through a signed route or generate a temporary URL at send time; don't store a public URL on the entry, it will expire.
Testing. Use Storage::fake() and Mail::fake() and assert on both:
Storage::fake('invoices');
Mail::fake();
// ... trigger save ...
Storage::disk('invoices')->assertExists($entry->fresh()->invoice_pdf);
Mail::assertQueued(InvoiceMail::class);
Add Bus::fake() too if generation is dispatched, so the test asserts dispatch rather than the rendered output.
Extension points. The Blade template is the intended customisation surface — override it in your app rather than editing vendor files. Reach for a custom listener only when you need conditional logic (draft vs. published, per-site templates, different numbering schemes). Keep number generation outside the template so it stays consistent across regenerations.
How can I help you explore Laravel packages today?