# Convert ZPL to PDF in PHP

Source: https://openzpl.com/guides/zpl-to-pdf-php/ (updated 2026-10-20)

To convert ZPL to PDF in PHP, POST the ZPL with cURL to the OpenZPL render endpoint with the header Accept: application/pdf and write the response to a file. Check the HTTP status before using the file: errors come back as text that starts with ERROR:.

## What you need

- PHP 7.4 or later with the cURL extension (php -m | grep curl).
- The ZPL, the label size in inches and the printer density (8dpmm = 203 dpi).

## The minimal request

```php
<?php
$zpl = file_get_contents('label.zpl');
$ch = curl_init('https://api.openzpl.com/v1/printers/8dpmm/labels/4x2/');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => $zpl,
    CURLOPT_HTTPHEADER => ['Accept: application/pdf'],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$pdf = curl_exec($ch);
if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) !== 200) {
    throw new RuntimeException($pdf);   // "ERROR: ..."
}
file_put_contents('label.pdf', $pdf);
```

Passing the ZPL as a string to CURLOPT_POSTFIELDS sends it as the raw body. Passing an array would send a form; if you want that, use ['file' => new CURLFile('label.zpl')], which the API also accepts.

```zpl
^XA
^FO20,20^GB772,366,4^FS
^FO40,40^A0N,30,30^FDWAREHOUSE BIN^FS
^FO40,85^A0N,110,110^FDA12 03^FS
^BY3,3,110
^FO40,215^B3N,N,110,N,N^FDA1203^FS
^XZ
```

## A function with retries and no memory spike

CURLOPT_FILE writes the body straight to disk, so a 50-page PDF never sits in a PHP string. A header callback picks up Retry-After and X-Total-Count as they arrive.

```php
<?php
function zpl_to_pdf(string $zpl, string $path, int $dpmm = 8, string $size = '4x6',
                    ?string $apiKey = null, int $retries = 3): int
{
    $url = "https://api.openzpl.com/v1/printers/{$dpmm}dpmm/labels/{$size}/";
    $headers = ['Accept: application/pdf', 'Content-Type: text/plain; charset=utf-8'];
    if ($apiKey) {
        $headers[] = "X-API-Key: $apiKey";
    }
    for ($attempt = 0; ; $attempt++) {
        $out = fopen($path, 'wb');
        $meta = [];
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $zpl,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_FILE => $out,            // the body goes straight to disk
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$meta) {
                if (preg_match('/^(retry-after|x-total-count):\s*(\d+)/i', $line, $m)) {
                    $meta[strtolower($m[1])] = (int) $m[2];
                }
                return strlen($line);
            },
        ]);
        $ok = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $error = curl_error($ch);
        fclose($out);
        if ($ok === false) {
            throw new RuntimeException("Request failed: $error");
        }
        if (($status === 429 || $status === 503) && $attempt < $retries) {
            sleep($meta['retry-after'] ?? 1);
            continue;
        }
        if ($status !== 200) {
            $message = trim(file_get_contents($path));   // "ERROR: ..."
            unlink($path);
            throw new RuntimeException("HTTP $status: $message");
        }
        return $meta['x-total-count'] ?? 1;
    }
}

$count = zpl_to_pdf(file_get_contents('label.zpl'), 'label.pdf');
echo "label.pdf: $count label(s)\n";
```

Tested on PHP 8.5 with a four-label file: it printed label.pdf: 4 label(s). A 60-copy label threw HTTP 413: ERROR: The ZPL produces 60 labels; the maximum per request is 50 and left no half-written file behind. The code calls no curl_close(): it does nothing since PHP 8.0 and is deprecated in 8.5.

## Serving the PDF to a browser

To send the label to the user instead of saving it, render it to a temporary file and stream it with header('Content-Type: application/pdf') and readfile(). Don't echo the cURL result before checking the status, or users download an error message named label.pdf.

## Errors

| Status | What it means | What to do |
|---|---|---|
| 400 | A parameter in the URL is wrong, or a barcode's data is invalid. | Read the text after ERROR: and fix the request. Don't retry it unchanged. |
| 404 | The ZPL has no label, or the label index is out of range. X-Total-Count says how many labels there are. | Check that the ZPL has complete ^XA … ^XZ blocks, or ask for an index below X-Total-Count. |
| 406 | The Accept header asks for a format the API doesn't produce. | Send image/png or application/pdf. |
| 413 | The body is over 1 MB, or the ZPL produces more than 50 labels. | Split the job into several requests. |
| 429 | A limit of your plan was hit: requests per second, or labels per UTC day. | Wait the seconds in Retry-After, then retry. |
| 503 | The label took more than 10 seconds to render, or the service is at capacity. | Retry once after Retry-After; if it repeats, lower the density or split the label. |

## Limits

- Endpoint: POST https://api.openzpl.com/v1/printers/8dpmm/labels/4x6/ with the ZPL as the request body. The density is 6dpmm, 8dpmm, 12dpmm or 24dpmm; the size is in inches, up to 15 per side.
- Accept: application/pdf without a label index returns every label, one per page. Add an index (.../4x6/0/) to get a single label.
- Free plan: no key, 3 requests per second and 5,000 labels per day per IP address. Every label counts, ^PQ copies and PDF pages included. See pricing.
- Per request: up to 50 labels and 1 MB of ZPL.

**Can I use Guzzle instead of cURL?** Yes. Send the ZPL as the body option with the Accept: application/pdf header and use the sink option to write the response to a file. The API doesn't care which client you use.

**Why does my PDF have question marks instead of accents?** Send the ZPL as UTF-8 and start the label with ^CI28. Without ^CI28 the printer, and the renderer, read the bytes in another character set.

**Do I need an API key?** Not to start. Without a key the free limits apply per IP address. Add X-API-Key when you need your plan's limits.
