tomatophp/tomato-invoices-module
An invoices management module for the TomatoPHP ecosystem, adding invoice creation, tracking, and billing screens to your Filament admin panel. Integrates with other Tomato modules to handle customers, payments, and exports.
Day-one install (scratch branch, non-production DB):
composer require tomatophp/tomato-invoices-module:^1.0
php artisan vendor:publish # interactive list — read the tags before choosing
php artisan migrate
Publishing is the step that matters. Run the interactive publisher first and record every tag it exposes (config, migrations, lang, views, assets). Tag names vary across TomatoPHP modules — don't assume --tag=tomato-invoices-config exists until you've seen the list.
Then register the plugin with your Filament panel provider:
// app/Providers/Filament/AdminPanelProvider.php
public function panel(Panel $panel): Panel
{
return $panel
// ...
->plugin(\TomatoPHP\Invoices\InvoicesPlugin::class); // verify exact FQCN
}
Where to look first — in this order:
vendor/tomatophp/tomato-invoices-module/composer.json — Laravel, PHP, Filament and sibling tomatophp/* constraints. A Filament v2/v3 mismatch is the single most common silent blocker here; check this before anything else.src/ — locate the service provider, then the plugin class. That tells you what gets registered globally (routes, views, migrations, translations) and whether anything can collide with existing invoice-ish routes.database/migrations/ — this is the real specification. The release notes for v1.0.0 are a link and nothing more, so the schema is your only source of truth: table names, column types, enum vs. string statuses, soft-delete columns, decimal precision, and whether numbering is stored or derived.src/Filament/ — the resources and forms. This is what you're actually adopting (the CRUD layer); read the form schema to see whether totals and tax are computed server-side or trusted from the request.resources/views/ + any PDF-related service — determine whether rendering is Blade→DomPDF/Snappy inline or queued.First use case to prove out: in a throwaway branch, create one invoice with two line items through the admin panel, confirm the persisted rows and computed totals in the DB, then produce a PDF from it. If you cannot get a PDF and a correct persisted total inside a day, the module's value proposition is already eroding — stop and reassess before building on it.
Wrap it behind an adapter on day one. This is the highest-leverage decision in the whole adoption, because the package is v1.0.0 with a single maintainer and no dependents. Application code should never reference the module's models directly.
final class BillingService
{
public function createDraft(Customer $customer, array $lines): InvoiceDTO { /* ... */ }
public function issue(InvoiceDTO $invoice): void { /* numbering, lock, event */ }
public function void(InvoiceDTO $invoice, string $reason): void { /* ... */ }
public function pdfUrl(InvoiceDTO $invoice): string { /* signed, versioned */ }
}
Everything — controllers, jobs, Filament actions, listeners — talks to BillingService. When you fork or replace the module, you rewrite one class instead of grepping the codebase.
Vendor the migrations immediately. Copy the published migrations into your own database/migrations/ with fresh timestamps on the first day, and then stop relying on upstream migrations. Invoice tables become your financial system of record; a future upstream column add or rename landing on a table you're reading directly is the most expensive failure mode in this package class. Forking at install time converts that into a non-event.
Compute money in exactly one place. Persist amounts as integer minor units (or decimal(12,2) with explicit casts) — never float. Do not trust totals submitted from the Filament form's line-item repeater: recalculate subtotal, tax and total in the adapter inside a transaction, and write them. If the module ships its own calculator, keep it behind your adapter so you can swap it for a jurisdiction-aware implementation later.
Solve invoice numbering yourself. Generic invoicing modules typically ship a naive max(id)+1 or count()+1 sequence, which duplicates under concurrency and leaves gaps on rollback — both are compliance incidents, not bugs. Use a dedicated sequence table with lockForUpdate() inside the issuing transaction, plus a unique index on (series, year, number) so the DB is the final referee:
DB::transaction(function () use ($invoice) {
$seq = InvoiceSequence::where('series', 'INV')->where('year', $year)
->lockForUpdate()->firstOrCreate([...]);
$invoice->forceFill(['number' => ++$seq->last, 'issued_at' => now(), 'status' => 'issued'])->save();
event(new InvoiceIssued($invoice));
});
Enforce immutability on issued invoices with model events, not just UI. Block updates and deletes once status === 'issued' in a saving/deleting observer, and add a policy. Filament's edit page is one door; background jobs and tinker are others.
Filament integration tips. Don't subclass the module's resources — upstream refactors break subclasses at upgrade time. Instead: register your own resource for panels where behavior diverges, or use getEloquentQuery() overrides via a custom resource that extends Filament's base, plus table-level Actions for "Download PDF", "Mark as Paid", "Void". On list pages, ensure line items are eager-loaded (->with('items')) and pagination is on — Filament will otherwise N+1 the line-item repeater.
Queue PDF generation. A GenerateInvoicePdf job that writes to a stable, versioned disk path (invoices/{id}/{hash}.pdf), idempotent by content hash, dispatched on issue. Never render inside the Filament request — PDF rendering is CPU-bound and the first customer with 40 line items and a logo will time you out.
Order → invoice flow. Listen for your own domain event (OrderPaid) and call BillingService::createDraft(); keep the module out of checkout entirely. This preserves the boundary that it's an admin/records tool, not a payment system.
Testing. Factory the invoice via the adapter, not the model. Assert the job is dispatched (Queue::fake()), assert totals for edge cases (zero-rate tax, discounts, rounding on 3-decimal currencies), and assert a unique-constraint violation when two processes issue concurrently.
composer.json and, ideally, a CI matrix. There is no published compatibility table to fall back on.invoices (and likely invoice_items). Run php artisan migrate --pretend on a clone of your schema before touching a real database.src/ and database/ yourself, and pin the exact version in composer.json.php artisan vendor:publish menu and write down the tags in your onboarding notes; the next developer will otherwise re-run it blindly.model key in the published config (lets you swap in your own Invoice extends ModuleInvoice), a configurable table prefix, and any events the module fires. If none exist, the adapter layer is your extension point — don't fight the package internals.BillingService" and "never edit published migrations after forking." Both erode within two sprints if undocumented.How can I help you explore Laravel packages today?