PARQORE Document Intelligence Plugin for NativePHP Mobile#
Native multi-page document scanning and on-device OCR — VisionKit/Vision on iOS, ML Kit Document Scanner/Text Recognition on Android — driven entirely from PHP.
Overview#
partek/document-intelligence gives a NativePHP Mobile app two things: a way to present the platform's own document-scanner UI so a user can capture a paper document with automatic edge detection, perspective correction, and cropping, and a way to run on-device OCR — either on the pages that scanner just captured, or on any existing image the app already has.
use PARTek\DocumentIntelligence\DTO\ScanOptions;use PARTek\DocumentIntelligence\Facades\DocumentIntelligence; $scanId = DocumentIntelligence::scan( ScanOptions::make(20)->outputPdf()->recognizeText());
What this is not#
- Not cloud OCR. Every recognition operation — from
scan(..., recognizeText: true)and fromrecognize()— runs entirely on-device via Apple Vision or Google ML Kit. No image or text is uploaded to run OCR, and this plugin has no server component of its own. - Not guaranteed handwriting recognition. The underlying platform OCR engines are built for printed text. Handwriting may or may not recognize usefully depending on the platform, the handwriting, and factors this plugin does not control — don't build a feature that depends on it working.
- Not identity-document verification. This plugin scans and reads text from a document; it does not verify that a document is genuine, unaltered, or belongs to a particular person. Don't use it as the sole check in an identity-verification flow.
- Not a generic receipt/invoice parser.
OcrResultgives you text, structure (blocks/lines/elements), positions, and confidence — it does not know what a "total" or a "line item" is. Extracting structured fields from a receipt or invoice is application code you write against the OCR output, not something this plugin does for you. - Not a guarantee of financial or legal accuracy. OCR output can misread characters, especially on low-quality captures. Don't feed
OcrResulttext directly into anything financial or legal without a human or application-level review step appropriate to the stakes.
Installation#
composer require partek/document-intelligence
Register the plugin:
php artisan native:plugin:register partek/document-intelligence
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, default_retention, max_pages, or max_image_dimension:
php artisan vendor:publish --tag=document-intelligence-config
Rebuild after installing or after a manifest change:
php artisan native:run
iOS requires NSCameraUsageDescription (declared in nativephp.json and applied automatically) since the scanner UI uses the camera. See Platform behavior for the Android permission story.
Usage#
scan() is asynchronous — it returns a scan id, not a result. Read this first.#
DocumentIntelligence::scan(ScanOptions $options): ?string presents the native scanner UI and returns immediately with the new scan's id (or null if the native call wasn't even accepted) — not the scanned pages, not a PDF, not OCR text. Presenting a multi-page scanner is an interactive flow with no fixed duration — the user might capture one page or twenty, take thirty seconds or five minutes, or back out entirely — so there is no synchronous "here's your document" value for scan() to return. The real outcome arrives later as an event (ScanProgress, ScanCompleted, ScanCancelled, or ScanFailed), and once ScanCompleted fires you call result($scanId) — a separate, synchronous call — to fetch the actual DocumentScanResult.
use Native\Mobile\Attributes\On;use PARTek\DocumentIntelligence\DTO\ScanOptions;use PARTek\DocumentIntelligence\Events\ScanCancelled;use PARTek\DocumentIntelligence\Events\ScanCompleted;use PARTek\DocumentIntelligence\Events\ScanFailed;use PARTek\DocumentIntelligence\Facades\DocumentIntelligence; class ScanScreen extends NativeComponent{ public ?string $scanId = null; public function startScan(): void { // $this->scanId here means only "the scanner UI was requested and // accepted" — NOT "the user has finished scanning." null means the // native call itself was rejected (see Troubleshooting). $this->scanId = DocumentIntelligence::scan( ScanOptions::make(20)->outputPdf()->recognizeText() ); } #[On(ScanCompleted::class)] public function onCompleted(string $scanId, int $pageCount): void { // The event does not carry the pages themselves — fetch them. $result = DocumentIntelligence::result($scanId); // $result->pages, $result->pdfPath, $result->pages[0]->ocr, etc. } #[On(ScanCancelled::class)] public function onCancelled(string $scanId): void { $this->scanId = null; // a normal outcome, not an error } #[On(ScanFailed::class)] public function onFailed(string $scanId, string $errorCode, string $errorMessage): void { $this->scanId = null; }}
recognize() is synchronous — it returns the OCR result directly#
Unlike scan(), DocumentIntelligence::recognize(string $path): ?OcrResult has no UI to present. It's a bounded, non-interactive ML operation on a single existing image — the same shape of work as decoding an image or resizing it — so there's nothing indeterminate about its duration, and it returns the OcrResult (or null on failure) directly, with no event round-trip needed. This mirrors NativeMaps::visibleRegion() in the sibling nativephp-native-maps plugin: a synchronous bridge call/response, not an asynchronous UI flow.
use PARTek\DocumentIntelligence\Facades\DocumentIntelligence; $ocr = DocumentIntelligence::recognize('app/uploads/receipt.jpg'); if ($ocr !== null) { echo $ocr->fullText();}
$path is validated by PathGuard before this method does anything else — see Security.
purgeTemporaryFiles() — pure PHP cleanup, no native call#
use PARTek\DocumentIntelligence\Facades\DocumentIntelligence; // Deletes this plugin's own captured pages/PDFs older than the default// retention (config('document-intelligence.default_retention'), '1 day').$deleted = DocumentIntelligence::purgeTemporaryFiles(); // Or an explicit cutoff:$deleted = DocumentIntelligence::purgeTemporaryFiles(now()->subWeek());
This only deletes files this plugin itself wrote under its own output subdirectory (config('document-intelligence.output_subdirectory'), one folder per scan id) — it never touches anything else under storage_root, and it never runs automatically. Call it from your own scheduled task if you want periodic cleanup.
Cancelling a scan#
DocumentIntelligence::cancel($scanId); // bool — accepted, not confirmed; watch for ScanCancelled
Cancelling a scan that's already finished is a no-op success, not an error.
JavaScript (Vue / React / Inertia)#
resources/js/index.js mirrors the PHP API for apps that never touch a PHP request cycle to scan or recognize:
import { scan, result, cancel, recognize, Events } from '../../vendor/partek/document-intelligence/resources/js/index.js'; // Same async-acceptance contract as the PHP facade — the resolved value is// the new scan's id, not the scanned pages.const scanId = await scan({ maxPages: 20, outputPdf: true, recognizeText: true }); // Call after your app's own event bridge tells you ScanCompleted fired.const documentScanResult = await result(scanId); await cancel(scanId); // No UI, no event — resolves directly with the OcrResult wire shape.const ocrResult = await recognize('app/uploads/receipt.jpg');
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. Events gives you the fully-qualified PHP class name string for each of the four native-side events, for whatever event-bridging your app already uses — the events themselves are always dispatched in PHP (see Events); this plugin does not fire a separate JS-only event.
Events#
| Event | Fires | Properties |
|---|---|---|
ScanStarted |
Synchronously in PHP, the instant scan() is called — before the native bridge call, so a listener sees every scan that was attempted even if native never responds. |
scanId (string) |
ScanProgress |
The native scanner reports progress during an in-flight scan. | scanId (string), currentPage (int), totalPages (int) |
ScanCompleted |
The scan finished successfully. Does not carry the pages themselves — call result($scanId) to fetch the DocumentScanResult. |
scanId (string), pageCount (int) |
ScanCancelled |
The user dismissed the scanner UI without capturing any pages, or cancelled after capturing some. A normal, first-class outcome, not an error — check result($scanId) for whatever was captured before cancellation, if anything. |
scanId (string) |
ScanFailed |
The native side reported a failure. | scanId (string), errorCode (string), errorMessage (string — always the sanitized message the native layer reported, never a raw platform exception, stack trace, or filesystem path) |
ScanProgress, ScanCompleted, ScanCancelled, and ScanFailed are populated from a native event payload bound by constructor parameter name (the same mechanism nativephp/mobile's NativeComponent::makeEventInstance() uses for every other plugin in this portfolio) — which only works with flat scalar constructor parameters. All four have flat, no-nested-DTO constructors by design. ScanStarted is dispatched directly in PHP from inside scan() itself, so it isn't bound this way, but it happens to also be flat scalars (just scanId).
Methods#
scan(ScanOptions $options): ?string#
Presents the native multi-page document scanner UI. Returns the new scan's id if the native call was accepted, null otherwise. See Usage.
result(string $scanId): ?DocumentScanResult#
Fetches the full result of a scan — call this after ScanCompleted fires. Returns null if the scan id is unknown or its result has expired or been purged.
cancel(string $scanId): bool#
Requests cancellation of an in-flight scan by id. Returns whether the native side accepted the request, not confirmation that it actually stopped — watch for ScanCancelled.
recognize(string $path): ?OcrResult#
Runs OCR on an existing image. $path is validated by PathGuard before anything else happens. Returns the result directly (no events) — null if recognition failed (see your app's logs), since "no text found" and "recognition failed" both plausibly mean "nothing to show the user."
purgeTemporaryFiles(?\DateTimeInterface $olderThan = null): int#
Deletes this plugin's own output files older than $olderThan (default: config('document-intelligence.default_retention')) from its output subdirectory. Pure PHP filesystem cleanup — no native call. Returns the number of files deleted.
Result shapes#
ScanOptions (builder passed to scan())#
ScanOptions::make(20) // maxPages, default 20 ->outputPdf() // bool, default false — also generate a PDF from the captured pages ->recognizeText() // bool, default false — run OCR on each captured page ->imageFormat(ImageFormat::Png) // ImageFormat::Jpeg (default) or ImageFormat::Png ->quality(0.8); // 0.0–1.0 JPEG compression quality; ignored for Png (always lossless)
maxPages has a hardcoded sanity ceiling of 1000 inside the DTO itself (InvalidScanOptionsException above that) — the real, operator-configurable ceiling is config('document-intelligence.max_pages') (default 100), enforced by DocumentIntelligence::scan() itself: a requested maxPages above the configured limit is silently clamped down to it before the native call is made, it does not throw.
Build one ScanOptions per scan() call — it's a fluent value object, not meant to be shared/reused across calls.
DocumentScanResult (returned by result())#
final readonly class DocumentScanResult{ public string $id; public array $pages; // ScannedPage[] public ?string $pdfPath; // null unless ScanOptions::outputPdf() was set public function pageCount(): int;}
DocumentScanResult::fromFixture('invoice') loads the bundled resources/fixtures/invoice.json fixture for deterministic tests — see Testing.
ScannedPage#
final readonly class ScannedPage{ public int $index; public string $imagePath; // always inside config('document-intelligence.storage_root') public int $widthPixels; public int $heightPixels; public int $rotationDegrees; // informational; normally 0 — the scanner already corrected orientation public ?OcrPage $ocr; // only populated when ScanOptions::recognizeText() was set}
$ocr is null on every page unless the ScanOptions passed to scan() had ->recognizeText() enabled.
The OCR hierarchy — OcrResult → OcrPage → OcrBlock → OcrLine → OcrElement#
final readonly class OcrResult{ public array $pages; // OcrPage[] public ?string $recognizedLanguage; public function fullText(): string; // every page's text joined with a blank line between pages} final readonly class OcrPage{ public int $index; public string $text; public array $blocks; // OcrBlock[] public int $widthPixels; public int $heightPixels; public ?string $recognizedLanguage;} final readonly class OcrBlock // a paragraph-like grouping of lines{ public string $text; public array $lines; // OcrLine[] public BoundingBox $boundingBox; public ?float $confidence;} final readonly class OcrLine{ public string $text; public array $elements; // OcrElement[] public BoundingBox $boundingBox; public ?float $confidence;} final readonly class OcrElement // the smallest unit — typically a single word{ public string $text; public BoundingBox $boundingBox; public ?float $confidence;}
Blocks/lines/elements are grouped the way the platform's OCR engine grouped them as documented design intent, not something this plugin infers or re-groups itself — treat the exact grouping boundaries as platform behavior, not a guaranteed cross-platform algorithm.
confidence is nullable at every level (OcrBlock, OcrLine, OcrElement) — never fabricated when the platform doesn't supply one. Per the DTOs' own docblocks, iOS Vision is documented to always supply a confidence value; ML Kit on Android is documented to not supply one for every level. Don't assume confidence is present; always null-check before using it.
A worked example, summarizing the shape of the shipped resources/fixtures/invoice.json fixture (one page, three text blocks — an invoice number, a "Bill To" line, and a total — each with one line and 2–4 word-level elements, all with bounding boxes and confidence scores):
$result = DocumentScanResult::fromFixture('invoice');$page = $result->pages[0]; $page->ocr->text; // "INVOICE #1042\nBill To: Acme Corp\nTotal: $450.00"$page->ocr->blocks[0]->text; // "INVOICE #1042"$page->ocr->blocks[0]->confidence; // 0.98$page->ocr->blocks[0]->lines[0]->elements[0]->text; // "INVOICE"$page->ocr->blocks[0]->boundingBox->x; // 0.1 (normalized, top-left origin)
Bounding box coordinates#
BoundingBox ($x, $y, $width, $height) is always normalized 0.0–1.0, origin top-left — matching both Vision's and ML Kit's own normalized-rect convention — never raw pixels. Values are relative to the already-upright, already-rotation-corrected page image (see ScannedPage::$rotationDegrees, normally 0) — a box is never expressed relative to the original, unrotated capture.
To convert to pixel coordinates, multiply by the containing page's widthPixels/heightPixels:
$pixelX = $boundingBox->x * $ocrPage->widthPixels;$pixelY = $boundingBox->y * $ocrPage->heightPixels;$pixelWidth = $boundingBox->width * $ocrPage->widthPixels;$pixelHeight = $boundingBox->height * $ocrPage->heightPixels;
Platform behavior#
The bridge function names, event names, and DTO shapes below are the fixed contract between this package and the native plugin code (resources/ios/, resources/android/). The native implementation is being developed in parallel with this documentation, so deeper mechanics — exactly how blocks are grouped, exactly when a confidence score is or isn't present — are documented design intent, not independently verified behavior.
| Behavior | iOS | Android |
|---|---|---|
| Scanner UI | VisionKit (VNDocumentCameraViewController per this package's own CHANGELOG.md) |
ML Kit Document Scanner |
| OCR engine | Vision | ML Kit Text Recognition |
| Minimum OS version | 18.0 | API 26 |
| Camera permission | NSCameraUsageDescription (declared in nativephp.json, applied automatically) |
Handled internally by the platform's own scanner UI — nativephp.json's Android permissions array is currently empty in this repository. Native Android permission handling was still being implemented in parallel with this documentation at the time of writing; check nativephp.json in your installed version for the current, authoritative list rather than assuming none is required. |
| Confidence scores | Documented to be supplied at every OCR level | Documented to not be supplied at every level — confidence is nullable for exactly this reason |
| Processing location | On-device only | On-device only |
Because the native scanner/OCR implementation is still being built out, treat scan/OCR output fidelity (exact crop quality, exact text-recognition accuracy, exact block-grouping boundaries) as design intent until you've verified it against a real build on each platform. The Testing section, by contrast, is fully accurate today — it exercises real, passing PHP code, and this documentation has not been verified against a running device or simulator build.
Security#
Every local filesystem path this plugin touches — an existing image passed to recognize(), and everything this plugin itself writes under its output subdirectory — is validated by PARTek\DocumentIntelligence\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('document-intelligence.storage_root')(UnsafePathExceptionotherwise,InvalidImageSourceExceptionif it simply doesn't exist or isn't readable). storage_rootdefaults to the app's whole private storage tree (storage_path()), not just this plugin's own output — so an app can OCR an image it already keeps elsewhere in its own storage, not just this plugin's own captures. Narrow it inconfig/document-intelligence.phpif you want to restrict this plugin to a smaller subtree.- Output goes under
output_subdirectory(defaultapp/document-intelligence), one subdirectory per scan id. max_pages(config, default 100) is the real, operator-configurable ceiling onScanOptions::maxPages()— enforced by the manager'sscan()method, which silently clamps an over-limit request down to it before the native call.max_image_dimension(config, default 8000px) bounds the width/heightrecognize()will accept for an existing image source.- On-device processing only. Per the product requirements this plugin was built against, no document image or OCR text is uploaded anywhere by this plugin — both scanning and recognition run entirely through Apple Vision / Google ML Kit on the device itself. If your application chooses to transmit a scan or OCR result elsewhere (e.g. uploading a captured PDF to your own backend), that is explicit application code you write — see Uploading a result in
examples/.
See SECURITY.md for the full attack-surface writeup and how to report a vulnerability.
Testing with the fake#
DocumentIntelligence::fake() swaps the bound manager for FakeDocumentIntelligence, which records every scan()/recognize()/cancel() call instead of crossing the native bridge. Unlike a typical fake, result() and recognize() return the bundled invoice fixture by default (not null) — useful for exercising your UI without any extra setup. PathGuard validation still runs for recognize() — that's plain PHP logic, not a native call — so the fake still catches an unsafe or missing path in a test.
use PARTek\DocumentIntelligence\DTO\DocumentScanResult;use PARTek\DocumentIntelligence\DTO\OcrPage;use PARTek\DocumentIntelligence\DTO\OcrResult;use PARTek\DocumentIntelligence\DTO\ScanOptions;use PARTek\DocumentIntelligence\Facades\DocumentIntelligence; it('scans with the requested options', function () { DocumentIntelligence::fake(); DocumentIntelligence::scan(ScanOptions::make(10)->outputPdf()); DocumentIntelligence::assertScanned(fn (array $params) => $params['options']['max_pages'] === 10);}); it('result() returns the invoice fixture by default', function () { DocumentIntelligence::fake(); $result = DocumentIntelligence::result('any-scan-id'); expect($result->id)->toBe('fixture-invoice');}); it('withScanResult() overrides the default fixture', function () { $fake = DocumentIntelligence::fake(); $fake->withScanResult(DocumentScanResult::fromArray(['id' => 'custom-scan', 'pages' => []])); expect(DocumentIntelligence::result('any-id')->id)->toBe('custom-scan');}); it('withOcrResult() overrides the default OCR result', function () { $fake = DocumentIntelligence::fake(); $fake->withOcrResult(new OcrResult([new OcrPage(0, 'custom text', [], 10, 10)])); expect(DocumentIntelligence::recognize('path.jpg')->fullText())->toBe('custom text');}); it('reports failure when the native side rejects the call', function () { $fake = DocumentIntelligence::fake()->failing(); expect(DocumentIntelligence::scan(ScanOptions::make()))->toBeNull(); $fake->assertScanned(); // still recorded as attempted, even though it "failed"}); it('asserts nothing was scanned', function () { DocumentIntelligence::fake(); DocumentIntelligence::assertNothingScanned();});
| Assertion | Checks |
|---|---|
assertScanned(?callable $callback = null) |
A scan() call happened; the optional callback receives the call's params array (e.g. $params['options']['max_pages']) to narrow the match. |
assertRecognized(?callable $callback = null) |
A recognize() call happened; the optional callback receives the resolved image path. |
assertCancelled(?string $scanId = null) |
A cancel() call happened; pass a scan id to require that specific one. |
assertNothingScanned() |
No scan()/recognize()/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#
recognize() throws UnsafePathException. The path you gave resolves outside config('document-intelligence.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/document-intelligence.php if that's the intended location.
recognize() throws InvalidImageSourceException. The path exists inside storage_root but the file itself is missing, unreadable, or is a directory rather than a file.
result($scanId) returns null. Either the scan id is unknown to native code, or its result has expired/been purged (e.g. by purgeTemporaryFiles() or by the app being restarted long after the scan). There is no way to recover a purged result — the user needs to scan again.
scan() returns null immediately. The native call was never accepted — check that the plugin is registered (php artisan native:plugin:register partek/document-intelligence) 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 ScanFailed, which means it did get through and then failed natively.
The scanner is unsupported on this device. Expect a ScanFailed (or, depending on how the OS surfaces it, a ScanCancelled) rather than a distinct "unsupported" event from this plugin — check errorCode/errorMessage on ScanFailed for whatever detail the native layer provides.
ScanOptions::maxPages()/quality() throws InvalidScanOptionsException immediately. These are PHP-side sanity checks (maxPages 1–1000, quality 0.0–1.0) that run before any native call — they're intentionally stricter than nothing, but looser than config('document-intelligence.max_pages'), which is enforced separately (by clamping, not throwing) inside scan().
Performance & limits#
max_pages(config, default 100) is the real ceiling on how many pages a singlescan()can request —ScanOptions::maxPages()only enforces a much higher sanity ceiling (1000) on its own; the manager clamps down to the configured value.max_image_dimension(config, default 8000px) bounds the pixel dimensionsrecognize()will accept for an existing image source.- Per the product requirements this plugin was built against, the native layer is expected to avoid loading every full-resolution page into memory at once when handling a multi-page scan or a large
recognize()source — this is a design requirement for the native implementation (still in progress alongside this documentation), not something verified here against running code.
Complete example#
See examples/ for a full NativeComponent screen (DocumentScanScreen.php + Blade view) that calls scan(), listens for the four outcome events, fetches the result, and renders scanned pages and OCR text — including a commented-out illustration of how an app would explicitly upload or persist a result, since this plugin never does that itself.
License#
Proprietary commercial. See LICENSE.