treeshainfotech / laravel-vapi
A Laravel wrapper for the Vapi PHP server SDK — config, facade, webhooks, and testing fakes.
Requires
- php: ^8.3
- guzzlehttp/guzzle: ^7.8 || ^8.0
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- vapi/vapi: ^2.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.24
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^4.0 || ^5.0
- pestphp/pest-plugin-type-coverage: ^4.0 || ^5.0
- phpstan/phpstan-strict-rules: ^2.0
Suggests
- phpunit/phpunit: Required to use the Vapi::fake() test assertions.
Provides
None
Conflicts
None
Replaces
None
README
A Laravel wrapper for the Vapi PHP server SDK (vapi/vapi) — configuration, a Vapi facade with full IDE autocomplete, verified webhooks with typed events, generated type-safe request builders, raw API access, and first-class testing fakes.
use TreeshaInfotech\Vapi\Builders\CreateAssistantBuilder; use TreeshaInfotech\Vapi\Facades\Vapi; use Vapi\Types\CreateAssistantDtoFirstMessageMode; $assistant = Vapi::assistants()->create( CreateAssistantBuilder::make() ->name('Support bot') ->firstMessage('Hi, how can I help?') ->firstMessageMode(CreateAssistantDtoFirstMessageMode::AssistantSpeaksFirst) ->build(), );
Table of contents
- Requirements
- Installation
- Configuration
- Usage
- Request builders
- Raw endpoint access
- Webhooks
- Testing your application
- Concurrency
- Known upstream SDK limitations
- License
Requirements
- PHP 8.3 or newer
- Laravel 12 or 13
- A Vapi private API key (Dashboard → Organization Settings → API Keys)
Installation
composer require treeshainfotech/laravel-vapi
The service provider and the Vapi facade alias are registered through package auto-discovery — no manual registration needed.
Configuration
Set your API key in .env:
VAPI_API_KEY=your-private-api-key
Optionally publish the config file:
php artisan vendor:publish --tag=vapi-config
All options:
| Env var | Config key | Default | Purpose |
|---|---|---|---|
VAPI_API_KEY |
vapi.api_key |
— | Private API key (required; a missing key throws MissingApiKey on first use) |
VAPI_BASE_URL |
vapi.base_url |
https://api.vapi.ai |
API endpoint; change only for proxies |
VAPI_TIMEOUT |
vapi.timeout |
client default | Request timeout in seconds |
VAPI_MAX_RETRIES |
vapi.max_retries |
2 |
Retries for 408/429/5xx, with exponential backoff |
| — | vapi.headers |
[] |
Extra headers sent with every request |
VAPI_WEBHOOK_ENABLED |
vapi.webhook.enabled |
false |
Registers the webhook route when true |
VAPI_WEBHOOK_PATH |
vapi.webhook.path |
webhooks/vapi |
Webhook route path |
VAPI_WEBHOOK_SECRET |
vapi.webhook.secret |
— | Shared secret (x-vapi-secret header or Bearer token) |
VAPI_WEBHOOK_HMAC_SECRET |
vapi.webhook.hmac.secret |
— | HMAC credential secret (alternative to the shared secret) |
VAPI_WEBHOOK_HMAC_HEADER |
vapi.webhook.hmac.header |
x-vapi-signature |
Signature header, must match your Vapi credential |
VAPI_WEBHOOK_HMAC_ALGORITHM |
vapi.webhook.hmac.algorithm |
sha256 |
Hash algorithm, must match your Vapi credential |
Bringing your own HTTP client
The package resolves a PSR-18 client via discovery (Guzzle ships with it). To use a specific client — a configured Guzzle instance, a middleware-wrapped client — bind it in the container before the manager is resolved:
$this->app->instance(\Psr\Http\Client\ClientInterface::class, $myPsr18Client);
Usage
The Vapi facade exposes one method per Vapi resource, returning the SDK's typed clients:
use TreeshaInfotech\Vapi\Facades\Vapi; Vapi::assistants(); // AssistantsClient Vapi::calls(); // CallsClient Vapi::chats(); // ChatsClient Vapi::campaigns(); // CampaignsClient Vapi::sessions(); // SessionsClient Vapi::phoneNumbers(); // PhoneNumbersClient Vapi::squads(); // SquadsClient Vapi::tools(); // ToolsClient Vapi::files(); // FilesClient Vapi::structuredOutputs(); // StructuredOutputsClient Vapi::analytics(); // AnalyticsClient Vapi::insight(); // InsightClient Vapi::eval(); // EvalClient Vapi::observabilityScorecard(); // ObservabilityScorecardClient Vapi::providerResources(); // ProviderResourcesClient Vapi::client(); // the underlying Vapi\VapiClient Vapi::raw(); // raw JSON access, see below
Example calls:
use TreeshaInfotech\Vapi\Builders\ListCallsRequestBuilder; // List recent calls for an assistant $calls = Vapi::calls()->list( ListCallsRequestBuilder::make() ->assistantId('asst_123') ->limit(25.0) ->createdAtGt(new DateTime('-7 days')) ->build(), ); // Fetch one call $call = Vapi::calls()->get('call_abc'); $call?->status; // string, see Vapi\Types\CallStatus for the enum values
Prefer constructor injection? The manager is a container singleton — type-hint TreeshaInfotech\Vapi\VapiManager (alias vapi) anywhere instead of using the facade.
Error handling
The SDK throws Vapi\Exceptions\VapiApiException for non-2xx API responses and Vapi\Exceptions\VapiException for transport failures:
use Vapi\Exceptions\VapiApiException; try { $call = Vapi::calls()->get($id); } catch (VapiApiException $exception) { $exception->getCode(); // HTTP status code $exception->getBody(); // decoded error body }
Note: exceptions thrown while deserializing responses (malformed payloads) are bare \Exceptions from the generated SDK code — catch \Throwable around SDK calls when you need a hard guarantee.
Request builders
The SDK's request DTOs take untyped arrays, and enum-valued fields are plain strings. This package generates 40 fluent builders — one for every request DTO the SDK's clients accept that can be typed soundly — restoring autocomplete and strict types, including native enum parameters:
use TreeshaInfotech\Vapi\Builders\CreateAssistantBuilder; use TreeshaInfotech\Vapi\Builders\CreateCampaignBuilder; use Vapi\Types\CreateAssistantDtoClientMessagesItem; $dto = CreateAssistantBuilder::make() ->name('Support bot') ->clientMessages( CreateAssistantDtoClientMessagesItem::ConversationUpdate, CreateAssistantDtoClientMessagesItem::StatusUpdate, ) ->build(); // Required DTO fields become typed make() arguments: $campaign = CreateCampaignBuilder::make('August outreach') ->phoneNumberId('pn_1') ->build();
Rules of the road:
- Unset fields are omitted from the payload entirely — a builder never sends nulls you didn't set.
- Enum-annotated fields accept only the enum (e.g.
CreateAssistantDtoFirstMessageMode::AssistantSpeaksFirst), which is what makes typos impossible. - Array fields of a single type take variadics:
->clientMessages(Item::A, Item::B). - A few deeply-unioned fields (e.g.
hooks, free-formmetadata) have no setter — each builder's docblock lists them; pass those through the DTO constructor. - Seven discriminated-union wrapper DTOs are excluded on purpose (they have their own static factories in the SDK).
Raw endpoint access
The live Vapi API has endpoints the official SDK does not cover yet (Knowledge Bases V2, Simulations, Reporting Boards, Campaigns V2, call recordings and logs). Vapi::raw() reaches any endpoint with the configured authentication, base URL, retries, and HTTP client:
$bases = Vapi::raw()->get('/v2/knowledge-base', ['limit' => 10]); $created = Vapi::raw()->post('/v2/knowledge-base', ['name' => 'Docs']); Vapi::raw()->patch("/v2/knowledge-base/{$id}", ['name' => 'Docs v2']); Vapi::raw()->delete("/v2/knowledge-base/{$id}");
get/post/patch/put/delete return decoded JSON (null for empty responses). Non-2xx responses throw Vapi\Exceptions\VapiApiException with the status code, the decoded error body, and a message naming the method and path.
Webhooks
Vapi POSTs server messages (end-of-call reports, transcripts, tool calls, status updates, …) to your app. Enable the route and configure verification:
VAPI_WEBHOOK_ENABLED=true VAPI_WEBHOOK_SECRET=your-shared-secret
The package registers POST /webhooks/vapi (route name vapi.webhook, path configurable) outside the web middleware group, so CSRF and sessions don't apply. Point your assistant, phone number, or organization Server URL at it.
Custom routing
Every piece of the webhook stack is public, so you choose your level of control:
Change the path — set VAPI_WEBHOOK_PATH=integrations/vapi/hooks. (Re-run php artisan route:cache after changing it if you cache routes.)
Place the route yourself — set VAPI_WEBHOOK_ENABLED=false and register it in any route file, with any group:
Route::vapiWebhook('integrations/vapi'); // macro WebhookRoute::register('integrations/vapi'); // static-analysis-friendly equivalent Route::domain('hooks.example.com')->middleware('throttle:60,1')->group(function (): void { Route::vapiWebhook('vapi', 'tenant.vapi.webhook'); // custom name for multi-registration });
Both forms register the controller + verification middleware and compose with prefixes, domains, and extra middleware. If you place the route in routes/web.php, exclude it from CSRF ($middleware->validateCsrfTokens(except: ['integrations/vapi']) in bootstrap/app.php) — Vapi's POSTs carry no CSRF token. routes/api.php needs no exclusion but adds the /api prefix.
Write your own controller — inject WebhookProcessor to reuse the whole pipeline (decode, validate, typed deserialization, events, responder) while adding your own concerns:
use TreeshaInfotech\Vapi\Webhooks\WebhookProcessor; final class MyVapiController { public function __invoke(Request $request, WebhookProcessor $processor): JsonResponse { Log::info('vapi webhook', ['tenant' => $request->route('tenant')]); return $processor->handle($request); } }
Keep VerifyVapiWebhook (or a stricter replacement) on any route that reaches the processor — verification is middleware, not part of the pipeline.
Go fully custom — ServerMessageTypeMap, the event classes, and the responder contract are all public API; a bespoke controller can reuse the type-safe deserialization and handle everything else its own way.
Verification
Requests are rejected with 401 unless they carry valid credentials, and with 500 when no verification is configured (fail closed). Accepted methods:
- Shared secret (
VAPI_WEBHOOK_SECRET) — matched against the legacyx-vapi-secretheader or anAuthorization: Bearertoken, covering Vapi's credential-based bearer auth. - HMAC signature (
VAPI_WEBHOOK_HMAC_SECRET) — verifies the signature header against the raw request body; header name and algorithm are configurable and must match the credential configured in your Vapi dashboard. Hex,sha256=-prefixed, and base64 digests are accepted.
Events
Every verified webhook dispatches WebhookReceived. Common message types also dispatch a typed event:
| Event | Message type | Typed payload property |
|---|---|---|
WebhookReceived |
all | ?JsonSerializableType $message |
EndOfCallReportReceived |
end-of-call-report |
ServerMessageEndOfCallReport $report |
StatusUpdateReceived |
status-update |
ServerMessageStatusUpdate $statusUpdate |
TranscriptReceived |
transcript, transcript[transcriptType="final"] |
ServerMessageTranscript $transcript |
ToolCallsReceived |
tool-calls |
ServerMessageToolCalls $toolCalls |
Every event also carries array $payload (the raw decoded body) and string $type. Messages with types the SDK doesn't know yet still dispatch WebhookReceived with the raw payload and message: null — new Vapi message types never break your endpoint.
use TreeshaInfotech\Vapi\Events\EndOfCallReportReceived; Event::listen(function (EndOfCallReportReceived $event): void { ProcessCallReport::dispatch($event->report); });
Listeners can use queue attributes on Laravel 13 (#[Tries(3)], #[Backoff(60)], …).
Transcripts arrive under two discriminators. Vapi sends partial transcripts as
transcriptand final ones astranscript[transcriptType="final"]. Both dispatchTranscriptReceived, so listening for the event is always safe. Only if you compare the raw$event->typestring do you need both —ServerMessageTypeMap::TRANSCRIPTandServerMessageTypeMap::TRANSCRIPT_FINAL.
The package deserializes messages by their
message.typediscriminator into the correct SDK class itself. The SDK's ownServerMessageunion resolves every payload to its first arm and is not used.
Responding to webhooks
Some messages (assistant-request, tool-calls, transfer-destination-request) expect a response body. Bind a responder:
use TreeshaInfotech\Vapi\Contracts\RespondsToVapiWebhooks; use TreeshaInfotech\Vapi\Events\WebhookReceived; use Vapi\Types\ServerMessageResponse; final class VapiResponder implements RespondsToVapiWebhooks { public function respond(WebhookReceived $webhook): ?ServerMessageResponse { // return a ServerMessageResponse to answer, or null for the default acknowledgement } } // In a service provider: $this->app->bind(RespondsToVapiWebhooks::class, VapiResponder::class);
Testing webhooks locally
Run php artisan serve, tunnel with ngrok or Expose (ngrok http 8000), and set the tunnel URL + your secret as the Server URL credential in the Vapi dashboard. Real traffic then exercises the full verification and event pipeline.
Testing your application
Vapi::fake() routes every Vapi call — resource clients and raw() alike — through an in-memory transport. Faked calls still exercise the real SDK request pipeline (headers, serialization, URL building); only the network is fake:
use GuzzleHttp\Psr7\Response; use TreeshaInfotech\Vapi\Facades\Vapi; $fake = Vapi::fake(new Response(200, [], '[]')); // code under test Vapi::calls()->list(); $fake->assertSentCount(1); $fake->assertSent(fn ($request) => str_contains((string) $request->getUri(), '/call'));
The returned VapiFake handle:
| Method | Purpose |
|---|---|
queue(Response ...$responses) |
Append more queued responses |
requests() |
All recorded PSR-7 requests |
lastRequest() |
The most recent request, or null |
assertSent(callable $callback) |
At least one request matches |
assertSentCount(int $count) |
Exact request count |
assertNothingSent() |
No requests were made |
Queued responses are consumed in order. When the queue is empty, an empty 200 is returned, which the SDK surfaces as null — unqueued calls never explode. Retries are disabled under the fake, so queued error responses don't trigger backoff sleeps.
Concurrency
The Vapi PHP SDK is synchronous (there is no PHP equivalent of the Python SDK's AsyncVapi), but Laravel's Concurrency facade runs multiple calls in parallel processes — several requests cost one round-trip of wall time:
use Illuminate\Support\Facades\Concurrency; [$call, $assistant, $numbers] = Concurrency::run([ fn () => Vapi::calls()->get($callId), fn () => Vapi::assistants()->get($assistantId), fn () => Vapi::phoneNumbers()->list(), ]);
For fire-and-forget work, dispatch a queued job and let the webhook events close the loop.
Testing caveat: the default process driver runs closures in fresh PHP processes where a Vapi::fake() from your test does not exist — switch to the sync driver in tests (config(['concurrency.default' => 'sync'])).
Known upstream SDK limitations
Validated against the live OpenAPI spec (https://api.vapi.ai/api-json); these apply to Vapi's official PHP and Python SDKs equally:
- ~58 API operations have no SDK client (Knowledge Bases V2, Simulations, Reporting Boards, Campaigns V2, call recordings/logs). Use
Vapi::raw(). - Two webhook message types (
call.artifact.upload,campaign.predial) aren't modeled yet — they arrive as untypedWebhookReceivedevents. CreateCallDtolacks the API'sassistantVersionfield — pass it via the$options['bodyProperties']argument the SDK's client methods accept.- Credential fields are typed non-nullable, so a response containing a redacted secret fails to decode (VapiAI/server-sdk-php#4). Reach the endpoint with
Vapi::raw()until it is fixed. - The SDK repository currently ships without a license file.
License
MIT — see LICENSE.md. Contributing and package maintenance (quality gates, builder regeneration, SDK upgrades) are covered in CONTRIBUTING.md.