PARQORE Print Studio Plugin for NativePHP Mobile#
Native document printing and PDF generation for NativePHP Mobile apps — UIPrintInteractionController on iOS, PrintManager on Android — driven entirely from PHP.
Overview#
partek/print-studio gives a NativePHP Mobile app two things: a way to hand HTML, a Blade view, plain text, an existing PDF, or an existing image to the platform's own print UI, and a way to render the same sources to a PDF file on disk instead. Both are reached through one fluent builder:
PrintStudio::html('<h1>Invoice #1042</h1>')->paper(PaperSize::Letter)->print();
What this is not#
- Not a guarantee that paper came out. Neither iOS nor Android reliably reports that a physical printer finished a job —
PrintJobPresentedmeans the native print UI was shown to the user, not that ink hit paper. See Events and Platform behavior. - Not a thermal/ESC-POS receipt printer SDK.
PaperSize::Receipt58mm/Receipt80mmare page-size presets for the platform print UI and PDF rendering — they don't speak ESC/POS, don't discover Bluetooth/USB receipt printers, and don't drive a continuous paper feed directly. - Not a cloud print service. There's no queue, no remote printer directory, no email-to-print. Every job goes through whatever printer the OS print UI already knows about (AirPrint / a Mopria-compatible Android print service) or is rendered straight to a local PDF file.
Installation#
composer require partek/print-studio
Register the plugin:
php artisan native:plugin:register partek/print-studio
If you haven't published NativePHP's plugin provider yet:
php artisan vendor:publish --tag=nativephp-plugins-provider
Optionally publish the config file if you want to change storage_root, output_subdirectory, or the size limits:
php artisan vendor:publish --tag=print-studio-config
Rebuild after installing or after a manifest change:
php artisan native:run
No Android permissions and no iOS Info.plist entries are required — printing needs no runtime permission grant on either platform, and nativephp.json declares an empty permission/info_plist set for both.
Usage#
print() and toPdf() are asynchronous — read this first#
PrintJobBuilder::print() and ::toPdf() both return bool, not a result. The return value only tells you whether the native bridge accepted the call — not whether the user actually printed, cancelled, or whether the PDF finished writing. The OS print dialog is an interactive, indefinite-duration UI (the user might leave it open for minutes), and native HTML rendering has no synchronous "done" signal either — so there is nothing for these methods to return but "did the request get through."
use PARTek\PrintStudio\Facades\PrintStudio; $accepted = PrintStudio::html('<h1>Invoice #1042</h1>')->print(); // $accepted === true means only "the native side took the job." It is NOT// "the document printed." For the real outcome, listen for the events below —// PrintJobPresented, PrintJobCompleted, PrintJobCancelled, or PrintJobFailed.if (! $accepted) { // The call never reached native code at all (see Troubleshooting).}
The same is true for toPdf(): it returns whether native accepted the render request, not the final byte size or page count — those arrive on PdfGenerated.
A full, listener-driven example:
use Native\Mobile\Attributes\On;use PARTek\PrintStudio\Enums\Orientation;use PARTek\PrintStudio\Enums\PaperSize;use PARTek\PrintStudio\Events\PrintJobCancelled;use PARTek\PrintStudio\Events\PrintJobCompleted;use PARTek\PrintStudio\Events\PrintJobFailed;use PARTek\PrintStudio\Events\PrintJobPresented;use PARTek\PrintStudio\Facades\PrintStudio; class InvoiceScreen extends NativeComponent{ public bool $printing = false; public ?string $status = null; public function printInvoice(): void { $this->printing = PrintStudio::html($this->renderInvoiceHtml()) ->jobName('Invoice #1042') ->paper(PaperSize::Letter) ->orientation(Orientation::Portrait) ->margins(24) ->print(); // $this->printing here is only "was the request accepted," not // "the print dialog is open." The events below carry the real state. } #[On(PrintJobPresented::class)] public function onPresented(string $jobId, string $jobName): void { $this->status = "{$jobName}: print dialog shown"; } #[On(PrintJobCompleted::class)] public function onCompleted(string $jobId, string $jobName): void { $this->printing = false; $this->status = "{$jobName}: done"; } #[On(PrintJobCancelled::class)] public function onCancelled(string $jobId, string $jobName): void { $this->printing = false; $this->status = "{$jobName}: cancelled by user"; } #[On(PrintJobFailed::class)] public function onFailed(string $jobId, string $jobName, string $errorCode, string $errorMessage): void { $this->printing = false; $this->status = "{$jobName}: failed ({$errorMessage})"; }}
html()->print()#
PrintStudio::html('<h1>Invoice #1042</h1><p>Total: $128.00</p>') ->jobName('Invoice #1042') ->paper(PaperSize::Letter) ->margins(24) ->print();
view()->toPdf()#
view() renders a Blade view to an HTML string in PHP (via Illuminate\View) and hands that HTML to the same builder html() does — by the time a job reaches native code it's always plain HTML, there's no separate "Blade" wire type.
PrintStudio::view('invoices.print', ['invoice' => $invoice]) ->jobName("Invoice #{$invoice->number}") ->paper(PaperSize::A4) ->toPdf("invoice-{$invoice->number}.pdf");
pdf()->print()#
Prints an existing local PDF file. The path is validated by PathGuard (see Security) before the builder is even returned — an unsafe or missing path throws immediately, in PHP, before any native call.
PrintStudio::pdf('app/downloads/contract-signed.pdf') ->jobName('Signed Contract') ->copies(2) ->print();
image()->fit()->print()#
Prints an existing local PNG/JPEG. fit() only applies to an image source — calling it after html()/pdf()/file() throws InvalidPrintableSourceException rather than silently doing nothing.
use PARTek\PrintStudio\Enums\ImageFit; PrintStudio::image('app/labels/shipping-label.png') ->fit(ImageFit::ActualSize) ->paper(PaperSize::ShippingLabel4x6) ->print();
ImageFit::Fit (default) and ImageFit::Fill both preserve aspect ratio — Fit letterboxes to stay fully visible, Fill crops to cover the page. ImageFit::ActualSize applies no scaling at all and may overflow the page.
text()->print()#
Wraps a raw string as escaped, print-safe HTML — the string is never interpreted as markup, so this is the one printable-source path that guarantees its input can't inject script or markup into the rendered document.
PrintStudio::text("Pickup code: 48213\nReady in 10 minutes") ->paper(PaperSize::Receipt58mm) ->print();
file() — an existing local HTML file#
PrintStudio::file('app/exports/report.html')->paper(PaperSize::Legal)->print();
Custom page size#
PrintStudio::html($labelHtml) ->customSize(widthPoints: 216, heightPoints: 360) // selects PaperSize::Custom ->print();
Cancelling a job#
PrintStudio::cancel($jobId); // bool — accepted, not confirmed; watch for PrintJobCancelled
JavaScript (Vue / React / Inertia)#
resources/js/index.js mirrors the PHP builder for apps that never touch a PHP request cycle for printing — it computes page dimensions itself (PAPER_SIZES, matching PaperSize::dimensions() point-for-point), so a pure-JS/Inertia screen never needs a PHP round trip just to print.
import { printHtml, printPdfFile, printImageFile, generatePdf, cancel, Events } from '../../vendor/partek/print-studio/resources/js/index.js'; // Same async-acceptance contract as the PHP builder — the resolved value is// "native accepted the call," not "printing finished."await printHtml('<h1>Invoice #1042</h1>', { jobName: 'Invoice #1042', paper: 'letter', margins: 24 }); await printPdfFile('app/downloads/contract-signed.pdf', { copies: 2 }); await printImageFile('app/labels/shipping-label.png', 'actual_size', { paper: 'shipping_label_4x6' }); await generatePdf('<h1>Invoice #1042</h1>', 'invoice-1042.pdf', { paper: 'a4' }); await cancel(jobId);
Each call POSTs to /_native/api/call with an X-CSRF-TOKEN header (read from <meta name="csrf-token">) and throws on an HTTP error or a {status: 'error'} response.
Listening for the outcome events from JS follows NativePHP Mobile's normal native-event mechanism — the Events export gives you the fully-qualified PHP class name string each event is dispatched under, for whatever event-bridging your app already uses (e.g. broadcasting the PHP event to the frontend). The events themselves are always dispatched in PHP (see Events) — this plugin does not fire a separate JS-only event.
Events#
| Event | Fires | Properties |
|---|---|---|
PrintJobCreated |
Synchronously in PHP, the moment print()/toPdf() is called — before any native call, so a listener sees every job that was attempted even if native never responds. |
job — a PARTek\PrintStudio\DTO\PrintJob (id, name, sourceType, outputPath), not a flat scalar list. |
PrintJobPresented |
The native print UI was shown to the user. Not confirmation that a physical printer produced paper. | jobId (string), jobName (string) |
PrintJobCompleted |
The print flow finished successfully from the OS's point of view. | jobId (string), jobName (string) |
PrintJobCancelled |
The user dismissed the native print UI without printing — a normal, first-class outcome, not an error. | jobId (string), jobName (string) |
PrintJobFailed |
The native side reported a failure. | jobId (string), jobName (string), errorCode (string), errorMessage (string, sanitized — never a raw platform exception or stack trace) |
PdfGenerated |
toPdf() finished writing the file. |
jobId (string), jobName (string), path (string), filename (string), mimeType (string, always application/pdf today), byteSize (int), pageCount (?int — null when native couldn't determine it, never fabricated) |
PdfGenerationFailed |
toPdf() failed. |
jobId (string), jobName (string), errorCode (string), errorMessage (string, sanitized) |
PrintJobCreated is the one exception to "flat scalars only" — it's dispatched directly in PHP from the already-constructed PrintJob object, so it carries that object whole. The other six are populated from a native event payload bound by parameter name (see NativeComponent::makeEventInstance() in nativephp/mobile), which only works with flat scalar constructor parameters — and every one of those six is verified to have a flat, no-nested-DTO constructor by this package's own test suite (tests/PluginTest.php).
Methods#
PrintStudio (facade)#
html(string $html): PrintJobBuilder
Starts a builder from a raw HTML string.
view(string $view, array $data = []): PrintJobBuilder
Renders a Blade view to HTML in PHP, then starts a builder exactly as html() would.
text(string $text): PrintJobBuilder
Escapes $text and wraps it in print-safe HTML (see Support\Text) — never interpreted as markup.
file(string $path): PrintJobBuilder
Starts a builder from an existing local HTML file. $path is resolved and validated by PathGuard before this method returns.
pdf(string $path): PrintJobBuilder
Starts a builder from an existing local PDF file, PathGuard-validated.
image(string $path, ImageFit $fit = ImageFit::Fit): PrintJobBuilder
Starts a builder from an existing local PNG/JPEG, PathGuard-validated. $fit can also be set/overridden later with ->fit().
cancel(string $jobId): bool
Requests cancellation of an in-flight job by the id from its PrintJobCreated event. Returns whether native accepted the cancellation request, not confirmation it stopped — watch for PrintJobCancelled.
PrintJobBuilder (returned by every method above)#
Each PrintStudio:: call above returns a fresh, unshared builder — PrintStudio::html($a) and PrintStudio::html($b) in the same request never interfere with each other.
jobName(string $name): static
Sets the job's display name. Default: 'Print Job'.
paper(PaperSize $paper): static
Sets the page size. Default: PaperSize::Letter.
iOS print() selects the closest paper that the system offers to the app. The size (minus margins) also sets the width your HTML is laid out at. On the physical iPad and HP ENVY used for verification, iPadOS passed only US Letter to the plugin even though A4 was requested, so the sheet remained on Letter. The user can choose another paper when iPadOS exposes it. toPdf() is unaffected: it renders at exactly the size you request. Android maps the named paper sizes to the platform's recognized media identifiers so the system sheet can select them. See Platform behavior.
customSize(float $widthPoints, float $heightPoints): static
Selects PaperSize::Custom and sets its dimensions (in points) in one call. PaperSize::Custom alone has no fixed dimensions — this is the only way to give it any.
orientation(Orientation $orientation): static
Orientation::Portrait (default) or Orientation::Landscape. Landscape swaps the resolved width/height.
margins(PrintMargins|array|int|float $margins): static
A single number sets all four sides; an array (['top' => ..., 'right' => ..., 'bottom' => ..., 'left' => ...], missing sides default to 0) or a PrintMargins instance sets them individually. Default: PrintMargins::zero().
copies(int $copies): static
1–999. Default: 1. print() only — meaningless for toPdf() (a PDF has no copy count).
iOS does not apply this value. UIKit gives no way to preset the copy count, so the value is accepted but the user sets copies on the system print sheet. (This comes from the plugin's iOS source and has not yet been tested on a device.) Android has not been checked for this yet. See Platform behavior.
dpi(int $dpi): static
72–2400. toPdf() only — print() ignores it and uses the OS's own print resolution handling.
fit(ImageFit $fit): static
Image sources only. Throws InvalidPrintableSourceException if the builder's source isn't PrintStudio::image() — invalid usage fails predictably rather than being silently ignored.
print(): bool
Presents the native print UI. Asynchronous — see above. Returns whether the native call was accepted.
toPdf(string $filename, bool $overwrite = false): bool
Renders to a PDF at a PathGuard-resolved destination. Asynchronous, same contract as print(). Throws DestinationExistsException before any native call if the destination already exists and $overwrite is false.
Page sizes & margins#
All dimensions are in points (1/72 inch) — the unit both iOS's PDFKit/UIPrintInteractionController and Android's PdfDocument.PageInfo already use natively, so PrintStudio does no unit conversion on either platform.
PaperSize |
Label | Width × Height (points, portrait) |
|---|---|---|
Letter |
Letter | 612 × 792 |
Legal |
Legal | 612 × 1008 |
A4 |
A4 | 595 × 842 |
A5 |
A5 | 420 × 595 |
ShippingLabel4x6 |
4×6 Shipping Label | 288 × 432 |
Receipt58mm |
58mm Receipt | 164 × 595 |
Receipt80mm |
80mm Receipt | 227 × 842 |
Custom |
Custom | none — requires ->customSize($widthPoints, $heightPoints) |
orientation(Orientation::Landscape) swaps width and height for whichever size you picked.
Receipt presets use a fixed nominal length, not a true continuous roll. Receipt58mm/Receipt80mm give you a generous fixed page height (595pt / 842pt) rather than modeling an actually-continuous thermal roll — content longer than one nominal page paginates onto a second page instead of growing a single strip. If your receipt content is genuinely variable-length and you want one continuous page sized to fit it, use paper(PaperSize::Custom)->customSize($width, $height) with a height you compute yourself as the documented escape hatch.
customSize() requires both width and height (points), each greater than 0 and at most 5000 — passing only one, or a value outside that range, throws InvalidPageConfigurationException before anything reaches native code. Passing a custom width/height without also selecting PaperSize::Custom also throws.
Margins#
PrintMargins values are points, 0–288 (4 inches) per side — the 288pt ceiling is a sanity bound to catch an obvious unit mistake (e.g. passing pixels), not a platform limit.
use PARTek\PrintStudio\DTO\PrintMargins; PrintMargins::all(24.0); // all four sides = 24ptPrintMargins::symmetric(10.0, 20.0); // top/bottom = 10pt, left/right = 20ptPrintMargins::zero(); // no margins (the builder's default)PrintMargins::from(24); // same as ::all(24.0) — a bare number works tooPrintMargins::from(['top' => 10, 'left' => 5]); // right/bottom default to 0
Platform behavior#
The bridge function names, event names, and parameters below are the fixed contract between this package and the native plugin code (resources/ios/, resources/android/). Native implementations exist for both platforms and have been verified on real devices. On iOS (a physical iPad), toPdf() and print() both produced correct one-page output that was opened and inspected. On Android, the generated PDF was validated as a well-formed one-page file but has not been visually inspected — the rendering mechanics below are still phrased at a general level for that reason.
| Behavior | iOS | Android |
|---|---|---|
| Print UI | UIPrintInteractionController (Apple's standard print/AirPrint sheet) |
PrintManager (the standard Android print dialog, compatible with Mopria print services) |
Paper size for print() |
paper() sets the layout width and asks iOS to select the closest paper it offers. With the tested HP ENVY selected, iPadOS offered only US Letter for an A4 request, so the sheet remained on Letter. toPdf() renders at exactly the requested size. |
Named papers use Android's recognized media identifiers. A physical Galaxy A54 opened on ISO A4 for an A4 request; custom and receipt sizes retain their exact dimensions. |
Copies for print() |
Not applied — the user sets the copy count on the print sheet (per the iOS source; not yet tested on a device) | Not yet verified |
| HTML rendering | The platform's native HTML rendering engine, used for layout only | The platform's native HTML rendering engine, used for layout only |
| PDF output | PdfResult/PdfGenerated report byte size and, when determinable, page count |
Same contract — pageCount is null rather than fabricated when the native layer can't determine it |
| Minimum OS version | 18.0 | API 26 |
| Extra permissions | None — printing needs no runtime permission on iOS | None — printing needs no runtime permission on Android (nativephp.json declares an empty permissions array) |
| "Printed" confirmation | Not available — PrintJobPresented only means the sheet was shown |
Not available — same caveat |
The exact rendering engine's edge-case behavior (CSS support, font substitution, how it paginates content taller than one page) has not been visually verified against real printed/PDF output on either platform — treat print output fidelity as design intent, not a verified guarantee until you've checked a real print/PDF against your own content. The Testing section below, by contrast, is fully accurate today — it exercises real, passing PHP code.
Security#
Every local filesystem path this plugin touches — an existing PDF/image/HTML-file source given as a plain path, and every toPdf() destination — is validated by PARTek\PrintStudio\Support\PathGuard before it is used, let alone crosses the native bridge:
- Path traversal is rejected. A source path is resolved with
realpath()and checked to fall insideconfig('print-studio.storage_root')(UnsafePathExceptionotherwise); a destination filename is rejected outright if it contains a..or.path segment, is given as an absolute path, or resolves (after joining to the storage root) outside it. - Destination filenames are character-restricted. Each path segment of a
toPdf()destination must match^[A-Za-z0-9 _.\-]+$— anything else (quotes, control characters, shell metacharacters, etc.) throwsUnsafePathExceptionbefore a file is ever opened for writing. - Overwrite protection is on by default.
toPdf('invoice.pdf')throwsDestinationExistsExceptionif that file already exists; passoverwrite: trueto replace it deliberately. Nothing is ever silently overwritten. storage_rootdefaults to the app's whole private storage tree (storage_path()), not just this plugin's own output — so an app can print a file it already keeps elsewhere in its own storage (e.g. a downloaded invoice PDF) without moving it first. Narrow it inconfig/print-studio.phpif you want to restrict PrintStudio to a smaller subtree.toPdf()writes underoutput_subdirectory(defaultapp/print-studio) when given a bare filename; an explicit relative subpath is still honored as long as it resolves insidestorage_root.- HTML you pass to
html()/view()/file()is rendered for layout only — embedded<script>content does not execute. This is a hard design requirement of the plugin's printable-HTML path, not an incidental property of whichever rendering component the native implementation happens to use. text()is the one source type that's guaranteed injection-proof by construction — the input ishtmlspecialchars()-escaped and wrapped in a fixed template (Support\Text), never treated as markup at all.
See SECURITY.md for the full attack-surface writeup and how to report a vulnerability.
Testing with the fake#
PrintStudio::fake() swaps the bound manager for FakePrintStudio, which records every print()/toPdf()/cancel() call instead of crossing the native bridge and reports success unless you configure a failing scenario. Path validation (PathGuard) still runs — it's plain PHP logic, not a native call — so the fake still catches an unsafe or missing path in a test.
use PARTek\PrintStudio\DTO\PrintJob;use PARTek\PrintStudio\Facades\PrintStudio; it('prints the invoice with the right job name', function () { PrintStudio::fake(); PrintStudio::html('<p>Invoice</p>')->jobName('Invoice 1042')->print(); PrintStudio::assertPrinted(fn (PrintJob $job) => $job->name === 'Invoice 1042');}); it('generates a PDF with the expected filename', function () { PrintStudio::fake(); PrintStudio::html('<p>Invoice</p>')->toPdf('invoice-1042.pdf'); PrintStudio::assertPdfGenerated('invoice-1042.pdf');}); it('reports failure when the native side rejects the job', function () { $fake = PrintStudio::fake()->failing(); expect(PrintStudio::html('<p>x</p>')->print())->toBeFalse(); $fake->assertPrinted(); // still recorded as attempted, even though it "failed"}); it('asserts nothing was printed', function () { PrintStudio::fake(); PrintStudio::assertNothingPrinted();});
| Assertion | Checks |
|---|---|
assertPrinted(?callable $callback = null) |
A print() call happened; the optional callback receives the resulting PrintJob to narrow the match. |
assertPdfGenerated(callable|string|null $filenameOrCallback = null) |
A toPdf() call happened. A plain string matches the destination's basename; a callback receives the PrintJob. |
assertCancelled(?string $jobId = null) |
A cancel() call happened; pass a job id to require that specific one. |
assertNothingPrinted() |
No print()/toPdf()/cancel() call was issued at all. |
->failing(bool $failing = true) (chainable off fake()) makes every subsequent call report failure, as if native rejected it — useful for testing your app's own failure-handling UI without a real device.
Troubleshooting#
toPdf() throws DestinationExistsException. The target file already exists. Pass overwrite: true if you intend to replace it: ->toPdf('invoice-1042.pdf', overwrite: true).
pdf()/image()/file() throws UnsafePathException. The path you gave resolves outside config('print-studio.storage_root') (which defaults to the app's whole storage_path() tree) — move the file somewhere inside it, or widen/adjust storage_root in config/print-studio.php if that's the intended location.
A destination toPdf() path throws UnsafePathException even though it looks like a normal filename. Destination filenames only allow A-Za-z0-9 _.- per path segment, and reject ../. segments and absolute paths outright — rename the file or strip unusual characters before passing it in.
print()/toPdf() returns false immediately. The native call was never accepted — check that the plugin is registered (php artisan native:plugin:register partek/print-studio) and that you've rebuilt (php artisan native:run) since installing or updating it. This is a PHP-side "the call didn't get through" signal, distinct from a PrintJobFailed/PdfGenerationFailed event, which means it did get through and then failed natively.
No printers available on the device. The OS print UI itself handles this (e.g. AirPrint discovery, Android's print service picker) — expect a PrintJobCancelled or PrintJobFailed depending on how the user backs out, rather than a distinct "no printers" event from this plugin.
fit() throws InvalidPrintableSourceException. fit() only applies to a builder started from PrintStudio::image() — remove the call, or switch the source, if you're printing HTML/PDF/text.
Performance & limits#
max_html_bytes(config, default 5 MiB) bounds an HTML string source (html()/view()) before it's allowed to cross the bridge —InvalidPrintableSourceExceptionif exceeded.max_image_dimension(config, default 8000px) bounds the width/height PrintStudio will accept for an image source. This is a guard against pathological input, not a claim about printable resolution.- These limits protect the PHP side; equally, the native renderer should avoid holding multiple full documents in memory at once when handling large HTML/PDF/image sources or high
copiescounts — this remains project guidance rather than a device-verified property, since large-document memory behavior specifically hasn't been profiled on either platform yet.
Complete example#
See examples/ for full Blade/print-CSS templates (invoice, receipt, packing slip, 4×6 shipping label, event ticket) and a walkthrough of wiring each into PrintStudio::view(...)->paper(...)->print() or ->toPdf(...).
License#
Proprietary commercial. See LICENSE.