Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Invoices Laravel Package

elegantly/laravel-invoices

A robust Laravel package for managing invoices with database storage, serial numbering, and PDF generation. Add taxes, discounts, payment instructions, and QR codes, then render, store, download, or customize invoices with ease.

View on GitHub
Deep Wiki
Context7

Getting Started

Read this first: the assessment below was written against v5.1.0. The package is now on v6.1.1.

That's a major version jump with no published release notes to review, so treat every class name, method signature, and config key below as probably correct but unverified for v6. Before you write a line of integration code, spend ten minutes on three things:

  1. composer.json of the package — check the actual php and illuminate/* constraints. v5 required PHP 8.1+ / Laravel 11+. Major versions routinely raise that floor (PHP 8.2+, Laravel 12+), and this is the #1 reason an install fails on a legacy app.
  2. The v6 upgrade guide / CHANGELOG in the repo — look specifically for renamed value objects (PdfInvoice, PdfInvoiceItem, Seller, Buyer, Address, PaymentInstruction, InvoiceDiscount), changed config keys, and any signature change on getPdfOutput() / stream() / toPdfInvoice().
  3. The README's PdfInvoice example in v6 — it was the most complete snippet in v5 and is the fastest way to see whether the value-object shape moved.

If none of that surfaces breaking changes, the rest of this document stands as-is.

Install and publish the bits you need.

composer require elegantly/laravel-invoices

# Only if you want DB-backed invoices
php artisan vendor:publish --tag="invoices-migrations"
php artisan migrate

php artisan vendor:publish --tag="invoices-config"

Upgrading from v5? Your published config/invoices.php and migration stubs are yours now — vendor:publish will not refresh them. Diff the package's config/invoices.php against yours before assuming new keys exist, and check whether the migration stub gained columns (serial-number handling is the usual suspect). Use --force on a throwaway branch if you want to see the delta.

Where to look first, in this order:

  1. config/invoices.php — this is the real API surface of the package. Everything you'll fight with later (serial number format, currency, default_seller, dompdf options, template, logo) lives here.
  2. The PdfInvoice + PdfInvoiceItem example in the README — it's the most complete snippet and shows you the shape of every value object: Seller, Buyer, Address, PaymentInstruction, InvoiceDiscount.
  3. The default template (resources/views/.../default.layout) — read it before you decide to customize it. It tells you which templateData keys actually exist.
  4. The Invoice Eloquent model + migration stubs, only after you've decided you need persistence.

First use case: ship a PDF from a controller without touching the database.

The PdfInvoice class is fully standalone. Start here — it's the fastest path to value and it means you can defer the whole migration question until you actually need serial numbers and history.

use Elegantly\Invoices\Pdf\{PdfInvoice, PdfInvoiceItem};
use Elegantly\Invoices\Support\{Seller, Buyer, Address};
use Brick\Money\Money;

public function show()
{
    return (new PdfInvoice(
        name: 'Invoice',
        state: 'paid',
        serial_number: 'INV-241200001',
        seller: new Seller(company: 'Acme', address: new Address(...), tax_number: 'FR123456789'),
        buyer: new Buyer(name: 'John Doe', address: new Address(...)),
        created_at: now(),
        due_at: now()->addDays(30),
        items: [
            new PdfInvoiceItem(
                label: 'Pro Plan',
                unit_price: Money::of(99.0, config('invoices.default_currency')),
                tax_percentage: 20.0,
                quantity: 1,
            ),
        ],
    ))->stream();
}

Set default_seller and default_currency in the config on day one. If you don't, you'll be constructing a Seller with an Address in every single call site and you'll be hardcoding 'USD' in tests.

Two additional install-time chores that bite people later, so do them now:

  • Ensure storage/app/dompdf is writable if you plan to use Google Fonts (fontDir/fontCache are pointed there by default).
  • Decide early whether logos are static assets or dynamic (per-tenant). Static → config('invoices.pdf.logo'). Dynamic → pass a logo per invoice, as a local path or base64.

Implementation Patterns

Pattern 1 — Two layers, pick deliberately. The package is really two products stapled together:

Layer Use when
PdfInvoice (value objects) Invoices are computed, ephemeral, or already in your own tables
Invoice model (Eloquent) You need serial number assignment, invoice history, states, attachments

Teams often assume they need the Eloquent model and then discover their domain already has an orders table. Don't create a parallel invoice table just because the package ships one. Conversely, don't hand-roll serial numbers — that's the single hardest part and the model handles it.

Pattern 2 — Wrap PDF generation in an action, not a controller.

Dompdf rendering is CPU-bound and creates temp files. Put it behind a single class you control:

final class InvoicePdf
{
    public function __construct(private Invoice $invoice) {}

    public function render(): PdfInvoice
    {
        return $this->invoice->toPdfInvoice(); // model → PdfInvoice
    }

    public function output(): string
    {
        return $this->render()->getPdfOutput();
    }

    public function store(string $disk = 'invoices'): string
    {
        $path = "invoices/{$this->render()->getFilename()}";
        Storage::disk($disk)->put($path, $this->output());
        return $path;
    }
}

Everything downstream (mail, download, archive) calls this. When you need a custom PdfInvoice subclass, or a queued job, or a snapshot test, you change one file. It's also your cheapest insurance against a v6 signature change — one file to fix, not forty.

Pattern 3 — Persist the PDF, not just the data. Invoices are legal artifacts. A record that renders correctly today may render differently after you tweak template_data.color — or after a major package upgrade. On issue (state transition to paid/sent), generate the PDF once and store it on a private disk:

Storage::disk('invoices')->put(
    "{$invoice->serial_number}.pdf",
    $invoice->toPdfInvoice()->getPdfOutput()
);

Serve it back through a policy-gated controller. Use a private disk — customer invoices in public/ with guessable serial numbers is a real leak. Stored PDFs are also the thing that lets you upgrade the package without re-rendering history.

Pattern 4 — Serial numbers: configure once, never at call sites.

The format string is positional and length-driven. PPYYCCCC means: 2-char prefix, 2-digit year, 4-digit count → IN220123. The config ships prefixes per invoice type (IN, QO, CR, PF) and supports a series segment (SSSS) for multi-tenant or multi-entity numbering:

'serial_number' => [
    'auto_generate' => true,
    'format' => 'PPSSSS-YYCCCC', // INV0001-220123
    'prefix' => [/* per InvoiceType */],
],

For multi-entity setups, treat the series as the tenant/company discriminator and confirm whether the counter resets per series — that's an accounting question, not a code question, and it's the thing your finance team will ask first.

Pattern 5 — Money in, money out, never floats. unit_price is a Brick\Money\Money, not a number. That's a feature: it makes currency mismatches loud. Always construct with an explicit currency (Money::of(99.0, $invoice->currency)) rather than relying on default_currency, and keep rounding_mode (RoundingMode::HalfUp) consistent between line items and totals so your PDF totals reconcile with your ledger.

Pattern 6 — Taxes and discounts: decide the order, then test it.

Both support percentage and fixed-amount forms:

items: [
    new PdfInvoiceItem(
        label: 'Pro Plan',
        unit_price: Money::of(99.0, 'USD'),
        tax_percentage: 20.0,   // or tax_amount: Money::of(19.80, 'USD')
        quantity: 1,
    ),
],
discounts: [
    new InvoiceDiscount(name: 'Summer offer', code: 'SUMMER', percent_off: 50),
],

Whether tax is computed on the pre- or post-discount subtotal is a legal requirement per jurisdiction and is not something you should infer from the default output. Write a test that asserts your expected totals for a taxed-and-discounted invoice, and keep it. If the package's order doesn't match your jurisdiction, that's a discount_class / custom-total problem — better found in a test than in an audit. That test doubles as your v6 regression check.

Pattern 7 — Attachments: generate bytes, attach bytes.

For mailables and notifications, the reliable pattern is getPdfOutput() + attachData() rather than relying on path-based attachment:

public function attachments(): array
{
    return [
        Attachment::fromData(
            fn () => $this->invoice->toPdfInvoice()->getPdfOutput(),
            "{$this->invoice->serial_number}.pdf"
        )->withMime('application/pdf'),
    ];
}

Because it's a closure, generation is deferred until send — but it still runs in the request if you're not queueing. For ShouldQueue mailables, generation moves to the worker, which is exactly where you want it.

Pattern 8 — Customize by subclassing, not forking. For branding beyond template_data (color, font, Google Font URLs), publish the template and point pdf.template at your own Blade view. If you need different logic — a watermark, a per-country layout — extend PdfInvoice and override it via the model_invoice config / a custom class, keeping the template separate. Two layers of customization: template for look, subclass for behavior.

Pattern 9 — Cast states and types to enums. The config explicitly supports casting state and type in the model. Do it immediately and let PHPStan catch the string-typo class of bug:

protected function casts(): array
{
    return [
        'state' => InvoiceState::class,
        'type' => InvoiceType::class,
    ];
}

Then InvoiceType::Credit is your void/credit-note path — never delete an issued invoice. Note: these enum class names are prime candidates for a rename in a major release — verify before you type them into a model.

Pattern 10 — Preview in HTML before you generate PDF. PdfInvoice renders as a view as well as a PDF. In development, render the view and iterate on the template in the browser; only generate PDFs when the HTML looks right. Dompdf debugging cycles are measured in seconds per render and will destroy your afternoon otherwise.


Gotchas and Tips

Major-version upgrades are config-merge exercises first, code exercises second. After bumping to v6, your first failure will usually be a missing config key rather than a missing class. Diff the package config against your published copy, and diff the migration stub against your database/migrations. Do that before you start renaming anything in your own code.

Pin the version, and read the upgrade guide before composer update. At a few hundred stars and zero dependents, this is a single-maintainer package with a small blast radius and no deprecation runway. "elegantly/laravel-invoices": "^6.1" with a committed composer.lock is the correct posture. An unpinned major bump in a routine composer update on a Friday is how you find out about breaking changes in production.

Dompdf is not a browser. No flexbox, no grid, no modern CSS. Use tables and floats. If a layout looks fine in Blade-in-browser but breaks in the PDF, it's almost certainly a CSS feature dompdf doesn't implement. Budget for this on day one of template work, not day three.

isRemoteEnabled => true is on by default, and that's a tradeoff. It's required for external CSS, remote images, and Google Fonts — but it means dompdf will fetch URLs. If any part of your invoice content is user-controlled (buyer name, custom fields, logo URL), you've potentially got SSRF. Keep isPhpEnabled => false (the default) and prefer base64 logos over URLs for anything dynamic:

logo: 'data:image/png;base64,' . base64_encode(file_get_contents($path)),

chroot is set to realpath(base_path()). Local logo paths and any file:// assets outside the project root will silently fail to load. If your logo lives on a different mount, base64 is the escape hatch.

Fonts: Helvetica will let you down. The default Helvetica font lacks broad symbol/glyph coverage — currency symbols, accented names, and CJK will render as tofu. The config comment recommends Arimo; take that advice if your customer base is international. Google Font imports require fontDir/fontCache to point at a writable directory, and the first render is slow while the font is fetched and cached. Warm the cache on deploy.

Serial number formats are positional and length-sensitive. PPYYCCCC is eight characters exactly; repeating a letter sets its width. Get the format wrong at launch and your legal numbering sequence is wrong forever — you cannot rename issued invoices. Lock the format in a test:

expect($invoice->serial_number)->toMatch('/^IN\d{2}\d{4}$/');

Run that test immediately after the v6 upgrade. If the format engine changed, you want to know before the next invoice is issued, not after.

Concurrency on auto_generate. The README describes automatic generation on creation but doesn't document locking. Two simultaneous creates can plausibly race on the count segment. Mitigate it yourself: wrap creation in a transaction and add a unique index on serial_number in the published migration so the DB is the final arbiter. A duplicate invoice number discovered months later is a genuinely painful cleanup.

auto_generate => true plus a manual serial number is a conflict waiting to happen. If you ever need to import historical invoices with their own numbers, flip auto_generate to false in an import-specific path (or set the number and verify it survives) rather than trusting the interaction.

Currency mismatch throws, and that's good. Money enforces currency equality. If you build line items in default_currency but a seller/buyer record says otherwise, or if you mix Money::of(...,'EUR') items into a USD invoice, expect an exception at render time — not at construction. Centralize currency resolution in one place.

cascade_invoice_delete_to_invoice_items defaults to true. Convenient, and dangerous: hard-deleting an Invoice takes the line items with it. For anything legally retained, use soft deletes plus states, or a Credit type invoice, and audit any code path that calls delete().

The template is a dot-notated view name, not a path. 'template' => 'default.layout' means a registered Blade view. Publishing the package views and pointing at 'invoices.my-template' is the correct customization path; passing a filesystem path won't work.

getFilename() defaults to the serial number. Fine — but it makes stored files guessable and it means a missing serial number produces a useless filename. Prefix it yourself ("{$invoice->id}-{$pdf->getFilename()}") when storing.

Test the HTML, not the PDF bytes. PDF output is binary, non-deterministic across dompdf versions, and hostile to snapshot assertions. Assert against the rendered view (line items present, totals correct, taxes applied) and treat getPdfOutput() as "returns a non-empty string starting with %PDF". Use Storage::fake('invoices') and Mail::fake() to verify attachments without rendering.

Queue PDF generation in production. A 20-page invoice with embedded fonts is a few hundred milliseconds of pure CPU. Generate on state transition in a job, store the result, and serve the stored file. Regenerating on every download will show up in your p95.

Wrap it behind your own interface. Put your calls to PdfInvoice and Invoice behind a thin service (Pattern 2 above) so that a future major bump, a pinned version, or a patch fork doesn't ripple through your mailables and controllers. This is the single highest-leverage thing you can do with a package this size — and the v6 release is the reminder of why.

Debugging order when something renders blank: (1) view renders in browser? (2) logo is a path inside chroot or a valid base64 data URI? (3) remote CSS blocked? (4) font cache directory writable? (5) template_data keys matching what the template actually reads? In that order — it's roughly the frequency of each failure.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect
youbar/easy-crud
c-point-labs/laravel-hostyourai
dbravoan/dbcms-admin-theme-dbcms
dbravoan/dbcms-theme-db-cyber
dbravoan/dbcms-blocks-basic
dbravoan/dbcms-module-content-audit
dbravoan/dbcms-module-ai-assistant
paygate-to/paygate-php
padosoft/askmydocs-connector-base
defstudio/supercharged-carbon
bernskioldmedia/laravel-matomo