mike42/escpos-php
A PHP driver implementing the ESC/POS protocol for thermal receipt printers. Generate and print receipts with formatting, cutting and barcodes over Ethernet, USB, serial, parallel or SMB, making it easy to add receipt printing to POS apps.
Install the package and confirm the three hard requirements are present on the host (json, intl, zlib, PHP 8.2+). Missing intl is the most common silent failure on shared hosting and minimal Docker images.
composer require mike42/escpos-php
Where to look first, in order:
example/interface/ — one file per OS/transport (ethernet, linux-usb, windows-usb, smb, cups). Copy the closest match rather than reading the whole source tree.src/Mike42/Escpos/PrintConnectors/ — the connector is the only part you must get right; the printer API is uniform after that.Printer.php — the public method list (text, setEmphasis, setJustification, feed, cut, barcode, graphics, qrcode).CapabilityProfile::load(...) — the list of bundled profile names, which determines codepage, image commands, and font support.The smallest useful first use case is writing to stdout and piping bytes to a device, which separates "is my PHP correct" from "is my printer reachable":
<?php
require __DIR__ . '/vendor/autoload.php';
use Mike42\Escpos\PrintConnectors\FilePrintConnector;
use Mike42\Escpos\Printer;
$connector = new FilePrintConnector('php://stdout');
$printer = new Printer($connector);
$printer->text("Hello World!\n");
$printer->cut();
$printer->close();
php hello-world.php | nc 10.x.x.x 9100 # network printer, raw port
php hello-world.php > /dev/usb/lp0 # Linux USB
In a Laravel app, the first real integration is usually a config/printing.php entry plus a container binding:
// config/printing.php
return [
'default' => env('PRINTER_DRIVER', 'network'),
'printers' => [
'receipt' => [
'connector' => 'network',
'host' => env('PRINTER_HOST', '192.168.1.50'),
'port' => env('PRINTER_PORT', 9100),
'profile' => env('PRINTER_PROFILE', 'default'),
'width' => 48, // chars, 80mm Font A
],
],
];
Do not resolve a Printer as a singleton in the container — it is stateful and bound to one connection lifecycle. Bind a factory instead.
Connector factory keyed by config. Keep the transport choice out of your receipt code so dev, staging, and the shop floor use the same call site.
final class PrinterFactory
{
public function make(string $name = 'receipt'): Printer
{
$cfg = config("printing.printers.$name");
$connector = match ($cfg['connector']) {
'network' => new NetworkPrintConnector($cfg['host'], $cfg['port']),
'file' => new FilePrintConnector($cfg['path']),
'windows' => new WindowsPrintConnector($cfg['share']), // smb://host/printer
'cups' => new CupsPrintConnector($cfg['queue']),
default => throw new InvalidArgumentException("Unknown connector"),
};
$profile = CapabilityProfile::load($cfg['profile'] ?? 'default');
return new Printer($connector, $profile);
}
}
Always close() in finally. The connector buffers and flushes on close; an exception mid-receipt otherwise leaves a half-printed ticket and a dangling socket.
$printer = $this->factory->make('receipt');
try {
// ... render
} finally {
$printer->close();
}
Queue the print, not the receipt render. Printing is I/O with unpredictable latency; a dead printer should not block an HTTP request or a checkout. A ShouldQueue job with $tries = 3 and $backoff is the right unit of work.
final class PrintReceipt implements ShouldQueue
{
public int $tries = 3;
public array $backoff = [2, 10, 30];
public function __construct(public int $orderId) {}
public function handle(PrinterFactory $factory): void
{
$order = Order::findOrFail($this->orderId);
$printer = $factory->make($order->printer_name ?? 'receipt');
try {
(new ReceiptRenderer($printer, width: 48))->render($order);
} finally {
$printer->close();
}
$order->forceFill(['printed_at' => now()])->save();
}
}
Guard against double prints by checking printed_at at the top of handle() and by keeping the job idempotent — queue retries after a partial successful print are a real source of duplicate tickets.
Renderer class with column helpers. The single most useful abstraction is a line($left, $right) helper that pads to the paper width. 80mm at Font A is 48 columns, 58mm is 32. Never hardcode; drive it from config.
private function line(string $left, string $right): void
{
$pad = $this->width - mb_strlen($left) - mb_strlen($right);
$this->printer->text($left . str_repeat(' ', max(1, $pad)) . $right . "\n");
}
Fluent formatting sequence per section. Methods return $this, so receipts read top-to-bottom:
$printer->setJustification(Printer::JUSTIFY_CENTER)
->setEmphasis(true)
->setTextSize(2, 2)
->text("ACME STORE\n")
->setTextSize(1, 1)
->setEmphasis(false)
->setJustification(Printer::JUSTIFY_LEFT);
$printer->setEmphasis(true)->text("TOTAL ")->setEmphasis(false);
$printer->text(number_format($totalCents / 100, 2));
$printer->feed(2)->cut(Printer::CUT_PARTIAL);
Money handling. Keep totals in integer cents everywhere and format only at render time. number_format() on floats drifts, and the printer has no concept of a decimal type.
Images: resize before loading, and have a fallback ladder. graphics() is the modern command; bitImage() and bitImageColumnFormat() are progressively more compatible. A logo that is 800px wide will be slow to process and can blow the printable width.
$img = EscposImage::load(storage_path('app/logo.png'), false);
$printer->graphics($img, Printer::IMG_DOUBLE_WIDTH);
// fallback for cheap clones:
// $printer->bitImage($img);
Capability profiles as a compatibility dial. Start with default (Epson TM-series assumptions). If you see mojibake, garbled graphics, or the printer ignores commands, drop to simple. For Star hardware use SP2000. For anything not in the bundled list, consult receipt-print-hq/escpos-printer-db and load a JSON profile by name.
Multi-station routing. Real shops have a receipt printer at the till and a kitchen/bar printer. Store a printer_name on the order or the station, resolve via PrinterFactory::make($name), and dispatch one job per station so a kitchen outage doesn't block the customer copy.
Capture-bytes preview for tests and dev. FilePrintConnector pointed at a temp file gives you the raw ESC/POS stream without hardware. Assert on it in tests, or replay it later with nc.
$connector = new FilePrintConnector($path = tempnam(sys_get_temp_dir(), 'escpos'));
$printer = new Printer($connector, CapabilityProfile::load('simple'));
// ... render ...
$printer->close();
$this->assertStringContainsString('TOTAL', file_get_contents($path));
Log what you send. A thin decorator implementing PrintConnector that forwards to the real connector while appending to a log or PSR-3 logger is the fastest way to debug "the printer did nothing" versus "we sent nothing".
Printer is not reusable. After close() the connector is finished; calling text() again throws. Construct one per receipt, per job. Never inject it as a singleton.close() is not optional. Skipping it can drop the final buffer, including the cut() command, leaving your ticket hanging out of the printer.? or accented garbage. CapabilityProfile::load("simple") is the fastest fix; it also forces simpler image commands.intl. Currency symbols like ₦, ₹, or €, em dashes, and curly quotes frequently do not exist in the target codepage. Transliterate to ASCII for receipt copy, or pick a profile/codepage that covers your locale. Test with a real print, not a bytes-diff.setTextSize(2,2) halves the usable columns. Compute widths from a single config value; don't scatter 48 through the code.mb_strlen not strlen when padding columns, or a UTF-8 product name will shift the price column.cut(Printer::CUT_FULL, 3) already feeds three lines, but if your last element is a barcode you usually want an extra feed(2) so the cut doesn't clip it.WindowsPrintConnector submits via the Windows spooler, so the printer must be shared first (smb://computer/printer). It behaves differently from raw sockets — job failures appear in the print queue, not in PHP.gd or imagick; without them the pure-PHP gfx-php fallback works but is measurably slower. Pre-resize logos to the exact dot width of the paper and cache the processed asset.BARCODE_UPCA, BARCODE_JAN13, BARCODE_ITF, BARCODE_CODABAR are numeric-only; feeding them letters produces garbage or nothing. BARCODE_CODE39 handles alphanumerics.pulse() for the cash drawer (where supported) must be sent before cut(); combining drawer kick with the cut in one call sequence sometimes gets swallowed by clones.Escpos\Printer versus Mike42\Escpos\Printer — mixing them silently autoloads the wrong classes or nothing.PrintConnector for a custom transport (e.g. a relay service, a Bluetooth bridge); subclass or compose around Printer to add helpers; ship a custom capability profile JSON if you standardize on one obscure model.intl throws at construction time, not at first print, which is actually convenient — add a health-check route that instantiates a Printer against a FilePrintConnector so deploys fail fast rather than at the till.NetworkPrintConnector timeout will blow past PHP's max_execution_time and leave the customer staring at a spinner after a completed payment.How can I help you explore Laravel packages today?