HELGE SVERREAll-stack Developer
Bergen, Norwayv13.0
est. 2012  |  197 repos  |  12.8k+ contributions
Tools  |   Theme:
PHP Is a Printer Language
August 6, 2026

PHP has a new SAPI. It targets printers. You configure with --enable-cups and you can lp my-script.php.

Brother QL-820NWB label printer with stickers generated at runtime by a PHP script executing inside the CUPS filter chain

This is not a joke. Well, it is, but it also compiles.

The SAPI

sapi/cups is a new SAPI module you enable with --enable-cups at configure time. It produces a binary called php-cups that implements the CUPS filter interface — the same argv convention every CUPS filter has used since the 1990s:

php-cups job-id user title copies options [filename]

When invoked, the SAPI boots PHP from scratch (MINIT, RINIT, all extensions loaded, php.ini parsed), populates $_SERVER with every piece of CUPS job metadata, and executes your script. Whatever you echo becomes print data on stdout. Whatever you emit through the helper functions becomes the CUPS scheduler protocol on stderr.

The functions

Twelve functions, all prefixed cups_, all writing single-line protocol messages to stderr:

cups_state('+processing');   // STATE: +processing
cups_state('-toner-low');    // STATE: -toner-low
cups_page(1, 1);             // PAGE: 1 1
cups_total_pages(42);        // PAGE: total 42
cups_error('out of paper');  // ERROR: out of paper
cups_warning('low toner');   // WARNING: low toner
cups_info('job started');    // INFO: job started
cups_attr('marker', '42');   // ATTR: marker=42
// ... plus debug, emerg, alert, crit, notice

The sanitizer replaces \n and \r with spaces before writing — a single newline in a message string would let you inject arbitrary CUPS protocol commands.

The test

I have a Brother QL-820NWB USB label printer. The CUPS queue was already configured. The label roll was loaded. Sensible people stop here. I wrote a PHP script that generates PostScript for a 62mm × 100mm label:

<?php
cups_state('+processing');
cups_info('Generating label via PHP CUPS SAPI');

echo "%!PS-Adobe-3.0\n";
echo "%%BoundingBox: 0 0 175.68 282.96\n";
echo "/Helvetica-Bold findfont 18 scalefont setfont\n";
echo "20 242 moveto (PHP CUPS SAPI LABEL) show\n";
echo "/Helvetica findfont 12 scalefont setfont\n";
echo "20 212 moveto (Brother QL-820NWB) show\n";
echo "/Courier findfont 10 scalefont setfont\n";
echo "20 180 moveto (Generated: " . date('Y-m-d H:i:s') . ") show\n";
echo "20 160 moveto (SAPI: " . php_sapi_name() . ") show\n";
echo "showpage\n";

cups_page(1, 1);
cups_total_pages(1);
cups_state('-processing');

The pipeline:

php-cups label.php → PostScript on stdout (962 bytes)
  → ps2pdf → PDF (4746 bytes)
  → lp -d Brother_QL_820NWB
  → cgpdftoraster → rastertobrotherQL800
  → thermal print head → sticker

The stderr stream CUPS read during the job:

STATE: +processing
INFO: Generating label via PHP CUPS SAPI
PAGE: 1 1
PAGE: total 1
STATE: -processing
INFO: Label generated successfully

How it works

A CUPS filter is a program that reads print data from stdin, writes converted data to stdout, and emits status messages on stderr — that's the entire interface. A PHP SAPI module is a struct of function pointers that determines how PHP reads input, writes output, handles errors, and populates $_SERVER. Those two abstractions map onto each other with a precision that suggests someone designed them for this.

The SAPI struct sits in sapi/cups/php_cups.c. The key hooks:

ub_write points at write(STDOUT_FILENO, ...) with a partial-write retry loop. PHP's output buffering calls this whenever echo or print flushes. Every byte of PostScript or ESC/P your script generates travels through this function on its way to the next filter in the CUPS chain.

log_message writes to fprintf(stderr, "DEBUG: %s\n", ...). When error_log isn't set, PHP's internal errors come out here, automatically prefixed for the CUPS scheduler. The twelve cups_*() helper functions bypass this — they write directly to stderr with their specific protocol prefixes (STATE, PAGE, ERROR, ATTR, etc.).

register_server_variables calls php_register_variable() for every field CUPS makes available: CUPS_JOB_ID (from argv[1]), CUPS_USER (argv[2]), CUPS_TITLE (argv[3]), CUPS_COPIES (argv[4]), CUPS_OPTIONS (argv[5]), CUPS_FILENAME (argv[6]), plus CUPS_PRINTER, CUPS_PPD, CUPS_CONTENT_TYPE, CUPS_FINAL_CONTENT_TYPE, CUPS_DEVICE_URI, and several more pulled from the environment variables CUPS sets for every filter process.

header_handler, send_headers, send_header are stubs. There are no HTTP headers inside a printer. header_handler returns 0, send_headers returns SAPI_HEADER_SENT_SUCCESSFULLY, send_header is an empty function.

read_cookies returns NULL. read_post is NULL. Printers do not have cookies. Printers do not receive POST requests. These facts required no deliberation.

The main() entry point lives in sapi/cups/cups_filter.c. It parses the CUPS argv, registers SIGPIPE as ignored (a broken pipe from an upstream filter shouldn't kill the process mid-page), and sets a volatile sig_atomic_t flag on SIGTERM so the PHP script can implement graceful shutdown via pcntl_signal(). Then it checks whether the input file or stdin contains <?php — if yes, it boots PHP and executes the script. If no, it writes an error to stderr and exits.

The PHP bootstrap is identical to the embed SAPI — seven calls in sequence:

sapi_startup(&php_cups_module);                    // SINIT
php_cups_module.startup(&php_cups_module);         // MINIT — loads extensions, parses php.ini
php_request_startup();                             // RINIT — registers $_SERVER, module RINIT hooks
php_execute_script(&file_handle);                  // Execute the PHP script
php_request_shutdown(NULL);                        // RSHUTDOWN
php_module_shutdown();                             // MSHUTDOWN
sapi_shutdown();                                   // SSHUTDOWN

The hard-coded INI settings disable anything that interferes with a print pipeline: html_errors=0 (stderr is parsed by CUPS, not a browser), implicit_flush=1 and output_buffering=0 (print data should stream immediately, not buffer), max_execution_time=0 and max_input_time=-1 (a 2000-page print job should not die because PHP's watchdog fired).

The build system integration is three files: config0.m4 declares --enable-cups, config.m4 wires PHP_SELECT_SAPI with the platform-specific linker command, and Makefile.frag provides the cups and install-cups targets. The autoconf machinery handles the rest — adding cups to PHP_BINARIES, generating PHP_CUPS_OBJS, and inserting the build rule into the generated Makefile.

The whole thing is 700 lines of C across two source files.

github.com/HelgeSverre/php-src, branch cups-sapi. Configure with --enable-cups.

The RFC goes deeper — the complete stderr protocol table, a step-by-step trace from lp to paper, the security model and protocol injection prevention, benchmarks (25ms bootstrap, 20MB RSS), and the filter chain mechanics for every supported MIME type. It's the full sixteen sections because I don't know when to stop.




<!-- generated with nested tables and zero regrets -->