DOCX-to-PDF in PHP splits into two camps: shell out to LibreOffice (high fidelity, needs the binary) or use a pure-PHP rendering library like PhpWord+Dompdf (zero dependencies, lower fidelity). LibreOffice is the only way to get accurate font substitution and complex table layouts. Pure-PHP is fine for simple documents but falls apart on anything with multi-column layouts or embedded objects. The API is the right call when you can't install LibreOffice.
Method 1: LibreOffice headless (highest fidelity)
LibreOffice converts DOCX to PDF with near-Word fidelity. It handles complex tables, footnotes, headers/footers, and embedded images correctly.
# Check LibreOffice is installed
soffice --version
# Install on Ubuntu/Debian
apt install libreoffice&1";
$output = shell_exec($cmd);
// Clean up temp HOME
shell_exec("rm -rf " . escapeshellarg($tmpHome));
$basename = pathinfo($docxPath, PATHINFO_FILENAME);
$pdfPath = rtrim($outDir, '/') . '/' . $basename . '.pdf';
if (!file_exists($pdfPath)) {
throw new RuntimeException("LibreOffice conversion failed: {$output}");
}
return $pdfPath;
}
$pdf = docxToPdf('report.docx', './output');
echo "PDF: {$pdf}\n";
The unique HOME trick is critical for concurrent requests. LibreOffice uses a lock file in ~/.config/libreoffice — two simultaneous conversions with the same HOME will deadlock. Using a per-request temp directory prevents this.
Method 2: PhpWord + Dompdf (pure PHP, lower fidelity)
PhpWord reads DOCX and can output HTML, which Dompdf renders to PDF. No binary dependencies — but complex layouts often break.
composer require phpoffice/phpword dompdf/dompdfsave($outPath);
}
docxToPdfPhpWord('simple-doc.docx', './output/result.pdf');
echo "Done\n";
PhpWord+Dompdf works well for simple text documents. It breaks on: complex tables, custom fonts not embedded in the DOCX, multi-column layouts, text boxes, and most drawing objects. Use LibreOffice or the API for anything customer-facing.
Method 3: ChangeThisFile API (Guzzle, no installs)
The ChangeThisFile API runs LibreOffice server-side. Source format is auto-detected from the filename — just pass target=pdf. Free tier: 1,000 conversions/month, no card.
composer require guzzlehttp/guzzle 120]);
$response = $client->post('https://changethisfile.com/v1/convert', [
'headers' => ['Authorization' => 'Bearer ' . $apiKey],
'multipart' => [
['name' => 'file', 'contents' => fopen($docxPath, 'r'), 'filename' => basename($docxPath)],
['name' => 'target', 'contents' => 'pdf'],
],
]);
file_put_contents($outPath, $response->getBody()->getContents());
}
docxToPdfApi('report.docx', './output/report.pdf', 'ctf_sk_your_key_here');
echo "Done\n";
Or with PHP's built-in curl (no Guzzle needed):
true,
CURLOPT_TIMEOUT => 120,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey],
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => [
'file' => new CURLFile($docxPath, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', basename($docxPath)),
'target' => 'pdf',
],
]);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
throw new RuntimeException("API error {$httpCode}: " . substr($result, 0, 200));
}
file_put_contents($outPath, $result);
}
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| LibreOffice headless | Complex DOCX with tables, images, custom fonts | Needs LibreOffice installed; one conversion at a time per HOME |
| PhpWord + Dompdf | Simple text documents, no binary dependencies | Poor fidelity on complex layouts; won't handle embedded objects |
| ChangeThisFile API | No server installs, shared hosting, serverless | Network call, 25MB limit on free tier |
Production tips
- Use a unique HOME per conversion. LibreOffice's lock file causes deadlocks under concurrency. Create /tmp/lo_UNIQUE before each conversion and clean up after.
- Set exec timeouts. A large DOCX can take 30+ seconds. Use set_time_limit(0) and pass a timeout to your process management.
- Validate the output PDF exists. LibreOffice sometimes exits 0 even on partial failures. Always check file_exists() on the expected output path.
- Sanitize upload filenames. Never pass user-controlled strings to shell commands. Use escapeshellarg() on every argument, always.
- Consider a job queue for high volume. LibreOffice conversion is CPU-heavy. For more than a few concurrent users, queue conversions with Redis+Resque or a simple DB queue rather than processing synchronously.
LibreOffice headless is the right default for any server-side DOCX-to-PDF pipeline — it's the same engine Word alternatives use. For shared hosting or Lambda-style environments, the API skips the binary dependency entirely. Free tier: 1,000 conversions/month.