premmohantyagi / apihub-laravel
ApiHub for Laravel: one unified, driver-based interface over global third-party APIs covering payments, AI, email, SMS & messaging, and more.
Requires
- php: ^8.0
- guzzlehttp/guzzle: ^7.0
- illuminate/contracts: ^8.0|^9.0|^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^8.0|^9.0|^10.0|^11.0|^12.0|^13.0
- illuminate/queue: ^8.0|^9.0|^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^8.0|^9.0|^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^1.0|^2.0|^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^6.0|^7.0|^8.0|^9.0|^10.0
- pestphp/pest: ^1.22|^2.0|^3.0
README
ApiHub is a unified integration package for Laravel. It gives you one clean, consistent, driver-based interface over global third-party APIs (Payments, AI, Email, and SMS & Messaging) so you can swap providers with a config change instead of a rewrite.
Every driver is built on Laravel's HTTP client (no heavy vendor SDKs), so the package stays light and conflict-free while sharing one timeout/retry policy, a normalised response object, a unified exception hierarchy, secret redaction in logs, and webhook signature verification.
Table of contents
- Why ApiHub
- Requirements
- Installation
- Configuration
- Core concepts
- Email
- Mailgun | SendGrid | Amazon SES | Resend
- AI
- OpenAI | Anthropic | Google Gemini | DeepSeek
- SMS & Messaging
- Payments
- Environment variables reference
- Development
- Roadmap
- License
Why ApiHub
- One interface per category. Swap Stripe for Razorpay, or OpenAI for Anthropic, by changing a config value, not your code.
- No SDK bloat. Drivers talk raw REST over
illuminate/http. Official SDKs are optional, never required. - Production-grade core. Retries, timeouts, mapped exceptions, redacted logging, and constant-time webhook verification are built in for free.
- Escape hatch always.
->raw()exposes the underlying response for provider-specific features the unified interface doesn't cover. - Test-friendly. Every category ships a fake (
Email::fake(),Ai::fake(),Sms::fake(),Payments::fake()) with assertions.
Requirements
- PHP 8.0 to 8.4
- Laravel 8, 9, 10, 11, 12, or 13
Installation
Install via Composer:
composer require premmohantyagi/apihub-laravel
The service provider and the Payments, Ai, Email, and Sms facades are
auto-discovered, so no manual registration is needed.
Publish the config file:
php artisan vendor:publish --tag=apihub-config
This creates config/apihub.php. Add the credentials you need to your .env
(see Environment variables reference).
Configuration
Everything lives in a single config/apihub.php. Each category has a default
driver and a drivers array of per-provider credentials. Global HTTP, logging,
and queue settings apply to every driver.
return [ 'http' => [ 'timeout' => env('APIHUB_HTTP_TIMEOUT', 10), 'retries' => env('APIHUB_HTTP_RETRIES', 2), 'retry_delay' => env('APIHUB_HTTP_RETRY_DELAY', 250), // ms ], 'logging' => [ 'enabled' => env('APIHUB_LOGGING', false), // logs each call at debug level 'redact_keys' => ['password', 'secret', 'token', 'api_key', /* ... */], ], 'queue' => [ 'connection' => env('APIHUB_QUEUE_CONNECTION'), 'name' => env('APIHUB_QUEUE_NAME', 'default'), ], 'payments' => ['default' => env('APIHUB_PAYMENTS_DRIVER', 'stripe'), 'drivers' => [/* ... */]], 'ai' => ['default' => env('APIHUB_AI_DRIVER', 'openai'), 'drivers' => [/* ... */]], 'email' => ['default' => env('APIHUB_EMAIL_DRIVER', 'mailgun'), 'drivers' => [/* ... */]], 'messaging' => ['default' => env('APIHUB_MESSAGING_DRIVER', 'twilio'), 'drivers' => [/* ... */]], ];
When logging is enabled, every request is logged at debug level with any key
matching redact_keys masked, so credentials never reach the log channel.
Core concepts
Drivers & facades
Each category exposes a facade that resolves the default driver, or a
specific one via driver():
use ApiHub\Laravel\Facades\Email; Email::send($message); // uses the configured default driver Email::driver('resend')->send($m); // uses a specific driver
| Category | Facade | Default env | Drivers |
|---|---|---|---|
Email |
APIHUB_EMAIL_DRIVER |
mailgun, sendgrid, ses, resend |
|
| AI | Ai |
APIHUB_AI_DRIVER |
openai, anthropic, gemini, deepseek |
| SMS & Messaging | Sms |
APIHUB_MESSAGING_DRIVER |
twilio, vonage, msg91, telegram, whatsapp, slack, discord |
| Payments | Payments |
APIHUB_PAYMENTS_DRIVER |
stripe, razorpay, paypal, square, authorizenet |
Responses & the raw() escape hatch
Every result is a typed DTO with a consistent shape. The original provider payload is always available through the response:
$result = Email::send($message); $result->accepted(); // bool $result->id(); // provider message id $result->response?->raw(); // the underlying Laravel HTTP response $result->response?->json('some.provider.specific.field');
Error handling
On a failed HTTP response, drivers throw a mapped exception. Catch the base class to handle any provider uniformly:
use ApiHub\Laravel\Exceptions\ApiHubException; use ApiHub\Laravel\Exceptions\AuthenticationException; use ApiHub\Laravel\Exceptions\RateLimitException; try { Ai::chat($request); } catch (AuthenticationException $e) { // 401 / 403 // bad or missing credentials } catch (RateLimitException $e) { // 429 // back off and retry later } catch (ApiHubException $e) { // any other 4xx / 5xx report($e); $e->statusCode; // HTTP status $e->driver; // which driver failed $e->response; // the ApiHub Response (->raw(), ->json(), ->status()) }
| Exception | HTTP status |
|---|---|
AuthenticationException |
401, 403 |
RateLimitException |
429 |
RequestException |
other 4xx |
ServerException |
5xx |
ApiHubException (base) |
anything else / catch-all |
Webhook verification and the fakes never throw on a bad signature or in tests; instead they return
falseor record the call.
Testing with fakes
Each category swaps in an in-memory implementation with assertions:
$fake = Email::fake(); // ...code under test calls Email::send(...)... $fake->assertSent(fn ($message) => $message->subject === 'Welcome'); $fake->assertSentCount(1);
See each category below for its specific fake assertions.
use ApiHub\Laravel\Facades\Email; use ApiHub\Laravel\Email\DTO\EmailMessage; use ApiHub\Laravel\Email\DTO\Attachment; $message = EmailMessage::make() ->from('noreply@acme.test', 'Acme') ->to('user@example.com') // string, or an array of addresses ->cc(['team@acme.test']) ->bcc('audit@acme.test') ->replyTo('support@acme.test', 'Support') ->subject('Welcome') ->html('<p>Hello!</p>') ->text('Hello!') // optional plain-text part ->header('X-Campaign', 'welcome') ->attach(Attachment::fromPath(storage_path('invoice.pdf'))); $result = Email::send($message); $result->accepted(); // bool $result->id(); // provider message id
EmailMessage validates that it has a from, at least one to, and an html
or text body before any driver sends it.
Attachments: Attachment::fromPath($path, $filename = null, $contentType = null)
or new Attachment($filename, $rawBytes, $contentType).
Testing
$fake = Email::fake(); Email::send($message); $fake->assertSent(fn ($message) => $message->subject === 'Welcome'); $fake->assertSentCount(1); $fake->assertNothingSent();
Mailgun
Posts to /v3/{domain}/messages with HTTP basic auth. Supports attachments
(multipart).
// config/apihub.php at email.drivers.mailgun 'mailgun' => [ 'api_key' => env('MAILGUN_API_KEY'), 'domain' => env('MAILGUN_DOMAIN'), 'endpoint' => env('MAILGUN_ENDPOINT', 'https://api.mailgun.net'), // use https://api.eu.mailgun.net for EU ],
Email::driver('mailgun')->send($message);
SendGrid
Posts JSON to /v3/mail/send with a bearer token. The message id is read from
the X-Message-Id response header.
'sendgrid' => [ 'api_key' => env('SENDGRID_API_KEY'), ],
Email::driver('sendgrid')->send($message);
Amazon SES
Sends a SigV4-signed request to the SES v2 API in your region.
'ses' => [ 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), ],
Email::driver('ses')->send($message);
Note: the SES driver currently sends "Simple" content. Attachments require a raw MIME body and are not yet supported; passing one throws an
InvalidArgumentException.
Resend
Posts JSON to /emails with a bearer token. The id is returned in the body.
'resend' => [ 'api_key' => env('RESEND_API_KEY'), ],
Email::driver('resend')->send($message);
AI
use ApiHub\Laravel\Facades\Ai; use ApiHub\Laravel\Ai\DTO\ChatRequest; $request = ChatRequest::make() ->model('gpt-4o-mini') // optional; falls back to config/driver default ->system('You are concise.') ->user('Summarise Laravel in one sentence.') ->assistant('Sure, ') // optional prior turn ->temperature(0.7) ->maxTokens(256) ->option('top_p', 0.9); // provider-specific passthrough $response = Ai::chat($request); $response->content; // assistant text (content blocks already flattened) $response->model; $response->finishReason; $response->usage->promptTokens; $response->usage->completionTokens; $response->usage->totalTokens;
One ChatRequest works across every provider; the drivers translate it to each
API's shape, so switching providers is just ->driver('anthropic').
Testing
$fake = Ai::fake('Mocked reply'); // or: Ai::fake()->respondWith(fn ($request) => strtoupper($request->messages[0]->content)); $response = Ai::chat(ChatRequest::make()->user('Hi')); // 'Mocked reply' $fake->assertChatted(fn ($request) => $request->messages[0]->content === 'Hi'); $fake->assertChattedCount(1);
OpenAI
Chat Completions API (/v1/chat/completions), bearer auth.
'openai' => [ 'api_key' => env('OPENAI_API_KEY'), 'organization' => env('OPENAI_ORGANIZATION'), // optional 'base_url' => env('OPENAI_BASE_URL', 'https://api.openai.com/v1'), // 'model' => 'gpt-4o', // optional default model ],
Ai::driver('openai')->chat($request);
Anthropic
Messages API (/v1/messages), x-api-key auth. The system prompt is sent as a
top-level field and max_tokens is always supplied (defaults to 1024).
'anthropic' => [ 'api_key' => env('ANTHROPIC_API_KEY'), 'version' => env('ANTHROPIC_VERSION', '2023-06-01'), 'base_url' => env('ANTHROPIC_BASE_URL', 'https://api.anthropic.com/v1'), ],
Ai::driver('anthropic')->chat($request->model('claude-3-5-sonnet-latest'));
Google Gemini
generateContent endpoint. The model goes in the path, the key is a query
param, and the system prompt becomes a systemInstruction.
'gemini' => [ 'api_key' => env('GEMINI_API_KEY'), 'base_url' => env('GEMINI_BASE_URL', 'https://generativelanguage.googleapis.com/v1beta'), ],
Ai::driver('gemini')->chat($request->model('gemini-1.5-flash'));
DeepSeek
OpenAI-compatible Chat Completions API with the same request/response shape, just a different
endpoint and default model (deepseek-chat).
'deepseek' => [ 'api_key' => env('DEEPSEEK_API_KEY'), 'base_url' => env('DEEPSEEK_BASE_URL', 'https://api.deepseek.com/v1'), ],
Ai::driver('deepseek')->chat($request);
Default model names are sensible fallbacks only and overridable per driver via the
modelconfig key; pass->model(...)explicitly in production.
SMS & Messaging
use ApiHub\Laravel\Facades\Sms; use ApiHub\Laravel\Messaging\DTO\TextMessage; $message = TextMessage::make() ->to('+15551234567') // phone (SMS/WhatsApp), chat id (Telegram), channel (Slack/Discord) ->from('Acme') // optional sender override (SMS) ->text('Your code is 1234') ->option('parse_mode', 'Markdown'); // provider-specific passthrough $result = Sms::send($message); $result->accepted(); // bool $result->id(); // provider message id, where one is returned
Testing
$fake = Sms::fake(); Sms::send(TextMessage::make()->to('+15551234567')->text('Hi')); $fake->assertSent(fn ($message) => $message->to === '+15551234567'); $fake->assertSentCount(1); $fake->assertNothingSent();
Twilio
Posts form fields to the Messages resource with HTTP basic auth. Returns the
Twilio sid as the id.
'twilio' => [ 'sid' => env('TWILIO_SID'), 'token' => env('TWILIO_AUTH_TOKEN'), 'from' => env('TWILIO_FROM'), // default sender, overridable per message ],
Sms::driver('twilio')->send(TextMessage::make()->to('+15551234567')->text('Hi'));
Vonage
Posts to the Nexmo SMS API. Vonage returns HTTP 200 even on logical failure, so
acceptance is read from the per-message status (0 = success).
'vonage' => [ 'key' => env('VONAGE_KEY'), 'secret' => env('VONAGE_SECRET'), 'from' => env('VONAGE_FROM'), ],
$result = Sms::driver('vonage')->send($message); $result->accepted(); // false if the gateway reported a non-zero status
MSG91
Uses the plain-text HTTP endpoint (popular in India). The request id is returned as the response body.
'msg91' => [ 'auth_key' => env('MSG91_AUTH_KEY'), 'sender' => env('MSG91_SENDER'), ],
Sms::driver('msg91')->send(TextMessage::make()->to('919876543210')->text('Hi'));
Telegram
Posts JSON to /bot{token}/sendMessage; to is the chat id.
'telegram' => [ 'bot_token' => env('TELEGRAM_BOT_TOKEN'), ],
Sms::driver('telegram')->send(TextMessage::make()->to('123456789')->text('Hi'));
WhatsApp Business Platform (Cloud API) via the Graph API; bearer auth. to is
the recipient phone number.
'whatsapp' => [ 'token' => env('WHATSAPP_TOKEN'), 'phone_number_id' => env('WHATSAPP_PHONE_NUMBER_ID'), // 'api_version' => 'v21.0', // optional, defaults to v21.0 ],
Sms::driver('whatsapp')->send(TextMessage::make()->to('15551234567')->text('Hi'));
Slack
Two modes, chosen by what you configure:
- a bot token uses
chat.postMessage(tois the channel; id is thets), - a webhook URL does a simple post (no id returned).
'slack' => [ 'token' => env('SLACK_BOT_TOKEN'), // preferred when set 'webhook_url' => env('SLACK_WEBHOOK_URL'), // fallback ],
// Bot token mode, posting to a channel: Sms::driver('slack')->send(TextMessage::make()->to('#general')->text('Deploy finished')); // Webhook mode, no channel needed: Sms::driver('slack')->send(TextMessage::make()->text('Deploy finished'));
Discord
Two modes:
- a bot token + channel id (
to) uses the channel messages endpoint (returns id), - a webhook URL does an execute-webhook post (204, no body).
'discord' => [ 'bot_token' => env('DISCORD_BOT_TOKEN'), 'webhook_url' => env('DISCORD_WEBHOOK_URL'), ],
// Webhook mode: Sms::driver('discord')->send(TextMessage::make()->text('Build passed')); // Bot mode, to a channel id: Sms::driver('discord')->send(TextMessage::make()->to('123456789012345678')->text('Hi'));
Payments
use ApiHub\Laravel\Facades\Payments; use ApiHub\Laravel\Payments\DTO\ChargeRequest; use ApiHub\Laravel\Payments\DTO\RefundRequest; $charge = Payments::charge( ChargeRequest::make() ->amount(1050, 'USD') // 1050 = $10.50, held in the currency's minor units ->source('pm_card_visa') // gateway token / nonce / payment method ->customer('cus_123') // optional ->description('Order #42') ->reference('order-42') // used as the idempotency key where supported ->metadata(['order_id' => '42']) ); $charge->successful(); // bool $charge->id(); // gateway id (payment intent / order / payment / transaction) $charge->status(); $refund = Payments::refund( RefundRequest::make() ->payment($charge->id()) ->amount(500, 'USD') // omit for a full refund ->reason('requested_by_customer') );
charge()semantics differ by gateway: it maps to each one's primary server-side call: Stripe creates+confirms a PaymentIntent; Razorpay and PayPal create an order (completed by the buyer); Square creates a payment; Authorize.Net runs an auth-capture. Theid,status, and->raw()let you continue each gateway's own flow.
Money: amounts are always in minor units (cents, paise). The Money
DTO formats decimals correctly, including zero-decimal currencies like JPY.
Testing
$fake = Payments::fake(); Payments::charge(ChargeRequest::make()->amount(1050, 'USD')->source('tok')); $fake->assertCharged(fn ($request) => $request->money?->minorUnits === 1050); $fake->assertRefunded(); $fake->assertNothingCharged(); $fake->webhookValid = false; // control verifyWebhook() in tests
Stripe
Creates and confirms a PaymentIntent (form-encoded). Refunds act on the payment intent id.
'stripe' => [ 'secret' => env('STRIPE_SECRET'), 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), ],
Payments::driver('stripe')->charge( ChargeRequest::make()->amount(2000, 'USD')->source('pm_card_visa') );
Razorpay
charge() creates an Order (the payment is completed by the client checkout and
captured against this order). refund() acts on a payment id.
'razorpay' => [ 'key' => env('RAZORPAY_KEY'), 'secret' => env('RAZORPAY_SECRET'), 'webhook_secret' => env('RAZORPAY_WEBHOOK_SECRET'), ],
$order = Payments::driver('razorpay')->charge( ChargeRequest::make()->amount(50000, 'INR')->reference('rcpt-1') ); // $order->id() returns "order_...", pass it to Razorpay Checkout on the client.
PayPal
Uses OAuth2 client-credentials for a bearer token. charge() creates an Orders
v2 order; refund() acts on a capture id.
'paypal' => [ 'client_id' => env('PAYPAL_CLIENT_ID'), 'client_secret' => env('PAYPAL_CLIENT_SECRET'), 'mode' => env('PAYPAL_MODE', 'sandbox'), // 'sandbox' | 'live' 'webhook_id' => env('PAYPAL_WEBHOOK_ID'), // required for webhook verification ],
$order = Payments::driver('paypal')->charge( ChargeRequest::make()->amount(1050, 'USD')->description('Order #42') );
Square
charge() creates a Payment from a card nonce (source). refund() refunds a
payment.
'square' => [ 'access_token' => env('SQUARE_ACCESS_TOKEN'), 'environment' => env('SQUARE_ENV', 'sandbox'), // 'sandbox' | 'production' 'version' => env('SQUARE_VERSION', '2024-10-17'), 'signature_key' => env('SQUARE_WEBHOOK_SIGNATURE_KEY'), // for webhook verification 'notification_url' => env('SQUARE_WEBHOOK_URL'), // the URL Square posts to ],
Payments::driver('square')->charge( ChargeRequest::make()->amount(1050, 'USD')->source('cnon:card-nonce-ok')->reference('idem-1') );
Authorize.Net
charge() runs an authCaptureTransaction against an Accept.js opaque-data
token (the source).
'authorizenet' => [ 'login_id' => env('AUTHORIZENET_LOGIN_ID'), 'transaction_key' => env('AUTHORIZENET_TRANSACTION_KEY'), 'environment' => env('AUTHORIZENET_ENV', 'sandbox'), // 'sandbox' | 'production' 'signature_key' => env('AUTHORIZENET_SIGNATURE_KEY'), // for webhook verification ],
Payments::driver('authorizenet')->charge( ChargeRequest::make() ->amount(1050, 'USD') ->source('eyJjb2RlIjoi...') // Accept.js opaque dataValue ->option('data_descriptor', 'COMMON.ACCEPT.INAPP.PAYMENT') ); // Refunds require the original transaction id plus the card's last four and expiry: Payments::driver('authorizenet')->refund( RefundRequest::make() ->payment('60160000001') ->amount(1050, 'USD') ->option('card_number', '1111') // last four ->option('expiration_date', '2026-12') );
Verifying webhooks
Verify the signature against the raw request body (never a re-encoded array):
use ApiHub\Laravel\Facades\Payments; public function handle(Request $request) { $valid = Payments::driver('stripe')->verifyWebhook( $request->getContent(), // the raw body $request->headers->all(), // all request headers ); abort_unless($valid, 400); // ...handle the verified event... }
| Gateway | Verification |
|---|---|
| Stripe | HMAC-SHA256 over {timestamp}.{body} (Stripe-Signature: t=...,v1=...) |
| Razorpay | HMAC-SHA256 of the body (X-Razorpay-Signature) |
| Square | base64 HMAC-SHA256 over notification_url + body (x-square-hmacsha256-signature) |
| Authorize.Net | HMAC-SHA512 hex over the body (X-ANET-Signature: sha512=...) |
| PayPal | Calls PayPal's verify-webhook-signature API (needs webhook_id) |
Environment variables reference
# Global APIHUB_HTTP_TIMEOUT=10 APIHUB_HTTP_RETRIES=2 APIHUB_HTTP_RETRY_DELAY=250 APIHUB_LOGGING=false APIHUB_QUEUE_CONNECTION= APIHUB_QUEUE_NAME=default # Email APIHUB_EMAIL_DRIVER=mailgun MAILGUN_API_KEY= MAILGUN_DOMAIN= MAILGUN_ENDPOINT=https://api.mailgun.net SENDGRID_API_KEY= AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 RESEND_API_KEY= # AI APIHUB_AI_DRIVER=openai OPENAI_API_KEY= OPENAI_ORGANIZATION= ANTHROPIC_API_KEY= GEMINI_API_KEY= DEEPSEEK_API_KEY= # SMS & Messaging APIHUB_MESSAGING_DRIVER=twilio TWILIO_SID= TWILIO_AUTH_TOKEN= TWILIO_FROM= VONAGE_KEY= VONAGE_SECRET= VONAGE_FROM= MSG91_AUTH_KEY= MSG91_SENDER= TELEGRAM_BOT_TOKEN= WHATSAPP_TOKEN= WHATSAPP_PHONE_NUMBER_ID= SLACK_BOT_TOKEN= SLACK_WEBHOOK_URL= DISCORD_BOT_TOKEN= DISCORD_WEBHOOK_URL= # Payments APIHUB_PAYMENTS_DRIVER=stripe STRIPE_SECRET= STRIPE_WEBHOOK_SECRET= RAZORPAY_KEY= RAZORPAY_SECRET= RAZORPAY_WEBHOOK_SECRET= PAYPAL_CLIENT_ID= PAYPAL_CLIENT_SECRET= PAYPAL_MODE=sandbox PAYPAL_WEBHOOK_ID= SQUARE_ACCESS_TOKEN= SQUARE_ENV=sandbox SQUARE_WEBHOOK_SIGNATURE_KEY= SQUARE_WEBHOOK_URL= AUTHORIZENET_LOGIN_ID= AUTHORIZENET_TRANSACTION_KEY= AUTHORIZENET_ENV=sandbox AUTHORIZENET_SIGNATURE_KEY=
Development
composer install composer test # Pest test suite composer pint # Laravel Pint (code style) composer stan # PHPStan / Larastan (level 5)
The package runtime supports PHP 8.0+ and Laravel 8+. The modern test tooling (Pest 2/3) needs PHP 8.1+, so CI runs the full suite across PHP 8.1 to 8.4 and Laravel 10 to 12, and additionally lints every source file under PHP 8.0 to guarantee it stays parse-compatible with the lowest supported version.
Roadmap
- Core engine: HTTP connector, normalised responses, exception hierarchy, redaction, webhook verification, managers & facades.
- Email: Mailgun, SendGrid, SES, Resend (+ AWS SigV4 signer).
- AI: OpenAI, Anthropic, Gemini, DeepSeek.
- SMS & Messaging: Twilio, Vonage, MSG91, Telegram, WhatsApp, Slack, Discord.
- Payments: Stripe, Razorpay, PayPal, Square, Authorize.Net (+ webhook verification).
- Future categories (Cloud & Storage, Social, Maps, Auth, Monitoring) build on the same core without changes to it.
License
ApiHub for Laravel is open-source software licensed under the MIT license.