QR Target

Design your scanner. Don’t accept one.

iOS Android
$ composer require all1web/nativephp-qr-target
QR Target — Design your scanner. Don’t accept one.

Configurable QR and barcode scanning. One call gives you a full-screen scanner, or embed the camera preview inside a screen you designed — your own reticle, live focus and torch control. Results arrive as events rather than a return value.

  • A full-screen scanner in one call, or an embedded camera preview
  • Your own reticle, with live focus and torch control
  • Decode an image you already have with decodeImage()

Documentation

Synced from GitHub 0 seconds ago

QR Target

A QR and barcode scanner for NativePHP Mobile, built to be configured rather than worked around.

Status: pre-release. The PHP layer is tested (81 tests) and the plugin passes native:plugin:validate, but the Kotlin and Swift have not yet been compiled or run on a device. Treat this as unreleased until that happens — see What's left.

  • Every symbology — QR, Aztec, Data Matrix, PDF417, Code 39/93/128, Codabar, ITF, EAN-8/13, UPC-A/E, with a per-scan filter. (iOS live scanning omits Codabar — see Platform differences.)
  • Two presentation modes — a full-screen native scanner with a configurable targeting overlay, or the camera composited behind your WebView so you author the overlay in HTML and CSS.
  • A reticle you can actually style — size, shape, aspect, corner brackets, borders, scan line, pulse, success flash, live detection boxes.
  • Four scan modes — once, continuous, unique, and batch.
  • Live camera control — torch (with intensity on iOS), zoom, lens switching, tap-to-focus, frame capture.
  • Structured payloads — Wi-Fi, vCard, calendar events, geo, email, SMS, and driver's licences decoded into typed objects.
  • Barcode generation — natively rendered, no PHP imaging extension needed.
  • Testable — a recording fake, so scanner code runs in CI without a device.

Android and iOS are close to parity. The differences we know about are listed under Platform differences — it is a list, not a guarantee.


Installation

composer require all1web/nativephp-qr-target

Requires PHP 8.4 and NativePHP Mobile v4. Everything in this package — scanning and generation — runs through the native bridge, so it needs the mobile runtime. Server-side generation is planned but not built; see What's left.

Register the plugin so its native code is compiled in. NativePHP requires this explicitly, so a transitive dependency can't smuggle native code into your build:

php artisan native:plugin:register all1web/nativephp-qr-target

If this is your first plugin, publish the provider first:

php artisan vendor:publish --tag=nativephp-plugins-provider

Optionally publish the config to set app-wide defaults:

php artisan vendor:publish --tag=qr-target-config

Native code changes need a rebuild:

php artisan native:run

Quick start

use All1Web\QrTarget\Facades\QrTarget;

QrTarget::scan();

That gives you a full-screen scanner reading any format once, with a standard reticle and a close button.

Results arrive as events, not as a return value. scan() returns a session id straight away — the user hasn't pointed the camera at anything yet.

use Livewire\Attributes\On;

#[On('native:All1Web\QrTarget\Events\BarcodeScanned')]
public function onScanned(array $barcode): void
{
    $this->ticket = $barcode['value'];
}

A configured scan reads as one chain:

use All1Web\QrTarget\Config\Reticle;
use All1Web\QrTarget\Enums\BarcodeFormat;

QrTarget::formats(BarcodeFormat::QrCode, BarcodeFormat::Ean13)
    ->continuous()
    ->maxResults(10)
    ->dedupeFor(2000)
    ->prompt('Scan each ticket')
    ->reticle(fn (Reticle $r) => $r
        ->size(0.7)
        ->cornerColor('#00E5FF')
        ->cornerLength(28)
        ->scanLine()
        ->pulse())
    ->controls(fn ($c) => $c->torchButton()->galleryButton())
    ->feedback(fn ($f) => $f->vibrate()->beep())
    ->scan();

Narrowing the formats is the single most effective thing you can do for performance and for avoiding false positives. Use qrOnly(), retail(), twoDimensional(), oneDimensional(), or formats(...).


The two presentation modes

Full-screen scanner — scan()

The overlay is drawn natively, which keeps it perfectly in sync with the camera frames. Configure it through reticle(), overlay(), and controls().

Reach for this unless you specifically need HTML on top of the camera.

Embedded preview — startPreview()

The camera is composited behind the WebView, so everything on top of it is your own HTML and CSS.

QrTarget::qrOnly()
    ->bounds(x: 20, y: 120, width: 335, height: 335)
    ->previewCornerRadius(16)
    ->startPreview();

From JavaScript, let the DOM supply the bounds:

import { qrTarget, movePreview, on } from './qrTarget.js'

const window = document.querySelector('#scanner-window')

await qrTarget().qrOnly().behind(window).previewCornerRadius(16).startPreview()

on('PreviewStarted', () => window.classList.add('is-live'))

// Bounds are absolute, so follow the element when the page moves.
addEventListener('scroll', () => movePreview(window), { passive: true })
addEventListener('resize', () => movePreview(window))

The page has to be transparent over the camera. This is the one thing that catches everyone out. An opaque body hides the preview completely:

html, body { background: transparent; }
#scanner-window { background: transparent; }

Wait for PreviewStarted before revealing the transparent region — binding a camera takes a few hundred milliseconds, and until then the user sees a hole in your UI.


Option reference

Formats

Method Notes
formats(...) Accepts BarcodeFormat cases, strings, or arrays.
allFormats() The default.
qrOnly() Fastest and most common.
twoDimensional() QR, Aztec, Data Matrix, PDF417.
oneDimensional() All linear symbologies.
retail() EAN-8/13, UPC-A/E.

Session behaviour

Method Default Notes
once() Ends on the first decode.
continuous() Emits an event per detection until stopped.
unique() Continuous, but never the same value twice.
batch() Everything visible in one frame, returned together.
maxResults(int) 0 Stop after this many.
dedupeFor(ms) 1500 Suppress a repeat within this window. 0 emits every frame.
timeout(ms) 0 End automatically. 0 disables.
confirmBeforeReturn(bool, ?string) false Show the value and ask before finishing.
tag(string) Echoed on every event this session produces.

Camera

Method Default Notes
lens(Lens) / front() / back() back
torch(TorchMode) off Auto enables it when the scene is too dark.
zoom(float) Absolute ratio, clamped to the lens range.
zoomLinear(0..1) Position across the range; safer when you don't know the device.
autoZoom(bool) false Creeps in while nothing decodes. Helps with distant codes.
resolution(Resolution) auto Higher reads denser codes, costs frame rate.
fps(int) uncapped Caps analysis rate. Worth setting for long sessions.
orientation(Orientation) unspecified Full-screen scanner only.
keepScreenOn(bool) true

Detection region

Method Default Notes
restrictToReticle(bool) false Ignore codes outside the reticle.
targetRegion(Rect) Explicit region, normalised 0..1. Overrides the above.

Leaving restrictToReticle off is deliberate: a code the user can see but the app refuses to read is confusing unless the reticle makes the rule obvious.

Result payload

Method Default Notes
includeCorners(bool) false Corner points and bounding box, normalised 0..1.
includeImage(bool) false Saves the frame. You own the file — see below.
includeRawBytes(bool) false Base64 of the original bytes, for binary payloads.
parseStructured(bool) true Wi-Fi, vCard, calendar, geo, and so on.

Reticle

Everything on Reticle, via reticle(fn ($r) => ...):

visible() hidden() size() dimensions() aspectRatio() wide() shape() circle() align() offset() cornerColor() cornerLength() cornerThickness() cornerRadius() cornersOnly() border() scanLine() pulse() successFlash() highlightDetected() raw()

size() takes a fraction of the shorter screen edge when ≤ 1, or density- independent pixels when larger — so 0.7 and 280 both do what you'd expect.

Overlay

scrim() noScrim() title() prompt() counter() background() raw()

Controls

torchButton() lensButton() closeButton() galleryButton() doneButton() all() none() pinchToZoom() tapToFocus() doubleTapToZoom() dismissible() tint() activeTint() buttonBackground() buttonSize() labels() raw()

Only the close button is on by default — a full-screen scanner the user can't dismiss is a trap.

Enabling a button does not check the hardware. Ask capabilities() first and only enable what the device supports, or a torchless phone shows a torch button that does nothing when tapped.

Feedback

vibrate() beep() speak() all() none() raw()

Unlike the overlay options, feedback applies to embedded previews too.

Colours

Every colour option accepts #RGB, #RGBA, #RRGGBB, #RRGGBBAA, rgb(), rgba(), the CSS named colours, and transparent. They're normalised for you — don't pre-convert to Android's #AARRGGBB argument order.

Escape hatch

Every builder has raw(array), which merges keys straight through to the native side. If something isn't exposed yet, you aren't blocked waiting for a release.


Events

All under All1Web\QrTarget\Events\, listened to as native:All1Web\QrTarget\Events\<Name>.

Event When
BarcodeScanned Every decode, in every mode. Do your work here.
ScanCompleted Session ended with at least one result.
ScanCancelled Session ended with none. A normal outcome, not an error.
ScanFailed Something went wrong. Match on $event->code.
PreviewStarted Camera bound; carries the device capabilities.
PreviewStopped Camera released.
TorchChanged / ZoomChanged / LensChanged Live state — keep your own controls in sync.
FrameCaptured A still was written.
PermissionResult The user answered the prompt.

In continuous mode, act on BarcodeScannedScanCompleted only arrives when the whole session ends.

Events carry the raw native payload plus typed accessors:

public function onScanned(array $barcode, string $sessionId, ?string $tag): void
{
    $event = new BarcodeScanned($barcode, $sessionId, $tag);

    $code = $event->decoded();          // Support\Barcode
    $wifi = $code->wifi();              // Support\Parsed\Wifi|null
    $point = $code->boundingBox?->centre();
}

Permissions

Starting a scan prompts automatically. Ask separately only when you want to explain why first — it measurably improves grant rates.

if (! QrTarget::hasPermission()) {
    QrTarget::requestPermission(); // outcome arrives as PermissionResult
}

if (QrTarget::permissionStatus()->requiresSettings()) {
    QrTarget::openSettings();
}

Live camera control

Valid only while a session is running; otherwise they fail with NO_SESSION.

QrTarget::torchOn();
QrTarget::setTorch(TorchMode::Toggle, level: 0.5); // level is iOS only
QrTarget::setZoom(2.0);
QrTarget::setLens(Lens::Toggle);
QrTarget::focusAt(0.5, 0.5);       // normalised 0..1
QrTarget::captureFrame();          // arrives as FrameCaptured
QrTarget::pause();                 // keeps the camera warm
QrTarget::resume();
QrTarget::cancel();

Query the hardware before rendering controls:

$capabilities = QrTarget::capabilities();

if ($capabilities->hasTorch) { /* show a torch button */ }
if ($capabilities->canSwitchLens()) { /* show a lens switcher */ }

Decoding an existing image

No camera, no permission:

$barcodes = QrTarget::decodeImage('/path/to/photo.jpg');
$barcodes = QrTarget::decodeImage($base64);
$barcodes = QrTarget::decodeImage('content://media/…');

Generating barcodes

Rendered natively — ZXing on Android, Core Image plus hand-rolled encoders on iOS — so no PHP imaging extension is required.

use All1Web\QrTarget\Enums\ErrorCorrection;

$path = QrTarget::generate('https://example.com')
    ->size(512)
    ->margin(4)
    ->errorCorrection(ErrorCorrection::High)
    ->foreground('#101828')
    ->background('#FFFFFF')
    ->logo(storage_path('app/mark.png'), scale: 0.2)
    ->save();

$uri = QrTarget::generate('SKU-12345')->toDataUri();          // for <img src>
$svg = QrTarget::generate('x')->imageFormat(ImageFormat::Svg)->save();

A logo occludes part of the payload, so raise the error correction to compensate — High recovers about 30%, comfortably covering the default 20% logo. It isn't raised automatically, because that would silently change the code's density.

1D formats validate their payloads and report INVALID_PAYLOAD rather than producing an unscannable image — EAN-13 needs 12 or 13 digits, Code 39 rejects characters outside its alphabet.


Testing

use All1Web\QrTarget\Facades\QrTarget;

QrTarget::fake([
    'QrTarget.Scan' => ['sessionId' => 'test-session'],
]);

$this->get('/checkout/scan');

expect(QrTarget::recorded()[0]['parameters']['formats'])->toBe(['qr_code']);

The package's own suite runs without a device:

composer test

It includes manifest tests that fail if a declared bridge function has no native implementation, or if the two platforms disagree about event names.


Where this package runs

Runtime Camera scanning Barcode generation
NativePHP Mobile (iOS / Android) ✅ on-device
Plain Laravel server (planned for v0.3)
NativePHP Desktop (planned for v0.3) — will behave like a server
Browser ❌ — webcam scanning is out of scope

Today both halves need the native bridge. The server column turns green when milestone M1 lands; until then a generate() call off-device throws.

Ask the runtime rather than guessing:

QrTarget::isNative();   // the NativePHP Mobile runtime is present
QrTarget::canScan();    // camera scanning is possible here

isSupported() still works as a deprecated alias of isNative() and is removed at 1.0. It was renamed because "supported" stopped being one question once generation started working off-device.

Off-device behaviour

Outside a NativePHP runtime — php artisan serve, a queue worker, a test — every call throws NotRunningNativelyException, generation included, because a silent no-op is far harder to debug than a loud failure. There is one bridge and it has no per-function exemptions.

For a codebase shared between web and mobile, either guard your calls:

if (QrTarget::canScan()) {
    QrTarget::scan();
}

…or set qr-target.strict to false and let them become no-ops.


Platform differences

Everything works on both platforms except:

Android iOS
Torch intensity On/off only (CameraX exposes no level) Variable 0..1
Structured parsing ML Kit Parsed by this plugin, so exotic variants may differ
Generation All formats (ZXing) All except Data Matrix, Code 93, ITF, Codabar, UPC-E
UPC-A detection Native format Reported as EAN-13 with a leading zero (Vision has no UPC-A symbology)
Denied permission May re-prompt Never re-prompts; Settings is the only route

QrTarget::capabilities() reports the truth for the device in hand — prefer it over assuming from this table.


Notes worth reading once

  • includeImage() files are yours. Full-resolution frames land in the app's cache directory and are not cleaned up. Delete them when you're done.
  • dedupeFor() matters in continuous mode. Without it, holding the camera over one code fires an event every frame. It defaults to 1500ms.
  • Wi-Fi and licence payloads are sensitive. Wifi redacts its password and DriverLicense its licence number when serialised, but the raw properties are readable. Keep them out of logs, and only decode licences where you have a lawful basis.
  • A generated Wi-Fi code embeds the password in plain text. Anyone who can photograph it has the credentials. Fine for a printed guest-network card; think twice about anything else.
  • restrictToReticle needs a visible reticle to make sense to the user.

What's left

Honest status, so nobody is surprised.

Done and verified

  • The mobile scanner: 19 bridge functions, 11 events, Android (CameraX + ML Kit) and iOS (AVFoundation + Vision) at feature parity.
  • The PHP layer: 81 tests, 409 assertions.
  • native:plugin:validate passes.
  • Packaging: installs on Laravel 11, 12 and 13.

Not yet done

Native code has never been compiled ~8,600 lines of Kotlin and Swift. Needs php artisan native:run with the Android SDK and Xcode. Expect to fix compile errors on the first pass.
Never run on a device No scan has ever happened. Every behavioural claim in this README is derived from the source, not observed.
Server-side generation Planned — milestone M1 in docs/PLAN.md.
Shared payload vocabulary Planned — M2.
Signed, offline-verifiable codes Planned — M3.

chillerlan/php-qrcode is currently a required dependency that nothing uses; it lands properly with M1.


Licence

MIT — see LICENSE.