DEV Community

Ianstudios
Ianstudios

Posted on

Solved: Direct Thermal Printing from a Web App without the Print Dialog (PHP/Laravel)

PagoraPOS Print Receipt Dialog

Building a Point of Sale (POS) system on the web comes with one massive headache: Printing.

If you've ever built a web-based invoicing app, you know the drill. You call window.print(), the browser opens a print dialog, the user has to select the printer, maybe adjust margins, and finally click "Print".

For a busy retail cashier, that 5-second delay per transaction is unacceptable.

I recently built PagoraPOS, a TALL stack (Tailwind, Alpine, Laravel, Livewire) POS system. My goal was to make it feel like a native desktop app. That meant the printing had to be instant, silent, and pixel-perfect on thermal paper.

Here is how I solved the "Web-to-Thermal-Printer" challenge using PHP, bypassing the browser dialog entirely.

The Problem with window.print()
Standard browser printing relies on CSS. While you can style a page to look like a receipt (width: 80mm), you hit limitations fast:

The Solution: Raw ESC/POS Commands
Thermal printers (Epson, Star, Xprinter) speak a raw language called ESC/POS. Instead of sending an HTML image, you send hex commands.

For example, sending x1B\x40 initializes the printer. Sending x1D\x56\x42\x00 cuts the paper.

In the PHP ecosystem, the hero library for this is mike42/escpos-php.

> Step 1: The Setup
First, I installed the library in my Laravel project:

composer require mike42/escpos-php
Enter fullscreen mode Exit fullscreen mode

> Step 2: Generating the Receipt on the Backend
Instead of rendering a Blade view, I created a Service Class responsible for generating the print commands.

Here is a simplified version of my PrinterService:

use Mike42\Escpos\Printer;
use Mike42\Escpos\PrintConnectors\NetworkPrintConnector;
use Mike42\Escpos\CapabilityProfile;

class ThermalPrintService
{
    public function printReceipt($transaction)
    {
        // 1. Connect to the printer (e.g., Network Printer on LAN)
        // In a real app, this IP comes from the database settings
        $connector = new NetworkPrintConnector("192.168.1.87", 9100);

        // 2. Load profiles (optimizes for specific brands like Epson/Star)
        $profile = CapabilityProfile::load("default");

        // 3. Initialize Printer
        $printer = new Printer($connector, $profile);

        try {
            // --- HEADER ---
            $printer->setJustification(Printer::JUSTIFY_CENTER);
            $printer->selectPrintMode(Printer::MODE_DOUBLE_WIDTH | Printer::MODE_BOLD);
            $printer->text("PAGORA STORE\n");
            $printer->selectPrintMode(); // Reset
            $printer->text("123 Laravel Blvd, Code City\n");
            $printer->feed();

            // --- ITEMS ---
            $printer->setJustification(Printer::JUSTIFY_LEFT);
            $printer->text("--------------------------------\n");

            foreach ($transaction->items as $item) {
                // Helper to pad text for alignment
                $line = $this->formatRow($item->name, $item->qty . 'x ' . $item->price);
                $printer->text($line . "\n");
            }

            $printer->text("--------------------------------\n");

            // --- TOTAL ---
            $printer->setJustification(Printer::JUSTIFY_RIGHT);
            $printer->setEmphasis(true);
            $printer->text("TOTAL: $" . $transaction->total . "\n");
            $printer->setEmphasis(false);
            $printer->feed(2);

            // --- HARDWARE ACTIONS ---
            $printer->cut(); // Cut the paper
            $printer->pulse(); // Open the Cash Drawer (Kick)

        } finally {
            $printer->close();
        }
    }

    private function formatRow($left, $right, $width = 32)
    {
        // Simple logic to create "Product Name ...... $10.00" layout
        $len = $width - strlen($right);
        return str_pad(substr($left, 0, $len), $len) . $right;
    }
}
Enter fullscreen mode Exit fullscreen mode

> Step 3: Bridging the Server and the Printer
This is where it gets tricky. PHP runs on the server, but the printer is in the client's shop.

In PagoraPOS, I handle this in two ways depending on the deployment:

Scenario A: Self-Hosted / Local Server If the client runs the app on a local server (e.g., a Mini PC in the store), PHP can talk directly to the printer's IP address (like the code above). It's lightning fast.

Scenario B: Cloud Server (SaaS) If the app is hosted on the cloud (AWS/DigitalOcean), PHP cannot reach 192.168.1.x. To solve this, instead of executing the print command in PHP, I generate the Raw Base64 Data of the ESC/POS commands and return it to the frontend (Alpine.js).

The frontend then sends this raw data to a small "Print Proxy" agent running on the cashier's computer (or uses a library like QZ Tray) to forward the data to the USB/Network printer.

Why this approach wins

  • Speed: No rendering HTML, no waiting for Chrome.
  • Control: I can trigger the Cash Drawer automatically when a cash payment is made.
  • Consistency: The receipt looks exactly the same regardless of the browser or OS.

Conclusion
Building a POS in Laravel has been an interesting journey. While frameworks like Filament V3 are amazing for the UI, diving into low-level hardware communication like ESC/POS is what makes the application feel "professional" for end-users.

If you are building a retail app, I highly recommend ditching the browser print dialog. Your users will thank you.

I'm currently refining this system in my project PagoraPOS. If you have questions about handling raw printing or the TALL stack structure for POS, drop a comment below!

Top comments (0)