rawilk/laravel-printing
Laravel Printing lets your app send raw print jobs directly to printers through multiple drivers, including CUPS and Windows, with a simple, unified API for listing printers and dispatching print jobs from anywhere in your Laravel application.
Install and publish config
composer require rawilk/laravel-printing
php artisan vendor:publish --tag="printing-config"
config/printing.php has a default driver plus a drivers array. Two drivers ship with the package:
printnode — cloud service. Needs an API key and the PrintNode Client running on a machine that can see the physical printer. The Laravel app never talks to the printer directly.cups — talks to a CUPS server over IPP. Needs the CUPS client binaries on the web/queue server and network access to the print host.Set the driver in .env and add credentials to the relevant driver block (drivers.printnode.key, CUPS host/queue settings).
Verify before writing any code. The single fastest way to separate "my code is wrong" from "the printer infrastructure is wrong" is:
use Rawilk\Printing\Facades\Printing;
Printing::printers(); // Collection of Printer value objects
If this returns an empty collection, stop — the agent isn't running, the API key is wrong, or CUPS isn't reachable. Don't debug your PrintTask yet.
First real use case — print an invoice on order completion:
$job = Printing::newPrintTask()
->printer($printerId)
->file(storage_path("app/invoices/{$order->id}.pdf"))
->title("Invoice #{$order->id}")
->send();
$job->id();
Look first at Rawilk\Printing\Facades\Printing, PrintTask, and the Printer / PrintJob value objects — those three classes are the whole mental model. Everything else is configuration.
Never hardcode printer IDs. They're environment-specific (dev CUPS queue ≠ prod PrintNode ID). Store them in a printers table or a settings key per location/station, and resolve them by name in your app code.
Wrap the facade in one service. Don't sprinkle Printing:: across controllers:
class PrintDocument
{
public function __invoke(string $printerKey, string $path, string $title): ?PrintJob
{
$printer = PrinterSetting::where('key', $printerKey)->firstOrFail();
return Printing::newPrintTask()
->printer($printer->remote_id)
->file($path)
->title($title)
->send();
}
}
This gives you one choke point for logging, retries, and driver switching.
Always print from a queued job, never inline in a request. PrintNode is a synchronous HTTP call to a third party; a slow or dead printer agent will hang your checkout response. Dispatch a job, and put it behind afterCommit() (or DB::afterCommit()) so a rolled-back order never prints.
Content sources: ->content($string) for raw text/plain output, ->file($absolutePath) for local PDFs, ->url($url) for remote content. For invoices, the usual flow is: render Blade → PDF (dompdf/Snappy) → temp file → ->file().
Guard against double prints. Print jobs are not idempotent, and queue retries will re-send. Keep a print_jobs table keyed on (order_id, document_type) with a unique constraint, and check it before sending.
Reconcile status asynchronously. ->send() returning a job ID only means "accepted". For PrintNode, poll Printing::printJob($id) (or listen to the package's job events) to move your record from sent to printed/failed. Treat "no status update" as its own state — printers go offline and jobs sit in the queue.
Per-call driver override is the cleanest dev/prod split:
Printing::driver(config('printing.default'))->newPrintTask()...
Point local at CUPS (or a fake), production at PrintNode — same application code.
Testing: fake the facade rather than hitting a printer.
Printing::shouldReceive('newPrintTask')->once()->andReturn($task = Mockery::mock(PrintTask::class));
$task->shouldReceive('printer')->with('abc')->andReturnSelf();
// ... assert the chain ended in send()
Assert which printer and which content was sent — that's the part that actually breaks.
lpstat -p work as the same user PHP runs as (www-data usually lacks printer permissions by default)?->file() requires a local, absolute, readable path. Storage::path() is fine for the local disk; for S3 you must download to a temp file first. Queue workers run as a different user than your shell — permissions differ.->url() means the driver fetches it, not your app. PrintNode needs a publicly reachable URL; use a long-TTL signed URL, and remember signed URLs leak job content into logs.copies, duplex, color, media, bin map differently between PrintNode and CUPS. Don't share one options array across drivers and assume parity; test both.config/printing.php, run config:cache. Under Octane/queue workers, the resolved driver is held in the container — restart workers after config changes or you'll print to yesterday's printer.printing.drivers if you need something exotic (e.g. an internal IPP service or a wrapper that adds audit logging around PrintNode). A custom driver is also the cleanest test double — cleaner than mocking a fluent chain.How can I help you explore Laravel packages today?