sparkworldke / nativephp-sms-reader-bridge
A NativePHP Mobile plugin that lets your Android app read SMS messages from the device inbox and listen for incoming SMS in real time.
Package info
github.com/sparkworldke/nativephp-sms-reader-bridge
Type:nativephp-plugin
pkg:composer/sparkworldke/nativephp-sms-reader-bridge
Requires
- php: ^8.2
- laravel/framework: ^11.0|^12.0|^13.0
- nativephp/mobile: ^3.0|^4.0
Requires (Dev)
- laravel/boost: ^2.6
- laravel/pint: ^1.24
- mockery/mockery: ^1.6
- nunomaduro/collision: ^8.6
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^4.1|^5.0
- pestphp/pest-plugin-laravel: ^4.0|^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A NativePHP Mobile plugin for Laravel that lets your Android app read SMS messages from the device inbox and listen for incoming SMS in real time.
Fork notice: This package is a maintained fork of
atendwa/nativephp-sms-readerby Anthony Tendwa, published on Packagist assparkworldke/nativephp-sms-reader-bridgeand updated to support Laravel 11, 12, and 13 with NativePHP Mobile 3 and 4.
Introduction
sparkworldke/nativephp-sms-reader-bridge bridges the Android SMS ContentProvider and BroadcastReceiver into your Laravel application. It provides:
SmsReader::getMessages()— query the device inbox (or any other box: sent, drafts, spam, …) with optional sender, limit, folder, and date filters.SmsReader::getMessagesForSenders()— batch inbox queries across multiple senders.SmsReceivedevent — fired in real time whenever a new SMS arrives on the device.
The plugin only operates inside the NativePHP Android runtime. On the web, in Artisan, or on iOS it returns empty results rather than throwing, so your code runs safely in all environments.
Requirements
| Requirement | Version |
|---|---|
| PHP | 8.2+ (8.3+ for Laravel 13) |
| Laravel | 11, 12, or 13 |
| NativePHP Mobile | 3.x or 4.x |
| Android | API 26+ (Android 8.0) |
Installation
1. Add the package via Composer
composer require sparkworldke/nativephp-sms-reader-bridge
2. Register the plugin with NativePHP
NativePHP requires every plugin to be explicitly registered as a security measure — it prevents transitive Composer dependencies from silently bundling native code into your app.
php artisan native:plugin:register sparkworldke/nativephp-sms-reader-bridge
This adds the service provider to app/Providers/NativeServiceProvider.php:
public function plugins(): array { return [ \Sparkworldke\SmsReader\SmsReaderServiceProvider::class, ]; }
3. Rebuild the app
Plugin changes require a full rebuild:
php artisan native:run android
Verifying the Installation
Check the plugin is registered
php artisan native:plugin:list
You should see sparkworldke/nativephp-sms-reader-bridge in the output.
Validate the plugin manifest
php artisan native:plugin:validate
Test the bridge on-device
Add a temporary debug button in a Livewire component to confirm the bridge is reachable:
use Sparkworldke\SmsReader\Facades\SmsReader; public function debugSms(): void { $raw = SmsReader::getRawResponse(['sender' => 'MPESA', 'limit' => 1]); dd($raw); // inspect the raw JSON from the native bridge }
If the bridge is working you will see a JSON string like:
{"messages":[{"id":"123","sender":"MPESA","body":"...","timestamp":1234567890000}]}
If nativephp_call() does not exist (e.g. you are running on the web), the method returns a descriptive string rather than throwing.
Usage
Reading inbox messages
use Sparkworldke\SmsReader\Facades\SmsReader; // All messages from MPESA in the last 30 days $sinceMs = now()->subDays(30)->timestamp * 1000; $messages = SmsReader::getMessages([ 'sender' => 'MPESA', 'limit' => 500, 'since' => $sinceMs, ]); // Each message: ['id' => string, 'sender' => string, 'body' => string, 'timestamp' => int] foreach ($messages as $sms) { echo $sms['body']; }
Available options:
| Option | Type | Description |
|---|---|---|
sender |
string |
Filter by originating address (exact match) |
limit |
int |
Maximum number of messages to return (default 500) |
since |
int |
Only return messages newer than this Unix timestamp in milliseconds |
folder |
string |
Which box to read (default inbox) — see Folders |
Folders
By default only the inbox is read. Pass the folder option to query a different box:
| Folder | Contents |
|---|---|
inbox |
Received messages (default) |
all |
Every box combined |
sent |
Sent messages |
draft |
Drafts |
outbox |
Messages waiting to be sent |
queued |
Messages queued by the SMS app (matched by type) |
failed |
Messages that failed to send (matched by type) |
spam |
The device's spam folder, when the device has one |
use Sparkworldke\SmsReader\Facades\SmsReader; // Read the spam folder $spam = SmsReader::getMessages(['folder' => 'spam', 'limit' => 50]); // The folder also works for batch queries $sent = SmsReader::getMessagesForSenders(['MPESA'], folder: 'sent');
Passing an unsupported folder throws an InvalidArgumentException before any bridge call is made.
Spam folder caveats: Android has no standard spam box in its SMS provider. Some OEM ROMs (e.g. certain Samsung and Xiaomi devices) expose one and will return real results. On stock Android and devices using Google Messages, spam lives in private app storage and cannot be read by third-party apps — on those devices the plugin queries the OEM spam box, finds none, and returns an empty array (with a log line explaining why) instead of failing. Treat an empty spam result as "this device exposes no spam folder", not as "there is no spam".
Reading from multiple senders
$messages = SmsReader::getMessagesForSenders( senders: ['MPESA', 'airtelmoney'], sinceMs: now()->subDays(90)->timestamp * 1000, limitEach: 500, folder: 'inbox', // any folder from the table above );
Results are merged and sorted by timestamp descending.
Listening for incoming SMS (real time)
Use the #[OnNative] attribute in any Livewire component:
use Sparkworldke\SmsReader\Events\SmsReceived; use Native\Mobile\Attributes\OnNative; class Dashboard extends Component { #[OnNative(SmsReceived::class)] public function onSmsReceived( string $sender, string $body, int $timestamp, string $id, ): void { // $sender — originating address, e.g. "MPESA" // $body — full SMS text // $timestamp — Unix milliseconds since epoch // $id — "{sender}_{timestamp}", stable per message } }
Livewire v3 & v4: The
#[OnNative]attribute is provided by NativePHP Mobile and works with both Livewire v3 and v4.
JavaScript Usage
For Vue, React, Inertia, or vanilla JS apps, import directly from the package's JS file:
import { getMessages, getMessagesForSenders, onSmsReceived, offSmsReceived, } from './vendor/sparkworldke/nativephp-sms-reader-bridge/resources/js/smsReader.js';
TypeScript definitions are included at resources/js/smsReader.d.ts.
Reading inbox messages
// All MPESA messages from the last 30 days const sinceMs = Date.now() - 30 * 24 * 60 * 60 * 1000; const messages = await getMessages({ sender: 'MPESA', limit: 500, since: sinceMs }); for (const sms of messages) { console.log(sms.sender, sms.body, sms.timestamp); }
The folder option works here too — see Folders:
import { getMessages, SMS_FOLDERS } from './vendor/sparkworldke/nativephp-sms-reader-bridge/resources/js/smsReader.js'; const spam = await getMessages({ folder: 'spam', limit: 50 }); console.log('Supported folders:', SMS_FOLDERS.join(', '));
Reading from multiple senders
const messages = await getMessagesForSenders( ['MPESA', 'airtelmoney'], sinceMs, // null to skip date filter 500, // limitEach 'inbox', // folder — any value from SMS_FOLDERS ); // Results are merged and sorted by timestamp descending
Listening for incoming SMS (real time)
const handler = ({ sender, body, timestamp, id }) => { console.log(`New SMS from ${sender}: ${body}`); }; // Register onSmsReceived(handler); // Unregister when the component unmounts offSmsReceived(handler);
If you prefer to use NativePHP's own JS event bus directly, the exported constant SMS_RECEIVED_EVENT holds the fully-qualified event class name:
import { SMS_RECEIVED_EVENT } from './vendor/sparkworldke/nativephp-sms-reader-bridge/resources/js/smsReader.js'; window.Native?.on(SMS_RECEIVED_EVENT, handler);
Vue 3 (Composition API)
<script setup> import { onMounted, onUnmounted, ref } from 'vue'; import { getMessages, onSmsReceived, offSmsReceived } from './vendor/sparkworldke/nativephp-sms-reader-bridge/resources/js/smsReader.js'; const messages = ref([]); const error = ref(null); async function loadMessages() { try { messages.value = await getMessages({ sender: 'MPESA', limit: 50 }); } catch (e) { error.value = e.message; } } const handleIncoming = (sms) => { messages.value.unshift(sms); }; onMounted(() => { loadMessages(); onSmsReceived(handleIncoming); }); onUnmounted(() => { offSmsReceived(handleIncoming); }); </script>
React
import { useEffect, useState } from 'react'; import { getMessages, onSmsReceived, offSmsReceived } from './vendor/sparkworldke/nativephp-sms-reader-bridge/resources/js/smsReader.js'; export default function SmsList() { const [messages, setMessages] = useState([]); const [error, setError] = useState(null); useEffect(() => { getMessages({ sender: 'MPESA', limit: 50 }) .then(setMessages) .catch((e) => setError(e.message)); const handleIncoming = (sms) => setMessages((prev) => [sms, ...prev]); onSmsReceived(handleIncoming); return () => offSmsReceived(handleIncoming); }, []); if (error) return <p>{error}</p>; return ( <ul> {messages.map((sms) => ( <li key={sms.id}>{sms.body}</li> ))} </ul> ); }
Permissions
The plugin declares the required permissions automatically via nativephp.json. You do not need to add them manually.
| Permission | Purpose |
|---|---|
READ_SMS |
Query the inbox ContentProvider |
RECEIVE_SMS |
Listen for incoming messages via BroadcastReceiver |
On Android 6.0+ these are runtime permissions. The plugin requests them automatically the first time getMessages() is called. If the user has not granted them yet, a RuntimeException is thrown with the message:
SmsReader bridge error [PERMISSION_REQUIRED]: READ_SMS permission has not been granted...
Show the user a prompt and retry after they grant the permission.
Error Handling
getMessages() throws a RuntimeException in these situations:
| Condition | Exception message |
|---|---|
nativephp_call not in registry |
SmsReader.GetMessages not found in bridge registry |
| Bridge returned invalid JSON | SmsReader bridge returned invalid JSON: ... |
| Permission not granted | SmsReader bridge error [PERMISSION_REQUIRED]: ... |
| Permission denied by system | SmsReader bridge error [PERMISSION_DENIED]: ... |
| Any other native error | SmsReader bridge error [ERROR_CODE]: ... |
An unsupported folder option throws an InvalidArgumentException (Invalid folder '...') before any bridge call is made — the same validation runs in the JavaScript getMessages().
use RuntimeException; try { $messages = SmsReader::getMessages(['sender' => 'MPESA']); } catch (RuntimeException $e) { // surface the error to the user $this->error = $e->getMessage(); }
The JavaScript getMessages() function throws an Error with the same message format:
try { const messages = await getMessages({ sender: 'MPESA' }); } catch (e) { // e.message — "SmsReader bridge error [PERMISSION_REQUIRED]: ..." console.error(e.message); }
Testing
The package ships with a Pest test suite that covers all three methods and the SmsReceived event. Because the bridge only exists inside the NativePHP Android runtime, tests use a thin subclass that overrides two protected hook methods (isOnDevice() and callBridge()) to simulate on-device behaviour without requiring a real device.
# Install dev dependencies inside the package directory cd packages/nativephp-sms-reader-bridge composer install # Run all tests composer test
Support
Found a bug or have a question? Open an issue on GitHub.
For issues inherited from the original package, you may also consult the upstream repository: atendwa/nativephp-sms-reader.
Credits
- Anthony Tendwa — original author
- Sparkworld — Laravel 13 / NativePHP Mobile 4 support and ongoing maintenance
License
MIT — Copyright © 2026 Anthony Tendwa. Fork maintained by Sparkworld under the same MIT license.