BLE Plugin for NativePHP Mobile#
Bluetooth Low Energy scanning and connection tracking for NativePHP Mobile applications.
Overview#
Scan for nearby peripherals, connect to them, and react to arrivals and departures from Laravel. The plugin tracks presence and connection state — it does not read or write GATT characteristics.
Scanning needs a real device: BLE does not work in the iOS Simulator or the Android emulator.
Installation#
composer require nativephp/mobile-blephp artisan native:plugin:register nativephp/mobile-ble
Then rebuild the native app so the plugin's Swift and Kotlin are compiled in:
php artisan native:run ios # or: android
| Requirement | Version |
|---|---|
| PHP | 8.2+ |
| NativePHP Mobile | 3.0+ / 4.0+ |
| iOS | 18.2+ |
| Android | 8.0+ (API 26+) |
Usage#
use NativePHP\BLE\Facades\BLE; // Scan for everything in range...BLE::startScanning(); // ...or only for devices advertising a service you care aboutBLE::startScanning(['180D']); // 180D = Heart Rate foreach (BLE::scannedDevices() as $device) { $device->id; // "AA:BB:CC:DD:EE:FF" on Android, a UUID on iOS $device->name; // string|null — many peripherals advertise no name $device->rssi; // int|null — signal strength in dBm, closer to 0 is nearer $device->services; // ["180D"] $device->connected; // bool} BLE::connect($deviceId);BLE::stopScanning();
Nothing here throws. Off-device — in tests, in CI, under php artisan serve — the methods return
false or an empty array, so a screen that scans still renders on the desktop.
Methods#
startScanning(array $services = [], bool $allowDuplicates = true): bool#
Starts a scan. Discoveries arrive as DeviceDiscovered events and accumulate in scannedDevices().
Pass service UUIDs — 16-bit shorthand like '180D' or full UUIDs — to have the platform filter for
you. An empty list reports everything in range, which is heavier on the radio and on the event
stream. allowDuplicates keeps the platform reporting repeat advertisements, which is what keeps
rssi fresh and makes out-of-range detection prompt; turn it off to save battery.
Returns false when Bluetooth is off, or — on Android — when permission has just been requested.
The Android permission prompt is asynchronous: the first call requests BLUETOOTH_SCAN /
BLUETOOTH_CONNECT and returns false, and the next call after the user allows it starts the scan.
stopScanning(): bool#
Stops the scan. Safe to call when nothing is running.
connect(string $deviceId): bool#
Opens a GATT connection. Returns as soon as the connection is initiated — the outcome arrives as a
DeviceConnected or ConnectionFailed event. false means the request itself was rejected
(unknown device, Bluetooth off, already connected), not that the connection failed.
Neither platform times a connection attempt out on its own: iOS retries until it succeeds or you
call disconnect(), and Android surfaces its own timeout as a ConnectionFailed event.
disconnect(string $deviceId): bool#
Closes the connection, or abandons an attempt still in progress. A DeviceDisconnected event
follows.
scannedDevices(): Device[]#
Devices seen during the current scan, in the order they were first seen. Cleared when a scan starts, and pruned when a device stops advertising.
connectedDevices(): Device[]#
Devices this app currently holds a connection to.
systemPeripherals(array $services): Device[]#
Devices already connected to the operating system by another app — a watch paired to its companion app, say. They never show up in a scan because they are not advertising.
Both platforms look them up by service UUID: on iOS the list is required (CoreBluetooth exposes these devices no other way), while on Android an empty list returns every system-connected device.
// Anything exposing Heart Rate or Battery$wearables = BLE::systemPeripherals(['180D', '180F']);
reconnect(array $deviceIds): int#
Connects to devices by an id you stored earlier, without waiting for them to advertise — the way to restore connections on app launch. Returns how many the platform could resolve and attempt; each outcome still arrives as an event.
BLE::reconnect(auth()->user()->paired_device_ids);
The Device object#
| Property | Type | Notes |
|---|---|---|
id |
string |
Peripheral UUID (iOS) or MAC address (Android). Stable per device on Android; per device per app install on iOS. |
name |
?string |
From the advertisement, falling back to the cached GAP name. |
rssi |
?int |
dBm. null for devices that were not scanned (system peripherals, connections). |
services |
string[] |
Advertised service UUIDs, uppercased. |
manufacturerId |
?int |
Bluetooth SIG company identifier from the advertisement. |
connected |
bool |
|
seenAt |
?int |
Milliseconds since the epoch. |
Two helpers save some string wrangling:
$device->advertises('180d'); // case-insensitive service check$device->madeBy(0x004C); // Bluetooth SIG company ID — 0x004C is Apple
Events#
All events live in NativePHP\BLE\Events and are dispatched from native code, so listen for them
with #[OnNative]:
use Livewire\Component;use Native\Mobile\Attributes\OnNative;use NativePHP\BLE\Events\DeviceDiscovered;use NativePHP\BLE\Events\DeviceDisconnected; class DeviceList extends Component{ public array $devices = []; #[OnNative(DeviceDiscovered::class)] public function found(string $id, ?string $name = null, ?int $rssi = null): void { $this->devices[$id] = ['name' => $name, 'rssi' => $rssi]; } #[OnNative(DeviceDisconnected::class)] public function gone(string $id, string $reason = ''): void { unset($this->devices[$id]); }}
| Event | Payload |
|---|---|
DeviceDiscovered |
id, name, rssi, services, manufacturerId, timestamp |
DeviceConnected |
id, name, timestamp |
DeviceDisconnected |
id, reason, timestamp |
ConnectionFailed |
id, error, timestamp |
ScanningStarted |
timestamp |
ScanningStopped |
timestamp |
Two things worth knowing about the event stream:
DeviceDiscoveredfires once per device per scan, not once per advertisement. Later advertisements refreshrssiinscannedDevices()silently — one event per advertisement would flood the bridge.- A scanned device that stops advertising for 8 seconds fires
DeviceDisconnectedwithreason: "out_of_range". Connected devices are exempt: they stop advertising by design, and their departure is reported by the connection itself dropping. - A scan that cannot run fires
ConnectionFailedwith an emptyid, followed byScanningStopped.
Permissions#
The plugin declares what it needs; the build injects it.
iOS contributes NSBluetoothAlwaysUsageDescription to Info.plist and the bluetooth-central
background mode, which keeps connections alive when the app is backgrounded. Override the usage
string in your app's nativephp.json if you want wording specific to your app — the system shows it
verbatim in the permission prompt.
Android declares BLUETOOTH_SCAN, BLUETOOTH_CONNECT, BLUETOOTH, BLUETOOTH_ADMIN,
ACCESS_FINE_LOCATION and ACCESS_COARSE_LOCATION, and requires the bluetooth_le hardware
feature. Android 12+ uses the two runtime Bluetooth permissions; earlier versions treat BLE scanning
as a location capability, hence the location permissions.
Platform differences#
| iOS | Android | |
|---|---|---|
Device id |
Peripheral UUID, stable per app install | MAC address, stable |
| Scan filtering | CBCentralManager service filter |
ScanFilter service filter |
systemPeripherals() |
Requires service UUIDs | Service UUIDs optional |
reconnect() |
retrievePeripherals(withIdentifiers:) |
Address lookup, no scan needed |
| Permission prompt | On first radio use, from the OS | Requested by the plugin, asynchronous |
Because iOS peripheral UUIDs are per app install, ids saved by another app — or by your app before a reinstall — will not resolve. Android MAC addresses do not have this problem.
Testing#
The plugin extends the NativePHP testing suite with BLE-specific helpers, so your app tests can fake a radio and assert against it without knowing any bridge internals:
use Native\Mobile\Testing\Native; it('lists devices in range', function () { Native::fakeBridge()->withBleDevices([ ['id' => 'AA:BB:CC', 'name' => 'Heart Monitor', 'rssi' => -55, 'services' => ['180D']], ]); Native::test(DeviceList::class) ->tap('Scan') ->assertScanStarted(['180D']) ->assertSee('Heart Monitor');}); it('pairs with the device you tap', function () { Native::fakeBridge()->withBleDevices([ ['id' => 'AA:BB:CC', 'name' => 'Heart Monitor'], ]); Native::test(DeviceList::class) ->tap('Scan') ->tap('Heart Monitor') ->assertBleConnected('AA:BB:CC');});
Helpers#
withBleDevices(array $devices = [])— stand a fake radio up around a fixed set of devices. Scans and system lookups report them, connect/disconnect succeed for ids in the set and fail for anything else, andconnectedDevices()tracks what the test connected. Onlyidis required per device.assertScanStarted(?array $services = null)— a scan ran, optionally with exactly this filter.assertScanStopped()/assertNotScanning()assertBleConnected(?string $deviceId = null)/assertBleDisconnected(?string $deviceId = null)assertNoBleConnection()
The helpers are available on Native::fakeBridge() and chain directly off Native::test(...). They
register automatically while running tests (requires a core with a macroable FakeBridge; on older
cores they simply don't register).
Run the plugin's own suite with composer test.