leo-moshko/gotenberg-bundle
Integrates the Gotenberg document-conversion API into your PHP app, offering a simple wrapper for turning HTML, Markdown, and Office files into PDFs. Send documents over HTTP and get clean, print-ready output with minimal setup.
Gotenberg is a stateless Docker service that exposes an HTTP API for document conversion: HTML/Markdown/URL → PDF, Office documents → PDF, PDF merge/split/convert/flatten, and URL screenshots. This package is the PHP-side wrapper around that API.
Minimal setup
composer require leomoshko/gotenberg-bundle
docker run --rm -p 3000:3000 gotenberg/gotenberg:8
.env rather than the config file:
GOTENBERG_URL=http://gotenberg:3000
GOTENBERG_TIMEOUT=60
curl before touching PHP. If curl -F [email protected] http://localhost:3000/forms/chromium/convert/html -o out.pdf fails, the problem is Docker networking, not your code.Where to look first
composer.json → "type" and extra. A Bundle-suffixed name usually means a Symfony bundle, i.e. no Laravel auto-discovery and possibly no extra.laravel.providers. Confirm this in the first five minutes; it changes the integration plan completely (see Gotchas).src/ for the client/service class — that's the object you'll inject.config/ or Resources/config/ for the endpoint map. Gotenberg 8's routes are the contract you're really coding against: /forms/chromium/convert/html, /forms/chromium/convert/url, /forms/chromium/screenshot/url, /forms/libreoffice/convert, /forms/pdfengines/{merge,convert,flatten}.First use case
The 90% case is "render this Blade view to a PDF and store it." Get that working end to end before exploring the API surface:
$html = view('invoices.pdf', ['invoice' => $invoice])->render();
$pdf = Gotenberg::html($html)
->paperSize('A4')
->margin('10mm')
->pdf();
Storage::disk('s3')->put("invoices/{$invoice->id}.pdf", $pdf);
Bind the client, don't new it. Register the package service in a service provider behind a small interface (PdfRenderer) and inject that. When the package's API shifts, or when you want to swap to raw Http:: calls, you change one binding instead of 40 call sites.
Queue the work. Chromium rendering is 200ms–10s and occasionally unbounded. Anything user-facing (invoice on checkout, report export) belongs in a job:
final class RenderInvoicePdf implements ShouldQueue
{
public int $timeout = 120; // must exceed the Gotenberg HTTP timeout
public int $tries = 3;
public function handle(PdfRenderer $renderer, Storage $storage): void { /* ... */ }
}
Dispatch, return a "generating" state to the UI, and notify on completion. This also gives you free retries for container restarts.
Multipart, not JSON. Gotenberg is form-data only. The entry document for HTML conversion must be named index.html; CSS/JS/images ride along as additional files entries with names that match the src/href paths in your markup. Inline critical CSS and base64 small images to keep the attachment list short — every extra file is a chance for a path mismatch.
Store, don't stream, for anything persisted. Use Storage::disk(...)->put() on the raw response body. For download-to-user flows, return response($pdf)->header('Content-Type', 'application/pdf'); avoid writing temp files just to hand them to response()->download().
Header/footer partials are a separate HTML document passed as its own field — they do not inherit your main document's CSS. Use inline styles only, and remember Chromium ignores @page margins inside them.
Webhooks for long jobs. Gotenberg accepts a Gotenberg-Webhook-Url header (plus method/extra headers) and will POST the result back. Pair this with a signed, api-middleware route to avoid holding a worker open during LibreOffice conversions of large decks.
Testing. Http::fake() with a fixture %PDF-1.4 body covers the wiring and your job logic cheaply. Keep one real integration test against a gotenberg/gotenberg service container in CI — that's the only place you'll catch Chromium quirks, missing fonts, and route renames.
"Bundle" almost certainly means Symfony, not Laravel. Check composer.json before assuming php artisan vendor:publish will do anything. If there's no extra.laravel, you have three options: wire the services manually in a provider, use the underlying HTTP client directly (Gotenberg's API is simple enough that the wrapper is optional), or add symfony/http-client just for this. Decide deliberately — don't spend an hour debugging why the container isn't resolved.
Error bodies are plain text, not JSON. On a 4xx/5xx, Gotenberg returns a human-readable string. ->json() on the failure path throws a confusing JsonException and hides the actual message ("Chromium failed to load the page", "invalid paper size"). Always read ->body() on error and log it.
Timeouts stack. Browser timeout (Gotenberg), GOTENBERG_TIMEOUT (HTTP client), $timeout (queue worker), and PHP max_execution_time all apply. Any one of them being the smallest produces a truncated failure that looks like a Gotenberg bug. Set the HTTP timeout below the worker timeout, always.
The container has its own limits. Chromium needs more memory than you think; --api-timeout, --chromium-restart-after, and --libreoffice-restart-after flags matter under load. A single-worker Gotenberg will serialize requests — scale with chromium-... auto-restart or run multiple containers behind a queue.
Fonts. The image ships with a limited font set. Cyrillic, CJK, and emoji glyphs render as tofu unless you mount a font volume or attach the font files with your request. Verify with real production content, not Lorem ipsum.
External assets need network access from inside the container. https://cdn.tailwindcss.com in a Blade layout will silently fail (or hang until timeout) if the container is network-isolated. Inline the CSS for PDF layouts; treat PDF templates as a separate, self-contained design rather than a reuse of your web layout.
index.html naming is non-negotiable for HTML conversion, and relative asset paths must resolve against the flat set of attached files. Nested paths are a common source of blank pages with no error.
Gotenberg has no authentication. Never expose port 3000 publicly, and be very careful with URL-conversion endpoints — you're handing an internal service the ability to fetch arbitrary URLs (SSRF). Validate or allowlist hosts if users influence the target.
Pin the image tag. Gotenberg 7 → 8 renamed LibreOffice routes and changed flags. An unpinned :latest will break your deploy on someone else's schedule. Match the image tag to whatever the package's README targets.
Debugging playbook: send Gotenberg-Trace: <uuid>, read the trace header on the response, then docker logs the container filtering by that ID. Nine times out of ten it tells you exactly which asset or expression failed. For a fast bisect, curl the same multipart payload from the host — if curl works and PHP doesn't, it's your client; if curl fails too, it's the document.
On v1.1.0: the release notes are only a changelog link, so read the commits before upgrading. A minor bump on a young package typically means new fluent options or config keys — the risky part is config reshuffles that silently fall back to defaults (paper size, timeouts) rather than throwing. Diff your published config against the package's after upgrading, and re-run your one real integration test.
How can I help you explore Laravel packages today?