faerber/pdf-to-zpl
faerber/pdf-to-zpl is a Laravel/PHP package for converting PDF documents into ZPL, the native command language for Zebra label printers. It helps turn existing PDFs into printer-ready labels within your app.
1. Install and verify the toolchain (this is the real "getting started" step).
composer require benfaerber/pdf-to-zpl
The library is a thin PHP wrapper over image rasterisation, so the system dependencies matter more than the Composer install:
php -m | grep -i imagick # ext-imagick required
gs --version # Ghostscript required for PDF -> raster
convert -list format | grep -i pdf # ImageMagick PDF delegate must be enabled
If convert -list format shows PDF as rw- with no delegate, PHP will happily parse your file and then blow up deep inside Imagick. Confirm the delegate first.
2. First conversion. The entry point is a converter object configured with a settings value object (namespace Faerber\PdfToZpl). Check src/ in the installed version for the exact class names, but the shape is:
use Faerber\PdfToZpl\PdfToZplConverter;
use Faerber\PdfToZpl\Settings\ConverterSettings;
$converter = new PdfToZplConverter(new ConverterSettings(
dpi: 203, // 8 dpmm — match your printer, not your PDF
labelWidth: 4, // inches
labelHeight: 6,
));
$zpl = $converter->convertFile(storage_path('app/labels/shipping.pdf'));
file_put_contents(storage_path('app/labels/shipping.zpl'), $zpl);
3. Where to look first, in order:
README.md — the only documentation; read it fully before writing code, the API is small.src/Settings/ — DPI, label dimensions, and page selection live here. This is the object you will actually be tuning.tests/ — contains fixture PDFs and expected output; the fastest way to understand what the package considers "correct".vendor/bin/ — if a CLI wrapper is shipped, use it to prototype conversions outside Laravel before wiring anything up.4. The first real use case: shipping labels. A PDF label from a carrier (or a generated PDF invoice) gets rasterised to a 1-bit image and embedded as a ^GF graphic field inside ^XA ... ^XZ. Treat the output as an image, not as structured ZPL.
5. Verify before printing. Never test on hardware first. POST the ZPL to Labelary:
curl -X POST "https://api.labelary.com/v1/printers/8dpmm/labels/4x6/0/" \
-H "Accept: image/png" \
--data-binary @storage/app/labels/shipping.zpl -o preview.png
This catches DPI, orientation, and cropping problems in seconds instead of wasting media.
Bind the converter as a singleton with config-driven settings. You will pass the same settings object around constantly; make it container-resolved and override only per-job.
// config/labels.php
return [
'dpi' => env('ZPL_DPI', 203),
'width' => env('ZPL_WIDTH', 4),
'height' => env('ZPL_HEIGHT', 6),
'printer_ip' => env('ZEBRA_IP'),
'printer_port'=> env('ZEBRA_PORT', 9100),
];
// AppServiceProvider::register()
$this->app->singleton(PdfToZplConverter::class, fn () => new PdfToZplConverter(
new ConverterSettings(
dpi: config('labels.dpi'),
labelWidth: config('labels.width'),
labelHeight: config('labels.height'),
)
));
Wrap it in your own service, not the vendor type. The public API is small and the package is pre-1.0 in spirit — one adapter class means a future swap costs one file, not a codebase grep.
final class ZplConverter
{
public function __construct(private PdfToZplConverter $converter) {}
public function fromPdf(string $absolutePath): string
{
throw_unless(is_readable($absolutePath), new RuntimeException("Missing PDF: {$absolutePath}"));
return retry(2, fn () => $this->converter->convertFile($absolutePath), 200);
}
}
Queue the conversion, always. Rasterising a multi-page PDF at 203–300 DPI is CPU-bound and memory-hungry; a synchronous request will time out on anything past a couple of pages.
class ConvertLabelToZpl implements ShouldQueue
{
public int $timeout = 120;
public int $tries = 3;
public function __construct(public int $shipmentId) {}
public function handle(ZplConverter $converter): void
{
$pdf = Storage::path("labels/{$this->shipmentId}.pdf");
$zpl = $converter->fromPdf($pdf);
Storage::put("zpl/{$this->shipmentId}.zpl", $zpl);
}
}
Stream to the printer over raw TCP 9100. Zebra printers accept ZPL as a raw socket payload — no driver, no CUPS. Use stream_socket_client and send the string as-is; do not wrap it in HTTP-shaped framing.
$socket = stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr, 5);
fwrite($socket, $zpl);
fclose($socket);
Guard this behind a feature flag or a PrintLabel job that can be faked in tests — you do not want a CI run discovering a printer on the network.
Generate a preview endpoint. Store the ZPL, then proxy a Labelary render for a human to eyeball:
return Http::withBody($zpl, 'application/octet-stream')
->withHeaders(['Accept' => 'image/png'])
->post("https://api.labelary.com/v1/printers/8dpmm/labels/{$w}x{$h}/0/")
->body();
Cache these aggressively — Labelary is a third-party service and free-tier rate-limited.
Only rasterise what must be rasterised. The highest-value pattern: keep the label layout as native ZPL (^FO, ^A0, ^BC, ^B3) and use this package only for the parts that arrive as PDF/image (carrier logos, signature blocks, pre-rendered manifests). Composite them by wrapping the package output:
$zpl = "^XA"
. "^FO50,50^A0N,40,40^FDSHIP TO: {$name}^FS"
. "^FO50,120^BCN,100,Y,N,N^FD{$tracking}^FS"
. $rasterBlock // from the converter, ^FO/^GF already included
. "^XZ";
This keeps barcodes natively rendered (crisp, always scannable) and keeps ZPL payload size down.
Page selection for multi-page PDFs. If your source is a multi-page document, select the page in the settings rather than converting and discarding output — otherwise memory and file size scale with the whole document.
ImageMagick's policy.xml is the #1 failure. Many distro packages ship a policy that blocks the PDF coder entirely (<policy domain="coder" rights="none" pattern="PDF" />). Symptoms range from an empty image to a cryptic delegate error. Either enable the coder or, preferably, let Ghostscript do the rasterisation directly and only use Imagick for post-processing.
Match DPI to the printer, not to the PDF. Zebra printers are typically 203 dpi (8 dpmm) or 300 dpi (12 dpmm). Exporting at 300 dpi and sending to a 203 dpi printer produces a label that is ~1.5× too large and gets cropped on the right edge. Read the model number, not the spec sheet's "maximum resolution". Labelary's 8dpmm / 12dpmm path segment must match too.
ZPL payloads are huge. A full-page 4×6 label at 300 dpi uncompressed is roughly 1.8 MB of ^GF hex. Check whether your version of the package emits compression (^GFA with a compression type, or :Z64: base64) and enable it if offered. Also watch memory_limit and PHP's max_execution_time when converting several pages per job.
Output is pixels, so everything becomes pixels. Text in the source PDF loses selectability (irrelevant for labels, but surprising if you expected structured ZPL), and low-resolution source scans produce fuzzy barcodes that fail scan verification. Printers render a 3 px-wide bar at 203 dpi as a marginal bar; scanners reject it. Prefer native ^BC/^B3 for anything that must be read.
Bleed and margins will bite you. Rasterising to the exact label dimensions means a 1–2 mm offset in the PDF becomes clipped content at the label edge. Add a small inner margin in the settings and calibrate the printer's media offset (~JA, then ^MN) separately — the package cannot fix hardware registration.
Output is not byte-deterministic across environments. Different Ghostscript/ImageMagick versions antialias differently, so a ZPL file that renders correctly on your Mac may differ on the production container. Pin the base image and, if you snapshot-test conversions, snapshot against the containerised renderer, not your laptop.
There is no Laravel integration and no dependents. No service provider, no facade, no config publishing, no releases you can lean on for support. Consequences:
"benfaerber/pdf-to-zpl": "1.0.x") — the maintainer has no downstream consumers to catch breaking changes.Missing fonts fail quietly. If the PDF references fonts not installed in the container, Ghostscript substitutes them and you get a label with the wrong metrics — no exception, just a wrong-looking label. Install the font family your documents use in the Docker image.
Debugging order when a label comes out blank or garbage: (1) does the intermediate raster look right — dump it to disk; (2) does Labelary render the ZPL; (3) does nc printer 9100 accept a trivial ^XA^FO50,50^A0N,30,30^FDHELLO^FS^XZ; (4) only then blame the library. That order eliminates 90% of "the package is broken" reports.
Extension points are minimal by design. There are no events, drivers, or macros. The practical seams are: the settings object (DPI/dimensions/page), subclassing the converter to post-process the ZPL string, and your own adapter layer. Post-processing the string — prepending ^XA, injecting native fields, adjusting ^FO offsets — is the most common and most maintainable integration technique.
How can I help you explore Laravel packages today?