NFC Plugin for NativePHP Mobile#
A full-featured NFC plugin for NativePHP Mobile. Read and write NFC tags on both iOS and Android with support for URLs, plain text, vCard contacts, JSON/MIME data, Android Application Records, tag erasure, and continuous scanning. Includes tag hardware info, platform-aware session options, typed events, and comprehensive error handling.
License: proprietary — see License and LICENSE.md before use.
Installation#
composer require weswecan/nfc php artisan native:plugin:register weswecan/nfc
JavaScript Setup#
Add the #nfc import mapping to your project's package.json:
{ "imports": { "#nfc": { "types": "./vendor/weswecan/nfc/resources/js/dist/index.d.ts", "default": "./vendor/weswecan/nfc/resources/js/dist/index.js" } }}
Quick Start#
JavaScript (Vue/React/Inertia)#
import { nfc, NfcEvent, NfcErrorCode, NfcTagType } from '#nfc'; // Check if NFC is availableconst status = await nfc.isAvailable(); if (status.available && status.enabled) { // Write URL to NFC tag await nfc.writeUrl('https://example.com'); // Or read from NFC tag await nfc.read();} else if (status.available && !status.enabled) { // Open settings to enable NFC (Android only — see platform notes) await nfc.openSettings();} // Listen for successful writenfc.on(NfcEvent.UrlWritten, (data) => { console.log('URL written:', data.url);}); // Listen for tag read with tag infonfc.on(NfcEvent.TagRead, (data) => { console.log('Content:', data.content); console.log('Type:', data.type); if (data.tagInfo) { console.log('Capacity:', data.tagInfo.capacity, 'bytes'); console.log('Writable:', data.tagInfo.isWritable); }}); // Listen for errorsnfc.on(NfcEvent.Error, async (data) => { if (data.code === NfcErrorCode.NfcDisabled) { await nfc.openSettings(); // Android only }});
PHP (Livewire/Blade)#
use Nativephp\Nfc\Facades\Nfc; $status = Nfc::isAvailable(); if ($status->available && $status->enabled) { Nfc::writeUrl('https://example.com');}
Platform Support Matrix#
| Feature | iOS | Android | Notes |
|---|---|---|---|
isAvailable() |
✅ | ✅ | See platform notes below |
read() |
✅ | ✅ | |
writeUrl() |
✅ | ✅ | |
writeText() |
✅ | ✅ | |
writeRecord() - text |
✅ | ✅ | |
writeRecord() - uri |
✅ | ✅ | |
writeRecord() - vcard |
✅ | ✅ | iOS can write but not read vCards — see below |
writeRecord() - mime |
✅ | ✅ | |
writeRecord() - aar |
❌ | ✅ | Android Application Record |
writeUrlWithAar() |
✅* | ✅ | *iOS writes URL only, ignores AAR |
erase() |
✅ | ✅ | Writes empty NDEF message |
cancel() |
✅ | ✅ | |
openSettings() |
⚠️ | ✅ | iOS: opens Settings app but NFC cannot be toggled — not useful |
isIos() / isAndroid() |
✅ | ✅ | Platform detection |
| Tag Info | ✅ | ✅ | Capacity, writable, technologies |
| Tag ID | ❌ | ✅ | Hardware UID — iOS NDEF sessions do not expose tag identifiers |
Session Options by Platform#
| Option | iOS | Android | Description |
|---|---|---|---|
timeout |
❌ | ✅ | Session timeout in seconds (iOS manages its own timeout) |
alertMessage |
✅ | ❌ | Custom message shown in NFC prompt |
successMessage |
✅ | ❌ | Message shown after successful operation |
keepSessionActive |
✅ | ✅ | Keep session active for continuous scanning |
Platform-Specific Notes#
iOS isAvailable() Behavior
On iOS, NFC cannot be disabled by the user — it is always enabled on supported devices (iPhone 7+). As a result, isAvailable() returns the same value for both available and enabled:
- Supported device:
{ available: true, enabled: true } - Unsupported device or simulator:
{ available: false, enabled: false }
The available: true, enabled: false state only occurs on Android where users can toggle NFC in settings.
vCard Reading on iOS
Limitation: iOS can write vCards to NFC tags but cannot correctly read them back. When iOS reads a tag containing a native vCard MIME type (text/vcard), it returns the raw content with a MIME type string instead of the vcard type. This is a platform limitation of iOS's CoreNFC framework. Android handles both reading and writing vCards correctly.
NfcCancelled Event on Android
On Android, the NfcCancelled event is not dispatched when cancel() is called. This is because Android's Reader Mode API has no user-facing NFC dialog that can be cancelled (unlike iOS's NFC sheet). The cancel() call returns success synchronously, and your app should update UI state from the promise response. On iOS, both programmatic cancellation and user-initiated cancellation (dismissing the NFC sheet) dispatch the NfcCancelled event.
API Reference#
Platform Detection#
Detect the current platform to conditionally enable features.
// Check platformconst platform = nfc.getPlatform();// { isIos: true, isAndroid: false, isMobile: true } // Convenience methodsif (nfc.isAndroid()) { // Use timeout option} if (nfc.isIos()) { // Use alertMessage option}
isAvailable()#
Check if NFC is available and enabled on the device.
const status = await nfc.isAvailable();// { available: true, enabled: true, status: 'enabled', message: '...' }
writeUrl(url, options?)#
Write a URL to an NFC tag.
// Basic usageawait nfc.writeUrl('https://example.com'); // With session options (platform-specific)await nfc.writeUrl('https://example.com', { timeout: 15, // Android only alertMessage: 'Tap the NFC tag', // iOS only successMessage: 'URL saved!' // iOS only});
writeText(text, languageCode?, options?)#
Write plain text to an NFC tag.
// Basic usageawait nfc.writeText('Hello World'); // With language codeawait nfc.writeText('Bonjour le monde', 'fr'); // With session optionsawait nfc.writeText('Hello', 'en', { timeout: 20 });
writeRecord(record, options?)#
Write various NDEF record types to an NFC tag.
// Write a URIawait nfc.writeRecord({ type: 'uri', uri: 'https://example.com' }); // Write text with languageawait nfc.writeRecord({ type: 'text', text: 'Hello', languageCode: 'en' }); // Write a vCard contactawait nfc.writeRecord({ type: 'vcard', vcard: { firstName: 'John', lastName: 'Doe', phone: '+1234567890', organization: 'Acme Corp', title: 'Developer', url: 'https://example.com' }}); // Write JSON data via MIMEawait nfc.writeRecord({ type: 'mime', mimeType: 'application/json', data: JSON.stringify({ userId: 123, action: 'checkin' })}); // Write Android Application Record (Android only)await nfc.writeRecord({ type: 'aar', packageName: 'com.example.myapp' });
writeUrlWithAar(url, packageName, options?)#
Write a URL and Android Application Record (AAR) to a single NFC tag. When scanned on Android, this opens the URL in the browser or launches the specified app if installed. On iOS, only the URL is written (AAR is ignored).
// Write URL + AAR combo (standard Android NFC deployment pattern)await nfc.writeUrlWithAar('https://example.com', 'com.example.myapp'); // With session optionsawait nfc.writeUrlWithAar('https://example.com', 'com.example.myapp', { timeout: 15, alertMessage: 'Tap the NFC tag'});
erase(options?)#
Erase an NFC tag by writing an empty NDEF message.
// Erase a tagawait nfc.erase(); // With session optionsawait nfc.erase({ alertMessage: 'Tap the tag to erase' }); // Listen for successnfc.on(NfcEvent.TagErased, (data) => { console.log('Tag erased!', data.tagId);});
read(options?)#
Read data from an NFC tag. Returns tag content and hardware information.
// Basic usageawait nfc.read(); // With session optionsawait nfc.read({ timeout: 30, alertMessage: 'Hold near product tag', keepSessionActive: true // Read multiple tags in one session}); // Handle the resultnfc.on(NfcEvent.TagRead, (data) => { console.log('Content:', data.content); console.log('Type:', data.type); // url, text, vcard, empty, aar, mime type string, etc. console.log('Tag ID:', data.tagId); // Android only — empty string on iOS // Tag hardware information if (data.tagInfo) { console.log('Capacity:', data.tagInfo.capacity, 'bytes'); console.log('Used:', data.tagInfo.usedSize, 'bytes'); console.log('Writable:', data.tagInfo.isWritable); console.log('Technologies:', data.tagInfo.technologies); console.log('Can lock:', data.tagInfo.canMakeReadOnly); }});
cancel()#
Cancel the current NFC session.
await nfc.cancel();
openSettings()#
Open the device's NFC settings. Only useful on Android where users can toggle NFC on/off. On iOS, NFC cannot be disabled by the user, so this function has no practical effect (it opens the general Settings app).
// Android only — check if NFC is disabled and prompt the userconst status = await nfc.isAvailable();if (nfc.isAndroid() && status.available && !status.enabled) { await nfc.openSettings();}
writeWifiConfig(ssid, password, security?, options?)#
Write WiFi credentials to an NFC tag. Uses the standard application/vnd.wfa.wsc MIME type. When an Android device scans the tag, it offers to connect to the network automatically. Useful for hotels, offices, cafes, and events.
// Write WiFi config (WPA2 by default)await nfc.writeWifiConfig('MyNetwork', 'MyPassword'); // Specify security typeawait nfc.writeWifiConfig('MyNetwork', 'MyPassword', 'WPA2'); // Open network (no password)await nfc.writeWifiConfig('GuestWifi', '', 'OPEN'); // Supported security types: 'WPA2', 'WPA', 'WEP', 'OPEN'
Session Options#
Configure NFC session behavior with optional parameters:
interface NfcSessionOptions { /** Timeout in seconds (default: 30, set to 0 for no timeout) */ timeout?: number; /** Custom message shown during NFC prompt */ alertMessage?: string; /** Message after success (iOS only) */ successMessage?: string; /** Keep session active after first tag (iOS and Android) */ keepSessionActive?: boolean;}
Unsupported options are logged to console and gracefully ignored.
Continuous Scanning#
Use keepSessionActive: true to read multiple tags in one session. On iOS, the NFC sheet stays open. On Android, Reader Mode remains active.
await nfc.read({ keepSessionActive: true }); // Each tag fires a separate TagRead eventnfc.on(NfcEvent.TagRead, (data) => { console.log('Scanned:', data.content);}); // Call cancel() when doneawait nfc.cancel();
Events#
NfcUrlWritten#
Dispatched when a URL is successfully written.
url- The URL that was writtentagId- Tag identifier (Android only, empty string on iOS)
NfcTextWritten#
Dispatched when text is successfully written.
text- The text that was writtenlanguageCode- Language code usedtagId- Tag identifier (Android only, empty string on iOS)
NfcRecordWritten#
Dispatched when a custom record is successfully written.
recordType- Type of record (text, uri, vcard, mime, aar)tagId- Tag identifier (Android only, empty string on iOS)
NfcTagRead#
Dispatched when a tag is successfully read.
content- Content read from the tagtype- Content type:url,text,vcard,empty,aar,mifare_classic,mifare_ultralight,unknown, or a MIME type string (e.g.application/json)tagId- Tag identifier (Android only, empty string on iOS)tagInfo- Tag hardware information (capacity, usedSize, isWritable, technologies, canMakeReadOnly)
NfcTagErased#
Dispatched when a tag is successfully erased.
tagId- Tag identifier (Android only, empty string on iOS)
NfcError#
Dispatched when an error occurs.
code- Error codemessage- Human-readable error message
NfcCancelled#
Dispatched when the session is cancelled (iOS only — see platform notes above).
reason- Reason for cancellation
Error Codes#
| Code | Description | Action |
|---|---|---|
TIMEOUT |
Session timed out | Retry or extend timeout |
NO_TAG |
No tag detected | Ensure tag is present |
READ_ERROR |
Error reading tag | Tag may have been removed |
WRITE_ERROR |
Error writing tag | Check tag capacity |
WRITE_FAILED |
Write failed | Tag may be read-only |
INVALID_URL |
Invalid URL format | Check URL scheme |
INVALID_PARAMETERS |
Missing parameters | Check function arguments |
INVALID_RECORD_TYPE |
Invalid record type | Use text, uri, vcard, mime, or aar |
NFC_NOT_SUPPORTED |
No NFC hardware | Device doesn't support NFC |
NFC_DISABLED |
NFC disabled | Call openSettings() |
SESSION_START_FAILED |
Session failed | Try again |
UNSUPPORTED_PLATFORM |
Feature not on platform | AAR not on iOS |
NOT_SUPPORTED |
Tag doesn't support NDEF | Use an NDEF-compatible tag |
READ_ONLY |
Tag is read-only | Tag has been locked |
PAYLOAD_ERROR |
Failed to create NDEF payload | Check record data |
MESSAGE_TOO_LARGE |
Content exceeds tag capacity | Reduce content or use larger tag |
CONNECTION_FAILED |
Failed to connect to tag (iOS) | Tag may have been moved |
QUERY_FAILED |
Failed to query tag status (iOS) | Retry the operation |
SYSTEM_BUSY |
NFC system is busy (iOS) | Wait and try again |
Framework Examples#
Vue (Inertia)#
A complete NFC scanner component using Vue 3 with Composition API.
<script setup lang="ts">import { ref, onMounted, onUnmounted } from 'vue';import { nfc, NfcEvent, NfcErrorCode, type NfcTagReadPayload } from '#nfc'; const isAvailable = ref(false);const isEnabled = ref(false);const isScanning = ref(false);const result = ref<NfcTagReadPayload | null>(null);const error = ref<string | null>(null); onMounted(async () => { const status = await nfc.isAvailable(); isAvailable.value = status.available; isEnabled.value = status.enabled;}); const cleanupRead = nfc.on(NfcEvent.TagRead, (data) => { result.value = data; isScanning.value = false;}); const cleanupError = nfc.on(NfcEvent.Error, (data) => { error.value = `${data.code}: ${data.message}`; isScanning.value = false; if (data.code === NfcErrorCode.NfcDisabled && nfc.isAndroid()) { nfc.openSettings(); }}); const cleanupCancel = nfc.on(NfcEvent.Cancelled, () => { isScanning.value = false;}); onUnmounted(() => { cleanupRead(); cleanupError(); cleanupCancel();}); async function scan() { error.value = null; result.value = null; isScanning.value = true; await nfc.read({ alertMessage: 'Hold near NFC tag' });} async function writeUrl(url: string) { error.value = null; isScanning.value = true; await nfc.writeUrl(url, { alertMessage: 'Tap tag to write URL' });}</script> <template> <div v-if="!isAvailable"> <p>NFC is not supported on this device.</p> </div> <div v-else-if="!isEnabled"> <p>NFC is disabled.</p> <button @click="nfc.openSettings()">Open NFC Settings</button> </div> <div v-else> <button @click="scan" :disabled="isScanning"> {{ isScanning ? 'Scanning...' : 'Scan NFC Tag' }} </button> <button @click="writeUrl('https://example.com')" :disabled="isScanning"> Write URL </button> <div v-if="result"> <p>Content: {{ result.content }}</p> <p>Type: {{ result.type }}</p> <p v-if="result.tagInfo"> Capacity: {{ result.tagInfo.capacity }} bytes ({{ result.tagInfo.isWritable ? 'writable' : 'read-only' }}) </p> </div> <p v-if="error" style="color: red;">{{ error }}</p> </div></template>
React (Inertia)#
The same NFC scanner as a React functional component.
import { useState, useEffect, useCallback } from 'react';import { nfc, NfcEvent, NfcErrorCode, type NfcTagReadPayload } from '#nfc'; export default function NfcScanner() { const [isAvailable, setIsAvailable] = useState(false); const [isEnabled, setIsEnabled] = useState(false); const [isScanning, setIsScanning] = useState(false); const [result, setResult] = useState<NfcTagReadPayload | null>(null); const [error, setError] = useState<string | null>(null); useEffect(() => { nfc.isAvailable().then((status) => { setIsAvailable(status.available); setIsEnabled(status.enabled); }); const cleanupRead = nfc.on(NfcEvent.TagRead, (data) => { setResult(data); setIsScanning(false); }); const cleanupError = nfc.on(NfcEvent.Error, (data) => { setError(`${data.code}: ${data.message}`); setIsScanning(false); if (data.code === NfcErrorCode.NfcDisabled && nfc.isAndroid()) { nfc.openSettings(); } }); const cleanupCancel = nfc.on(NfcEvent.Cancelled, () => { setIsScanning(false); }); return () => { cleanupRead(); cleanupError(); cleanupCancel(); }; }, []); const scan = useCallback(async () => { setError(null); setResult(null); setIsScanning(true); await nfc.read({ alertMessage: 'Hold near NFC tag' }); }, []); const writeUrl = useCallback(async (url: string) => { setError(null); setIsScanning(true); await nfc.writeUrl(url, { alertMessage: 'Tap tag to write URL' }); }, []); if (!isAvailable) { return <p>NFC is not supported on this device.</p>; } if (!isEnabled) { return ( <div> <p>NFC is disabled.</p> <button onClick={() => nfc.openSettings()}>Open NFC Settings</button> </div> ); } return ( <div> <button onClick={scan} disabled={isScanning}> {isScanning ? 'Scanning...' : 'Scan NFC Tag'} </button> <button onClick={() => writeUrl('https://example.com')} disabled={isScanning}> Write URL </button> {result && ( <div> <p>Content: {result.content}</p> <p>Type: {result.type}</p> {result.tagInfo && ( <p> Capacity: {result.tagInfo.capacity} bytes ({result.tagInfo.isWritable ? 'writable' : 'read-only'}) </p> )} </div> )} {error && <p style={{ color: 'red' }}>{error}</p>} </div> );}
Livewire#
A complete Livewire component with event handlers.
Component class:
<?php namespace App\Livewire; use Livewire\Component;use Native\Mobile\Attributes\OnNative;use Nativephp\Nfc\Facades\Nfc;use Nativephp\Nfc\Events\NfcTagRead;use Nativephp\Nfc\Events\NfcUrlWritten;use Nativephp\Nfc\Events\NfcTextWritten;use Nativephp\Nfc\Events\NfcRecordWritten;use Nativephp\Nfc\Events\NfcTagErased;use Nativephp\Nfc\Events\NfcError;use Nativephp\Nfc\Events\NfcCancelled; class NfcScanner extends Component{ public ?string $content = null; public ?string $type = null; public ?array $tagInfo = null; public ?string $error = null; public bool $isScanning = false; public function scan(): void { $this->reset('content', 'type', 'tagInfo', 'error'); $this->isScanning = true; Nfc::read(['alertMessage' => 'Hold near NFC tag']); } public function writeUrl(string $url): void { $this->reset('error'); $this->isScanning = true; Nfc::writeUrl($url, ['alertMessage' => 'Tap tag to write URL']); } #[OnNative(NfcTagRead::class)] public function handleTagRead(string $content, string $type, ?string $tagId = null, ?array $tagInfo = null): void { $this->content = $content; $this->type = $type; $this->tagInfo = $tagInfo; $this->isScanning = false; } #[OnNative(NfcUrlWritten::class)] public function handleUrlWritten(string $url, ?string $tagId = null): void { $this->content = $url; $this->type = 'url'; $this->isScanning = false; } #[OnNative(NfcTextWritten::class)] public function handleTextWritten(string $text, string $languageCode, ?string $tagId = null): void { $this->content = $text; $this->isScanning = false; } #[OnNative(NfcRecordWritten::class)] public function handleRecordWritten(string $recordType, ?string $tagId = null): void { $this->type = $recordType; $this->isScanning = false; } #[OnNative(NfcTagErased::class)] public function handleTagErased(?string $tagId = null): void { $this->content = null; $this->type = null; $this->isScanning = false; } #[OnNative(NfcError::class)] public function handleNfcError(string $code, string $message): void { $this->error = "{$code}: {$message}"; $this->isScanning = false; } #[OnNative(NfcCancelled::class)] public function handleNfcCancelled(string $reason = 'user_cancelled'): void { $this->isScanning = false; } public function render() { return view('livewire.nfc-scanner'); }}
Blade template (resources/views/livewire/nfc-scanner.blade.php):
<div> <button wire:click="scan" @disabled($isScanning)> {{ $isScanning ? 'Scanning...' : 'Scan NFC Tag' }} </button> <button wire:click="writeUrl('https://example.com')" @disabled($isScanning)> Write URL </button> @if ($content) <p>Content: {{ $content }}</p> <p>Type: {{ $type }}</p> @if ($tagInfo) <p> Capacity: {{ $tagInfo['capacity'] }} bytes ({{ $tagInfo['isWritable'] ? 'writable' : 'read-only' }}) </p> @endif @endif @if ($error) <p style="color: red;">{{ $error }}</p> @endif</div>
Features Not Included#
The following features are intentionally not supported:
Lock Tags#
Making tags permanently read-only is not included due to its irreversible nature. Once locked, a tag can never be written to again.
Raw Tag Commands#
Low-level tag communication (transceive, MIFARE Classic sector read/write, ISO-DEP APDUs) is not supported. This plugin operates at the NDEF level only.
Host Card Emulation (HCE)#
Making the device act as an NFC tag (Host Card Emulation) is not supported. This feature is Android-only and requires complex service registration.
Background Tag Detection#
Automatically handling NFC tags when the app is in the background is not implemented. This requires platform-specific configuration and varies significantly between iOS and Android.
Platform Requirements#
Android#
- Android 7.0+ (SDK 24)
- NFC hardware
- NFC enabled in settings
iOS#
- iPhone 7 or newer
- iOS 13+
- NFC entitlement in app
Support#
For technical issues or questions, email [email protected].
For licensing, seats, or purchase, use the same address — see License.
License#
Proprietary software (composer.json declares license: proprietary). Use is governed by the End User License Agreement (EULA) in LICENSE.md. By installing, copying, or using this package, you agree to that agreement.
The following is a summary only and does not replace the full EULA:
| Topic | Summary |
|---|---|
| Copyright | © 2026–present Context Undefined. All rights reserved. |
| Seats | One license per developer who accesses, modifies, or compiles the Software’s source; additional developers require additional seats. |
| You may | Use on your projects and in client work when integrated into a final product (not as a standalone offering); modify for integration in authorized projects. |
| You may not | Redistribute, sublicense, or sell the Software; publish source on public GitHub or registries; use it to build a competing product; remove proprietary notices; share license credentials. |
Governing law and liability terms are in LICENSE.md (Netherlands).
Licensing inquiries: [email protected]