Text-to-Speech Plugin for NativePHP Mobile#
On-device text-to-speech. PHP and JavaScript ask the OS to speak a string.
Used in: PARqore's own apps New Life Daily, Hard Moments, Echo Relay, and GridUnlock.
See it in action#
Vipul Walia (vipertecpro) built a free, open-source demo app and a hands-on walkthrough of this plugin, together with PARqore's Speech to Text, Native Share and Force Update:
- Video: NativePHP Mobile: Speech to Text, Text to Speech, Share & Force Update
- Demo app on GitHub
- Hands-on blog post
Overview#
partek/text-to-speech calls the operating system's own speech engine from a NativePHP Mobile app. iOS uses AVSpeechSynthesizer. Android uses android.speech.tts.TextToSpeech. There is no cloud TTS, no window.speechSynthesis wrapper, and no extra permissions.
You call the facade from a Super Native press handler, a Livewire action, or Blade. The same methods are exported for JavaScript. Register the plugin once; there is no boot-time setup after that.
Buy this when you need the device to read a string aloud — confirmations, accessibility readbacks, spoken prompts — without adding a speech vendor, a network dependency, or microphone/camera entitlements.
Installation#
composer require partek/text-to-speech
Don't forget to register the plugin:
php artisan native:plugin:register partek/text-to-speech
Usage#
PHP (Super Native)#
Call the facade from a Super Native screen method. A button or pressable is enough.
use Native\Mobile\Attributes\On;use Native\Mobile\Edge\NativeComponent;use PARTek\TextToSpeech\Events\SpeechFailed;use PARTek\TextToSpeech\Events\SpeechFinished;use PARTek\TextToSpeech\Facades\TextToSpeech; class ReadAloud extends NativeComponent{ public function speakPassage(): void { TextToSpeech::speak('The oven is preheated to 220 degrees.', 'en-US', 0.5, 1.0); } public function stopSpeaking(): void { TextToSpeech::stop(); } #[On(SpeechFinished::class)] public function onSpeechFinished(?string $text = null): void { // Utterance finished } #[On(SpeechFailed::class)] public function onSpeechFailed(string $message, ?string $text = null): void { // Engine rejected the utterance }}
<native:column class="w-full p-4 gap-3"> <native:button label="Speak" @press="speakPassage" /> <native:button label="Stop" @press="stopSpeaking" /></native:column>
PHP (Livewire / Blade)#
use Native\Mobile\Attributes\OnNative;use PARTek\TextToSpeech\Events\SpeechStarted;use PARTek\TextToSpeech\Facades\TextToSpeech; TextToSpeech::speak('Hello from the device.');TextToSpeech::speak('Hello from the device.', 'en-GB', 0.45, 1.0); TextToSpeech::pause();TextToSpeech::resume();TextToSpeech::stop(); $speaking = TextToSpeech::isSpeaking();
#[OnNative(SpeechStarted::class)]public function onSpeechStarted(?string $text = null): void{ // Speech started}
Empty or whitespace-only text returns false and does not call the native bridge.
JavaScript (Vue / React / Inertia)#
import { speak, stop, pause, resume, isSpeaking } from 'vendor/partek/text-to-speech/resources/js/index.js'; await speak('Hello from the device.', 'en-GB', 0.5, 1.0); if (await isSpeaking()) { await pause(); await resume(); await stop();}
The JS helper POSTs to /_native/api/call. If the document has a CSRF token (meta[name="csrf-token"] or input[name="_token"]), it is sent as X-CSRF-TOKEN. Bridge errors throw.
Events#
The native engines emit Laravel events the NativePHP bridge can deliver to the live Super Native screen or a Livewire component.
| Event | When | Payload |
|---|---|---|
PARTek\TextToSpeech\Events\SpeechStarted |
Utterance begins | ?string $text |
PARTek\TextToSpeech\Events\SpeechFinished |
Utterance completes | ?string $text |
PARTek\TextToSpeech\Events\SpeechFailed |
Engine or utterance fails | string $message, ?string $text |
stop() cancels the current utterance. That is not a finish and not a failure.
Super Native#
use Native\Mobile\Attributes\On;use PARTek\TextToSpeech\Events\SpeechFinished; #[On(SpeechFinished::class)]public function onSpeechFinished(?string $text = null): void{ // ...}
Livewire#
use Native\Mobile\Attributes\OnNative;use PARTek\TextToSpeech\Events\SpeechFinished; #[OnNative(SpeechFinished::class)]public function onSpeechFinished(?string $text = null): void{ // ...}
Methods#
speak(string $text, ?string $locale = null, ?float $rate = null, ?float $pitch = null): bool#
Starts speaking. A new call replaces the current utterance.
| Parameter | Type | Required | Description |
|---|---|---|---|
$text |
string |
yes | Text to speak. Empty / whitespace fails closed (false, no bridge call). |
$locale |
?string |
no | BCP-47 voice tag such as en-US or en-GB. Omitted uses the system voice. |
$rate |
?float |
no | Speech rate on the iOS AVSpeechUtterance scale 0.1–1.0 (typical default 0.5). Values outside the range are clamped. Non-finite values become 0.5. |
$pitch |
?float |
no | Pitch 0.5–2.0 (default 1.0). Values outside the range are clamped. Non-finite values become 1.0. |
Returns true when the bridge accepted the request. Speech itself is asynchronous — listen for events for start/finish/failure.
Rate mapping
PHP / iOS rate |
iOS AVSpeechUtterance.rate |
Android setSpeechRate |
|---|---|---|
0.5 (default) |
0.5 |
1.0 (normal) |
0.25 |
0.25 |
0.5 |
1.0 |
1.0 |
2.0 |
Android rate = iOS rate × 2.0.
stop(): bool#
Stops speaking and discards the queue.
pause(): bool#
Pauses if the platform supports it. See Platform behavior.
resume(): bool#
Resumes a paused utterance if the platform supports it.
isSpeaking(): bool#
Returns the engine's current speaking flag when the bridge can answer synchronously. If a host cannot surface the value, this returns false — listen for SpeechStarted and SpeechFinished instead.
Platform behavior#
| iOS | Android | |
|---|---|---|
| Engine | AVSpeechSynthesizer |
android.speech.tts.TextToSpeech |
| Minimum | iOS 18.0 | API 26 |
| Permissions | none | none |
speak() |
Stops any current/paused utterance, then speaks | QUEUE_FLUSH replaces the current utterance |
pause() |
pauseSpeaking(at: .immediate) |
No reliable pause API. The call is accepted and may no-op. stop() is the real cancel. |
resume() |
continueSpeaking() |
No-op when pause was a no-op |
isSpeaking() |
synthesizer.isSpeaking (true while speaking or paused) |
TextToSpeech.isSpeaking (true only while audio is playing) |
| Locale | AVSpeechSynthesisVoice(language:) |
setLanguage(Locale.forLanguageTag) — unsupported tags fall back to the default voice |
| Events | Synthesizer delegate | UtteranceProgressListener |
Android TTS initializes asynchronously on first speak(). The bridge returns once the utterance is queued. Init failure emits SpeechFailed.
Some Android engines cap utterance length (commonly around 4,000 characters). Split long copy into multiple speak() calls if a device rejects the text.
Voices, accents, and quality are the OS engine's. This plugin does not download voices or ship a third-party TTS SDK.
Testing#
Test helpers#
On a NativePHP core with the macroable test harness (nativephp/mobile 4.0.0 or later), this plugin adds text-to-speech helpers to Native::fakeBridge() and Native::test(), so your app's tests can drive a screen that speaks without a device:
use App\NativeComponents\ReadAloud;use Native\Mobile\Testing\Native; Native::fakeBridge()->withTextToSpeech(); Native::test(ReadAloud::class) ->tap('Speak') ->assertSpoke('The oven is preheated to 220 degrees.', 'en-US') ->startSpeaking() ->finishSpeaking() ->tap('Stop') ->assertSpeakingStopped();
| Helper | Registered on | What it does |
|---|---|---|
withTextToSpeech(bool $speaking = false) |
FakeBridge |
Answers every TextToSpeech.* bridge call as a working engine does, so speak(), stop(), pause() and resume() return true and isSpeaking() returns $speaking. |
assertSpoke(?string $text = null, ?string $locale = null) |
FakeBridge |
Asserts speak() reached the bridge: any text, or exactly $text and/or $locale as sent. The facade trims both before sending. |
assertNothingSpoken() |
FakeBridge |
Asserts speak() never reached the bridge. Empty text never does. |
assertSpeakingStopped() |
FakeBridge |
Asserts stop() reached the bridge. |
startSpeaking(?string $text = null) |
TestableComponent |
Delivers SpeechStarted to the screen's #[On] handler. $text defaults to the text of the last speak() call. |
finishSpeaking(?string $text = null) |
TestableComponent |
Delivers SpeechFinished, with the same default. |
failSpeaking(string $message = 'Speech failed', ?string $text = null) |
TestableComponent |
Delivers SpeechFailed, with the same default. Pass '' to send no text, as the engines do when initialisation fails. |
FakeBridge helpers also chain off the harness: Native::test(...)->assertSpoke() returns the harness.
- Call
withTextToSpeech()beforeNative::test()when the screen speaks inmount(). Without it the fake bridge answersnulland every facade method returnsfalse, so the screen runs its failure branch whileassertSpoke()still passes. startSpeaking(),finishSpeaking()andfailSpeaking()fail with a clear message when the screen never calledspeak()and you did not pass$text.- If you mock the facade (
TextToSpeech::shouldReceive(...)), calls never reach the bridge, so theFakeBridgehelpers see nothing. The event helpers still work when you pass$text. - The helpers are registered only while the app runs unit tests (
APP_ENV=testing). Production boots and v3 cores are untouched.
This package's suite#
PHPUnit covers the PHP contract: facade argument shaping, fail-closed empty text, and bridge method names against nativephp.json. TextToSpeechMacrosTest boots the NativePHP core with Testbench and drives the test helpers through the real facade and a small fixture screen. No hardware runs in this repository.
composer installcomposer test
composer validate should pass. Native paths (resources/android/src/com/partek/plugins/texttospeech/*.kt, resources/ios/Sources/*.swift, resources/js/index.js) are in place so php artisan native:plugin:validate can resolve them from a NativePHP app.
Now device-verified on both platforms (2026-09-23), via the plugin sandbox's own test harness. TextToSpeech::speak() was called on a real Android phone and a real iPad; on both, speak() returned true, a real SpeechStarted event fired, and a real SpeechFinished event fired a genuine ~9 seconds later - a plausible real speaking duration for the test sentence (not instant, which would indicate a no-op), confirming the native engine actually ran a full synthesis cycle rather than just accepting the call. On the iPad, Paul independently heard the sentence spoken out loud on the second run - genuine audible confirmation, not just timing evidence. pause()/resume()/stop()/isSpeaking() and the empty-string fail-closed path were not separately re-exercised in this pass (the PHPUnit suite already covers the PHP-side contract for those).