Integration guide · Laravel

Generate a Brazilian invoice PDF in Laravel

A service class you can inject, a queued job for month-end batches, and a Rule object that rejects an invalid CPF/CNPJ before it ever reaches the API.

Updated 2026-08-07 · 9 min read · by the FaturaPDF team

Laravel makes the HTTP part trivial, so this guide spends its time on the parts that actually decide whether the feature survives contact with production: where the config lives, how the call is wrapped so it is testable, what happens when 400 invoices are generated at midnight on the last day of the month, and how to stop invalid CPF/CNPJ data at the form boundary.

Two things are worth reading first if you have not built for Brazil before: the CPF/CNPJ check-digit algorithm (the rule the Rule object below implements) and how BRL amounts and DD/MM/YYYY dates are formatted, which is where the integer-cent handling in this guide comes from.

Config first

Do not scatter env() calls through your code — outside of config files, env() returns null once the config is cached in production, which is a classic "works locally, breaks on deploy" bug.

phpconfig/services.php
<?php
// config/services.php
return [
    // ...
    'faturapdf' => [
        'key'     => env('RAPIDAPI_KEY'),
        'host'    => env('RAPIDAPI_HOST', 'brazilian-invoice-receipt-pdf-api-cpf-cnpj.p.rapidapi.com'),
        'timeout' => (int) env('FATURAPDF_TIMEOUT', 15),
    ],
];
bash.env
# .env  (never commit this)
RAPIDAPI_KEY=your-key-from-rapidapi

The service class

One object, one responsibility: turn a payload into bytes or throw. Everything else in the app talks to this, which means you can fake it in tests with a single container binding.

phpapp/Services/InvoicePdfService.php
<?php
declare(strict_types=1);

namespace App\Services;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use RuntimeException;

final class InvoicePdfException extends RuntimeException
{
    /** @param string[] $problems */
    public function __construct(
        public readonly int $status,
        public readonly string $code,
        public readonly array $problems,
        string $message
    ) {
        parent::__construct("[{$status}] {$code}: {$message}");
    }
}

final class InvoicePdfService
{
    public function __construct(
        private readonly string $key,
        private readonly string $host,
        private readonly int $timeout = 15,
    ) {}

    /** @return string raw PDF bytes */
    public function generate(array $payload): string
    {
        try {
            $response = Http::withHeaders([
                    'X-RapidAPI-Key'  => $this->key,
                    'X-RapidAPI-Host' => $this->host,
                ])
                ->timeout($this->timeout)
                ->connectTimeout(5)
                // Retry only what is transient. A 400 is your payload, not the network.
                ->retry(3, 500, function ($exception, $request) {
                    if ($exception instanceof ConnectionException) return true;
                    $status = $exception->response?->status();
                    return in_array($status, [500, 502, 503, 504], true);
                }, throw: false)
                ->post("https://{$this->host}/invoice", $payload);
        } catch (ConnectionException $e) {
            throw new InvoicePdfException(0, 'network_error', [], $e->getMessage());
        }

        if ($response->status() !== 200) {
            $detail  = $response->json() ?? [];
            $message = $detail['message'] ?? $response->body();
            throw new InvoicePdfException(
                $response->status(),
                $detail['error'] ?? 'http_error',
                explode('; ', (string) $message),   // one problem per element
                (string) $message
            );
        }

        return $response->body();   // binary-safe string of PDF bytes
    }
}
phpAppServiceProvider.php
<?php
// app/Providers/AppServiceProvider.php  -> register()
$this->app->singleton(InvoicePdfService::class, fn () => new InvoicePdfService(
    key: config('services.faturapdf.key'),
    host: config('services.faturapdf.host'),
    timeout: config('services.faturapdf.timeout'),
));
StatusMeaningRetry?
200Raw PDF bytes, Content-Type: application/pdf
400 invalid_paramsEvery validation failure at once, semicolon-separated (includes which CPF/CNPJ failed its check digit)No — fix the payload
401 unauthorizedRequest did not come through the RapidAPI gatewayNo
405 / 413Wrong method / body over ~200 KB or 200 line itemsNo
429Gateway quota exhaustedOnly if quota can free up
500 render_failed · 504 render_timeoutTransient server-side failureYes

Building the payload from an Eloquent model

Keep the mapping in one place. Note the money handling: if you store cents as integers (and you should), divide on the way out — the API wants reais as numbers.

phpapp/Support/InvoicePayload.php
<?php

namespace App\Support;

use App\Models\Order;

final class InvoicePayload
{
    public static function fromOrder(Order $order): array
    {
        return [
            'tipo'       => 'fatura',
            'numero'     => $order->number,
            'data'       => $order->issued_at->toDateString(),      // "2026-08-07"
            'vencimento' => $order->due_at?->toDateString(),
            'emitente' => [
                'nome'      => config('company.legal_name'),
                'documento' => config('company.cnpj'),
                'endereco'  => config('company.address'),
                'email'     => config('company.billing_email'),
            ],
            'destinatario' => array_filter([
                'nome'      => $order->customer->name,
                'documento' => $order->customer->tax_id,             // null is fine here
                'endereco'  => $order->customer->address,
            ]),
            'itens' => $order->lines->map(fn ($line) => [
                'descricao'      => $line->title,
                'quantidade'     => $line->quantity,
                'valor_unitario' => $line->unit_price_cents / 100,   // reais, not cents
            ])->all(),
            'desconto'        => $order->discount_cents / 100,
            'forma_pagamento' => $order->payment_method,
            'observacoes'     => $order->notes,
        ];
    }
}
array_filter on the customer block

Sending "documento" => null is fine (the field is optional), but sending "documento" => "" for a customer with no tax ID is also fine — both are treated as absent. What is not fine is sending a partially-typed document like "123.456", which fails the check digit and returns 400. Validate at the form, not here.

Serving the download from a controller

phpapp/Http/Controllers/InvoiceController.php
<?php

namespace App\Http\Controllers;

use App\Models\Order;
use App\Services\InvoicePdfException;
use App\Services\InvoicePdfService;
use App\Support\InvoicePayload;

class InvoiceController extends Controller
{
    public function __construct(private readonly InvoicePdfService $invoices) {}

    public function show(Order $order)
    {
        $this->authorize('view', $order);

        try {
            $pdf = $this->invoices->generate(InvoicePayload::fromOrder($order));
        } catch (InvoicePdfException $e) {
            report($e);
            return response()->json(['message' => 'Could not generate the invoice.'], 502);
        }

        return response($pdf, 200, [
            'Content-Type'        => 'application/pdf',
            'Content-Disposition' => 'inline; filename="fatura-' . $order->number . '.pdf"',
            'Content-Length'      => strlen($pdf),
        ]);
    }
}
Do not use streamDownload() for bytes you already hold

response()->streamDownload() exists for output you produce incrementally. Here the whole document is already in memory, so a plain response($pdf, 200, [...]) is simpler, sets Content-Length correctly, and does not keep a PHP-FPM worker busy on a streamed callback.

Queueing month-end batches

Generating hundreds of invoices inside an HTTP request will hit max_execution_time and give the user a 504. Push it to a queue, store the result, notify when done:

phpapp/Jobs/GenerateInvoicePdf.php
<?php

namespace App\Jobs;

use App\Models\Order;
use App\Services\InvoicePdfException;
use App\Services\InvoicePdfService;
use App\Support\InvoicePayload;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;

class GenerateInvoicePdf implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public array $backoff = [10, 60, 300];   // seconds between attempts

    public function __construct(public Order $order) {}

    public function handle(InvoicePdfService $invoices): void
    {
        $pdf  = $invoices->generate(InvoicePayload::fromOrder($this->order));
        $path = "invoices/{$this->order->id}/fatura-{$this->order->number}.pdf";

        Storage::disk('private')->put($path, $pdf);
        $this->order->update(['invoice_path' => $path, 'invoice_generated_at' => now()]);
    }

    public function failed(\Throwable $e): void
    {
        if ($e instanceof InvoicePdfException && $e->code === 'invalid_params') {
            // Bad data, not a transient failure - flag it for a human.
            $this->order->update(['invoice_error' => implode(' | ', $e->problems)]);
        }
    }
}

// Dispatch the whole month with automatic spacing so you do not burst the quota:
Order::readyToInvoice()->each(
    fn (Order $o, int $i) => GenerateInvoicePdf::dispatch($o)->delay(now()->addSeconds($i))
);

A CPF/CNPJ validation rule

Stop bad documents at the form. This is the same mod-11 checksum the API applies, as a first-class Laravel rule with a translatable message:

phpapp/Rules/CpfOrCnpj.php
<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class CpfOrCnpj implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $digits = preg_replace('/\D/', '', (string) $value);

        $ok = match (strlen($digits)) {
            11      => self::validCpf($digits),
            14      => self::validCnpj($digits),
            default => false,
        };

        if (! $ok) {
            $fail('O :attribute informado não é um CPF ou CNPJ válido.');
        }
    }

    private static function digit(array $n, array $weights): int
    {
        $sum = 0;
        foreach ($weights as $i => $w) { $sum += $n[$i] * $w; }
        $rest = $sum % 11;
        return $rest < 2 ? 0 : 11 - $rest;
    }

    private static function validCpf(string $d): bool
    {
        if (preg_match('/^(\d)\1{10}$/', $d)) return false;
        $n = array_map('intval', str_split($d));
        return self::digit($n, range(10, 2)) === $n[9]
            && self::digit($n, range(11, 2)) === $n[10];
    }

    private static function validCnpj(string $d): bool
    {
        if (preg_match('/^(\d)\1{13}$/', $d)) return false;
        $n  = array_map('intval', str_split($d));
        $w1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
        return self::digit($n, $w1) === $n[12]
            && self::digit($n, array_merge([6], $w1)) === $n[13];
    }
}
phpapp/Http/Requests/StoreInvoiceRequest.php
<?php
// In a FormRequest:
public function rules(): array
{
    return [
        'customer.name'   => ['required', 'string', 'max:200'],
        'customer.tax_id' => ['nullable', new \App\Rules\CpfOrCnpj],
        'lines'                    => ['required', 'array', 'min:1', 'max:200'],
        'lines.*.title'            => ['required', 'string', 'max:200'],
        'lines.*.unit_price_cents' => ['required', 'integer', 'min:0'],
    ];
}

max:200 on lines mirrors the API's own hard cap — past that you get a 413. Matching the limits locally turns a remote error into a friendly form message.

Testing without hitting the network

Http::fake() covers both branches. Note how the success case fakes binary body:

phptests/Feature/InvoiceTest.php
<?php

use Illuminate\Support\Facades\Http;

it('returns a pdf for an order', function () {
    Http::fake([
        '*/invoice' => Http::response(
            file_get_contents(base_path('tests/fixtures/sample.pdf')),
            200,
            ['Content-Type' => 'application/pdf']
        ),
    ]);

    $this->actingAs($user)
        ->get(route('orders.invoice', $order))
        ->assertOk()
        ->assertHeader('Content-Type', 'application/pdf');
});

it('surfaces validation problems from the api', function () {
    Http::fake([
        '*/invoice' => Http::response([
            'error'   => 'invalid_params',
            'message' => 'emitente.documento tem formato de CNPJ mas digito verificador invalido',
        ], 400),
    ]);

    $this->actingAs($user)
        ->get(route('orders.invoice', $order))
        ->assertStatus(502);
});
Retry + fake

->retry() and Http::fake() interact: a faked 500 will be retried three times with real sleep() calls, making the test slow. Use Http::preventStrayRequests() and fake a 400 (non-retryable) when you only want to assert error handling.

Laravel-specific traps

1. $response->body() vs $response->json()

body() gives you the raw string — correct for a PDF. Calling json() on a binary response returns null silently rather than throwing, so a bug here looks like "the API returned nothing".

2. Config cache and env()

After php artisan config:cache, env() outside of config/ returns null. Always read through config(), as this guide does.

3. Debugbar and the download

If Laravel Debugbar is enabled it injects HTML into responses it thinks are pages. It normally respects the content type, but a misconfigured middleware order can prepend markup to your PDF. If a download breaks only in local, disable Debugbar and try again.

4. Octane and shared state

The service class is a singleton holding only immutable strings, which is Octane-safe. Do not add mutable per-request state (a $lastResponse property, say) — under Octane it leaks between requests.

The framework-free PHP version — raw cURL, Guzzle and the output-buffering pitfalls that also apply under Laravel — is in the PHP guide.

Frequently asked questions

Which Laravel versions does this work with?

The Http facade exists since Laravel 7. The ValidationRule interface shown here is Laravel 10+; on Laravel 9 and earlier implement Illuminate\Contracts\Validation\Rule with passes() and message() instead. Everything else is unchanged.

Should I store the generated PDF or regenerate it on demand?

Store it once the document is final. Generation is deterministic for a fixed payload, but an invoice is a record — if a product name changes later, regenerating would silently alter a document the customer already received. Store to a private disk and serve through an authorized controller.

How do I add a PIX QR code to the invoice?

Set pix_copia_cola in the payload to a BR Code string you already have (from your PSP, or built from your own PIX key) and the QR is rendered on the PDF. The API renders the code, it does not mint it — see the PIX BR Code guide.

Generate these PDFs from your own code

FaturaPDF turns a JSON payload into a ready-to-send Brazilian invoice or receipt PDF — validated CPF/CNPJ, R$ formatting, amount in words, optional PIX QR. Free tier: 20 documents/month, no credit card.

Get an API key on RapidAPI → Or try the free browser generator

Related guides