Transcribe Audio in Laravel with the AI SDK: Uploads, Storage and Speaker Diarization

Laravel AI SDK transcription end to end: transcribe uploads, read diarized speaker segments off the response, and queue long audio without hitting a timeout.

Steven Richardson
Steven Richardson
· 8 min read

A client had six months of recorded sales calls and wanted them searchable. My first pass was the usual thing: a hand-rolled multipart POST to /audio/transcriptions, my own retry wrapper, and a test suite that either mocked Http by hand or quietly spent money in CI. All of that is dead code now. Laravel AI SDK transcription ships as a first-party class with queueing, diarization and faking already built, and almost nobody has found it because every article about the SDK is about agents and tool calling.

This is the whole path — upload, store, diarize, queue, persist, test. If you want the SDK to produce media rather than read it, generating and storing images with the AI SDK is the mirror image of this article.

Configure a provider for Laravel AI SDK transcription#

Speech-to-text runs through its own provider key, separate from the one your agents use, so the first job is pointing it at a provider that can actually hear. Install the package, publish the config, and set a key.

composer require laravel/ai
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate
# .env — one of these, matching the provider you pick
OPENAI_API_KEY=
ELEVENLABS_API_KEY=
MISTRAL_API_KEY=
GEMINI_API_KEY=

Four providers support STT: OpenAI, ElevenLabs, Mistral and Gemini. The one that gets used comes from a dedicated key in config/ai.php, which ships as openai:

'default' => 'openai',
'default_for_audio' => 'openai',
'default_for_transcription' => 'openai', // STT — separate from 'default'

Each provider brings its own default model — gpt-4o-transcribe-diarize on OpenAI, scribe_v2 on ElevenLabs, voxtral-mini-2602 on Mistral, gemini-3.5-flash on Gemini — overridable per provider at models.transcription.default. generate() also takes a provider and model argument, and it accepts a list, which is the same failover mechanism I covered in provider fallback and failover in the AI SDK.

Transcribe your first file from a local path#

Start synchronous and local, purely to learn the shape of the response. Transcription is a plain class you import directly — there is no Facades\Audio, and reaching for one is the first thing everybody gets wrong.

use Laravel\Ai\Transcription;

$transcript = Transcription::fromPath(storage_path('app/samples/standup.mp3'))->generate();

return (string) $transcript;

generate() returns a Laravel\Ai\Responses\TranscriptionResponse. It implements Stringable, so casting gives you the text, but the object carries more than that: $transcript->text, $transcript->segments, $transcript->usage and $transcript->meta. The segments collection is empty until you ask for diarization, which is the next thing worth doing.

Accept an upload and transcribe it with fromUpload#

For a small clip coming straight off a form, fromUpload() takes the UploadedFile and skips the disk entirely. Validate hard first — audio uploads are the easiest way to hand a stranger your provider bill.

use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Laravel\Ai\Transcription;

public function store(Request $request)
{
    $request->validate([
        'audio' => [
            'required',
            Rule::file()
                ->extensions(['mp3', 'wav', 'm4a', 'webm'])
                ->max(24 * 1024), // 24 MB — under OpenAI's 25 MB hard cap
        ],
    ]);

    $transcript = Transcription::fromUpload($request->file('audio'))
        ->language('en') // ISO-639-1; skip it and the provider guesses
        ->generate();

    return response()->json(['text' => $transcript->text]);
}

language() is a real builder method even though it isn't in the docs, and it measurably improves accuracy on accented English. If the audio is arriving through a Livewire form, direct-to-S3 temporary uploads keeps the bytes off your web server on the way in.

Store the audio first and transcribe from the disk instead#

This is the shape you actually ship. Write the file to a disk, keep the path on a model, and transcribe from storage — because the queued path in a moment will refuse to run any other way.

use Illuminate\Support\Facades\Storage;

$path = Storage::disk('s3')->putFile('recordings', $request->file('audio'));

$recording = $request->user()->recordings()->create([
    'audio_path' => $path,
]);

$transcript = Transcription::fromStorage($recording->audio_path, disk: 's3')->generate();

fromStorage() does take a disk — the signature is fromStorage(string $path, ?string $disk = null) — so non-default disks need no workaround. Omit it and you get the default disk from config/filesystems.php.

Turn on diarization to separate speakers#

diarize() is the method that turns a wall of text into meeting notes, and it fills in the part of the response you have been ignoring. Each entry in segments is a TranscriptionSegment with the speaker label and second-accurate boundaries.

use Laravel\Ai\Transcription;

$transcript = Transcription::fromStorage($recording->audio_path, disk: 's3')
    ->diarize()
    ->generate();

foreach ($transcript->segments as $segment) {
    // $segment->text, $segment->speaker, $segment->startSeconds, $segment->endSeconds
    logger()->info("[{$segment->speaker}] {$segment->text}");
}

// Segments are an Eloquent-style Collection, so this works:
$perSpeaker = $transcript->segments->groupBy->speaker->map->count();

TranscriptionSegment implements Arrayable and JsonSerializable, and toArray() snake-cases the timings to start_seconds and end_seconds — worth knowing before you write a mapper you don't need. Two provider notes: speaker labels come back as whatever the provider assigns (speaker_0, speaker_1), so map them to real people yourself; and on OpenAI, combining diarize() with a prompt provider option throws a LogicException rather than silently ignoring one of them.

Queue Laravel AI SDK transcription and handle the result in a callback#

A forty-minute recording is not a request-cycle operation, and the SDK's own timeout enforces that opinion. Queue the generation and take the result in a callback.

use Laravel\Ai\Transcription;
use Laravel\Ai\Responses\TranscriptionResponse;
use Throwable;

Transcription::fromStorage($recording->audio_path, disk: 's3')
    ->diarize()
    ->timeout(600) // seconds; the default is 30 and it will bite you
    ->queue()
    ->then(function (TranscriptionResponse $transcript) use ($recording) {
        $recording->markTranscribed($transcript);
    })
    ->catch(function (Throwable $e) use ($recording) {
        $recording->update(['transcription_failed_at' => now()]);
    });

Here is the gotcha that costs an afternoon: queue() throws a LogicException unless the audio is local or stored on a disk. Transcription::fromUpload($request->file('audio'))->queue() compiles, reads fine in review, and blows up the first time it runs — an upload becomes base64 audio internally and cannot be handed to a job. Store it, then queue from storage. If you want run status surfaced in the UI rather than logged, the pattern in running AI SDK agents in the background transfers directly.

Persist the transcript against the model that owns the recording#

Write the text and the segments back on the model in one place, so every caller — controller, console command, retry — goes through the same method. This is the bit every transcription tutorial skips.

Schema::create('recordings', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('audio_path');
    $table->longText('transcript')->nullable();
    $table->json('segments')->nullable();
    $table->timestamp('transcribed_at')->nullable();
    $table->timestamp('transcription_failed_at')->nullable();
    $table->timestamps();
});
protected function casts(): array
{
    return [
        'segments' => 'array',
        'transcribed_at' => 'datetime',
        'transcription_failed_at' => 'datetime',
    ];
}

public function markTranscribed(TranscriptionResponse $transcript): void
{
    $this->update([
        'transcript' => $transcript->text,
        'segments' => $transcript->segments->toArray(),
        'transcribed_at' => now(),
    ]);

    RecordingTranscribed::dispatch($this);
}

Treat that transcript as untrusted user content on the way back out — escape it in Blade, and don't feed it straight into a prompt without the same care you'd give a form field. For observability, the SDK dispatches Laravel\Ai\Events\GeneratingTranscription and Laravel\Ai\Events\TranscriptionGenerated; the latter carries the invocation ID, provider, model, prompt and response, so a single listener replaces logging at every call site. Be careful reading cost from it, though: $transcript->usage reports provider tokens, and STT is usually billed per minute of audio, so the token-based approach in tracking AI SDK token usage and cost needs a duration-based column here instead.

Fake transcription in your Pest tests#

Nothing about this should reach a provider in CI. Transcription::fake() intercepts generation, and the assertions run against the prompt objects the SDK recorded.

use Laravel\Ai\Transcription;
use Laravel\Ai\Prompts\TranscriptionPrompt;
use Laravel\Ai\Prompts\QueuedTranscriptionPrompt;

it('transcribes an uploaded recording', function () {
    Transcription::fake('Morning all, quick standup.')->preventStrayTranscriptions();

    $this->actingAs(User::factory()->create())
        ->post('/recordings', ['audio' => UploadedFile::fake()->create('standup.mp3', 900)])
        ->assertOk();

    Transcription::assertGenerated(
        fn (TranscriptionPrompt $prompt) => $prompt->language === 'en' && $prompt->isDiarized()
    );
});

it('queues transcription for stored recordings', function () {
    Transcription::fake();

    TranscribeRecording::dispatchSync($recording);

    Transcription::assertQueued(fn (QueuedTranscriptionPrompt $prompt) => $prompt->isDiarized());
    Transcription::assertNothingGenerated();
});

fake() also accepts an array of sequential responses or a closure over the prompt, and preventStrayTranscriptions() turns any unfaked call into a failure — which is the assertion I actually care about, because it catches the new code path someone adds next quarter. The same faking model applies across the rest of the SDK; faking agents in Pest covers the text side.

Build it in that order: local path to learn the response, storage to make it queueable, diarize() when you need speakers, and queue() before it ever meets production traffic. The only thing that changes between the sync example and the shipped version is where the callback lives. For everything the SDK does either side of transcription, the Laravel AI SDK complete guide is the map.

FAQ#

How do I transcribe audio in Laravel?

Install laravel/ai, set a provider key, then call the Laravel\Ai\Transcription class directly — it is a class, not a facade. Transcription::fromPath('/path/to/audio.mp3')->generate() returns a TranscriptionResponse you can cast to a string for the text. There are also fromStorage() for files on a filesystem disk and fromUpload() for an UploadedFile straight off the request.

Does the Laravel AI SDK support speech to text?

Yes, speech-to-text is first-party in the AI SDK, with its own Transcription class rather than an agent prompt. It supports language hints, speaker diarization, per-request timeouts, queued generation with then() and catch() callbacks, and full test faking. You no longer need a hand-rolled multipart HTTP client for the provider's transcription endpoint.

How do I identify different speakers in a Laravel transcription?

Call diarize() on the pending transcription before generate(). The TranscriptionResponse then exposes a segments collection of TranscriptionSegment objects, each with text, speaker, startSeconds and endSeconds properties. Speaker labels are provider-generated identifiers like speaker_0, so map them to real names in your own application.

How do I transcribe an uploaded file in Laravel without blocking the request?

Store the upload to a filesystem disk first, then call Transcription::fromStorage($path, disk: 's3')->queue()->then(...). Queueing directly from fromUpload() throws a LogicException, because only local or stored audio can be attached to the SDK's job. Raise timeout() above the 30-second default for long recordings, and make sure your queue worker's job timeout is higher still.

Which providers support transcription in the Laravel AI SDK?

Four providers support STT: OpenAI, ElevenLabs, Mistral and Gemini. The active one comes from ai.default_for_transcription in config/ai.php, which ships as openai and is separate from the ai.default used for text generation. Each provider has its own default transcription model, overridable at models.transcription.default in that provider's config block.

How do I test transcription code without calling a real API?

Call Transcription::fake() before exercising the code, optionally passing a fixed string, an array of sequential responses, or a closure receiving the TranscriptionPrompt. Then assert with assertGenerated(), assertQueued(), assertNotGenerated() or assertNothingGenerated(), inspecting $prompt->language and $prompt->isDiarized(). Chain preventStrayTranscriptions() so any unfaked transcription throws instead of silently hitting the provider.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.