Skip to content
TutorialLaravel

How We Built a Bilingual AI Voice Assistant in Laravel (Arabic + English) — Part 1 of 4

D

Dinesh Wijethunga

July 4, 2026 6 min readIntermediate
ShareX / TwitterLinkedIn
How We Built a Bilingual AI Voice Assistant in Laravel (Arabic + English) — Part 1 of 4

The Voice Assistant Problem

A customer opens the app, taps the mic, and says in Arabic: "أبي رحلة للمطار" ("I want a ride to the airport").

Three things need to happen in about 2 seconds:

  1. The audio gets converted to text.
  2. The text gets sent to an AI that understands the request and calls the right booking tool.
  3. The AI's response gets converted back to audio and played back.

Each step requires a different specialist API. No single service does all three well — especially in Arabic with Gulf dialect.

This 4-part series walks through exactly how we built this in a Laravel 12 modular app using:

  • Deepgram Nova-3 — speech-to-text, Arabic + English code-switching
  • Anthropic Claude Sonnet — reasoning and action through tool use
  • Cartesia Sonic-3 — text-to-speech at ~40ms first-chunk latency

Why These Three Providers?

Deepgram was the only provider with a model that handles Gulf dialect reliably (UAE, Saudi, Kuwaiti accents) and supports multi-language mode — so a user can switch between Arabic and English mid-sentence without re-configuring anything.

Anthropic Claude is the best model for structured tool use. In a booking assistant, the AI doesn't just answer questions — it calls real functions: get_fare_estimate, book_ride, cancel_booking. Claude's tool use API is reliable, well-documented, and works correctly with a two-turn pattern (more on that in Part 3).

Cartesia Sonic-3 produces natural-sounding Arabic voice at ~40ms first-chunk latency — fast enough to feel responsive in a mobile app. Google TTS and Amazon Polly both have noticeably more robotic Arabic output.

The Full Pipeline

Flutter App
    │
    │  POST /api/v1/customer/assistant/chat
    │  { audio_base64, module_context, session_id, location_context }
    ▼
AssistantController::chat()
    │
    ├─ 1. Rate limit check
    ├─ 2. Get or create session
    │
    ├─ 3. If voice: SpeechToTextService → transcript + language detected
    │     If text:  detect language from Unicode ranges
    │
    ├─ 4. Save user message to DB
    │
    ├─ 5. Build Claude context (user name, GPS, active booking, last fare)
    ├─ 6. Load conversation history (last 10 turns)
    │
    ├─ 7. LanguageModelService::complete()
    │       └─ If Claude calls a tool:
    │             ├─ Execute tool (fare estimate, book ride, track driver...)
    │             └─ LanguageModelService::resumeWithToolResult()
    │
    ├─ 8. TextToSpeechService::generateSpeech() → MP3 audio base64
    │
    ├─ 9. Save assistant message to DB
    ├─ 10. Log costs (STT/LLM/TTS) + increment rate limit counters
    │
    └─ Response: { response_text, audio_base64, transcript, action_taken, session_id }

One request, one response, everything in sequence. There is no streaming in the current implementation — the Flutter app shows a loading indicator, then plays the full audio when it arrives.

Database Schema

Eight migrations power the system. Here's what each table stores:

TablePurpose
assistant_sessionsOne conversation session per user. Stores module_context, total_cost_usd, session_metadata (last fare estimate, etc.)
assistant_messagesEvery turn — user and assistant. Stores input_type, transcript, stt_latency_ms, tts_duration_sec, etc.
assistant_actionsEvery tool call Claude makes — tool_name, input_payload, output_payload, status, duration_ms
assistant_api_usagesLine-item API cost log — one row per STT/LLM/TTS call with cost_usd
assistant_rate_limitsActive rate limit counters per user per window (minute/hour/day)
assistant_rate_limit_configsPer-user overrides of the default rate limits
assistant_cost_budgetsSpend budgets per user (daily/weekly/monthly limits)
assistant_cost_alertsFired when a user approaches or exceeds a budget

The Config File

At Modules/Assistant/config/assistant.php. All API keys come from .env — never hardcode them.

<?php

return [
    'deepgram' => [
        'api_key'  => env('DEEPGRAM_API_KEY'),
        'base_url' => 'https://api.deepgram.com/v1',
        'model'    => 'nova-3',
        'language' => 'multi',       // handles Arabic + English code-switching
        'options'  => [
            'smart_format'  => true, // auto punctuation
            'utterances'    => true, // sentence boundary detection
            'endpointing'   => 300,  // ms silence before finalising
            'keyterms'      => [     // vocabulary boost for ride-booking
                'حجز', 'مطار', 'Downtown', 'Marina', 'cancel', 'track',
                'تاكسي', 'رحلة', 'السايق', 'الغي',
            ],
        ],
        'price_per_second_usd' => 0.00011944,
    ],

    'anthropic' => [
        'api_key'       => env('ANTHROPIC_API_KEY'),
        'base_url'      => 'https://api.anthropic.com/v1',
        'model'         => env('ASSISTANT_LLM_MODEL', 'claude-sonnet-4-6'),
        'max_tokens'    => 1024,
        'temperature'   => 0.3,
        'api_version'   => '2023-06-01',
        'price_per_input_token_usd'  => 0.000003,
        'price_per_output_token_usd' => 0.000015,
    ],

    'cartesia' => [
        'api_key'  => env('CARTESIA_API_KEY'),
        'base_url' => 'https://api.cartesia.ai',
        'model'    => 'sonic-3',
        'voices'   => [
            'ar' => env('CARTESIA_ARABIC_VOICE_ID'),
            'en' => env('CARTESIA_ENGLISH_VOICE_ID'),
        ],
        'output_format' => [
            'container'   => 'mp3',
            'bit_rate'    => 128000,
            'sample_rate' => 44100,
        ],
        'price_per_char_usd' => 0.000065,
    ],

    'rate_limits' => [
        'minute' => ['max_requests' => 10, 'max_voice_requests' => 5,   'max_cost_usd' => 0.50],
        'hour'   => ['max_requests' => 60, 'max_voice_requests' => 30,  'max_cost_usd' => 5.00],
        'day'    => ['max_requests' => 200,'max_voice_requests' => 100, 'max_cost_usd' => 20.00],
    ],

    'session' => [
        'expiry_minutes' => 30,
        'max_turns'      => 20,
        'max_audio_sec'  => 30,
    ],

    'context' => [
        'max_history_turns' => 10,
    ],
];

.env keys to add:

DEEPGRAM_API_KEY=your_deepgram_key
ANTHROPIC_API_KEY=your_anthropic_key
CARTESIA_API_KEY=your_cartesia_key
CARTESIA_ARABIC_VOICE_ID=your_arabic_voice_id
CARTESIA_ENGLISH_VOICE_ID=your_english_voice_id
ASSISTANT_LLM_MODEL=claude-sonnet-4-6

The Input DTO

The controller receives one DTO that normalises all inputs — whether from voice or text. Place at Modules/Assistant/app/DTOs/AssistantMessageDTO.php:

<?php

namespace Modules\Assistant\DTOs;

use Modules\Assistant\Enums\InputTypeEnum;
use Modules\Assistant\Enums\LanguageEnum;
use Modules\Assistant\Enums\ModuleContextEnum;
use Modules\Assistant\Http\Requests\AssistantChatRequest;

class AssistantMessageDTO
{
    public function __construct(
        public readonly int               $userId,
        public readonly InputTypeEnum     $inputType,       // voice | text
        public readonly ModuleContextEnum $moduleContext,   // booking | cargo | general
        public readonly ?string           $sessionId,       // null = start new session
        public readonly ?string           $audioBase64,     // set when inputType = voice
        public readonly ?string           $text,            // set when inputType = text
        public readonly LanguageEnum      $language,        // ar | en | mixed
        public readonly ?string           $contextEntityId,
        public readonly ?string           $contextEntityType,
        public readonly ?array            $sessionMetadata,
    ) {}

    public static function fromRequest(AssistantChatRequest $request): self
    {
        // If audio is present, it's a voice message regardless of what else was sent
        $inputType = $request->filled('audio_base64')
            ? InputTypeEnum::Voice
            : InputTypeEnum::Text;

        return new self(
            userId:            $request->user()->id,
            inputType:         $inputType,
            moduleContext:     ModuleContextEnum::from($request->input('module_context', 'general')),
            sessionId:         $request->input('session_id'),
            audioBase64:       $request->input('audio_base64'),
            text:              $request->input('text'),
            language:          LanguageEnum::from($request->input('language', 'ar')),
            contextEntityId:   $request->input('context_entity_id'),
            contextEntityType: $request->input('context_entity_type'),
            sessionMetadata:   $request->input('session_metadata'),
        );
    }
}

What's Next

Part 2 dives into the Deepgram integration — how we configure Nova-3 for Arabic Gulf dialect, how multi-language mode handles code-switching between Arabic and English, and how we normalise the detected language before passing it to the rest of the pipeline.

D
Dinesh Wijethunga

Senior Full Stack Developer · Building SaaS products & teaching Laravel/React · 10+ years experience · Founder of Orion360 · Based in Dubai, UAE.

Was this post helpful?

Add a comment

Comments

Guest comments are held for moderation.

You might also like

Arabic Speech Recognition in Laravel with Deepgram Nova-3 (Part 2 of 4)
TutorialBeginnerLaravel

Arabic Speech Recognition in Laravel with Deepgram Nova-3 (Part 2 of 4)

Arabic speech recognition has a dirty secret: most models are trained on Modern Standard Arabic (MSA) and fail on Gulf dialects like UAE or Kuwaiti Arabic. Deepgram Nova-3 is the first model to handle Gulf dialect reliably, and its multi-language mode lets users seamlessly switch between Arabic and English mid-sentence — which is exactly how people in the UAE actually speak. In Part 2, we build the SpeechToTextService that transcribes voice input, detects the language automatically, and feeds.

D
Dinesh Wijethunga
about 2 months ago
5m
Designing a Voice AI System Prompt with Claude Tool Use in Laravel (Part 3 of 4)
TutorialIntermediateLaravel

Designing a Voice AI System Prompt with Claude Tool Use in Laravel (Part 3 of 4)

The hardest part of building a voice assistant isn't the speech — it's getting the AI to take the right action reliably. Claude's tool use API lets you define functions the AI can call (like get_fare_estimate or book_ride) and forces it to return structured data instead of making things up. In Part 3, we design the system prompt for Arabic/English responses, define 15+ tool schemas, and implement the two-turn tool use flow that's required whenever Claude needs to execute an action before it can.

D
Dinesh Wijethunga
about 2 months ago
11m
Arabic Text-to-Speech with Cartesia and API Cost Control in Laravel (Part 4 of 4)
TutorialIntermediateLaravel

Arabic Text-to-Speech with Cartesia and API Cost Control in Laravel (Part 4 of 4)

Generating a voice response is the last step in the pipeline — but it's also the one that's most exposed to runaway costs. Each TTS call charges per character. Each STT call charges per second. Claude charges per token. Multiply by thousands of users and the bill can spiral fast. In Part 4, we build the Cartesia TTS service for Arabic/English voice synthesis, then add the cost tracking and rate limiting layer that keeps your API spend under control at per-user granularity.

D
Dinesh Wijethunga
about 1 month ago
8m