cion/laravel-text-to-speech
A Laravel package for converting text to speech with a simple API, configurable voices, and support for multiple TTS providers. Easily generate audio files from text in your Laravel apps.
Install and verify compatibility first. The package has not shipped a release since December 2023 and has zero dependents, so before writing any code confirm it installs cleanly on your Laravel version:
composer require cion/laravel-text-to-speech
composer show cion/laravel-text-to-speech # check requires vs. your laravel/framework constraint
If Composer resolves to an old framework constraint, either pin the package and stay on that Laravel line, or fork it — this is the single biggest "day one" risk for a package this small.
Publish the config and register credentials. The package follows the standard Laravel driver-manager shape (a facade over a manager that resolves provider drivers from config), so the config file is the source of truth for which providers exist and which one is the default:
php artisan vendor:publish --tag=text-to-speech-config
Then set the provider keys in .env (ELEVENLABS_API_KEY, OPENAI_API_KEY, AWS_*, etc. — use whatever names the published config/text-to-speech.php references) and set your default driver there.
Where to read first, in order:
README.md — the only usage documentation; treat the method names below as illustrative and match them to the README.config/text-to-speech.php — the list of supported drivers and their options (voice, model, format, sample rate).src/ — the manager class and the Drivers/ directory. Each driver is a thin adapter, so reading one tells you exactly what the shared API supports and what it silently drops.composer.json — for the Laravel/PHP constraint and the autoloaded facade alias.First use case: synthesize a short string to a file on a disk. This is the 20-line path that proves credentials, driver selection, and storage all work:
use Cion\TextToSpeech\Facades\TextToSpeech;
$path = TextToSpeech::driver('openai') // or your configured default
->convert('Welcome to the show.')
->save(storage_path('app/public/hello.mp3'));
Once that file plays, you have validated the whole chain: config → driver resolution → HTTP call → binary handling → disk write. Everything else is scaling that pattern.
Wrap the facade in your own service, never call it from controllers. The facade is a static entry point to a manager; injecting it directly makes tests brittle and couples your app to a driver API that may change. Bind a small interface instead:
interface SpeechSynthesizer {
public function synthesize(string $text, array $options = []): string; // returns storage path
}
class ProviderSpeechSynthesizer implements SpeechSynthesizer
{
public function synthesize(string $text, array $options = []): string
{
$driver = $options['driver'] ?? config('text-to-speech.default');
return TextToSpeech::driver($driver)
->convert($text)
->save($this->pathFor($text, $options));
}
}
This gives you one seam for swapping providers, faking in tests, and adding logging/cost tracking.
Always run synthesis in a queued job. TTS calls are 1–10+ seconds for a paragraph and longer for anything substantial. A job with $timeout, $tries = 3, and backoff() is the correct default:
class GenerateSpeech implements ShouldQueue
{
public int $tries = 3;
public array $backoff = [5, 30, 120];
public function __construct(
public string $text,
public string $diskPath,
public array $options = [],
) {}
public function handle(SpeechSynthesizer $tts): void
{
$tts->synthesize($this->text, $this->options);
}
}
Dispatch on a dedicated queue so TTS latency doesn't starve your transactional jobs.
Cache by content hash, not by request. TTS is billed per character. Keying on a hash of (text, voice, model, format) and storing the resulting file on a shared disk means repeat content is free:
$key = 'tts:'.md5(json_encode([$text, $voice, $format]));
$path = Cache::remember($key, now()->addDays(30), fn () => $tts->synthesize($text, $options));
Serve audio through a controller with a signed URL, not a public disk. Audio files are user-generated and often large; Storage::disk('tts')->temporaryUrl(...) or a signedRoute keeps them off the public web and works fine with S3/R2.
Use the default driver as a switch, not a hardcode. Put provider selection in config so dev/staging/prod can differ (cheap provider in CI, premium voice in prod), and pass an explicit driver() only where a specific voice genuinely matters.
Chunk long text yourself. The shared API almost certainly does not implement provider-specific chunking or SSML splitting. Split on sentence boundaries at roughly the provider's character limit, synthesize each chunk, and concatenate — or better, store segments and concatenate at playback to avoid re-billing on retry.
For streaming APIs, don't write to disk at all. If you're returning audio to a browser, stream the provider response directly (response()->streamDownload(...)) so you skip a round trip through the filesystem; keep the file-write path for pre-generated/podcast-style content.
The package is effectively unmaintained. Last release December 2023, 43 stars, zero dependents. Pin the exact version in composer.json, and budget for the likelihood that you own bug fixes. Check whether it supports the current Laravel major before adopting — if it doesn't, fork and bump the constraint yourself rather than waiting.
config:cache will break naive env usage. If the package reads env() directly inside a driver instead of through config(), credentials silently become null in production. Audit src/ for env( calls and move any you find into the published config file. Same class of bug applies to app()->environment() checks.
Driver method surfaces are not identical. The promise of a "shared API" is the lowest common denominator. Voice cloning, SSML break/emphasis tags, word-level timestamps, and output formats will be supported by some providers and silently ignored — or throw — by others. Read each driver before assuming an option is portable; isolate provider-specific options in your own service so a provider swap is a diff in one file.
Output format and sample rate differ per provider. MP3 vs. WAV vs. Opus, 22.05kHz vs. 44.1kHz. Browsers and downstream tools (ffmpeg, podcast platforms) care. Normalize at the storage layer or constrain format in config per driver and document it.
Character limits cause silent truncation or 400s. Providers cap per-request characters differently. Validate text length before dispatch and fail loudly in your job; a truncated podcast intro that "worked" is worse than an exception.
Rate limits and 429s need backoff, not retries-in-a-loop. Use Laravel's job backoff() and retryUntil(); also consider RateLimited middleware on the queue by provider so a batch of 500 dispatches doesn't burn your quota in ten seconds.
Costs are per character, and retries are double-billed. A failed job that re-runs after a partial success still costs money. Persist a synthesis record (text_hash, provider, characters, cost_estimate, status) so you can see spend and avoid duplicate work; idempotency on the content hash is the cheapest defense.
Testing: the package likely has no fake(). Check src/ for a testing helper before assuming one exists. If not, bind your SpeechSynthesizer interface to a stub in the container, or use Http::fake() against the provider's endpoint and assert the driver wrote the expected file. This is the strongest argument for the service-wrapper pattern above.
Facade alias/namespace drift. Because the Packagist name (cion/...) and the GitHub repo (meemalabs/...) differ, verify the actual namespace and facade alias from composer.json autoload and the service provider rather than copying snippets from a blog post — a wrong use statement is the most common first-hour failure with small packages.
Extension point is the manager, not the driver. If you need a provider the package doesn't ship, look at how the manager resolves drivers (extend() / TextToSpeech::extend(...) if it follows Laravel's manager convention). Registering a custom driver in a service provider is far cleaner than forking — and it's the right way to survive the package's slow maintenance.
How can I help you explore Laravel packages today?