PHP has a new SAPI. It targets printers. You configure with --enable-cups and you can lp my-script.php.
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.
