Bluetooth Low Energy powers everything from smart bands to wireless thermometers, but reaching it from Laravel used to mean writing Swift or Kotlin by hand. The BLE plugin for NativePHP Mobile closes that gap: your app scans for peripherals, connects to them, and reacts to devices arriving and leaving, all driven from PHP and Livewire.

One thing to be clear about up front: the plugin tracks presence and connection state. It is built for knowing which devices are around, how strong their signal is, and whether you are connected to them. Reading and writing GATT characteristics is not part of it.

Why BLE in NativePHP?

If you have ever talked to BLE hardware from a hybrid stack, you know the pain. The native APIs differ between iOS and Android, and asynchronous events, permission prompts, and reconnections get messy fast. iOS identifies a peripheral one way, Android another. Android treats scanning as a location capability on older releases. Each platform has its own idea of when a device counts as gone.

NativePHP's plugin system puts a PHP facade and an event bridge over both, so the app code stays in Laravel. What the plugin gives you:

  • Scanning for nearby BLE devices, optionally filtered by service UUID so the radio and the event stream only carry what you care about.
  • Connecting and disconnecting on demand, with the outcome delivered as an event.
  • Signal strength (RSSI) that refreshes as advertisements come in.
  • Native events when devices are discovered, connect, disconnect, or fail to connect.
  • Access to system peripherals — devices already connected to the OS by another app, such as a watch paired to its companion app, which never show up in a scan because they are not advertising.
  • Reconnecting to devices by an identifier you stored earlier, without waiting for them to advertise — the way to restore connections when the app launches.
  • A fake radio for your test suite, so screens that scan can be tested on CI with no hardware.

Nothing in the API throws. Off-device — in tests, in CI, under php artisan serve — calls return empty rather than blowing up, so a screen that scans still renders on the desktop.

Requirements

Requirement Version
PHP 8.2+
NativePHP Mobile 3.0+ or 4.0+
iOS 18.2+
Android 8.0+ (API 26+)

Scanning needs real hardware. BLE does not work in the iOS Simulator or the Android emulator, so plan on testing against a phone — or against the plugin's fake radio.

Installation, in short

Install the package with Composer, register it as a NativePHP plugin with an Artisan command, then rebuild the app so the plugin's Swift and Kotlin are compiled in. That last step matters: BLE is native code, not something that can be hot-reloaded into a running build.

What a device looks like

Every device the plugin reports carries the same shape: an identifier, a name where the peripheral advertises one, signal strength in dBm, the service UUIDs it advertises, the manufacturer's Bluetooth SIG company identifier, whether you are connected to it, and when it was last seen.

Identifiers are the one detail worth internalising. On Android the identifier is a MAC address and it is stable. On iOS it is a peripheral UUID that is stable per device per app install, so an identifier saved before a reinstall — or by a different app — will not resolve. If you persist pairings, expect to re-pair on iOS after a reinstall.

Events, and what they promise

The plugin dispatches native events your Livewire components subscribe to: DeviceDiscovered, DeviceConnected, DeviceDisconnected, ConnectionFailed, ScanningStarted and ScanningStopped.

Listening is ordinary Livewire. The OnNative attribute wires a component method to an event coming off the native bridge, and the payload arrives as method arguments:

use Livewire\Component;
use Native\Mobile\Attributes\OnNative;

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]);
    }
}

Note the nullable arguments on discovery: plenty of peripherals advertise no name, and signal strength is not always present. Anything that assumes both will bite you on real hardware.

Three behaviours are worth knowing before you design a screen around them:

  • Discovery fires once per device per scan, not once per advertisement. Later advertisements refresh the device's signal strength quietly — an event per advertisement would flood the bridge. A live dashboard therefore reads RSSI from the scanned-device list on a poll, rather than expecting an event per reading.
  • A scanned device that goes quiet for eight seconds is reported as disconnected with an out-of-range reason. Connected devices are exempt: they stop advertising by design, and their departure shows up as the connection itself dropping.
  • A scan that cannot start — Bluetooth off, permission not yet granted — reports a failure and then a scan-stopped event, rather than silently doing nothing.

Connection attempts are asynchronous everywhere. Asking to connect tells you the request was accepted, not that it succeeded; the result arrives as an event. Neither platform times an attempt out on its own — iOS keeps retrying until it succeeds or you cancel, and Android surfaces its own timeout as a failure event.

RSSI and proximity

RSSI gives you a rough sense of how close a device is. It is noisy — bodies, walls and orientation all move it — so treat it as buckets rather than a distance. These work well enough in practice:

RSSI Range Proximity
>= -60 dBm Very Close
-61 to -75 dBm Nearby
< -75 dBm Far

Permissions

The plugin declares what it needs and the build injects it, so there is no manifest editing on your side.

iOS contributes a Bluetooth usage description to Info.plist plus the bluetooth-central background mode, which keeps connections alive when the app is backgrounded. You can override the usage string with wording specific to your app — the system shows it verbatim in the permission prompt.

Android declares the scan and connect permissions, the legacy Bluetooth permissions, and fine and coarse location, and requires BLE hardware. Android 12 and up use the two runtime Bluetooth permissions; earlier versions treat BLE scanning as a location capability, hence the location entries. The Android prompt is asynchronous, so the first scan request triggers the permission dialog and the scan starts on the next attempt once the user allows it.

Platform differences

iOS Android
Device identifier Peripheral UUID, stable per app install MAC address, stable
Native framework CoreBluetooth RxAndroidBle
System peripheral lookup Requires service UUIDs Service UUIDs optional
Reconnect by identifier Supported Supported
Permission prompt On first radio use, from the OS Requested by the plugin, asynchronous

Testing without a radio

The part I am happiest about. The plugin extends the NativePHP testing suite with BLE helpers, so a test can stand up a fake radio around a fixed set of devices and then assert against it: that a scan ran, that it used the service filter you expected, that tapping a device connected to it, that a screen stopped scanning when it should. No hardware, no bridge internals, and CI stays green.

Under the hood

  • iOS: Apple's CoreBluetooth framework.
  • Android: RxAndroidBle on top of RxJava, for non-blocking BLE streams.
  • Standard NativePHP plugin layout — a manifest declaring bridge functions, permissions and events, with Kotlin and Swift on one side and a PHP facade on the other.

What's next

Still on the list:

  • Auto-reconnect for dropped devices.
  • GATT characteristic reads and writes.
  • Signal strength charting in Livewire.