org_heigl/ghostscript
A PHP wrapper around Ghostscript for processing PDF and PostScript files from your application. It offers an object-oriented API to convert, merge, split, and render documents, making tasks like generating thumbnails and manipulating PDFs easier without writing raw shell commands.
This is a thin, framework-agnostic wrapper around the Ghostscript CLI binary. There is no Laravel service provider, no config file, and no facade — you install the binary, composer require the wrapper, and wire it up yourself. Budget an hour, not a sprint.
Steps
# Debian/Ubuntu image
apt-get update && apt-get install -y ghostscript
gs --version # must print, e.g. 10.02.1
Add this to your Dockerfile/sail/Forge provisioning. On Windows it's gswin64c, which the wrapper historically does not pick up automatically.composer require org_heigl/ghostscriptsrc/Ghostscript.php before anything else. It is the whole API surface: an input file, an output file, a device, a resolution, and createImage(). Everything else lives in src/Device/ (Jpeg, Png, Tiff, Pdf, Text) and src/Exception/. Five minutes there beats any documentation.use Org_Heigl\Ghostscript\Ghostscript;
$gs = new Ghostscript();
$gs->setInputFile(storage_path('app/inbox/spec.pdf'));
$gs->setOutputFile(storage_path('app/tmp/spec.jpg'));
$gs->setDevice('jpeg'); // or a Device\Jpeg instance, depending on version
$gs->setResolution(150);
$gs->createImage(); // invoke gs and write the file
The wrapper's job is building the gs command line and calling it. If createImage() returns without throwing, check the file exists and is non-zero bytes — a gs run can "succeed" with an unusable output.
Wrap it in one service class, once. You will otherwise repeat binary-path, temp-file, and cleanup logic across five controllers. Bind a PdfPreviewer in a service provider and inject it:
final class PdfPreviewer
{
public function thumbnail(string $localPdfPath, int $dpi = 150): string
{
$out = tempnam(sys_get_temp_dir(), 'gs_') . '.jpg';
(new Ghostscript())
->setInputFile($localPdfPath)
->setOutputFile($out)
->setDevice('jpeg')
->setResolution($dpi)
->createImage();
return $out; // caller moves it to a disk and unlinks
}
}
Always convert from a local path, never a stream. Ghostscript is a subprocess: it opens files by path. Storage::disk('s3')->get() into a tmpfile(), convert, then Storage::disk('s3')->put() the result and unlink() the temp files in a finally. Keep temp paths free of spaces and non-ASCII characters.
Do it in a queued job. PDF rasterisation is CPU-bound and unbounded in duration. implements ShouldQueue, set $timeout = 120, $tries = 2, and add a size/page guard before dispatching so a 300MB upload doesn't pin a worker for ten minutes:
dispatch(new GeneratePdfThumbnail($document))->onQueue('media');
Choose the device by intent, not by habit:
| Intent | Device | Notes |
|---|---|---|
| Thumbnail / preview | jpeg or png16m |
72–150 dpi is plenty; 300 dpi for print proofs |
| Normalise / shrink / merge | pdfwrite |
Ghostscript re-distils; good for flattening before storage |
| Text extraction / search index | txtwrite |
Cheaper than a full PDF parser for plain text |
| Multi-page archive | tiff |
Big files; rarely what you want in a web app |
Multi-page output requires a %d placeholder in the output filename. Without it you get one file (last page wins) or a Ghostscript error:
$gs->setOutputFile(storage_path('app/tmp/page-%03d.jpg')); // page-001.jpg, page-002.jpg …
For a first-page-only preview, either pass %d and keep page-001, or drop to a raw -dFirstPage=1 -dLastPage=1 flag if the wrapper version you have exposes extra arguments.
Pair with Intervention Image / your existing pipeline for the resize-and-store step rather than fighting Ghostscript's own -r and -g flags. Let Ghostscript produce a clean 150 dpi raster; let your image library handle cropping and WebP conversion.
Testing: don't shell out in CI unless the binary is present. Bind a fake PdfPreviewer that copies a fixture JPEG, and keep exactly one integration test guarded by if (! shell_exec('command -v gs')) { $this->markTestSkipped(...); }. Storage::fake() for the disk side as usual.
sh: gs: not found in production. Make gs --version part of your container healthcheck or a deploy smoke test.jpeg writing to .png produces a JPEG that browsers, Intervention, and your MIME sniffer will all disagree about. Keep device and extension derived from a single config value.shell_exec trust boundary. With 40 stars and zero dependents, assume you are the maintainer of last resort: pin the version in composer.json rather than floating ^.-dSAFER, but a malicious or malformed PDF can still hang or OOM the process. Sanitise by re-distilling through pdfwrite before you trust a file, cap the page count, and run conversions in a queue worker with a hard $timeout — not in the request cycle.file_exists() and filesize() > 0 after createImage(). Silent empty output is the classic symptom of a wrong device/extension pair or an unwritable target directory — very common when the temp dir is owned by root but PHP-FPM runs as www-data.Ghostscript::getInstance()), instance state can leak between jobs in a queue worker. Prefer new Ghostscript() per conversion, or explicitly reset input/output/device each time.-dSAFER in 9.50), and the wrapper exposes a version setter for a reason. Pin the Ghostscript version in your image alongside the PHP dependency so an OS package bump doesn't silently change output.; are the wrapper's problem only if it escapes them. Namespace your uploads to UUID filenames and store them under storage/app/tmp/ — this sidesteps the issue entirely.-dPDFSETTINGS=/ebook, or progress reporting, this wrapper will run out of API. A 20-line Symfony Process call gives you full flag control and is a legitimate replacement for a 40-star dependency; spatie/pdf-to-image (Imagick-backed) is the other common route if you already have Imagick compiled with Ghostscript support.How can I help you explore Laravel packages today?