gotenberg/gotenberg-php
A PHP client for the Gotenberg API, letting you convert HTML, Markdown, Word, Excel, and URLs into PDFs using Chromium and LibreOffice, with a simple, framework-agnostic interface.
Two moving parts: the Gotenberg container and the PHP client. You need both before the first PDF exists.
# 1. The conversion engine (run this locally, in CI, and in prod)
docker run --rm -p 3000:3000 gotenberg/gotenberg:8
# 2. The client + a PSR-18 adapter (Guzzle is the path of least resistance in Laravel)
composer require gotenberg/gotenberg-php php-http/guzzle7-adapter
The package is not Laravel-aware — no facade, no service provider, no config file. Wire it yourself in config/services.php:
'gotenberg' => [
'url' => env('GOTENBERG_URL', 'http://localhost:3000'),
'timeout' => env('GOTENBERG_TIMEOUT', 120),
],
First use case — an invoice PDF from a Blade view. This is what 90% of Laravel adoption looks like:
use Gotenberg\Gotenberg;
use Gotenberg\Stream;
$html = view('invoices.pdf', ['invoice' => $invoice])->render();
$request = Gotenberg::chromium(config('services.gotenberg.url'))
->pdf()
->assets(
Stream::string('style.css', file_get_contents(public_path('css/invoice.css')))
)
->html(Stream::string('index.html', $html))
->outputFilename("invoice-{$invoice->number}");
$filename = Gotenberg::save($request, storage_path('app/invoices'));
Where to look first, in order:
Gotenberg::chromium(), Gotenberg::libreOffice(), Gotenberg::pdfEngines() — these are your entire API surface.Stream factory (Stream::path, Stream::string, new Stream(...)) and DownloadFrom, which determine how files reach Gotenberg.The client returns PSR-7 requests and expects a PSR-18 client. That is awkward at a controller's call site. Collapse it into one injectable class, and let the rest of the application never see Gotenberg:: again:
namespace App\Services;
use Gotenberg\Gotenberg;
use Gotenberg\Stream;
use Psr\Http\Client\ClientInterface;
class PdfConverter
{
public function __construct(private ClientInterface $http) {}
public function fromHtml(string $html, array $assets = []): string
{
$request = Gotenberg::chromium(config('services.gotenberg.url'))
->pdf()
->assets(...array_map(
fn ($name, $contents) => Stream::string($name, $contents),
array_keys($assets),
$assets
))
->html(Stream::string('index.html', $html))
->correlationId((string) str()->uuid());
return Gotenberg::save($request, storage_path('app/pdf'), $this->http);
}
}
Binding ClientInterface in a service provider (e.g. $this->app->bind(ClientInterface::class, fn () => new \Http\Adapter\Guzzle7\Client(...))) is worth the ten lines: it makes the converter testable, lets you tune timeouts globally, and avoids relying on php-http/discovery picking a client at runtime.
Never build HTML strings by hand. Blade gives you escaping, components, and layout inheritance for free, and view()->render() returns the exact string Stream::string('index.html', ...) wants. Note that assets() uses the filename as the URL path — your <link href="style.css"> must match the stream name, which means inlining or overriding asset URLs rather than pointing at /css/app.css.
For anything a user uploads (.docx, .xlsx, .pptx), use the libreOffice() module and pass the uploaded file straight through:
$request = Gotenberg::libreOffice($apiUrl)
->convert(Stream::path($uploadedFile->getRealPath()))
->password($uploadedFile->getClientOriginalName() === 'secret.docx' ? $pw : null)
->exportBookmarks()
->pdfa('PDF/A-2b');
Stream::path() requires a real filesystem path — UploadedFile::getRealPath() gives you the PHP temp path, which is fine. If you're on a cloud disk, pull the file down first or use Stream::string() with the fetched bytes.
downloadFrom() is underused and saves a round trip through your app's memory. When the source document already lives on a URL your backend can reach, skip the upload entirely:
Gotenberg::libreOffice($apiUrl)
->downloadFrom([new DownloadFrom($signedUrl, ['Authorization' => "Bearer {$token}"])])
->convert();
Note the signature change in v2.7.1: all Stream arguments are now optional on convert() precisely because downloadFrom() can supply the files.
Chromium and LibreOffice are slow and single-request-hungry: a 200-page docx easily eats 30–60 seconds. Run conversions in a queued job with an explicit $timeout and $tries, then notify or store:
class GenerateStatementPdf implements ShouldQueue
{
public int $timeout = 180;
public int $tries = 2;
public function handle(PdfConverter $converter): void
{
$converter->fromHtml(view('statements.pdf', [...])->render());
}
}
Synchronous conversion belongs only on small, user-blocking documents (an invoice download button), and even then give the HTTP client a generous timeout.
Gotenberg::send() returns a PSR-7 ResponseInterface whose body is the binary PDF. Stream it out of a controller:
public function download(Invoice $invoice): Response
{
$response = Gotenberg::send(
Gotenberg::chromium($apiUrl)->pdf()
->html(Stream::string('index.html', view('invoices.pdf', compact('invoice'))->render()))
);
return response($response->getBody()->getContents(), 200, [
'Content-Type' => 'application/pdf',
]);
}
This avoids writing a temp file you then have to serve and clean up.
pdfEngines() is where multi-file workflows live: merge() a batch of invoices into one statement, or split() a PDF into a zip. These are genuinely useful in accounting and document-packaging features, and they're one-liners:
Gotenberg::pdfEngines($apiUrl)
->merge(Stream::path($a), Stream::path($b), Stream::path($c))
->pdfa('PDF/A-3b');
You must install a PSR-18 adapter. Without php-http/guzzle7-adapter (or equivalent), Gotenberg::send() and Gotenberg::save() rely on php-http/discovery and will blow up at runtime with an unhelpful "no client found" error. Explicitly passing the client (Gotenberg::send($request, $client)) removes the mystery entirely and is the safer habit in production.
Gotenberg::save() writes to a directory, not a Laravel disk. It returns only the filename. On local storage this is fine; on S3 you must use send() and then Storage::put($key, $response->getBody()->getContents()). Remember to rewind() a stream before reading it twice — PSR-7 bodies are not rewindable by default.
Default filename collisions. Gotenberg generates UUID filenames unless you call outputFilename(). Set it deliberately and let Gotenberg append the extension — pass invoice-123, not invoice-123.pdf, unless you want invoice-123.pdf.pdf. If two jobs generate the same fixed name concurrently, they clobber each other; include an ID or timestamp.
The Gotenberg-Trace header must agree with the server. correlationId('debug') sets a header that Gotenberg only echoes back if its --api-correlation-id-header property matches. If your getCorrelationId() returns null on errors, that mismatch is almost always why. This matters more than it sounds: without correlation IDs, diagnosing a failed conversion from Gotenberg's container logs is guesswork.
Catch GotenbergApiErrored, not a generic exception. Non-2xx responses throw, and the exception carries both getResponse() (so you can inspect Gotenberg's message, which is often a Chromium console log) and getCorrelationId(). Log both — the body explains the failure, the trace locates it in the server logs.
Version pairing is rigid. Client v2.x targets Gotenberg 8.x; v1.x targets 7.x. Pointing a v2 client at a v7 container produces 404s on routes that moved (the Chromium PDF/screenshot split into ->pdf() / ->screenshot() is the classic breakage). Pin the Docker tag in your compose file and upgrade the client in the same PR.
Asset URLs in Chromium are resolved server-side. Gotenberg fetches URLs from inside the container, so http://localhost/css/app.css and APP_URL-based links resolve to the container, not your app. Use assets() with matching filenames, absolute public URLs, or base_url on the Chromium module. This is the single most common silent failure — CSS missing, fonts wrong, images broken.
Tune the HTTP timeout, not just the queue timeout. Guzzle's default is often too short for large LibreOffice conversions, and the resulting ConnectException looks like a network problem rather than a slow render. Set a client-level timeout above your worst-case conversion and keep it below your queue worker's retry_after.
Reach for the failure flags when correctness matters. failOnHttpStatusCodes() (returns 409 on a bad main-page status) and, since v2.9, failOnResourceHttpStatusCodes() / failOnResourceLoadingFailed() turn "PDF generated but missing a stylesheet" into a loud error. Conversely, skipNetworkIdleEvent() noticeably speeds up pages with polling or long-lived connections — it's the difference between a fast render and a 30-second wait.
There are no Laravel dependents to borrow from. With essentially zero downstream packages, examples and StackOverflow answers are thin, and the README's "head to gotenberg.dev" is genuine advice rather than filler. Budget time to read the server docs directly; the method names in the PHP client map almost one-to-one to form fields, so it transfers easily. The Symfony bundle (sensiolabs/GotenbergBundle) is the closest architectural reference if you want to see how someone else structured the integration.
Testing. Don't Http::fake() this — it's PSR-18, not Laravel's HTTP client. Inject a stub ClientInterface returning a canned PSR-7 response, assert on the request body ((string) $request->getBody() contains your form fields), and keep one integration test in CI against a real Gotenberg container to catch version drift.
Screenshots are a sleeper feature. Gotenberg::chromium($url)->screenshot()->url(...) with png()/jpeg()->quality(100)/webp() gives you OG images, thumbnails, and visual-regression snapshots for free — no headless-browser plumbing in your app.
How can I help you explore Laravel packages today?