sghimire / mobile-biometric
Self-contained native biometric authentication (Face ID / Touch ID / Android BiometricPrompt) for NativePHP Mobile — own facade, fluent API, events, and JS bindings, with no dependency on the paid nativephp/mobile-biometrics plugin.
Package info
github.com/SandipGhimire/NativePHP-MobileBiometrics
Type:nativephp-plugin
pkg:composer/sghimire/mobile-biometric
Requires
- php: ^8.2
- nativephp/mobile: ^3.0
Requires (Dev)
- laravel/pint: ^1.29
- pestphp/pest: ^3.0
Suggests
- livewire/livewire: Required only if you use the #[OnNative] attribute to bind Completed events directly to component methods.
This package is auto-updated.
Last update: 2026-07-26 19:25:41 UTC
README
Native Face ID / Touch ID / Android BiometricPrompt authentication for NativePHP Mobile apps.
This package is a free, self-contained alternative to the paid nativephp/mobile-biometrics plugin. It ships its own Laravel facade, a fluent prompt builder, a Laravel event, JS/TypeScript bindings, and the native Kotlin/Swift implementation — with no dependency on the paid plugin.
Features
- One call to trigger the device's native biometric prompt (Face ID, Touch ID, or Android fingerprint/face unlock).
- Fluent, chainable prompt builder in both PHP and JavaScript.
- Optional fallback to the device PIN/pattern/password (
allowDeviceCredential). - A
CompletedLaravel event you can listen to, or bind straight to a Livewire method with#[OnNative]. - A ready-made
biometric.verifiedroute middleware for gating routes behind a successful unlock. - Works from PHP (Blade/Livewire) and from JavaScript (Vue, React, Inertia, or plain JS).
Requirements
- PHP ^8.2
nativephp/mobile^3.0livewire/livewire— only needed if you use the#[OnNative]attribute
Installation
composer require sghimire/mobile-biometric
Laravel's package auto-discovery registers BiometricsServiceProvider for you. Then register the plugin with NativePHP:
php artisan native:plugin:register
This wires up the plugin's nativephp.json manifest (bridge functions, Android permission, iOS NSFaceIDUsageDescription) into your native build. Rebuild/reinstall the native shell afterwards (php artisan native:install or native:run) so the permission and bridge function are picked up.
How It Works (Under the Hood)
The prompt request and its result travel two different paths — a synchronous "start" call, then an asynchronous result delivered back through two independent channels at once.
- Request out.
Biometrics::prompt()->prompt()(PHP) andBiometrics.prompt()(JS) both reach the same native bridge — JS viafetch('/_native/api/call', { method: 'Biometrics.Prompt', params }), PHP via the globalnativephp_call('Biometrics.Prompt', json_encode($params)). The bridge router matches"Biometrics.Prompt"toBiometricFunctions.Prompt(Kotlin on Android, Swift on iOS), which shows the OS biometric UI. - Immediate ack. That call returns right away with
{"started": true}— this is theboolyou get back from->prompt()/ the resolved value ofawait Biometrics.prompt(). It only confirms the OS prompt appeared, not that the user authenticated. - Result comes back later, twice. Once the user finishes (or cancels), the native side injects one script into the app's webview that does two independent things: fires a
native-eventCustomEventondocument(what the JSOn()/Off()helpers listen for), and makes a second call back into Laravel that instantiates and dispatches the realCompletedevent (whatEvent::listen()/#[OnNative]pick up).
PHP and JS each get their own notification of the same result — you don't need one side to relay to the other, and both can listen independently in the same app.
Because the result is asynchronous, always drive your UI from the Completed event/listener — never from the return value of prompt().
PHP Usage
The Biometrics facade
use Sandip\Biometric\Native\Facades\Biometrics; // Show the native biometric prompt with defaults Biometrics::prompt()->prompt(); // Fully configured Biometrics::prompt() ->id('checkout-auth') // correlate this prompt with its Completed event ->title('Confirm Payment') ->subtitle('Use Face ID to approve this purchase') ->cancelText('Not now') ->allowDeviceCredential() // let the user fall back to PIN/pattern/password ->prompt();
prompt() on the builder returns bool — true once the request reached the native bridge, false if it couldn't be started (e.g. running outside the native shell, or the call was already started once). It does not tell you whether the user was actually authenticated; wait for the Completed event for that.
If you never call ->prompt() explicitly, it fires automatically when the builder object is destructed (e.g. goes out of scope) — so Biometrics::prompt()->title('Unlock'); alone is enough to trigger it. Calling ->prompt() yourself is recommended so you can check the return value.
Builder methods
| Method | Description |
|---|---|
id(string $id) |
Custom correlation ID for this prompt (auto-generated UUID if omitted). |
title(string $title) |
Prompt title shown to the user. |
subtitle(string $subtitle) |
Prompt subtitle/description. |
cancelText(string $text) |
Label for the cancel button. |
allowDeviceCredential(bool $allow = true) |
Allow falling back to device PIN/pattern/password. |
event(string $eventClass) |
Dispatch a custom event class instead of the default Completed (must exist). |
remember() |
Flash the prompt's ID into the session so it survives a redirect; retrieve it later with PendingBiometricPrompt::lastId(). |
getId() |
Get (or lazily generate) this prompt's correlation ID. |
prompt() |
Send the prompt request to the native bridge. Returns bool. |
Tracking the app's unlock state
Biometrics also tracks a simple, process-lifetime "is this session unlocked" flag — handy for gating a whole app session behind one biometric check rather than checking per-action:
use Sandip\Biometric\Native\Facades\Biometrics; Biometrics::unlock(); // mark the app as unlocked Biometrics::lock(); // mark the app as locked again Biometrics::isUnlocked(); // bool
Typically you'd call Biometrics::unlock() inside your Completed event listener once success is true.
Listening for the result
use Sandip\Biometric\Native\Events\Biometric\Completed; use Illuminate\Support\Facades\Event; Event::listen(function (Completed $event) { $event->success; // bool $event->id; // ?string — matches the id you passed to ->id(), if any $event->message; // ?string — error message when success is false (null on user cancel) if ($event->success) { \Sandip\Biometric\Native\Facades\Biometrics::unlock(); } });
Livewire: #[OnNative]
Bind the event straight to a component method instead of registering a listener manually:
use Livewire\Component; use Sandip\Biometric\Native\Attributes\OnNative; use Sandip\Biometric\Native\Events\Biometric\Completed; use Sandip\Biometric\Native\Facades\Biometrics; class UnlockGate extends Component { public function requestUnlock(): void { Biometrics::prompt()->title('Unlock App')->prompt(); } #[OnNative(Completed::class)] public function onBiometricCompleted(bool $success, ?string $id, ?string $message): void { if ($success) { Biometrics::unlock(); $this->redirect(route('dashboard')); } } }
Route middleware
Protect routes behind a successful unlock with the bundled biometric.verified middleware alias:
use Illuminate\Support\Facades\Route; Route::middleware('biometric.verified')->group(function () { Route::get('/wallet', WalletController::class); }); // Optional: redirect to a named route instead of '/' when locked Route::middleware('biometric.verified:login')->group(function () { Route::get('/settings/security', SecuritySettingsController::class); });
The middleware checks Biometrics::isUnlocked() and redirects if it's false — pair it with the Completed listener pattern above so unlock() gets called once the user authenticates.
JavaScript Usage
Importing
This package doesn't publish a #nativephp import alias (that's reserved for NativePHP's first-party plugins). Import the file directly — either from the vendor path, or copy it into your own resources/js/ and import it from there:
import { Biometrics, On, Off, Events } from '../../vendor/sghimire/mobile-biometric/resources/js/biometric.js';
Full TypeScript types are included in biometric.d.ts alongside it, so editors get autocomplete either way.
Basic prompt
Biometrics.prompt() returns a thenable builder — await it directly, or chain builder methods first:
import { Biometrics } from '../../vendor/sghimire/mobile-biometric/resources/js/biometric.js'; await Biometrics.prompt(); // or fully configured await Biometrics.prompt() .id('checkout-auth') .title('Confirm Payment') .subtitle('Use Face ID to approve this purchase') .cancelText('Not now') .allowDeviceCredential();
Resolving just means the native prompt was shown, exactly like the PHP side — listen for the Completed event to get the actual result.
Listening for events
import { Biometrics, On, Off, Events } from '../../vendor/sghimire/mobile-biometric/resources/js/biometric.js'; function handleCompleted(payload) { // payload: { success: boolean, id: string | null, message: string | null } if (payload.success) { console.log('Unlocked!', payload.id); } } On(Events.Biometric.Completed, handleCompleted); // later, e.g. on component unmount Off(Events.Biometric.Completed, handleCompleted);
Vue 3 example
<script setup> import { ref, onMounted, onUnmounted } from 'vue'; import { Biometrics, On, Off, Events } from '../../vendor/sghimire/mobile-biometric/resources/js/biometric.js'; const unlocked = ref(false); function handleCompleted(payload) { unlocked.value = payload.success; } onMounted(() => On(Events.Biometric.Completed, handleCompleted)); onUnmounted(() => Off(Events.Biometric.Completed, handleCompleted)); async function unlock() { await Biometrics.prompt() .title('Unlock App') .subtitle('Confirm it\'s you') .allowDeviceCredential(); } </script> <template> <button @click="unlock">Unlock with biometrics</button> <p v-if="unlocked">✅ Unlocked</p> </template>
React example
import { useState, useEffect, useCallback } from 'react'; import { Biometrics, On, Off, Events } from '../../vendor/sghimire/mobile-biometric/resources/js/biometric.js'; export function UnlockButton() { const [unlocked, setUnlocked] = useState(false); useEffect(() => { const handleCompleted = (payload) => setUnlocked(payload.success); On(Events.Biometric.Completed, handleCompleted); return () => Off(Events.Biometric.Completed, handleCompleted); }, []); const unlock = useCallback(() => { Biometrics.prompt() .title('Unlock App') .subtitle("Confirm it's you") .allowDeviceCredential(); }, []); return ( <> <button onClick={unlock}>Unlock with biometrics</button> {unlocked && <p>✅ Unlocked</p>} </> ); }
JS API reference
| Export | Signature | Description |
|---|---|---|
Biometrics.prompt() |
() => PendingBiometricPrompt |
Start building a prompt. |
.id(id) |
(string) => this |
Custom correlation ID. |
.title(title) |
(string) => this |
Prompt title. |
.subtitle(subtitle) |
(string) => this |
Prompt subtitle. |
.cancelText(text) |
(string) => this |
Cancel button label. |
.allowDeviceCredential(allow?) |
(boolean = true) => this |
Allow PIN/pattern/password fallback. |
On(event, callback) |
(string, (payload, eventName) => void) => void |
Subscribe to a native event. |
Off(event, callback) |
(string, (payload, eventName) => void) => void |
Unsubscribe. |
Events.Biometric.Completed |
string |
Event name constant for the Completed event. |
await-ing (or .then-ing) a PendingBiometricPrompt sends the request to the native bridge exactly once — awaiting it twice is a no-op the second time.
Events reference
Completed
Dispatched once, asynchronously, after the user finishes (or cancels) the biometric prompt.
| Property | Type | Description |
|---|---|---|
success |
bool / boolean |
Whether authentication succeeded. |
id |
?string |
The correlation ID from .id(), if one was set. |
message |
?string |
Error message on failure; null for a user-initiated cancel. |
- PHP class:
Sandip\Biometric\Native\Events\Biometric\Completed - JS event name constant:
Events.Biometric.Completed
Implementation Guide: Building a Biometric Unlock Gate
A common pattern: lock part of the app (e.g. /wallet) behind a biometric check that resets every time the app is opened. Here's the full flow — Blade/Livewire first, then the JS-only equivalent.
1. Protect the route
// routes/web.php use Illuminate\Support\Facades\Route; Route::middleware('biometric.verified:login')->group(function () { Route::get('/wallet', WalletController::class); });
Anyone hitting /wallet without a matching unlock() call in this process's lifetime gets redirected to the login named route.
2. Reset the lock on app start
Call this once when the app boots (e.g. AppServiceProvider::boot()) so every fresh launch starts locked:
use Sandip\Biometric\Native\Facades\Biometrics; Biometrics::lock();
3a. Livewire lock screen
// app/Livewire/UnlockGate.php namespace App\Livewire; use Livewire\Component; use Sandip\Biometric\Native\Attributes\OnNative; use Sandip\Biometric\Native\Events\Biometric\Completed; use Sandip\Biometric\Native\Facades\Biometrics; class UnlockGate extends Component { public bool $failed = false; public function requestUnlock(): void { $this->failed = false; Biometrics::prompt() ->title('Unlock Wallet') ->subtitle("Confirm it's you to continue") ->allowDeviceCredential() ->prompt(); } #[OnNative(Completed::class)] public function onBiometricCompleted(bool $success): void { if ($success) { Biometrics::unlock(); $this->redirect(route('wallet')); return; } $this->failed = true; } public function render() { return view('livewire.unlock-gate'); } }
{{-- resources/views/livewire/unlock-gate.blade.php --}} <div> <button wire:click="requestUnlock">Unlock Wallet</button> @if ($failed) <p class="text-red-600">Authentication failed — try again.</p> @endif </div>
Tap the button, the native prompt appears, Completed lands on the component via #[OnNative], Biometrics::unlock() flips the flag the middleware checks, and the redirect takes the user straight into the now-unlocked route.
3b. Vue/React SPA lock screen
If your frontend is a JS SPA (Inertia or otherwise) hitting the same /wallet route, drive the prompt from JS and let a PHP-side listener registered once, globally, call Biometrics::unlock() — the JS side only needs to know when to navigate. This works because Completed reaches PHP and JS independently (see How It Works).
<!-- resources/js/Pages/UnlockGate.vue --> <script setup> import { ref, onMounted, onUnmounted } from 'vue'; import { router } from '@inertiajs/vue3'; import { Biometrics, On, Off, Events } from '../../vendor/sghimire/mobile-biometric/resources/js/biometric.js'; const failed = ref(false); function handleCompleted(payload) { if (payload.success) { router.visit('/wallet'); } else { failed.value = true; } } onMounted(() => On(Events.Biometric.Completed, handleCompleted)); onUnmounted(() => Off(Events.Biometric.Completed, handleCompleted)); function requestUnlock() { failed.value = false; Biometrics.prompt() .title('Unlock Wallet') .subtitle("Confirm it's you to continue") .allowDeviceCredential(); } </script> <template> <button @click="requestUnlock">Unlock Wallet</button> <p v-if="failed" class="text-red-600">Authentication failed — try again.</p> </template>
// resources/js/Pages/UnlockGate.jsx import { useState, useEffect, useCallback } from 'react'; import { router } from '@inertiajs/react'; import { Biometrics, On, Off, Events } from '../../vendor/sghimire/mobile-biometric/resources/js/biometric.js'; export function UnlockGate() { const [failed, setFailed] = useState(false); useEffect(() => { const handleCompleted = (payload) => { if (payload.success) { router.visit('/wallet'); } else { setFailed(true); } }; On(Events.Biometric.Completed, handleCompleted); return () => Off(Events.Biometric.Completed, handleCompleted); }, []); const requestUnlock = useCallback(() => { setFailed(false); Biometrics.prompt() .title('Unlock Wallet') .subtitle("Confirm it's you to continue") .allowDeviceCredential(); }, []); return ( <> <button onClick={requestUnlock}>Unlock Wallet</button> {failed && <p style={{ color: 'red' }}>Authentication failed — try again.</p>} </> ); }
The PHP-side Biometrics::unlock() call (registered once, e.g. via a global Event::listen(Completed::class, ...) in a service provider) is what actually satisfies the biometric.verified middleware — the JS listener above is purely a UI reaction, not what unlocks the route.
Platform notes
| Android | iOS | |
|---|---|---|
| Min OS version | API 23 | 15.0 |
| Permission | android.permission.USE_BIOMETRIC |
NSFaceIDUsageDescription in Info.plist |
| Native implementation | resources/android/BiometricFunctions.kt (AndroidX BiometricPrompt) |
resources/ios/BiometricFunctions.swift (LocalAuthentication) |
Both are configured automatically by nativephp.json — you don't need to edit native project files by hand.
Testing
composer install
composer test
Outside of a compiled native shell, Biometrics::prompt()->prompt() returns false (there's no bridge to call) — this is expected and is exactly what the test suite asserts.
License
MIT