nowo-tech/word-to-pdf-bundle
A Symfony bundle for converting Word documents to PDF. It provides a simple service and configuration to turn .doc and .docx files into PDFs within your PHP application, making it easy to generate PDFs from uploaded or stored Word documents.
Prerequisites before any code: LibreOffice must be installed on every host that will convert files, and the soffice binary must be on PATH (or its absolute path configured). PHP needs the ability to spawn processes (proc_open/exec not disabled) if the bundle shells out. This is an OS-level dependency, not a Composer one — container images and CI runners both need it, and a missing soffice is the single most common failure.
Minimal steps:
composer require nowo-tech/word-to-pdf-bundleconfig/bundles.php (Symfony Flex should do this automatically; verify if you use a non-standard kernel).which soffice / soffice --version on the same user the PHP-FPM/worker process runs as.Where to look first in the repo: src/DependencyInjection/Configuration.php and the extension class — that is the authoritative list of config keys, their defaults, and whether paths are required. Then the service class that performs the conversion to see what exception types it throws and whether it returns a path, a stream, or raw bytes. Then README.md for the one canonical usage snippet. Skip everything else until step 5 works.
First use case: a one-off admin action — "download this contract as PDF" from a Document entity. Convert on demand, return a binary response, and do not persist the output at first. That exercises the whole path (binary discovery → conversion → response) with the least moving parts, and gives you a baseline for timing before you design caching or async handling.
Inject the converter, don't new it. The bundle exposes the conversion logic as a DI service. Use constructor injection with the concrete service type or an interface if one ships; this keeps your code testable and lets you swap in a fake during tests.
Keep conversion behind your own thin wrapper. Do not call the bundle service directly from fifty controllers. Introduce an application-level DocumentToPdfConverter (or similar) that owns: temp-file creation, invoking the bundle, copying the result to permanent storage, and cleanup. This is your seam for the inevitable day you migrate off LibreOffice, and it keeps the bundle's namespace out of your domain code.
final readonly class ContractPdfGenerator
{
public function __construct(
private WordToPdfConverterInterface $converter, // bundle service
private FilesystemOperator $storage,
) {}
public function generate(string $docxPath): string
{
$pdfPath = $this->converter->convert($docxPath);
$target = sprintf('contracts/%s.pdf', bin2hex(random_bytes(8)));
$this->storage->write($target, file_get_contents($pdfPath));
@unlink($pdfPath);
return $target;
}
}
Treat conversion as slow and non-reentrant. LibreOffice startup is hundreds of milliseconds to seconds, and concurrent soffice invocations can collide on the same user profile directory (see Gotchas). Day-to-day patterns that hold up:
GeneratePdfMessage from the web request, convert in a worker, notify or poll for completion. Essential once documents exceed a few pages or traffic is non-trivial. Pin the worker to a bounded concurrency (more on this below).Command, one conversion at a time, with progress output. The bundle itself is not a batch engine; your command is.Validate the input before the converter sees it. Check MIME type and, better, the extension and that the file opens as a DOCX ZIP container. LibreOffice will happily chew on garbage for a while and then emit a cryptic error; failing fast with your own exception yields far better logs. Enforce a size cap too — a 200 MB DOCX will occupy a worker for minutes.
Normalise the output location. Whatever the service returns (path in a temp dir, SplFileInfo, etc.), copy it into your own storage abstraction immediately and delete the temp artifact. Don't let the temp path leak into your controller or entity — temp dirs get wiped, and the next request will 404.
Pin fonts and styles in the container. Print-quality layout depends on installed fonts. If the bundle's output looks "nearly right" but fonts differ between your laptop and production, that's a missing font package in the image, not a bundle bug. Bake the same font set (e.g. Liberation/Carlito as metric-compatible substitutes for Arial/Calibri) into every environment.
Laravel note: this is a Symfony bundle, so there is no service provider for it. Realistic options, in order of preference: (a) if the conversion logic is separable, use the underlying library or symfony/process directly with a soffice --headless --convert-to pdf call and skip the bundle; (b) bring in symfony/dependency-injection + the bundle and wire it manually in a service provider; (c) wrap it as an isolated CLI job. For a Laravel codebase, (a) is usually less work than the abstraction the bundle buys you.
Concurrent soffice runs fight over the profile lock. Two conversions starting near-simultaneously with the default user profile can hang or fail with a "profile is locked" style error. Mitigation on your side: pass a distinct -env:UserInstallation=file:///tmp/lo-<uniqid> per invocation if the bundle exposes a way to add arguments; otherwise serialise conversions through a single Messenger worker or a lock (symfony/lock). Check whether the service already isolates the profile before you add your own locking — if it does, the lock is redundant.
Zombie processes and timeouts. A malformed document can leave soffice running past your PHP max_execution_time. Ensure the bundle applies a process timeout; if it doesn't, set one at the process level and always let the failure path kill the child. Otherwise a queue worker will slowly accumulate orphans.
Config quirks. Config values are merged against defaults in the bundle's Configuration class — a key you think exists may be silently ignored if you typo it in YAML (Symfony validates unknown keys at the root of your bundle node, so a typo usually surfaces as a config error, but a typo inside a nested array may not). After changing config, run bin/console debug:config <bundle_alias> to see the resolved tree, and bin/console debug:container --parameter=<key> to verify it landed. Clear the cache — bundles are compiled into the container and stale caches produce "why isn't my new config taking effect" confusion.
Debugging a failing conversion. Reproduce outside PHP first:
soffice --headless --convert-to pdf --outdir /tmp /path/to/file.docx
If that fails, the bundle cannot succeed and the problem is the document, the binary, or the profile. If it succeeds standalone but fails in the app, the difference is almost always the user (permissions on HOME//tmp), a missing HOME env var in the FPM/worker context, or a resource limit (ulimit -n, memory). Log the full command line and the process exit code; the bundle's exception message alone is rarely enough.
Silent exit code 0 with no output file. LibreOffice occasionally exits successfully but writes nothing — usually a stale lock or an unwritable output directory. Always assert the returned path exists and is non-empty (filesize > 0) before handing it onward.
Extension points. If you need different behaviour (custom temp dirs, extra CLI flags, logging), look for a service that's defined as non-final or an interface you can decorate. Symfony's DI makes decoration clean — decorates: in services.yaml wrapping the bundle's service — and this preserves forward compatibility far better than editing the bundle. Avoid forking; a 0-star package is unlikely to be quick with upstream fixes, and your fork becomes yours to maintain.
Testing. Do not exercise real LibreOffice in unit tests. Because the conversion goes through a single injected service, bind a fake in the test container that copies a fixture PDF to a temp path. Reserve a small integration test (tagged, run only when soffice exists) for verifying the real pipeline, and skip it cleanly when the binary is absent so CI on minimal images doesn't go red.
Vendor risk. Zero stars and zero dependents means no community safety net: check the release history and the last commit date, and pin the version explicitly in composer.json. Because the essential work is a single well-understood shell call, keep your wrapper thin enough that migrating to a five-line Process invocation is an afternoon, not a project.
How can I help you explore Laravel packages today?