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.

Maintainers

Package info

github.com/SandipGhimire/NativePHP-MobileBiometrics

Type:nativephp-plugin

pkg:composer/sghimire/mobile-biometric

Transparency log

Statistics

Installs: 9

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-07-26 09:17 UTC

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 Completed Laravel event you can listen to, or bind straight to a Livewire method with #[OnNative].
  • A ready-made biometric.verified route 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.0
  • livewire/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.

  1. Request out. 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.
  2. Immediate ack. That call returns right away with {"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.
  3. 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-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().

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 booltrue 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