Search for Laravel text to speech and you get the same post every time: a hand-rolled Guzzle call to a provider endpoint, a file_put_contents, and no story at all for disks, queues or tests. Then the bill arrives, because a naive implementation regenerates the same notification voiceover on every single page load.
The Laravel AI SDK collapses the plumbing into Audio::of(...)->store(). What it does not give you is the caching layer, so I have written that part too.
Configure a text-to-speech provider and default model#
Install the SDK, publish its config, and put a key for a TTS-capable provider in your .env. The provider support table lists OpenAI, ElevenLabs and Gemini for TTS — three of the ten-plus providers the SDK speaks to, so check that table before you commit to one.
composer require laravel/ai
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate
OPENAI_API_KEY=sk-...
# or
ELEVENLABS_API_KEY=...
GEMINI_API_KEY=...
Two things worth knowing before you pick. Custom base URLs — for routing through LiteLLM, a corporate gateway or Azure OpenAI Gateway — are supported for OpenAI, Anthropic, Gemini, Groq, Cohere, DeepSeek, xAI and OpenRouter. ElevenLabs is not on that list. If your infrastructure requires a proxy in front of every outbound AI call, ElevenLabs is out before you start.
Second, the docs say default models "for text, images, audio, transcription, and embeddings may also be configured in your application's config/ai.php" but never show the audio key. Open the published config after vendor:publish and read the models array rather than guessing. Same for provider failover: generate(provider: [...]) is documented for agents and for Image, and it is not documented for Audio. I would not build a failover chain on audio until you have confirmed it against the installed package.
Generate your first text to speech clip and inspect the raw bytes#
Laravel\Ai\Audio is a plain class, not a facade. Call of() with your text, generate() to run it, and cast the result to a string when you want the raw bytes.
use Laravel\Ai\Audio;
$audio = Audio::of('I love coding with Laravel.')->generate();
$rawContent = (string) $audio;
That string is the file contents — MP3 bytes, typically. You can file_put_contents it if you insist, but the next step is the reason to use the SDK at all.
Store the clip on a disk instead of handling bytes yourself#
Four methods write the clip to the default disk from config/filesystems.php and return the path. This is the ergonomic win: you get a Storage path back, not a byte string you have to babysit.
$audio = Audio::of('I love coding with Laravel.')->generate();
$path = $audio->store(); // speech/9f2a....mp3 — random name, private
$path = $audio->storeAs('audio.mp3'); // audio.mp3 — private
$path = $audio->storePublicly(); // random name, public visibility
$path = $audio->storePubliclyAs('audio.mp3'); // audio.mp3, public visibility
There is no format(), mp3(), speed() or pitch() method. The extension you hand to storeAs() is the entire format lever — say that out loud now so you stop searching the docs for a builder method that does not exist. There is also no timeout() on Audio (Image has one; audio does not).
And there is no disk argument. store() writes to your default disk, full stop. If you need a specific disk, take the raw bytes and use Storage yourself — that is exactly what the cache class below does. It is the same store-and-queue shape as generating and storing images with the AI SDK, so if you have already wired that up, this will feel familiar.
Pick a voice and coach the delivery with instructions#
male(), female() and voice() select the voice; voice() takes a provider-specific ID or name. instructions() then coaches the delivery in plain English.
$audio = Audio::of('Your order has shipped.')
->female()
->generate();
$audio = Audio::of('Your order has shipped.')
->voice('voice-id-or-name')
->generate();
$audio = Audio::of('Avast, your order has shipped.')
->female()
->instructions('Said like a pirate')
->generate();
instructions() is natural language, not SSML. That is a real difference from calling a provider API directly, where tone control means hand-writing <prosody> tags against a dialect each vendor implements slightly differently. Here you write a sentence.
Chain synthesis off a Stringable with toAudio#
Laravel's Stringable carries a toAudio() method, so synthesis drops into an existing string pipeline alongside ->stripTags() and ->limit(). Note there is no ->generate() on this path — toAudio() returns the audio directly.
use Illuminate\Support\Str;
$audio = Str::of('I love coding with Laravel.')->toAudio();
Which makes a realistic chain read well:
$path = Str::of($post->summary)
->stripTags()
->limit(2000, '') // stay inside the provider's per-request character cap
->toAudio()
->store();
Queue the generation and store the result in a callback#
Synthesis is seconds-scale. Do not do it in a web request. queue() pushes the generation onto a worker and then() registers a callback that receives an AudioResponse.
use App\Models\Announcement;
use Laravel\Ai\Audio;
use Laravel\Ai\Responses\AudioResponse;
Audio::of($announcement->body)
->female()
->queue()
->then(function (AudioResponse $audio) use ($announcement) {
$announcement->update([
'audio_path' => $audio->storeAs("announcements/{$announcement->id}.mp3"),
]);
});
One asymmetry to plan around: catch() is documented for queued agent prompts but not for queued audio. Do not assume a failure closure exists — put your error handling in the queue's own failure path and monitor it the same way you would any other background AI SDK job.
For a Livewire screen, generate on the queued job and broadcast or poll when the model's audio_path fills in. Do not block a render on a provider round-trip.
Serve the audio to the browser with a temporary URL#
Private clips get a signed, expiring URL from the disk; public clips get a plain one. Feed either into an <audio> element.
use Illuminate\Support\Facades\Storage;
// Private clip on S3
$url = Storage::disk('s3')->temporaryUrl($announcement->audio_path, now()->addMinutes(15));
// Public clip
$url = Storage::disk('public')->url($announcement->audio_path);
@if ($announcement->audio_path)
<audio controls preload="none" src="{{ $url }}">
<a href="{{ $url }}">Download the audio version</a>
</audio>
@endif
If a route synthesises text the user submitted, you have built an anonymous free TTS API on your own bill. Authorise it, and rate limit the endpoint before it ships:
Route::post('/speak', SpeakController::class)
->middleware(['auth', 'throttle:10,1']);
Cache generated clips so you never pay for the same sentence twice#
TTS is billed per character. A page with thirty spoken labels must not be thirty API calls per request. Hash the inputs, check the disk, generate only on a miss — and because store() takes no disk argument, write the bytes through Storage so you control where they land.
<?php
namespace App\Ai;
use Illuminate\Support\Facades\Storage;
use Laravel\Ai\Audio;
class SpeechCache
{
public function __construct(
private readonly string $disk = 's3',
private readonly string $directory = 'speech',
) {}
public function path(string $text, ?string $voice = null, ?string $instructions = null): string
{
// Voice and instructions change the audio, so they belong in the key.
$key = hash('sha256', implode("\0", [$text, $voice ?? '', $instructions ?? '']));
$path = "{$this->directory}/{$key}.mp3";
if (Storage::disk($this->disk)->exists($path)) {
return $path;
}
$prompt = Audio::of($text);
$prompt = $voice !== null ? $prompt->voice($voice) : $prompt->female();
$prompt = $instructions !== null ? $prompt->instructions($instructions) : $prompt;
Storage::disk($this->disk)->put($path, (string) $prompt->generate());
return $path;
}
}
The exists() call is a HEAD request against S3 — cheap, but not free. On a hot path, wrap it in Cache::rememberForever() keyed by the same hash so a repeat render is a memory lookup.
If you also want spend reporting, back it with a small table rather than inferring cost from bucket contents:
Schema::create('audio_clips', function (Blueprint $table) {
$table->id();
$table->string('hash', 64)->unique(); // sha256 of text + voice + instructions
$table->string('path');
$table->unsignedInteger('characters'); // what you were billed on
$table->string('voice')->nullable();
$table->timestamps();
});
Populate characters from a listener on the AudioGenerated event (GeneratingAudio fires before, AudioGenerated after). The docs list the event names but not their namespaces — run php artisan event:list against your install to get the FQNs rather than guessing, and see tracking AI SDK token usage and cost for the wider spend-logging pattern.
This is an exact-match cache, deliberately. A near-identical sentence produces a different hash and a new clip. If you want "close enough" reuse, that is a semantic cache built on embeddings — a different tool, and overkill for fixed UI strings.
Fake audio generation in your Pest tests#
Audio::fake() intercepts generation so CI never calls a paid API. Pair it with Storage::fake() and assert on both the prompt and the written file.
use App\Ai\SpeechCache;
use Illuminate\Support\Facades\Storage;
use Laravel\Ai\Audio;
use Laravel\Ai\Prompts\AudioPrompt;
it('stores a generated clip on the disk', function () {
Storage::fake('s3');
Audio::fake()->preventStrayAudio();
$path = (new SpeechCache)->path('Your order has shipped.');
Storage::disk('s3')->assertExists($path);
Audio::assertGenerated(
fn (AudioPrompt $prompt) => $prompt->contains('Your order has shipped.')
&& $prompt->isFemale()
);
});
it('does not regenerate a clip it already has', function () {
Storage::fake('s3');
Audio::fake();
$cache = new SpeechCache;
$cache->path('Your order has shipped.');
Audio::fake(); // reset the recorded prompts
$cache->path('Your order has shipped.');
Audio::assertNothingGenerated();
});
The gotcha that will cost you twenty minutes: fake payloads must be base64-encoded. Pass raw bytes and you get a confusing failure well away from the cause.
Audio::fake([
base64_encode(file_get_contents(base_path('tests/fixtures/first.mp3'))),
base64_encode(file_get_contents(base_path('tests/fixtures/second.mp3'))),
]);
Audio::fake(fn (AudioPrompt $prompt) => base64_encode('...'));
Queued generations get their own assertions against QueuedAudioPrompt, and note that a faked queued generation still invokes your then() callback — which is what you want, since the callback is where the interesting logic lives. Add Queue::fake() if you would rather it did not.
use Laravel\Ai\Prompts\QueuedAudioPrompt;
Audio::assertQueued(fn (QueuedAudioPrompt $prompt) => $prompt->contains('Hello'));
Audio::assertNotQueued('Missing prompt');
Audio::assertNothingQueued();
preventStrayAudio() belongs in your Pest.php for the whole audio suite. It turns a forgotten fake into a loud exception instead of a silent live API call, the same discipline as faking agents in Pest.
Two last things. Providers cap the characters per request — OpenAI's TTS endpoint takes 4,096 — so chunk long text at sentence boundaries and concatenate, or fail loudly. Never silently truncate; a clip that stops mid-sentence is worse than an error. And generated speech is an enhancement, not an accessibility strategy. Screen readers need semantic HTML, real labels and sensible heading order. A play button on top of a badly marked-up page helps nobody.
FAQ#
How do I generate text to speech in Laravel?
Install laravel/ai, set an API key for a TTS-capable provider, then call Audio::of('Your text')->generate(). Cast the result to a string for the raw bytes, or call ->store() on it to write the clip to your default filesystem disk and get a path back. For anything user-facing, queue it with ->queue()->then(...) rather than synthesising inside the request.
Does the Laravel AI SDK support text to speech?
Yes. The Laravel\Ai\Audio class is the SDK's dedicated TTS surface, with voice selection, natural-language delivery instructions, disk storage, queueing and test fakes built in. There is also a Str::of($text)->toAudio() Stringable method for chaining synthesis onto an existing string pipeline.
How do I choose a voice for generated audio in Laravel?
Use ->male() or ->female() for the broad choice, or ->voice('voice-id-or-name') to name a specific provider voice. On top of that, ->instructions('Said like a pirate') coaches the delivery in plain English rather than SSML, so tone and pacing are a sentence you write instead of a markup dialect you learn.
How do I save generated audio to S3 in Laravel?
The simplest route is to make S3 your default disk in config/filesystems.php and call $audio->store() — the store methods have no disk argument, so they always target the default. If you need to write to a specific disk regardless of the default, take the raw bytes with (string) $audio and pass them to Storage::disk('s3')->put($path, $bytes) yourself.
Which providers support text to speech in the Laravel AI SDK?
The SDK's provider table lists OpenAI, ElevenLabs and Gemini for TTS. One caveat worth checking before you choose: custom base URLs are supported for OpenAI, Anthropic, Gemini, Groq, Cohere, DeepSeek, xAI and OpenRouter, but not for ElevenLabs — so if you route AI traffic through a proxy or gateway, ElevenLabs will not fit.
How do I test text to speech without calling a paid API?
Call Audio::fake() at the top of the test, optionally passing base64-encoded payloads for the responses you want back. Then assert with Audio::assertGenerated(fn (AudioPrompt $prompt) => $prompt->contains('Hello')), or assertQueued() for queued generations. Add Audio::fake()->preventStrayAudio() so any generation without a matching fake throws instead of quietly hitting the live API.