bitdreamit / laravel-qz-tray
Laravel QZ Tray is a complete silent printing solution that connects your Laravel application to desktop printers via QZ Tray. It allows you to print directly from the browser without print dialogs, with smart caching, printer memory, and automatic fallback.
Package info
github.com/bitdreamit/laravel-qz-tray
Language:JavaScript
pkg:composer/bitdreamit/laravel-qz-tray
Requires
- php: ^8.1
- ext-openssl: *
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/routing: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.0|^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-15 22:52:18 UTC
README
Enterprise silent printing for Laravel — zero dialogs, zero prompts, zero certificate fees.
Print PDFs, images, HTML, ZPL labels, ESC/POS receipts and raw commands straight from the browser —
no print dialog, no "Untrusted website" pop-up, no paid QZ.io certificate — with automatic
browser-print fallback so your business flow never breaks.
Table of Contents
- Why Laravel QZ Tray?
- How It Works
- Requirements
- Quick Start — 5 Minutes
- Installation — Full Step-by-Step Guide
- Zero-Prompt Trust — Production Deployment
- Printing with SmartPrint.js
- Real-World Recipes
- SmartPrint API Reference
- Events Reference
- Configuration Reference
- Artisan Commands
- HTTP API Endpoints
- Multi-Tenant Support
- Database Schema
- Security
- Environment Variables
- Troubleshooting
- File Structure
- Upgrade Guide
- FAQ
- Support & License
Why Laravel QZ Tray?
Browsers refuse to print silently — every window.print() shows a dialog, and every raw-WebSocket print bridge demands a paid certificate (QZ Industries charges $29+/site/mo for theirs) before it stops asking "Untrusted website — Allow?".
This package removes all three costs at once, for free:
| Pain point | Other solutions | Laravel QZ Tray |
|---|---|---|
| Print dialog on every job | window.print() — dialog always |
✅ Silent printing via QZ Tray WebSocket |
| "Untrusted website" prompt on every client | Buy QZ.io's certificate | ✅ 100% free — your own root CA + wildcard leaf (qz:generate-ca + qz:generate-certificate --ca) |
| Client-side trust deployment | Manual clicking per machine | ✅ One setup.bat / GPO / Intune bundle (qz:client-bundle --zip) |
| Auth-protected PDF routes (mPDF) | QZ has no session — prints your login page | ✅ Page-fetch hydration: bytes downloaded with cookies, sent to QZ as base64 |
| QZ Tray missing / offline | Your code crashes or hangs | ✅ Automatic browser-print fallback — the same call never dead-ends |
| Multi-tenant printers & job logs | Roll your own | ✅ Tenant-scoped printer memory + job audit table out of the box |
Everything is included in one package: certificate tooling, signing endpoints, a self-hosted QZ Tray JS library, a 2,000-line smart client (smart-print.js), printer memory per page/device/user/tenant, job logging, health diagnostics, demo pages, and a Windows client deployment bundle generator.
Feature Highlights
- 🖨️ All print types — PDF, HTML, images, ZPL (Zebra), ESC/POS (thermal), raw bytes
- 🤫 Zero pop-ups — silent printing plus a free zero-prompt certificate toolkit (v1.4+)
- 🧠 SmartPrint.js v1.5 —
printUrl()/printUrls()/printPdf()/printHtml()/printElement()built in, with per-call overrides ({ copies: 3 }) - 🧲 Declarative printing —
class="smart-print",data-qz-*attributes, or plainonclick="printUrl(…)"— pick your style - 🛟 Never dead-ends — automatic hidden-iframe / window / new-tab / download fallback when the tray is missing, offline, or fails mid-print
- 🔐 Server-side signing — SHA-512 signatures, CSRF-protected, rate-limited; the private key never leaves the server
- 🏢 Multi-tenant ready — native UUID/bigint tenant columns, per-path printer memory, device/user/session identity priority
- 🩺 Diagnostics —
qz:doctorhealth check,/qz/statusposture report, interactive demo pages at/qz/testand/qz/smart - ⚡ Laravel 10–13, PHP 8.1+, zero required
.enventries
How It Works
Browser Laravel server Client machine
┌─────────────────┐ HTTPS ┌──────────────────────┐ ┌─────────────────────┐
│ your Blade page │◄──────────►│ /qz/certificate │ │ QZ Tray (Java app) │
│ │ │ /qz/sign (SHA-512) │ │ · WebSocket :8181 │
│ smart-print.js │ │ /qz/print (job log) │ │ · verifies your │
│ · connects ────┼──WebSocket─────────────────────┼────────►│ signed requests │
│ · signs ───────┼──HTTPS────►│ /qz/sign │ │ · sends raw bytes │
│ · prints ──────┼──WebSocket─────────────────────┼────────►└──────────┬──────────┘
│ · fallback ────┼──► hidden iframe → browser print (if tray absent) ▼
└─────────────────┘ Printer
- QZ Tray runs on each machine that physically prints. It exposes a local WebSocket on port
8181and will only accept print requests signed by a certificate it trusts. - Your Laravel app serves its public certificate at
/qz/certificateand signs every request at/qz/signwith the private key that never leaves the server. - SmartPrint.js (this package's smart client) connects the page to QZ Tray, signs jobs, remembers printers, queues and retries.
- The print job reaches the printer silently — no dialog, no confirmation.
- If QZ Tray is missing or fails, the exact same call degrades to a hidden-iframe browser print (or new tab / download / custom handler) — your flow continues.
Supported print types:
| Type | Description | Typical printers |
|---|---|---|
pdf |
PDF documents (URL, bytes or base64) | Any printer |
html |
HTML content (string, element, selector) | Any printer |
image |
PNG / JPEG / GIF | Any printer |
zpl |
Zebra Programming Language | Zebra ZD420, ZT230, GK420d |
escpos |
ESC/POS thermal commands | Epson TM, Star Micronics |
raw |
Raw byte commands | Any raw-capable printer |
Requirements
| Requirement | Version |
|---|---|
| PHP | 8.1 or higher |
| Laravel | 10, 11, 12, or 13 |
| PHP extensions | ext-openssl (certificate generation/signing) |
| QZ Tray (client machines) | 2.x — only on computers that physically print |
| Browsers | Chrome / Edge / Firefox / Safari (current versions) |
Note: QZ Tray is a client-side desktop application. It is installed on the workstation connected to the printer — never on your Laravel server. Mobile browsers cannot reach a local WebSocket, so mobile keeps the normal browser print dialog (the fallback engine handles this automatically).
Quick Start — 5 Minutes
For the impatient: three commands, two script tags, one test print. Every step is explained in depth in the full installation guide below.
composer require bitdreamit/laravel-qz-tray php artisan qz:install # publishes config + migrations + views + assets, generates your certificate php artisan migrate # creates qz_print_jobs + qz_printer_preferences
Add the CSRF meta tag and the two script tags to your layout (exact snippet in Phase 2), install QZ Tray on the machine that will physically print, then visit /qz/smart in your browser.
That page shows the live connection status, lists your printers, and prints a real test job. If it prints — you're done. Wire up your own pages in two minutes via Method 0.
Prefer a guided tour? Visit
GET /qz/setup— a built-in browser wizard that walks you (or your client's IT person) through certificate trust, QZ Tray installation, and verification, and serves the ready-made Windows client bundle.
Installation — Full Step-by-Step Guide
This is the complete professional deployment: four phases, each ending with a ✅ verification checkpoint. Follow them in order — every phase depends on the previous one.
Phase 1 — Server Setup
Step 1 — Install via Composer
composer require bitdreamit/laravel-qz-tray
Laravel auto-discovers the service provider — nothing else is needed. The package registers its routes, commands, views and publish groups automatically.
Step 2 — Run the Installer
php artisan qz:install
This single command does everything a fresh install needs:
- Publishes
config/qz-tray.php - Publishes the database migrations
- Publishes the Blade views (demo/test pages) to
resources/views/vendor/qz-tray/ - Publishes all JavaScript and CSS assets to
public/vendor/qz-tray/ - Generates your QZ signing certificate automatically
Expected output:
🚀 Installing Laravel QZ Tray Package...
📁 Publishing configuration...
🗃️ Publishing migrations...
📄 Publishing blade views...
📦 Publishing JavaScript assets...
🔐 Generating certificate...
Generating private key...
Creating certificate signing request...
✅ Certificate generated successfully!
📄 Certificate: /var/www/html/storage/qz/digital-certificate.txt
🔑 Private key: /var/www/html/storage/qz/private-key.pem
✅ QZ Tray installed successfully!
Useful flags:
php artisan qz:install --force # re-install, overwriting previously published files php artisan qz:install --no-cert # publish everything but skip certificate generation
Production tip: on production you usually generate the certificate explicitly (so you can choose the domain, see Zero-Prompt Trust) rather than letting the installer create a generic self-signed one:
php artisan qz:install --no-certfirst, thenqz:generate-certificate --ca --domain=….
Step 3 — Run Migrations
php artisan migrate
Creates two tables:
| Table | Purpose |
|---|---|
qz_print_jobs |
Optional print-job audit log (what was printed, where, when, status) |
qz_printer_preferences |
Server-side backup of printer memory per device/user/session/path |
Multi-tenant / UUID apps: set
QZ_JOB_ID_TYPEin.envbefore the first migrate if your project uses bigint primary keys. See Multi-Tenant Support.
Step 4 — Confirm the Server Is Healthy
php artisan qz:doctor
Checks the certificate (fingerprint, issuer, expiry), the private key, the database tables and the config — and tells you exactly what (if anything) is wrong. Then hit the status endpoint:
GET /qz/status
{
"success": true,
"status": "operational",
"certificate": "present",
"private_key": "present"
}
✅ Checkpoint 1: /qz/status reports operational and qz:doctor reports no failures.
Phase 2 — Wire Up Your Layout
Every page that prints needs the CSRF meta tag (for the signing endpoints) and the two self-hosted script tags. Add them once to your main layout — resources/views/layouts/app.blade.php or equivalent:
<!DOCTYPE html> <html> <head> <!-- Required: CSRF token for /qz/sign and /qz/print --> <meta name="csrf-token" content="{{ csrf_token() }}"> <!-- Optional but recommended: page-wide client config --> <script> window.QZ_CONFIG = { fallbackMode: 'auto', // browser-print fallback when the tray is missing fetchMode: 'auto', // page-fetch same-origin URLs (auth routes work) }; </script> </head> <body> {{-- your content --}} <!-- 1) QZ Tray WebSocket library — self-hosted, ships with this package --> <script src="{{ asset('vendor/qz-tray/js/qz-tray.min.js') }}"></script> <!-- 2) SmartPrint — the smart client (must come AFTER qz-tray.min.js) --> <script src="{{ asset('vendor/qz-tray/js/smart-print.js') }}"></script> </body> </html>
The CSRF meta tag is required. SmartPrint sends it with every
POST /qz/signandPOST /qz/print. Without it you will get419 CSRF token mismatch.
v1.5.5 — skip the manual <script> tags and get automatic cache busting:
{{-- replaces the two <script> tags above — ?v={filemtime} busts the browser cache automatically on every file change --}} @qzTrayScripts {{-- your own app files get the same treatment --}} @qzTrayScripts(['js/lab-receipt.js'])
The URL only changes when the file actually changes, so browsers cache normally between deploys — no more deleting cache by hand after every JS update.
Multi-tenant apps — tag every print with the tenant once, page-wide:
<script> window.QZ_CONFIG = { tenantId: '{{ auth()->user()?->tenant_id }}', // or: projectId: '{{ $project->id }}', }; </script>
✅ Checkpoint 2: view-source on any page shows both script tags resolving to real files (HTTP 200), and the console shows no SmartPrint errors.
Phase 3 — Client Machines
Each computer that will physically print needs QZ Tray installed. It runs in the system tray and listens on localhost:8181.
Option A — manual install (fine for a few machines):
Download from qz.io/download — or link your users straight to the package's info endpoints: /qz/installer/windows, /qz/installer/linux, /qz/installer/macos. After installation QZ Tray starts automatically with the OS; there is no client configuration needed for the basic flow.
Option B — zero-prompt bundle (recommended for fleets):
Build a signed deployment bundle on the server and run setup.bat (as admin) on each Windows client — or push it silently via GPO/Intune. This also removes the "Untrusted website" prompt entirely. Full details in Zero-Prompt Trust:
php artisan qz:generate-ca
php artisan qz:generate-certificate --force --ca --domain=*.yourdomain.com,yourdomain.com
php artisan qz:client-bundle --zip
The bundle is also downloadable at any time from GET /qz/client-bundle.
✅ Checkpoint 3: QZ Tray icon is visible in the client's system tray; netstat -an | findstr 8181 shows it listening (Windows).
Phase 4 — Verify
Run through this checklist on a client machine (not the server):
-
Open
/qz/smart— the interactive SmartPrint demo.- Status indicator turns green (WebSocket connected).
- The printer dropdown lists the machine's real printers.
-
Print a test job — click Print sample PDF. The page at
/qz/test/pdfgenerates a streaming test PDF; the printer produces it silently. -
Test the signing pipeline end-to-end:
curl -X POST https://yourapp.test/qz/test-sign -H "Accept: application/json" # → "message": "Signing works correctly"
-
Check the job log (optional, if
QZ_LOGGING_ENABLED=true):DB::table('qz_print_jobs')->latest()->first(); // the test job is there
✅ Checkpoint 4: a physical page came out of the printer with no dialogs. You're production-ready — continue to Zero-Prompt Trust to remove the one remaining prompt, and Printing with SmartPrint.js to wire up your pages.
Publishing Assets — Manual Control
php artisan qz:install runs every publish group at once plus certificate generation — right for a first install. Use the individual tags when you need finer control: republishing only assets after an upgrade, keeping generated views out of CI, or Docker builds that shouldn't touch storage/.
| Tag | Publishes | Destination |
|---|---|---|
qz-config |
config/qz-tray.php |
config/qz-tray.php |
qz-migrations |
qz_print_jobs + qz_printer_preferences migrations |
database/migrations/ |
qz-blade |
Demo/test Blade views (smart, default, example, wizard) |
resources/views/vendor/qz-tray/ |
qz-assets |
smart-print.js, printer-switcher.js, printer-status.js, adapters, vendored qz-tray.min.js (+ unminified), CSS, fonts |
public/vendor/qz-tray/ |
qz-installers |
QZ Tray desktop installers (Windows/macOS/Linux), if bundled | public/vendor/qz-tray/installers/ |
# Publish everything without the installer's certificate step: php artisan vendor:publish --provider="Bitdreamit\QzTray\QzTrayServiceProvider" # Overwrite already-published files (e.g. after upgrading the package): php artisan vendor:publish --tag=qz-assets --force
Publishing only copies files — re-run
php artisan migrateif you re-publishedqz-migrations.
Zero-Prompt Trust — Production Deployment
By default, the first print on each client shows QZ Tray's "Untrusted website — Allow?" dialog. QZ Tray keys this prompt to the certificate fingerprint, not the domain, so the fix is to give every client a trust root it already believes in. This package ships a complete, 100% free toolkit — no QZ.io certificate fees.
Pick ONE path:
| Path A — Real CA certificate | Path B — Own Root CA + override.crt ⭐ recommended | Path C — Shared self-signed pair | |
|---|---|---|---|
| Best when | You already have Let's Encrypt / cPanel AutoSSL | You control the Windows clients | Quick small setups |
| Client work | Zero | One setup.bat (or GPO) per machine |
One "Always Allow" click per machine |
| Sign-trust mechanism | Real CA chain — QZ Tray trusts silently | Your own root as override.crt |
Fingerprint "Always Allow" entry |
| Wildcard / multi-tenant | Wildcard cert covers all subdomains | One wildcard leaf covers all subdomains | One shared fingerprint covers all subdomains |
| Cost | Free | Free | Free |
Path A — Import a Real CA Certificate (zero client deployment)
php artisan qz:certificate:import \
--cert=/etc/letsencrypt/live/app.example.com/fullchain.pem \
--key=/etc/letsencrypt/live/app.example.com/privkey.pem
Use the fullchain file (leaf + intermediates). A wildcard certificate covers every subdomain at once.
Keep it renewing automatically — qz:watch-certificate re-imports after every Let's Encrypt renewal:
QZ_WATCH_CERT=/etc/letsencrypt/live/app.example.com/fullchain.pem QZ_WATCH_KEY=/etc/letsencrypt/live/app.example.com/privkey.pem
Sites behind Cloudflare: your free Origin Certificate (
*.yourdomain.com, valid up to 15 years) doubles as the shared QZ certificate. See docs/multi-domain.md.
Path B — Your Own Root CA + override.crt (recommended when you control the clients)
# 1. Create your own free root CA php artisan qz:generate-ca # 2. Wildcard leaf signed by that CA — covers ALL subdomains/tenants. # Include the bare domain too. php artisan qz:generate-certificate --force --ca \ --domain=*.yourdomain.com,yourdomain.com # 3. Build the Windows deployment bundle (+ .zip) php artisan qz:client-bundle --zip --lna-domains='[*.]yourdomain.com'
qz:client-bundle produces storage/qz/client-bundle/ containing everything a client needs. Run setup.bat as administrator on each Windows machine — or deploy qz-client-setup.ps1 silently via GPO / Intune. The script installs, in one shot:
| Component | Installed to | Effect |
|---|---|---|
root-ca.crt → machine Root store |
certutil -addstore -f Root … |
localhost TLS trust — no browser certificate warning |
override.crt |
%PROGRAMFILES%\QZ Tray\override.crt |
QZ Tray signs against your root — the signing prompt disappears |
allowed.dat entry |
%APPDATA%\qz\allowed.dat |
Equivalent of pressing "Always Allow" once |
| Chrome/Edge LNA policy | Registry | Silently allows localhost:8181 WebSocket in Chromium browsers |
The bundle can be re-downloaded any time at GET /qz/client-bundle, and the step-by-step wizard for client IT staff lives at GET /qz/setup.
Certificate rotation: the signing trust is pinned to your root CA — when a leaf expires, re-issue the leaf with
qz:generate-certificate --force --ca …and redeploy only the server certificate; clients keep trusting. Rotate the root itself rarely, and redeploy the newoverride.crtbefore switching (qz:override:export --show).
Path C — Shared Self-Signed Keypair (smallest setups)
Ensure every subdomain shares the identical certificate pair, then press "Always Allow" (not plain "Allow" — that one is session-only and is why the prompt "comes back every time"):
# main domain php artisan qz:certificate:export # each subdomain php artisan qz:certificate:import --pfx=qz-tray.pfx --password=… # …or point every vhost's .env at the same files: # QZ_CERT_PATH=/home/shared/qz/digital-certificate.txt # QZ_KEY_PATH=/home/shared/qz/private-key.pem
Verify with php artisan qz:doctor — identical SHA-1 fingerprints across subdomains = one trust decision covers them all.
Full deep-dive with diagrams: docs/zero-prompt.md and docs/multi-domain.md.
Printing with SmartPrint.js
SmartPrint is the JavaScript client this package publishes to public/vendor/qz-tray/js/smart-print.js. It handles the WebSocket connection, request signing, queueing, retries, printer memory, content hydration and fallback automatically.
There are four ways to trigger a print. Pick whichever fits the page you're building — they all end up in the same engine, with the same fallback behavior and the same per-call options.
| # | Style | Best for | JS required? |
|---|---|---|---|
| 0 | Universal functions (printUrl, printHtml, …) |
Real application code, jQuery handlers, framework apps | Inline calls only |
| 1 | class="smart-print" |
Simple buttons/links in Blade — zero wiring | None |
| 2 | onclick="printUrl(…)" |
Quick inline handlers | One-liner |
| 3 | data-qz-* attributes |
Declarative buttons with per-button options | None |
| 4 | SmartPrint.* API |
Full programmatic control | Yes |
✨ Method 0 — The Universal Print API (v1.5+)
The generic functions are built into smart-print.js — no define(), no setup. Call them from anywhere: Blade, jQuery handlers, DataTables row buttons, React/Vue/Livewire, or the browser console. Every function accepts almost anything as input, detects PDF vs image vs HTML from the HTTP Content-Type, and falls back to the browser print dialog automatically when QZ Tray is not installed, not connected, or fails.
// Built-in — available as SmartPrint methods AND guarded window globals: printUrl('/receipt/5.pdf'); // PDF, image or HTML — auto-detected printUrls([url1, url2, url3]); // sequential batch printPdf('data:application/pdf;base64,JVBERi0x…'); // direct PDF bytes, no URL needed printPdfs([src1, src2]); // batch, any mix of url/base64/data: printImage('/barcode/123.png'); // native QZ image printing printImages([img1, img2]); printHtml('<h1>Queue Ticket</h1><p>A-042</p>'); // raw HTML string printElement('#worklist-table'); // any element by id/class/selector printElements(['#a', '.b']); // several elements, in order printPage(); // the current page printUrl(url, { copies: 3 }); // per-call overrides printAnyUrl(url); // readability alias of printUrl printAnyUrls([url1, url2]); // readability alias of printUrls
What every function accepts as input:
| Input | Example | Behavior |
|---|---|---|
| URL string | printUrl('/receipt/5.pdf') |
Fetched (see hydration) or handed to QZ; type auto-detected |
data: URI |
printPdf('data:application/pdf;base64,…') |
Printed directly — no server round-trip |
| Raw base64 | printPdf('JVBERi0xLj…') |
Detected by magic bytes (%PDF-, PNG, JPEG…) |
| Raw HTML string | printHtml('<h1>Hi</h1>') |
Sent as HTML payload |
| CSS selector | printElement('#worklist-table') |
Element snapshotted with the page stylesheets — the printout matches the screen |
HTMLElement / jQuery object |
printElement(document.body) |
Same snapshot behavior |
| Object spec | printUrl({ url, printer, copies }) |
Per-input options |
| Lazy function | printUrl(() => currentReceiptUrl()) |
Resolved at call time |
| Array of any | printUrls([u1, sel2, lazy3]) |
Sequential batch, in order |
Per-call options (third argument / second object):
printUrl('/receipt/5.pdf', { copies: 3, // number of copies printer: 'XP-80C', // exact OS printer name, or a registered alias profile: 'small', // paper: 'default' | 'small' (80mm) | 'label' (100×150mm) fallback: 'download', // 'auto' | 'iframe' | 'window' | 'newtab' | 'download' | 'queue' | 'none' | fn fetch: true, // force page-fetch hydration on/off for this call type: 'pdf', // force the payload type (skip auto-detection) filename: 'receipt.pdf', // hint for the download fallback onComplete: job => {}, // success callback onError: (err, job) => {}, // failure callback });
The result contract — promises never throw unhandled. A call resolves with a clear object:
const r = await printUrl('/receipt/5.pdf'); // { jobId, success: true } — printed via QZ Tray // { jobId, success: false, fallback: true } — QZ unavailable, browser printed it // { success: false, reason: 'unexpected-response', // contentType: 'text/html', status: 200 } — auth route answered a login page; nothing printed // rejection — only in 'queue' mode or raw-data errors
Optional presets — keep friendly business names with baked-in printer/profile choices (custom define() templates always take precedence over the built-ins):
// once, after smart-print.js is loaded: SmartPrint.define({ printLabReceipt: { type: 'pdf', profile: 'a4' }, printLabReceipts: { type: 'pdf', profile: 'a4' }, printWorkList: { type: 'pdf', profile: 'small', printer: 'receipt' }, printLabel: { type: 'pdf', profile: 'label', printer: 'label' }, }); SmartPrint.aliasPrinter('receipt', 'XP-80C'); // friendly name → real OS printer name SmartPrint.aliasPrinter('label', 'Zebra GK420d'); printLabReceipt('/receipt/5.pdf'); // now a real function everywhere
The legacy names
printLabReceipt/printLabReceiptsare already registered as aliases ofprintUrl/printUrls— old code keeps working even without thedefine()block above.
Method 1 — class="smart-print" (zero JS, zero attributes to learn)
Any button or link with class="smart-print" prints. No data-qz-action, no JavaScript — the library routes the click for you. Works on <button>, <a>, <div>, anything clickable:
<button class="smart-print" data-qz-url="/receipt/5.pdf">Print</button> <a class="smart-print" href="/receipt/5.pdf">Print receipt</a> <!-- plain href = the document --> <button class="smart-print" data-qz-urls="/r/1.pdf|/r/2.pdf|/r/3.pdf">Batch</button> <button class="smart-print" data-qz-element="#worklist-table">Worklist</button> <button class="smart-print" data-qz-html="<h1>Queue Ticket</h1>">Ticket</button> <button class="smart-print" data-qz-data="data:application/pdf;base64,JVBERi0…">Bytes</button> <button class="smart-print" data-qz-url="/receipt/5.pdf" data-qz-copies="3" data-qz-printer="receipt">x3</button>
Routing precedence for one element — the first match wins:
data-qz-action→ run that named action (checked first, see Method 3)- The element's own
onclick→ left entirely to that function (never double-prints; navigation is still suppressed) data-qz-urls→printUrlsbatchdata-qz-url/data-url→printUrldata-qz-html→printHtmldata-qz-element/data-qz-target→printElementdata-qz-data/data-qz-base64→ auto-detected base64 /data:URI- Plain
<a href>→ print the href (anchors like#,javascript:,mailto:,tel:are ignored) - Nothing to print → a silent console hint; the page is never broken
Buttons inside <form>s are safe — the click's default action (submit) is suppressed.
Method 2 — onclick function routing
Because the universal functions are guarded window globals, a plain inline onclick works with or without the class:
<button onclick="printUrl('/receipt/5.pdf')">Print</button> <button onclick="printElement('#worklist-table', { copies: 2 })">Worklist x2</button> <a href="#" onclick="printUrls(['/r/1.pdf','/r/2.pdf']); return false;">Batch</a>
Any name you register with SmartPrint.define() becomes a window global too (guarded — an existing app function is never overwritten), so onclick="printLabReceipt('/receipt/5.pdf')" works as well.
Method 3 — Data attributes
The classic declarative layer — named actions, per-button options, auto-print on load:
{{-- Named action (routed through SmartPrint.define templates) --}}
<button data-qz-action="printUrl" data-qz-url="/receipt/5.pdf">Print</button>
<button data-qz-action="printUrls" data-qz-urls="/r/1.pdf|/r/2.pdf|/r/3.pdf">Batch</button>
<button data-qz-action="printElement" data-qz-target="#worklist-table">Worklist</button>
<button data-qz-action="printUrl" data-qz-url="/receipt/5.pdf" data-qz-copies="3">x3</button>
{{-- Direct print attributes (legacy, still fully supported) --}}
<button data-qz-print="/invoices/123.pdf">Print Invoice</button>
<button data-qz-print="/invoices/123.pdf" data-qz-printer="HP LaserJet M404" data-qz-copies="2">
Print 2 Copies
</button>
<button data-qz-print="" data-qz-type="zpl" data-qz-printer="Zebra ZD420"
data-qz-data="^XA^FO50,50^ADN,36,20^FDHello World^FS^XZ">
Print Label
</button>
{{-- Auto-print on page load (optionally delayed) --}}
<div data-qz-auto-print="{{ route('invoices.pdf', $invoice) }}" data-qz-type="pdf"></div>
<div data-qz-auto-print="/receipts/99.pdf" data-qz-delay="2000"></div>
Method 4 — The full JavaScript API
Complete programmatic control — see SmartPrint API Reference for every method:
// Universal API (routed through the full pipeline: resolvers + hydration + fallback) SmartPrint.printUrl('/receipt/5.pdf', { copies: 3 }); SmartPrint.printHtml('<h1>Ticket</h1>'); SmartPrint.printElement('#worklist-table'); // Core API (direct queue, no action pipeline) SmartPrint.print('/invoices/123.pdf'); SmartPrint.print({ url: '/invoices/123.pdf', type: 'pdf', printer: 'HP LaserJet M404', copies: 2, profile: 'default', // 'default' | 'small' (80mm) | 'label' (100×150mm) }); SmartPrint.printZPL('^XA^FO50,50^ADN,36,20^FDHello^FS^XZ', 'Zebra ZD420'); SmartPrint.printESC('\x1B\x40Hello Thermal!\n\n\n', 'Epson TM-T88'); SmartPrint.printRaw('\x1B\x40Test\n', 'raw', 'My Printer'); // Shorthand globals (collision-guarded) smartPrint('/doc.pdf', { copies: 2 }); smartPrintZPL(zpl, 'Zebra'); smartPrintESC(esc, 'Epson TM'); smartPrintHTML('<h1>Hi</h1>'); smartPrintPdf(base64OrUrl); printElement('#some-id'); smartPrintRun('printLabReceipt', '/receipt/5.pdf'); smartPrintDefine('printLabel', { type: 'pdf', profile: 'label' });
Printing mPDF PDFs behind auth middleware
The answer to "does PDF::stream() / ->download() work? — yes, both, even behind auth.
QZ Tray downloads URLs itself — with no browser session and no cookies. An auth-protected route would hand QZ your login page (the classic Cannot parse … End-of-File error). v1.5 solves this inside the browser:
printUrl()fetches same-origin URLs through the page first — session cookies flow automatically (credentials: 'same-origin').- The response
Content-Typepicks the print path:application/pdf→ QZ pdf payload,image/*→ QZ image payload,text/html→ QZ html payload. - The bytes (base64) go to QZ Tray — the tray never needs its own session.
Content-Disposition: attachment(from->download()) is ignored — only the bytes matter.
// routes/web.php — a normal, protected mPDF route. Nothing special. Route::get('/receipt/{id}.pdf', [ReceiptController::class, 'pdf'])->middleware('auth'); public function pdf(Request $request, $id) { $pdf = \Mpdf\Mpdf::loadView('receipts.show', compact('id')); return $pdf->stream('receipt-'.$id.'.pdf'); // ->download() works identically }
printUrl('/receipt/5.pdf'); // that is the whole integration
Misdirected-response guard: if a .pdf/image URL answers with text/html (login redirect, 404 page) or JSON, it is detected and not printed — the call resolves { success: false, reason: 'unexpected-response', contentType, status } instead of feeding garbage to QZ. Unlabeled application/octet-stream responses are sniffed by magic bytes (%PDF-, PNG, JPEG, GIF).
Global policy (window.QZ_CONFIG.fetchMode):
| Mode | Behavior |
|---|---|
'auto' (default) |
Fetch same-origin URLs through the page; pass cross-origin URLs to QZ directly |
'always' |
Fetch every URL through the page (also works for CORS-enabled cross-origin routes) |
'never' / false |
Legacy behavior — hand every URL to QZ as-is |
Per call: printUrl(url, { fetch: true | false }) or the data-qz-fetch="true|false" attribute.
All Data Attributes Reference
| Attribute | Applies to | Default | Description |
|---|---|---|---|
class="smart-print" |
any element | — | Marks the element as a print trigger (routing precedence above) |
data-qz-url |
click triggers | — | URL to print (auto-detects PDF/image/HTML) |
data-url |
click triggers | — | Alias of data-qz-url |
data-qz-urls |
click triggers | — | Pipe-separated batch: `"/a.pdf |
data-qz-html |
click triggers | — | Raw HTML string to print |
data-qz-element / data-qz-target |
click triggers | — | CSS selector of the element to print |
data-qz-data / data-qz-base64 |
click triggers | — | Raw payload (base64 / data: URI / raw data for zpl/escpos) |
data-qz-action |
click triggers | — | Named action to run (built-in or SmartPrint.define()) |
data-qz-print |
click triggers | — | Legacy direct-print URL (still supported) |
data-qz-auto-print |
any element | — | URL printed automatically when the element appears |
data-qz-type |
any trigger | auto | Force type: pdf, html, image, zpl, escpos, raw |
data-qz-printer |
any trigger | saved/default | Printer name (or a registered alias) |
data-qz-copies |
any trigger | 1 |
Number of copies |
data-qz-profile |
any trigger | default |
default | small (80mm) | label (100×150mm) |
data-qz-delay |
any trigger | 0 |
Milliseconds to wait before printing |
data-qz-fetch |
url triggers | config | Force page-fetch hydration: "true" / "false" |
data-qz-filename |
any trigger | — | Filename hint for the download fallback |
Real-World Recipes
Copy-paste starting points for the situations you actually ship.
📄 Print an Invoice (mPDF, auth-protected, zero dialogs)
{{-- resources/views/invoices/show.blade.php --}} <div class="invoice-actions"> <button class="smart-print" data-qz-url="{{ route('invoices.pdf', $invoice) }}"> 🖨 Print Invoice #{{ $invoice->number }} </button> <button class="smart-print" data-qz-url="{{ route('invoices.pdf', $invoice) }}" data-qz-copies="2" data-qz-printer="Office HP LaserJet"> 🖨 Print 2 Copies </button> </div>
// routes/web.php — a completely normal Laravel route Route::get('/invoices/{invoice}/pdf', function (Invoice $invoice) { return \Mpdf\Mpdf::loadView('invoices.pdf', compact('invoice')) ->stream('invoice-'.$invoice->number.'.pdf'); })->name('invoices.pdf')->middleware('auth');
That is the entire integration — hydration, signing, fallback and printer memory are automatic.
🏷 Print a ZPL Label (Zebra)
<button class="smart-print" data-qz-type="zpl" data-qz-printer="Zebra ZD420" data-qz-data="^XA ^FO50,50^ADN,36,20^FD{{ $product->name }}^FS ^FO50,100^ADN,24,14^FD{{ $product->sku }}^FS ^FO50,150^BCN,80,Y,N,N^FD{{ $product->barcode }}^FS ^XZ"> 🏷 Print Label </button>
Server-side ZPL route:
Route::get('/api/labels/{product}/zpl', function (Product $product) { $zpl = "^XA\n^FO50,50^ADN,36,20^FD{$product->name}^FS\n" . "^FO50,100^BCN,80,Y,N,N^FD{$product->barcode}^FS\n^XZ"; return response($zpl, 200, ['Content-Type' => 'text/plain']); }); // JS side — one line: printUrl('/api/labels/7/zpl', { type: 'zpl', printer: 'Zebra ZD420' });
Or build labels programmatically with the bundled adapter (public/vendor/qz-tray/js/adapters/zpl.js):
const label = ZPL.label() .text('Product Name', 50, 50, { size: 'large' }) .barcode('1234567890', 50, 150, { height: 80 }) .build(); SmartPrint.printZPL(label, 'Zebra ZD420');
🧾 Print an ESC/POS Receipt (thermal)
Build receipts with the bundled adapter (adapters/escpos.js):
const receipt = ESCPOS.receipt() .initialize() .align('center').bold(true).text('MY STORE').bold(false).align('left') .divider() .line('Item 1', '$10.00') .line('Item 2', '$5.50') .divider() .bold(true).line('TOTAL', '$15.50').bold(false) .feed(3).cut() .build(); SmartPrint.printESC(receipt, 'Epson TM-T88VI');
Or generate the receipt server-side and print the fetched bytes:
public function receipt(Order $order) { $out = "\x1B\x40\x1B\x61\x01" . $order->store_name . "\n\x1B\x61\x00"; foreach ($order->items as $item) { $out .= str_pad($item->name, 24) . str_pad('$'.$item->price, 8, ' ', STR_PAD_LEFT) . "\n"; } $out .= "\n\n\n\x1D\x56\x41"; return response($out, 200, ['Content-Type' => 'application/octet-stream']); }
fetch('/orders/42/receipt').then(r => r.text()).then(d => SmartPrint.printESC(d, 'Epson TM-T88'));
📦 Sequential Batch Printing
// Order guaranteed — jobs are queued and printed one at a time: printUrls(['/packing-slip/1.pdf', '/label/1.pdf', '/label/2.pdf']); // Mixed content, lazy inputs, per-item options: printUrls([ '/invoice/9.pdf', () => '/receipt/' + currentId() + '.pdf', { url: '/barcode/9.png', printer: 'Label' }, 'data:application/pdf;base64,JVBERi0x…', ]); // Declarative, no JS at all: // <button class="smart-print" data-qz-urls="/r/1.pdf|/r/2.pdf|/r/3.pdf">Batch</button>
🖨️ Auto-Print After Save (server → page)
// Controller — after the order is created: return redirect()->route('orders.show', $order) ->with('auto_print', route('orders.pdf', $order));
{{-- layout or show view --}} @if(session('auto_print')) <div data-qz-auto-print="{{ session('auto_print') }}" data-qz-type="pdf" data-qz-delay="500"></div> @endif
🖱 Printer Selection & Memory
// Show the picker modal (also: press Ctrl+Shift+P anywhere): SmartPrint.showPrinterSwitcher(); // Set/remember a printer — per page by default, or globally: SmartPrint.setPrinter('HP LaserJet M404'); // remembered for this URL path SmartPrint.setPrinter('HP LaserJet M404', 'global'); // remembered everywhere // Friendly aliases — views reference 'receipt', real names live in ONE line: SmartPrint.aliasPrinter('receipt', 'XP-80C'); SmartPrint.aliasPrinter('label', 'Zebra GK420d'); printUrl('/receipt/5.pdf', { printer: 'receipt' }); // resolved at PRINT time
Printer memory is remembered per (tenant, device/user/session, URL path) — in localStorage on the client and mirrored to the qz_printer_preferences table, so a fresh browser on the same workstation picks its printer back up automatically.
🛟 Fallback Engine — Never Dead-End
When QZ Tray is missing, offline, fails mid-print, or the user cancels printer selection, the same call degrades automatically:
// Per call / per action: printUrl('/receipt/5.pdf', { fallback: 'download' }); // Global default: window.QZ_CONFIG = { fallbackMode: 'auto' };
| Mode | Behavior |
|---|---|
'auto' (default) |
Hidden-iframe browser print — silent, no new tab |
'iframe' |
Force the hidden-iframe engine |
'window' |
window.print() of the document |
'newtab' |
Open the document in a new tab |
'download' |
Download the file instead |
'queue' |
Park the job and retry when the tray reconnects (pre-1.4 behavior) |
'none' |
Do nothing — resolve with the failure reason |
(job) => … |
Your own function — full control |
Optional UI hooks:
window.QZ_CONFIG = { notify: true, // toast when a job degrades to the browser onFallback: job => analytics.track('browser-fallback', job), }; SmartPrint.on('fallback-print', job => console.log('browser fallback used', job));
📡 Wire Up Your UI with Events
SmartPrint.on('connected', data => { document.getElementById('printer-status').textContent = '🟢 QZ Tray Connected'; document.getElementById('printer-name').textContent = SmartPrint.getCurrentPrinter() || 'None selected'; }); SmartPrint.on('connection-failed', () => { document.getElementById('printer-status').textContent = '🔴 QZ Tray Offline'; document.getElementById('install-link').style.display = 'block'; }); SmartPrint.on('job-completed', data => { fetch('/print-log', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, }, body: JSON.stringify({ url: data.job.url, printer: data.job.printer }), }); });
Full event list: Events Reference.
🧩 Framework Integration
SmartPrint is a plain object on window — call it from anywhere:
// React window.printUrl(`/invoices/${id}.pdf`); // Vue printUrl({ url: `/invoices/${this.invoice.id}.pdf`, copies: 2 }); // Livewire (@script) printUrl(`/invoices/${$wire.invoiceId}.pdf`); // jQuery $('.print-btn').on('click', () => printUrl('/receipt/5.pdf')); // Or skip JS entirely: class="smart-print" works with any framework's markup.
🖥 Server-Side Triggered Printing
Printing is always executed by the browser (the printer is attached to the client), but your server decides when — return a page with an auto-print directive, or a flash message (recipe above). For raw/audit logging, every print also POSTs to /qz/print, which writes to qz_print_jobs when the tables exist.
SmartPrint API Reference
Universal Print API (v1.5+)
| Method | Signature | Description |
|---|---|---|
printUrl(input, options?) |
Promise<Result> |
Print any URL — type auto-detected from Content-Type |
printUrls(input, options?) |
Promise<Result[]> |
Sequential batch of anything printUrl accepts |
printAnyUrl / printAnyUrls |
alias | Readability aliases of the above |
printPdf(input, options?) |
Promise<Result> |
Force pdf type — URL, data: URI or raw base64 |
printPdfs(input, options?) |
Promise<Result[]> |
Batch pdf |
printImage(input, options?) |
Promise<Result> |
Force image type — URL, data: URI, base64 |
printImages(input, options?) |
Promise<Result[]> |
Batch image |
printHtml(input, options?) |
Promise<Result> |
Force html type — HTML string, element or selector |
printElement(input, options?) |
Promise<Result> |
Print a DOM element (matched by selector) — throws clearly if no match |
printElements(input, options?) |
Promise<Result[]> |
Batch elements |
printPage(options?) |
Promise<Result> |
Snapshot the whole page ({ mode: 'browser' } = plain window.print()) |
Result = { jobId, success, fallback?, cancelled?, reason? } — never throws unhandled (see the result contract).
Smart Actions
| Method | Description |
|---|---|
SmartPrint.define(nameOrMap, template?) |
Register (or override) a named action; auto-creates a guarded window global |
SmartPrint.run(name, input?, overrides?) |
Run a registered action programmatically |
SmartPrint.has(name) |
Is the action registered? |
SmartPrint.actions() |
List all registered action names |
SmartPrint.aliasPrinter(alias, realPrinter?) |
Map a friendly name to a real OS printer (resolved at print time); null unmaps |
Core Printing
| Method | Description |
|---|---|
SmartPrint.print(urlOrOptions, options?) |
Direct queue print — returns a Promise that resolves { jobId, success } |
SmartPrint.printRaw(data, type?, printer?) |
Raw bytes |
SmartPrint.printZPL(zpl, printer?) |
ZPL payload |
SmartPrint.printESC(escpos, printer?) |
ESC/POS payload |
Printer Management
| Method | Description |
|---|---|
SmartPrint.getPrinters() |
Promise<string[]> — real printer list via WebSocket |
SmartPrint.getCurrentPrinter() |
Currently selected printer name, or null |
SmartPrint.setPrinter(name, scope?) |
Remember printer — 'path' (default) or 'global' |
SmartPrint.showPrinterSwitcher() |
Open the printer picker modal |
SmartPrint.getDeviceId() |
Persistent workstation UUID (sent as X-Device-Id automatically) |
Health & Connection
| Method | Description |
|---|---|
SmartPrint.status() |
Full snapshot: { qzLibrary, connected, printers, currentPrinter, queued, failed, fallbackMode, actions } |
SmartPrint.whenReady(timeoutMs?) |
Promise<{ ok, reason }> — settles as soon as the tray is connected or definitively unavailable ('qz-library-missing', 'connection-refused', 'timeout') |
SmartPrint.connect() / disconnect() |
Manual WebSocket control |
SmartPrint.isConnected() |
Boolean |
Queue & Settings
| Method | Description |
|---|---|
SmartPrint.getQueue() |
Pending job objects |
SmartPrint.clearQueue() |
Cancel all pending jobs |
SmartPrint.retryJob(index) |
Retry one failed job |
SmartPrint.retryOffline() |
Flush the offline buffer |
SmartPrint.getSettings() / updateSettings({ defaultPrinter }) |
Local settings |
SmartPrint.clearCache() |
Clear stored printer memory + offline buffer |
Events Reference
SmartPrint.on('connected', data => { /* … */ }); SmartPrint.off('connected', handler); // remove a listener
| Event | Payload | Fires when |
|---|---|---|
connected |
{ printers: string[] } |
WebSocket connection established |
connection-failed |
{ error } |
Tray unreachable |
disconnected |
— | Connection closed |
printers-loaded |
{ printers: string[] } |
Printer list received from QZ Tray |
printer-saved |
{ printer, scope } |
User selected/saved a printer |
job-queued |
{ job } |
Job added to the queue |
job-processing |
{ job } |
Job being sent to QZ Tray |
job-completed |
{ job } |
Job sent successfully |
job-failed |
{ job, error } |
Job failed (offline-buffered) |
fallback-print |
{ job } |
Browser fallback printed the job |
queue-cleared |
— | Queue cleared |
cache-cleared |
— | Printer cache cleared |
settings-updated |
{ settings } |
Settings changed |
ready |
{ printers } |
SmartPrint fully initialized |
init-failed |
{ reason } |
qz-tray.min.js not loaded before smart-print.js |
Configuration Reference
config/qz-tray.php (published by qz:install / --tag=qz-config) — every key has a working default; none are mandatory:
return [ // Paths where the signing certificate and key live (never web-accessible) 'cert_path' => storage_path('qz/digital-certificate.txt'), 'key_path' => storage_path('qz/private-key.pem'), 'cert_ttl' => 3600, // seconds the browser may cache the cert // Certificate generation defaults 'certificate' => [ 'validity_days' => 7300, // ~20 years 'algorithm' => 'sha256', 'key_bits' => 2048, 'subject' => [ 'countryName' => 'US', 'organizationName' => 'My Company', 'commonName' => 'My App QZ Tray', 'emailAddress' => 'admin@myapp.com', ], ], // Auto-generate on first boot (dev convenience; use artisan commands in production) 'auto_generate_cert' => env('QZ_AUTO_GENERATE_CERT', false), // Allow HTTP endpoint to (re)generate the cert — keep OFF in production 'allow_public_cert_generate' => env('QZ_ALLOW_PUBLIC_CERT_GENERATE', false), // Default printer (optional — users pick via the switcher otherwise) 'default_printer' => env('QZ_DEFAULT_PRINTER'), 'allow_printer_switch' => true, 'remember_printer_per_page' => true, // memory per URL path 'printer_cache_duration' => 86400, // 24h server-side cache // Which identity wins when several match: 'device' first is correct for // shared/kiosk workstations (the MACHINE picks the printer). Use // ['user', 'device', 'session'] if the printer should follow the person. 'identity_priority' => ['device', 'user', 'session'], // Primary-key type for qz_print_jobs — set BEFORE first migrate. 'id_type' => env('QZ_JOB_ID_TYPE', 'uuid'), // 'uuid' | 'bigint' // 'v7' = time-ordered UUID (much better index locality than v4 for a // write-heavy table). Falls back to v4 on Laravel 10 automatically. 'uuid_version' => env('QZ_UUID_VERSION', 'v7'), // QZ Tray WebSocket connection (client-side) 'websocket' => [ 'host' => env('QZ_WEBSOCKET_HOST', 'localhost'), 'port' => env('QZ_WEBSOCKET_PORT', 8181), 'retries' => 1, 'timeout' => 10, ], // Browser fallback when the tray is not running (classic layer) 'fallback' => [ 'enabled' => true, 'open_in_new_tab' => true, 'show_warning' => true, ], // Keyboard shortcut that opens the printer switcher (Ctrl+Shift+P) 'hotkey' => [ 'enabled' => true, 'combination' => 'ctrl+shift+p', ], // Route configuration — ADD 'auth' IN PRODUCTION 'routes' => [ 'prefix' => 'qz', 'middleware' => ['web'], 'throttle' => '60,1', ], // Optional request-level logging to your Laravel log channel 'logging' => [ 'enabled' => env('QZ_LOGGING_ENABLED', false), 'channel' => env('QZ_LOGGING_CHANNEL', 'stack'), 'level' => env('QZ_LOGGING_LEVEL', 'info'), ], // Auto-tag every print/preference with a tenant id (see Multi-Tenant) // 'tenant_id_resolver' => fn ($request) => auth()->user()?->tenant_id, ];
Browser-side config (window.QZ_CONFIG, set in your layout before the scripts).
Every knob below lives in ONE documented table — QZ_DEFAULTS at the top of
smart-print.js, applied through a single accessor, so the file header is
always the authoritative list. SmartPrint.config() returns the merged values
actually in force on the current page (defaults + your overrides). Defaults are
silent-by-default: set nothing and a page makes zero localhost calls on
load, zero reconnect attempts, zero dialogs, zero console errors:
| Key | Default | Controls |
|---|---|---|
fallbackMode |
'auto' |
Browser fallback strategy (see the fallback table) |
fetchMode |
'auto' |
Page-fetch hydration: 'auto' | 'always' | 'never' |
tenantId / projectId |
— | Tenant tag sent on every printer-memory/job call |
notify |
false |
Toast when a job degrades to the browser |
onFallback(job) |
— | Hook invoked on every fallback print |
observeDom |
false |
MutationObserver: auto-print elements injected after load (Turbo/Livewire/AJAX) fire too |
hotkey |
from config | Printer-switcher shortcut |
connectOnInit |
false |
v1.5.5: connect at page load. Default OFF — the tray is contacted on the FIRST print or Ctrl+Shift+Q (no scans, no /qz/printer fetch, no console errors on machines without the tray) |
autoReconnect |
false |
v1.5.5: background reconnect ladder (10s → 5min) for live status pages. Print-time probing makes it unnecessary for printing |
unavailableCooldownMs |
60000 |
v1.5.5: after one failed tray scan, prints skip the tray entirely (straight to the browser, no reload) for this long; the next print probes once more |
connectTimeoutMs |
8000 |
Cap on waiting for a (possibly hung) handshake before the print degrades to the browser |
printTimeoutMs |
25000 |
Cap on a silent QZ print before the browser fallback takes over |
connectionHotkey |
ctrl+shift+q |
v1.5.5 tray connection-check shortcut ({ enabled: false } to disable, combination to rebind) |
connectRetryDelayMs |
1500 |
v1.5.5: nap between connect retries (0 = scan once, no retry nap) |
launchProtocol |
false |
Try qz:launch after a failed scan (pops a native browser prompt — opt-in only) |
printerPrompt |
false |
Auto "Select Printer" modal before printing when no printer is remembered |
mobileFallbackMode |
auto → 'newtab' |
Pin the phone/tablet fallback engine: 'newtab' | 'iframe' | 'download' |
fallbackLoadWatchdogMs |
20000 |
Hidden-iframe load watchdog — a frame that never fires load releases the fallback chain |
serverSync |
true |
false = localStorage-only printer memory + no job logging (pre-1.1 behavior) |
prefix |
'/qz' |
Route prefix if you republished the routes under another path |
endpoints |
— | Per-endpoint absolute overrides: { certificate, sign, print, printer, jobs } |
assetsBase |
'/vendor/qz-tray/js' |
Where qz-tray.min.js lives (only used in the "library missing" hint) |
uuidVersion |
'v7' |
Job-id flavor: time-sortable v7 (default) or 'v4' |
Connection check (v1.5.5): press Ctrl+Shift+Q anywhere for an instant
tray probe — toast + console shows QZ Tray connected — N printers · using X
or QZ Tray NOT running — printing via the browser dialog. It overrides any
cooldown, so it also doubles as "I just started the tray — reconnect now".
Programmatically: SmartPrint.connectionCheck(); current state:
SmartPrint.status().unavailableForMs; effective config (every knob + the
value in force): SmartPrint.config().
Artisan Commands
| Command | Description |
|---|---|
qz:install |
Full install: publish everything + generate certificate |
qz:install --force / --no-cert |
Re-install overwriting files / skip certificate step |
qz:generate-certificate |
Generate the signing certificate |
qz:generate-certificate --force |
Force regenerate |
qz:generate-certificate --show |
Show certificate details after generation |
qz:generate-certificate --ca --domain=*.app.com,app.com |
Wildcard leaf signed by your root CA (zero-prompt Path B) |
qz:generate-ca |
Create your own free Root CA |
qz:certificate:export |
Export cert+key as password-protected .pfx (share across subdomains) |
qz:certificate:import --cert=… --key=… |
Import a CA fullchain (Let's Encrypt / cPanel / Cloudflare Origin) |
qz:certificate:import --pfx=… --password=… |
Import a shared .pfx pair |
qz:override:export [--show] |
Export the override.crt file clients deploy for signing trust |
qz:client-bundle [--zip] [--force] [--lna-domains=…] |
Build the Windows client deployment bundle (setup.bat, .ps1, certs, policies) |
qz:watch-certificate |
Auto re-import the watched cert pair after renewals (QZ_WATCH_CERT/QZ_WATCH_KEY) |
qz:doctor |
Health check: cert fingerprint/issuer/expiry, DB tables, config |
qz:prune-jobs |
Prune old qz_print_jobs rows (--older-than, --status, --keep, --dry-run) |
qz:prune-preferences |
Delete stale qz_printer_preferences rows (--older-than=90 default, `--type=device |
qz:clear-cache |
Clear printer preferences for the requesting identity (--session, --all) |
Schedule the pruning (tables don't self-expire):
// routes/console.php (Laravel 11+) or app/Console/Kernel.php Schedule::command('qz:prune-preferences --older-than=90')->weekly(); Schedule::command('qz:prune-jobs --older-than=30')->weekly();
HTTP API Endpoints
All routes are prefixed /qz by default (config/qz-tray.php → routes.prefix). Add auth middleware in production.
Security
| Method | URL | Name | Description |
|---|---|---|---|
GET |
/qz/certificate |
qz.certificate |
Public signing certificate (plain text) |
GET |
/qz/ca-certificate |
— | Your Root CA certificate (v1.4+) |
POST |
/qz/sign |
qz.sign |
Signs data with SHA-512 for QZ Tray verification |
Status & Health
| Method | URL | Name | Description |
|---|---|---|---|
GET |
/qz/status |
qz.status |
Full status: cert, key, endpoints, trust posture |
GET |
/qz/health |
qz.health |
Simple health check |
GET |
/qz/test/connection |
qz.test.connection |
API connectivity test |
POST |
/qz/test-sign |
qz.test-sign |
End-to-end signing pipeline test |
Printer Management
| Method | URL | Name | Description |
|---|---|---|---|
GET |
/qz/printers |
qz.printers |
Info endpoint (real list arrives via WebSocket) |
POST |
/qz/printer |
qz.printer.set |
Remember a printer per path + identity |
GET |
/qz/printer/{path} |
qz.printer.get |
Get remembered printer for a path |
Identity scoping (v1.1+): every request to /qz/printer, /qz/print, /qz/jobs, /qz/clear-cache is scoped by whichever identities are present, in identity_priority order — device (the X-Device-Id UUID SmartPrint.getDeviceId() sends automatically), user (authenticated), session (anonymous). Preferences are written for every identity present; there is no identity-less fallback, so two workstations can never read each other's printer.
Print Jobs
| Method | URL | Name | Description |
|---|---|---|---|
POST |
/qz/print |
qz.print |
Accept and log a print job |
GET |
/qz/jobs |
qz.jobs |
List this workstation/user's active jobs (scoped) |
PATCH |
/qz/jobs/{id} |
— | Update a job's status |
DELETE |
/qz/jobs/{id} |
qz.jobs.cancel |
Cancel a job by UUID |
Cache, Setup & Wizard
| Method | URL | Name | Description |
|---|---|---|---|
POST |
/qz/clear-cache |
qz.clear-cache |
Clear printer cache |
GET |
/qz/setup |
— | Browser setup wizard (client onboarding, v1.4+) |
POST |
/qz/setup |
qz.setup |
Setup info (cert/key status + endpoint URLs) |
POST |
/qz/generate |
qz.generate |
Generate cert via HTTP (disabled by default) |
Client Bundle & Demo Pages
| Method | URL | Name | Description |
|---|---|---|---|
GET |
/qz/client-bundle |
— | Download the client deployment bundle (v1.4+) |
GET |
/qz/test |
qz.test |
Full QZ Tray demo (all print types) |
GET |
/qz/smart |
qz.smart |
SmartPrint interactive demo |
GET |
/qz/test/pdf |
qz.test.pdf |
Stream a generated test PDF |
GET |
/qz/installer/{os} |
qz.installer |
Installer info (windows/linux/macos) |
Optional Stateless API (routes/api.php)
Enable with QZ_API_ENABLED=true — Sanctum-protected mirror of printers/print/jobs/status for server-to-server and mobile use.
Multi-Tenant Support
Every table is tenant-aware, and identity is handled two ways:
1. Native-typed tenant columns. tenant_id (and qz_print_jobs' user_id/user_type) are uuid or unsignedBigInteger following qz-tray.id_type — one config to change, matching your project's primary-key convention:
# .env — set BEFORE the first migrate QZ_JOB_ID_TYPE=uuid # or bigint
2. Auto-tagging every request. Set a resolver once and every print/preference call is tenant-tagged without touching call sites:
// config/qz-tray.php 'tenant_id_resolver' => fn ($request) => auth()->user()?->tenant_id, // or with stancl/tenancy: 'tenant_id_resolver' => fn ($request) => tenant('id'),
Explicit per-call tagging also works — either param name writes the same column:
// POST /qz/print or POST /qz/printer ['tenant_id' => '482', // id_type = bigint 'project_id' => 'b2b1f6c0-3b3d-4c9a-9e2e-1a2b3c4d5e6f'] // id_type = uuid
Or page-wide from Blade, so smart-print.js sends it automatically:
<script>window.QZ_CONFIG = { tenantId: '{{ auth()->user()?->tenant_id }}' };</script>
GET /qz/jobs narrows by tenant on top of its device/user scoping, and printer memory (qz_printer_preferences) is tenant-partitioned too.
Validation is strict: a bigint tenant id on a
uuid-configured install (or vice versa) is a validation error, not a silent write.
Filtering your own reports:
$jobs = DB::table('qz_print_jobs') ->where('tenant_id', auth()->user()->tenant_id) ->where('status', 'completed') ->orderByDesc('created_at') ->paginate(20);
Database Schema
Two optional tables — the package works without them (logging and server-synced printer memory simply no-op), but you want both for anything beyond a demo.
qz_print_jobs
Written automatically by POST /qz/print on every successful print. You never write to it manually.
| Column | Type | Description |
|---|---|---|
id |
uuid or bigint |
PK — id_type config. UUIDs are non-guessable and safe to expose to the client |
tenant_id |
uuid/bigint, nullable |
Type follows id_type |
user_id / user_type |
morph, nullable | Typed by id_type (nullableUuidMorphs() / nullableMorphs()) |
device_id |
uuid, nullable | Workstation/browser that submitted the job |
printer_name |
string | Printer used |
document_url |
string, nullable | What was printed |
document_type |
string | pdf, html, image, zpl, escpos, raw |
copies |
int | Copies requested |
status |
string | pending → processing → completed / failed / cancelled |
error_message |
text, nullable | Populated on failure |
metadata |
JSON, nullable | Extra data |
processed_at |
timestamp, nullable | Completion time |
qz_printer_preferences
Server-side mirror of printer memory — one row per (tenant_id, identity_type, identity_value, path):
| Column | Type | Description |
|---|---|---|
id |
bigint | Auto-increment PK |
tenant_id |
uuid/bigint, nullable |
Type follows id_type |
identity_type |
string | device, user, or session |
identity_value |
string | Device UUID / user id / session id |
path |
string | URL path the printer was remembered for |
printer_name |
string | The remembered printer |
Rows are not pruned automatically — wire qz:prune-preferences into your scheduler (see Artisan Commands).
Security
Production Hardening Checklist
// 1. Protect all /qz/* routes with auth: // config/qz-tray.php 'routes' => [ 'prefix' => 'qz', 'middleware' => ['web', 'auth'], // ← add in production 'throttle' => '60,1', // signing endpoint rate limit ], // extra middleware is fine too: ['web', 'auth', 'verified', 'role:printer']
# 2. Verify the private key is NOT web-accessible (expect 404):
curl -I https://yourapp.com/storage/qz/private-key.pem
curl -I https://yourapp.com/storage/qz/ca/qz-root-ca.key
// 3. Never enable in production: 'allow_public_cert_generate' => false,
# 4. Keep debug bars away from /qz/* (a debug payload in /qz/sign responses # corrupts signature verification and test PDFs): # config/debugbar.php → 'except' => ['qz/*'] — or disable debugbar in production.
How the Trust Chain Works
- The private key (
storage/qz/private-key.pem) signs requests server-side. It never leaves the server andstorage/is not web-served by default. - The public certificate (
storage/qz/digital-certificate.txt) is served at/qz/certificate; QZ Tray uses it to verify that print requests genuinely came from your app. - Every
POSTfrom SmartPrint carries your CSRF token automatically — noVerifyCsrfTokenexclusions needed (do not addqz/signto$except). - With Zero-Prompt Path B, clients additionally trust your own root CA — which you control and can rotate.
Environment Variables
None are required — every variable has a working default. Add only the ones you want to override.
| Variable | Default | Controls |
|---|---|---|
QZ_DEFAULT_PRINTER |
(none) | Pre-select a printer instead of prompting on first print |
QZ_WEBSOCKET_HOST |
localhost |
Host QZ Tray's WebSocket listens on |
QZ_WEBSOCKET_PORT |
8181 |
Port QZ Tray's WebSocket listens on |
QZ_AUTO_GENERATE_CERT |
false |
Auto-generate a self-signed cert on boot — dev only |
QZ_ALLOW_PUBLIC_CERT_GENERATE |
false |
Allow cert generation via HTTP — leave false |
QZ_LOGGING_ENABLED |
false |
Write every POST /qz/print to the Laravel log |
QZ_LOGGING_CHANNEL |
stack |
Log channel, if enabled |
QZ_LOGGING_LEVEL |
info |
Log level, if enabled |
QZ_JOB_ID_TYPE |
uuid |
qz_print_jobs PK type — set before first migrate |
QZ_UUID_VERSION |
v7 |
v7 (time-ordered) or v4 (random) for UUID PKs |
QZ_API_ENABLED |
false |
Expose the Sanctum-protected stateless API routes |
QZ_CERT_PATH / QZ_KEY_PATH |
storage paths | Point several vhosts at one shared keypair |
QZ_WATCH_CERT / QZ_WATCH_KEY |
(none) | Files qz:watch-certificate re-imports after renewals |
Copy-paste block (all defaults — edit only what you change):
QZ_DEFAULT_PRINTER= QZ_WEBSOCKET_HOST=localhost QZ_WEBSOCKET_PORT=8181 QZ_AUTO_GENERATE_CERT=false QZ_ALLOW_PUBLIC_CERT_GENERATE=false QZ_LOGGING_ENABLED=false QZ_LOGGING_CHANNEL=stack QZ_LOGGING_LEVEL=info QZ_JOB_ID_TYPE=uuid QZ_UUID_VERSION=v7 QZ_API_ENABLED=false
Realistic production override:
QZ_DEFAULT_PRINTER="HP LaserJet M404" QZ_LOGGING_ENABLED=true QZ_LOGGING_CHANNEL=daily
Troubleshooting
Fast Diagnosis
php artisan qz:doctor # server-side health: cert, key, DB, config # GET /qz/status # HTTP posture incl. trust reporting # GET /qz/smart # client-side: WebSocket + printer list + live print
Symptom → Cause → Fix
| Symptom | Likely cause | Fix |
|---|---|---|
| "Certificate not found" | Cert missing or storage/qz unwritable |
php artisan qz:generate-certificate and chmod -R 775 storage/qz |
| "Could not connect — is QZ Tray running?" | Tray not installed/started, or page served from file:// |
Install from qz.io/download, check the system-tray icon, port 8181, serve over http(s):// |
QZ Tray error Cannot parse … End-of-File on a URL print |
The URL answered HTML, not a PDF — almost always an auth-protected route (QZ has no session) | Let hydration do its job: use printUrl() with fetchMode: 'auto' (default). For cross-origin routes use { fetch: true } or fetchMode: 'always' |
The call resolves reason: 'unexpected-response' |
Same as above — hydration caught a login/404 page where a document was expected | Working as intended: check the route's auth/session, then retry |
419 CSRF token mismatch on /qz/sign |
Missing CSRF meta tag | Add <meta name="csrf-token" content="{{ csrf_token() }}"> to <head> — do not exclude qz/* from CSRF |
| Print jobs produce garbled/failed output in dev | Laravel Debugbar injects HTML into responses | Disable Debugbar in production or add 'except' => ['qz/*'] to config/debugbar.php |
| "Untrusted website" prompt returns every print | The user pressed plain Allow (session-only) | Press Always Allow once — or eliminate it entirely with Zero-Prompt Path A/B |
| Prompt appears per subdomain | Each vhost generated its own self-signed cert (fingerprint-based trust) | Share one cert pair (Path C) or go zero-prompt (Path A/B) |
Chrome/Edge blocks localhost:8181 |
Chromium Local Network Access restrictions | Include --lna-domains='[*.]yourdomain.com' when building the client bundle (Path B) |
Client downloaded .zip is invalid |
Corrupting layer between server and disk | Use the package's own qz:client-bundle --zip (pure-PHP ZipBuilder, atomic write). Verify server-side: unzip -t storage/qz/client-bundle/*.zip |
| Printer list empty | No WebSocket / no printers in OS | Confirm tray running + connected on /qz/smart; add the printer in the OS first — QZ Tray lists whatever the OS lists |
| Works in dev, fails on HTTPS | Cert chain problems | Site must be https://, /qz/certificate must load cleanly, QZ Tray 2.x installed |
openssl_sign failed |
Corrupted private key | php artisan qz:generate-certificate --force |
init-failed event fires |
qz-tray.min.js loaded after smart-print.js (or missing) |
Load order: qz-tray.min.js then smart-print.js |
.smart-print button does nothing |
Nothing to print on the element | Add data-qz-url / data-qz-html / data-qz-element / data-qz-urls, or onclick="printUrl(…)" — the console hint lists the options |
Clean-Slate Reset
php artisan qz:clear-cache --all
php artisan qz:prune-preferences --older-than=0
php artisan cache:clear && php artisan config:clear
File Structure
Published to your Laravel app by qz:install:
your-laravel-app/
├── config/
│ └── qz-tray.php ← Main configuration
├── storage/
│ └── qz/
│ ├── digital-certificate.txt ← Public certificate (auto-generated)
│ ├── private-key.pem ← Private key — never expose this
│ └── client-bundle/ ← Windows deployment bundle (qz:client-bundle)
├── database/migrations/
│ ├── …_create_qz_print_jobs_table.php
│ ├── …_create_qz_printer_preferences_table.php
│ └── …_add_client_job_id_to_qz_print_jobs_table.php
├── resources/views/vendor/qz-tray/
│ ├── default.blade.php ← Full QZ demo (/qz/test)
│ ├── smart.blade.php ← SmartPrint demo (/qz/smart)
│ ├── wizard.blade.php ← Setup wizard (/qz/setup)
│ └── example.blade.php ← Usage examples
└── public/vendor/qz-tray/
├── js/
│ ├── qz-tray.min.js ← QZ Tray WebSocket library (self-hosted, 2.2.6)
│ ├── smart-print.js ← SmartPrint smart client ⭐
│ ├── printer-status.js ← Status widget
│ ├── printer-switcher.js ← Printer switcher widget
│ ├── sample/
│ │ └── smart-actions.lab.example.js ← copy-paste starter for your app
│ └── adapters/
│ ├── zpl.js ← ZPL label builder
│ ├── escpos.js ← ESC/POS receipt builder
│ └── raw-print.js ← Raw print helper
├── css/ … and fonts/ ← Bootstrap + Font Awesome for the demo pages
└── installers/ ← QZ Tray desktop installers (if published)
Package source (vendor/bitdreamit/laravel-qz-tray/): src/QzTrayServiceProvider.php, src/Http/Controllers/QzSecurityController.php, src/Support/{CertKit,ZipBuilder}.php, src/Console/Commands/* (all artisan commands), resources/js/smart-print.js (the smart client), docs/zero-prompt.md, docs/multi-domain.md.
Upgrade Guide
From v1.4.x to v1.5.0
v1.5.0 is a drop-in client upgrade — your existing buttons, events and PHP code keep working unchanged.
composer update bitdreamit/laravel-qz-tray
php artisan optimize:clear
php artisan vendor:publish --tag=qz-assets --force # the new smart-print.js
🆕 v1.5.0 — the Universal Print API
One JS file, four zero-config ways to print. The generic functions are now built in — no setup, no registration:
printUrl('/receipt/5.pdf'); printUrls([url1, url2, url3]); printPdf('data:application/pdf;base64,…'); printHtml('<h1>Ticket</h1>'); printElement('#worklist-table'); printUrl(url, { copies: 3 });…or declaratively, with no JavaScript at all:
<button class="smart-print" data-qz-url="/receipt/5.pdf">Print</button> <button onclick="printUrl('/receipt/5.pdf')">Print</button>Auth-protected Laravel PDF routes (mPDF
->stream()/->download()) now just work — same-origin URLs are fetched through the page (session cookies included) and handed to QZ Tray as bytes. Details: Method 0 and Printing mPDF PDFs behind auth.
What you get automatically, with zero code changes:
printUrl/printUrls/printPdf/printPdfs/printImage/printImages/printHtml/printElement/printPage/printAnyUrl(s)— built in asSmartPrintmethods, window globals anddata-qz-actiontargets.class="smart-print"binding on any button/link, plusonclick="printUrl(…)"routing.- Auth-protected mPDF routes work (page-fetch hydration) — no signed URLs needed.
- Automatic browser fallback everywhere.
Optional cleanup: remove any custom define() blocks that only aliased the generic names — the built-ins now do it. Custom presets (printer/profile baked in) still win over the built-ins, so keep those.
Legacy (v0.x → v1.0)
composer update bitdreamit/laravel-qz-tray php artisan vendor:publish --tag=qz-assets --force php artisan vendor:publish --tag=qz-config --force php artisan qz:generate-certificate --force php artisan migrate
Replace old data-smart-print attributes with data-qz-print (both still work; data-qz-print is preferred).
FAQ
Q: Does this package require a paid QZ Tray license? A: No. The certificate toolkit this package ships (root CA, wildcard leaf, client bundle) is 100% free and self-hosted. QZ Tray Community Edition is free for internal/self-hosted use; a commercial license from QZ Industries is only needed if you redistribute QZ Tray itself. See qz.io/pricing.
Q: Do mPDF ->stream() / ->download() URLs work?
A: Yes — both, even behind auth middleware. The page fetches same-origin URLs with session cookies and hands QZ the bytes; Content-Disposition is ignored. Requirements: GET route, same origin, logged-in session. See Printing mPDF PDFs behind auth.
Q: Does this work on mobile devices?
A: QZ Tray is a desktop application — mobile browsers cannot reach localhost:8181. The fallback engine prints via the normal mobile browser dialog instead, so nothing breaks.
Q: Can multiple users print to different printers at the same time? A: Yes. Each browser tab holds its own WebSocket to the tray on that machine; printer memory is scoped per device/user/session/path/tenant.
Q: What happens if QZ Tray is not installed?
A: Nothing breaks. The same call silently prints via the browser fallback engine (hidden iframe by default), or follows your configured fallbackMode. Listen for fallback-print if you want to show a hint or a download link.
Q: Can I use this with React, Vue, or Livewire?
A: Yes — SmartPrint is a plain window object, and the class="smart-print" binding is framework-agnostic markup. See Framework Integration.
Q: Can I print to network printers?
A: Yes — QZ Tray lists whatever the operating system has registered. Add the printer in the OS (USB driver, TCP/IP port, or a raw socket:// CUPS queue / Standard TCP/IP port for ZPL/ESC-POS) and it appears in SmartPrint.getPrinters() with its exact OS name. QZ Industries has step-by-step guides for Windows raw printers and macOS raw printers.
Q: Is the private key secure?
A: Yes — it lives in storage/qz/, which Laravel does not web-serve, is used only server-side for signing, and is never sent to the browser. Verify yourself: curl -I https://yourapp.com/storage/qz/private-key.pem → 404.
Q: The certificate is valid for 20 years — do I ever renew?
A: Only if you want to. qz:generate-certificate --force re-issues any time; with a root CA (Path B) re-issuing the leaf never requires touching clients.
Q: Does every browser need a special extension? A: No extensions. QZ Tray runs as a native desktop app and exposes a local WebSocket the browser talks to directly.
Support & License
- Issues: github.com/bitdreamit/laravel-qz-tray/issues
- Email: info@bitdreamit.com
- QZ Tray API docs: qz.io/api
- Deep-dive guides: docs/zero-prompt.md · docs/multi-domain.md
MIT License — see LICENSE.
Made with ❤️ by Bit Dream IT