pteal79 / nfc
NativePHP Mobile v4 plugin for writing and reading NFC tags on iOS and Android
Requires
- php: ^8.4
- nativephp/mobile: ^4.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0
- pestphp/pest: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A NativePHP Mobile v4 plugin that writes and reads NFC tags on iOS and Android.
Each tag holds a URL and a small "job" record: a job UUID and a date/time stamp. Phones without your app still open the URL when they scan the tag.
The plugin is built for apps that use SuperNative screens (NativeComponent classes with EDGE elements). It has no JavaScript API and needs no webview.
Contents
- Requirements
- Installation
- iOS setup in the Apple Developer account
- Configuration
- Usage
- Events
- SuperNative example
- Tag data layout
- Security
- Testing
- Notes on NativePHP Mobile 4.4
- License
Requirements
- PHP 8.4 or later
- NativePHP Mobile v4 (
nativephp/mobile^4.0) - An app built with SuperNative screens
- A physical device with NFC. Simulators and emulators cannot scan tags.
- iOS 18.2 or later (every iPhone that runs iOS 18.2 has NFC)
- Android 10 (API 29) or later with NFC hardware
- NFC Forum Type 2 tags such as NTAG215 or larger. An NTAG213 only has about 144 bytes of usable space, which is usually too small for a URL and the job record (see Tag data layout).
Installation
1. Require the package
From Packagist:
composer require pteal79/nfc
During development you can load the package from a local folder with a path repository in your app's composer.json:
{
"repositories": [
{ "type": "path", "url": "../packages/pteal79/nfc" }
],
"require": {
"pteal79/nfc": "*"
}
}
Or from a Git repository with a vcs repository:
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/pteal79/nfc" }
],
"require": {
"pteal79/nfc": "dev-main"
}
}
Then run composer update pteal79/nfc.
2. Register the plugin in your app
NativePHP only compiles native code for plugins you register explicitly. In your app (not in this package), run:
# Creates app/Providers/NativeServiceProvider.php if you do not have it yet
php artisan vendor:publish --tag=nativephp-plugins-provider
php artisan native:plugin:register pteal79/nfc
This adds \Pteal79\Nfc\NfcServiceProvider::class to the plugins() array in app/Providers/NativeServiceProvider.php.
3. Validate and rebuild
php artisan native:plugin:validate php artisan native:install --force
Run php artisan native:install --force again whenever you update the plugin and its nativephp.json manifest changes (new permissions, entitlements, bridge functions or native files).
What the manifest adds to your app:
| Platform | Added |
|---|---|
| Android | android.permission.NFC permission, and android.hardware.nfc as an optional feature (required="false"), so the app still installs on devices without NFC |
| iOS | NFCReaderUsageDescription in Info.plist, and the com.apple.developer.nfc.readersession.formats entitlement with TAG |
iOS setup in the Apple Developer account
Core NFC only works if your App ID has the NFC capability:
- Sign in to developer.apple.com and open Certificates, Identifiers & Profiles.
- Select Identifiers, then your app's identifier (the same value as
NATIVEPHP_APP_ID). - Under Capabilities, tick NFC Tag Reading and save.
- Regenerate your provisioning profiles so they include the new capability, then download and install them (or let Xcode manage signing).
- Rebuild the app with
php artisan native:install --force.
The plugin requests the TAG entitlement format only. NativePHP's iOS packaging rewrites this entitlement to TAG when it signs the app, and App Store Connect does not accept the older NDEF value for apps built with current SDKs. NFCNDEFReaderSession, which this plugin uses, works with TAG.
The permission text ("This app uses NFC to read and write job tags.") comes from NFCReaderUsageDescription in this package's nativephp.json and is merged into your app's Info.plist at build time. To use different wording, fork the package and change that value. Keep it specific: vague usage descriptions can lead to App Store rejection.
Configuration
Publish the config file:
php artisan vendor:publish --tag=nfc-config
config/nfc.php:
| Key | Default | Description |
|---|---|---|
session_timeout |
30 (env NFC_SESSION_TIMEOUT) |
Seconds a session waits for a tag before SessionCancelled fires with reason timeout. iOS also ends its sheet after 60 seconds. |
record_type |
pteal79.nfc:job (env NFC_RECORD_TYPE) |
The external record type name. Lowercase domain:type. Tags written with a different name are not recognised as job tags. |
ios.alerts.read |
Hold your iPhone near the tag to read it. |
iOS sheet text while reading |
ios.alerts.write |
Hold your iPhone near the tag to write it. |
iOS sheet text while writing |
ios.alerts.read_success |
Tag read. |
iOS sheet text after a successful read |
ios.alerts.write_success |
Tag written. |
iOS sheet text after a successful write |
ios.alerts.multiple_tags |
More than one tag was found. Please present a single tag. |
iOS sheet text when several tags are in range |
These values are sent to the native code with every call, so changes take effect without rebuilding. On failure the iOS sheet shows the error message from the matching NfcError event.
Usage
use Pteal79\Nfc\Facades\Nfc;
NFC is asynchronous. write() and read() start a session and return immediately. The result arrives later as an event.
Check availability
$availability = Nfc::isAvailable(); // ['supported' => true, 'enabled' => false]
supported: the device has NFC hardware.enabled: NFC is switched on. iOS has no NFC switch, soenabledalways matchessupportedthere.
On Android, send the user to the NFC settings when it is switched off:
if (! Nfc::openSettings()) { // iOS, or no settings screen was available. }
openSettings() returns true when Android opened the settings screen and false on iOS.
Write a tag
Nfc::write( url: 'https://example.com/jobs/42', jobId: $job->uuid, // any UUID, stored lowercase dataSet: now(), // DateTimeInterface or a parseable string );
Before anything is sent to the device, the plugin checks that:
urlis a validhttporhttpsURL,jobIdpassesStr::isUuid(),dataSetcan be parsed as a date.
If any check fails, Pteal79\Nfc\Exceptions\InvalidNfcPayload is thrown with a clear message. dataSet is converted to UTC and stored as ISO 8601 with no fractional seconds, for example 2026-09-16T10:30:00Z. A string without a timezone is read in your app's timezone (app.timezone).
Locking a tag
Nfc::write('https://example.com/jobs/42', $job->uuid, now(), lock: true);
Warning:
lock: truemakes the tag permanently read-only after a successful write. This cannot be undone. Nobody, including you, can change or erase the tag afterwards. The tag is only locked when the write succeeded.
If the tag was written but could not be locked, NfcError fires with code write_failed and context contains written: true, locked: false.
Checking the size first
$bytes = Nfc::messageSize('https://example.com/jobs/42', $job->uuid, now());
The native code does the same calculation and compares it to the tag's capacity before writing.
Read a tag
Nfc::read();
Cancel a session
Nfc::cancel();
SessionCancelled fires with reason user if a session was waiting for a tag. Once a tag has been detected and is being read or written, neither cancel() nor the session timeout interrupts it, so a tag is never left half written. The session then ends with TagRead, TagWritten or NfcError as usual.
One-shot callbacks
write() and read() return builder objects (PendingNfcWrite, PendingNfcRead) that work like NativePHP's own Scanner::scan(). The session starts when the statement ends, or straight away if you call ->start(). Callbacks chained on the builder fire once, for that session only, in addition to any #[On] handlers:
use Pteal79\Nfc\Events\NfcError; use Pteal79\Nfc\Events\TagRead; Nfc::read() ->tagRead(function (TagRead $event) { $this->jobId = $event->toTagData()->jobId; }) ->nfcError(fn (NfcError $event) => $this->message = $event->message) ->sessionCancelled(fn () => $this->state = 'idle'); // The generic form works for any of the events: Nfc::write($url, $jobId, now()) ->on(TagWritten::class, fn (TagWritten $event) => $this->locked = $event->locked);
Use ->id('...') to set the session ID yourself. Otherwise a UUID is generated.
Only one session at a time
Starting a session while another is running fires NfcError with code session_error. Call Nfc::cancel() first.
Events
All events live in Pteal79\Nfc\Events. Their public properties are named exactly like the payload keys, so they bind by name to #[On] handler parameters.
| Event | Properties |
|---|---|
TagRead |
?string $url, ?string $jobId, ?string $dataSet, ?int $schemaVersion, ?string $tagId, array $records |
TagWritten |
string $url, string $jobId, string $dataSet, bool $locked |
NfcError |
string $code, string $message, array $context |
SessionCancelled |
string $reason (user or timeout) |
Every event also has an internal ?string $id used for the one-shot callbacks.
TagRead
urlis the first URI record on the tag.jobId,dataSetandschemaVersioncome from the job record. They arenullwhen the tag has a URL but no job record. That is not an error.dataSetis an ISO 8601 UTC string.$event->toTagData()returns aPteal79\Nfc\Data\NfcTagDatavalue object withurl,jobId,dataSet(CarbonImmutableornull),schemaVersion,tagIdandrecords.tagIdis the tag UID in lowercase hex. It is only available on Android. iOS does not expose the UID toNFCNDEFReaderSession, so it is alwaysnullthere.recordslists every record on the tag, including ones the plugin ignores:
[
['index' => 0, 'tnf' => 1, 'type' => 'U', 'payload' => '04...', 'value' => 'https://example.com/jobs/42'],
['index' => 1, 'tnf' => 4, 'type' => 'pteal79.nfc:job', 'payload' => '7b22...', 'value' => '{"v":1,...}'],
]
payload is hex. value is the decoded text for URI, text, JSON, text MIME and external records, and is left out otherwise.
NfcError codes
$event->errorCode() returns a Pteal79\Nfc\Enums\NfcErrorCode enum case.
| Code | When |
|---|---|
unsupported |
The device has no NFC hardware. |
disabled |
NFC is switched off (Android). |
tag_not_ndef |
The tag cannot hold NDEF data, or (when reading on Android) it is blank and unformatted. |
tag_read_only |
The tag is locked and cannot be written. |
insufficient_capacity |
The message does not fit. The message gives both sizes, and context has requiredBytes and capacityBytes. |
write_failed |
Writing, formatting or locking failed, usually because the tag moved away. context has written and locked. |
invalid_payload |
The native side received incomplete data. |
unknown_format |
The tag has a job record but its JSON is invalid or its v is unknown. context has rawPayload, recordType and, if present, url. |
session_error |
The session could not start, another session is active, or the session ended unexpectedly. |
Listening outside a screen
All four events implement NativePHP's BroadcastsGlobally, so they are also sent through Laravel's event dispatcher and Event::listen(TagRead::class, ...) works anywhere in your app.
SuperNative example
A screen with "Write tag" and "Read tag" buttons. Android has no system scan sheet, so the screen shows its own "Hold the tag near the phone" state. iOS shows the system sheet on top.
app/NativeComponents/JobTag.php:
<?php namespace App\NativeComponents; use Illuminate\View\View; use Native\Mobile\Attributes\On; use Native\Mobile\Edge\NativeComponent; use Pteal79\Nfc\Events\NfcError; use Pteal79\Nfc\Events\SessionCancelled; use Pteal79\Nfc\Events\TagRead; use Pteal79\Nfc\Events\TagWritten; use Pteal79\Nfc\Exceptions\InvalidNfcPayload; use Pteal79\Nfc\Facades\Nfc; class JobTag extends NativeComponent { /** idle, waiting, success or error */ public string $state = 'idle'; public string $action = ''; public string $message = ''; public bool $nfcSupported = false; public bool $nfcEnabled = false; public ?string $url = null; public ?string $jobId = null; public ?string $dataSet = null; public function mount(): void { $availability = Nfc::isAvailable(); $this->nfcSupported = $availability['supported']; $this->nfcEnabled = $availability['enabled']; } public function writeTag(): void { try { Nfc::write( url: 'https://example.com/jobs/42', jobId: '9b2f4c1e-7a3d-4e5f-8a6b-1c2d3e4f5a6b', dataSet: now(), ); } catch (InvalidNfcPayload $e) { $this->showError($e->getMessage()); return; } $this->wait('write'); } public function readTag(): void { Nfc::read(); $this->wait('read'); } public function cancelScan(): void { Nfc::cancel(); } public function openSettings(): void { Nfc::openSettings(); } #[On(TagWritten::class)] public function tagWritten(string $url = '', string $jobId = '', string $dataSet = '', bool $locked = false): void { $this->state = 'success'; $this->url = $url; $this->jobId = $jobId; $this->dataSet = $dataSet; $this->message = $locked ? 'Tag written and locked.' : 'Tag written.'; } #[On(TagRead::class)] public function tagRead(?string $url = null, ?string $jobId = null, ?string $dataSet = null): void { $data = (new TagRead(url: $url, jobId: $jobId, dataSet: $dataSet))->toTagData(); $this->state = 'success'; $this->url = $data->url; $this->jobId = $data->jobId; $this->dataSet = $data->dataSet?->timezone(config('app.timezone'))->toDayDateTimeString(); $this->message = $data->hasJob() ? 'Job tag read.' : 'This tag has a link but no job.'; } #[On(NfcError::class)] public function nfcError(string $code = '', string $message = '', array $context = []): void { if ($code === 'disabled') { $this->nfcEnabled = false; } $this->showError($message); } #[On(SessionCancelled::class)] public function sessionCancelled(string $reason = 'user'): void { $this->state = 'idle'; $this->message = $reason === 'timeout' ? 'No tag was found in time.' : ''; } public function render(): View { return view('native.job-tag'); } private function wait(string $action): void { $this->state = 'waiting'; $this->action = $action; $this->message = ''; } private function showError(string $message): void { $this->state = 'error'; $this->message = $message; } }
resources/views/native/job-tag.blade.php:
<native:column class="w-full h-full safe-area p-6 gap-4 bg-theme-background text-theme-on-background"> <native:text class="text-2xl font-bold">Job tag</native:text> @if (! $nfcSupported) <native:text>This device does not support NFC.</native:text> @elseif (! $nfcEnabled) <native:text>NFC is turned off.</native:text> <native:pressable @tap="openSettings" class="p-4 rounded-lg bg-theme-primary"> <native:text class="text-theme-on-primary">Open NFC settings</native:text> </native:pressable> @elseif ($state === 'waiting') {{-- Android has no system sheet, so show our own prompt. --}} <native:text class="text-lg">Hold the tag near the phone</native:text> <native:text>{{ $action === 'write' ? 'Writing the job to the tag.' : 'Reading the tag.' }}</native:text> <native:pressable @tap="cancelScan" class="p-4 rounded-lg bg-theme-surface"> <native:text>Cancel</native:text> </native:pressable> @else <native:pressable @tap="writeTag" class="p-4 rounded-lg bg-theme-primary"> <native:text class="text-theme-on-primary">Write tag</native:text> </native:pressable> <native:pressable @tap="readTag" class="p-4 rounded-lg bg-theme-primary"> <native:text class="text-theme-on-primary">Read tag</native:text> </native:pressable> @endif @if ($message !== '') <native:text class="{{ $state === 'error' ? 'text-red-600' : '' }}">{{ $message }}</native:text> @endif @if ($state === 'success') <native:text>URL: {{ $url ?? 'none' }}</native:text> <native:text>Job: {{ $jobId ?? 'none' }}</native:text> <native:text>Data set: {{ $dataSet ?? 'none' }}</native:text> @endif </native:column>
Register the route in routes/mobile.php:
Route::native('/job-tag', \App\NativeComponents\JobTag::class);
The example only uses core EDGE elements. <native:button> and <native:activity-indicator> are not part of core: they come from the optional nativephp/native-ui package. If you have it installed you can use <native:button label="Write tag" @press="writeTag" /> and add an activity indicator to the waiting state.
Handler parameters
NativePHP passes event data to #[On] handlers by parameter name. Give every parameter a default value, as above. Use string, int, bool, array or nullable versions of these, and convert dates inside the handler (for example with toTagData()). Do not type a parameter as Carbon or an enum.
Tag data layout
The plugin stores one NDEF message with two records, in this order:
| # | Record | TNF | Type | Payload |
|---|---|---|---|---|
| 1 | URI | 0x01 (NFC Forum well-known) |
U |
URI identifier code + the rest of the URL |
| 2 | Job | 0x04 (NFC Forum external type) |
pteal79.nfc:job |
Compact UTF-8 JSON |
The URI record is first on purpose. iOS background tag reading and Android tag dispatch only act on the first record, so phones without your app open the URL in the browser. There is no Android Application Record (AAR), because an AAR would send phones without the app to the Play Store instead of opening the URL.
The job payload:
{"v":1,"job_id":"9b2f4c1e-7a3d-4e5f-8a6b-1c2d3e4f5a6b","data_set":"2026-09-16T10:30:00Z"}
| Field | Description |
|---|---|
v |
Schema version, an integer. Currently 1. |
job_id |
A UUID, always lowercase. |
data_set |
A UTC timestamp in ISO 8601 form YYYY-MM-DDTHH:MM:SSZ, with no fractional seconds. |
Schema versioning
v lets the record gain fields later without breaking old tags. The rules for version 1:
- Readers accept only
"v": 1. Any other version, a missingv, or invalid JSON firesNfcErrorwith codeunknown_formatand the raw payload incontext.rawPayload. - Readers ignore unknown extra fields in a version 1 record, so new optional fields can be added without a version bump.
- Incompatible changes must use a new version number, and the readers must be updated to understand it.
Size
With the URI prefix compression (https://www. is stored as 1 byte), the message size is:
URI record: 3 + 1 + 1 + (URL length without the prefix)
Job record: 3 + 15 + 89 = 107 bytes
For https://www.example.com/jobs/42 the message is 131 bytes. NTAG213 tags hold about 144 bytes, so only very short URLs fit. Use NTAG215 (about 496 bytes) or NTAG216 (about 872 bytes). If the message does not fit, the write stops and NfcError fires with code insufficient_capacity, giving both sizes.
On Android, blank, unformatted tags are formatted for NDEF during the first write. The capacity of an unformatted tag is only known after formatting, so a failed format of a tag that is too small fires write_failed.
Security
Anyone with an NFC reader app can read the data on a tag. The URL, job ID and timestamp are stored as plain, unencrypted text. Do not put secrets, personal data or anything that grants access on a tag. Treat the job ID as a reference that your server checks, not as proof of anything.
Tags that are not locked can also be overwritten by anyone with an NFC writer app. Use lock: true if tags must not change, keeping in mind that locking is permanent.
Testing
The PHP side is tested with Pest and Orchestra Testbench:
composer install composer test composer lint # Laravel Pint
In your app's tests you can swap the bridge for a fake. Bind your own Pteal79\Nfc\Bridge\BridgeContract implementation:
use Pteal79\Nfc\Bridge\BridgeContract; $this->app->instance(BridgeContract::class, $fakeBridge);
Or use NativePHP's own testing tools, which this plugin works with:
use Native\Mobile\Testing\Native; use Pteal79\Nfc\Events\TagRead; Native::test(JobTag::class) ->call('readTag') ->assertNativeCalled('Nfc.Read') ->emitNative(TagRead::class, ['url' => 'https://example.com', 'jobId' => $uuid, 'dataSet' => '2026-09-16T10:30:00Z']) ->assertSet('state', 'success');
When no native bridge is available (for example in a plain CLI process), the facade methods do nothing and isAvailable() reports supported: false.
Notes on NativePHP Mobile 4.4
These points were checked against the nativephp/mobile 4.4 source and differ from parts of the v4 documentation:
- Use
Native\Mobile\Attributes\On. The plugin documentation's#[OnNative]needs Livewire. NativeComponenthas no$this->on()method. Use the builder callbacks shown above, or$this->registerNativeEventListener(TagRead::class, fn (object $payload) => ...)inmount()for a listener that stays active while the screen is open.- The manifest's
ios.capabilitieskey is not used by the build, so this plugin does not set it. Enable the capability in the Apple Developer account instead.
License
The MIT License (MIT). See LICENSE.