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.
sghimire/mobile-biometric is a Laravel package for 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..
It currently has 1 GitHub stars and 12 downloads on Packagist (latest version 1.0.1).
Install it with composer require sghimire/mobile-biometric.
Discover more Laravel packages by sghimire
or browse all Laravel packages to compare alternatives.
Last updated
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.
allowDeviceCredential).Completed Laravel event you can listen to, or bind straight to a Livewire method with #[OnNative].biometric.verified route middleware for gating routes behind a successful unlock.Authenticator is a full example app built using this plugin
nativephp/mobile ^3.0livewire/livewire — only needed if you use the #[OnNative] attributecomposer 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.
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.
Biometrics::prompt()->prompt() (PHP) and Biometrics.prompt() (JS) both reach the same native bridge — JS via fetch('/_native/api/call', { method: 'Biometrics.Prompt', params }), PHP via the global nativephp_call('Biometrics.Prompt', json_encode($params)). The bridge router matches "Biometrics.Prompt" to BiometricFunctions.Prompt (Kotlin on Android, Swift on iOS), which shows the OS biometric UI.{"started": true} — this is the bool you get back from ->prompt() / the resolved value of await Biometrics.prompt(). It only confirms the OS prompt appeared, not that the user authenticated.native-event CustomEvent on document (what the JS On()/Off() helpers listen for), and makes a second call back into Laravel that instantiates and dispatches the real Completed event (what Event::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().
Biometrics facadeuse 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.
| 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. |
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.
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();
}
});
#[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'));
}
}
}
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.
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.
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.
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);
<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>
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>}
</>
);
}
| 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.
CompletedDispatched 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. |
Sandip\Biometric\Native\Events\Biometric\CompletedEvents.Biometric.CompletedA 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.
// 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.
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();
// 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.
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.
| | 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.
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.
MIT