Laravel integration: send transactional email

Updated

An official package is available: inboxili/inboxili-laravel on Packagist, with a facade, a queued job and a mail transport. The sections below show the same integration done by hand with Laravel's HTTP client.

The official package covers most cases. The rest of this page shows the same integration built by hand with Laravel's Http facade: a small service class, a config entry and a queued job.

Use the official package

composer require inboxili/inboxili-laravel

Add INBOXILI_API_KEY to .env. The package is auto-discovered and gives you:

use Inboxili\Laravel\Facades\Inboxili;
use Inboxili\Laravel\Jobs\SendInboxiliEmail;

Inboxili::emails()->send([
    'to' => $user->email,
    'from_email' => 'hello@yourdomain.com',
    'subject' => 'Welcome, {{first_name}}',
    'html_body' => '<p>Hi {{first_name}}, your account is ready.</p>',
    'template_data' => ['first_name' => $user->first_name],
]);

SendInboxiliEmail::dispatch([/* same fields */]); // runs once on your queue

It also registers an inboxili mail transport. Set a mailer with 'transport' => 'inboxili' in config/mail.php and MAIL_MAILER=inboxili, and Mailables and notifications go through the API. Because the API takes one recipient and has no CC, BCC, reply-to or attachment fields, the transport throws on a message that uses them instead of dropping them silently. Source: github.com/inboxili/inboxili-laravel. Package: inboxili/inboxili-laravel on Packagist.

Or call the API by hand

Configure

config/services.php:

'inboxili' => [
    'key' => env('INBOXILI_API_KEY'),
    'base_url' => env('INBOXILI_BASE_URL', 'https://api.inboxili.com/api/v1'),
    'from_email' => env('INBOXILI_FROM_EMAIL', 'hello@yourdomain.com'),
    'from_name' => env('INBOXILI_FROM_NAME', config('app.name')),
],

.env:

INBOXILI_API_KEY=ik_live_...
INBOXILI_FROM_EMAIL=hello@yourdomain.com

The service

app/Services/Inboxili.php:

<?php

namespace App\Services;

use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;

class Inboxili
{
    /**
     * @param  array{to:string,subject?:string,html_body?:string,text_body?:string,template_id?:string,template_data?:array,from_email?:string,from_name?:string}  $message
     * @return array{status:string,message_id:?string}
     *
     * @throws RequestException
     */
    public function send(array $message): array
    {
        $payload = array_filter($message + [
            'from_email' => config('services.inboxili.from_email'),
            'from_name' => config('services.inboxili.from_name'),
        ], fn ($v) => $v !== null);

        return Http::withToken(config('services.inboxili.key'))
            ->baseUrl(config('services.inboxili.base_url'))
            ->timeout(10)
            ->acceptJson()
            ->post('/transactional/send', $payload)
            ->throw()
            ->json();
    }
}

throw() raises RequestException for any 4xx or 5xx. The response body is available as $e->response->json('error.code').

Send from a queued job

<?php

namespace App\Jobs;

use App\Services\Inboxili;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Http\Client\RequestException;
use Illuminate\Queue\InteractsWithQueue;

class SendWelcomeEmail implements ShouldQueue
{
    use InteractsWithQueue, Queueable;

    public $tries = 4;

    public function __construct(public int $userId) {}

    public function backoff(): array
    {
        return [5, 30, 120];
    }

    public function handle(Inboxili $inboxili): void
    {
        $user = \App\Models\User::findOrFail($this->userId);

        try {
            $inboxili->send([
                'to' => $user->email,
                'subject' => 'Welcome, {{first_name}}',
                'html_body' => '<p>Hi {{first_name}}, your account is ready.</p>',
                'template_data' => ['first_name' => $user->first_name],
            ]);
        } catch (RequestException $e) {
            // Retry rate limits only; everything else will fail again the same way.
            if ($e->response->status() !== 429) {
                $this->fail($e);
            } else {
                throw $e;
            }
        }
    }
}

Dispatch with SendWelcomeEmail::dispatch($user->id);.

One caution: a connection timeout inside handle() also throws, and the retry could send the email twice because the API has no idempotency key. For mail that must not duplicate, record a sent_at before the job finishes and check it at the top of handle().

Testing

<?php

use App\Services\Inboxili;
use Illuminate\Support\Facades\Http;

it('sends through the API', function () {
    Http::fake([
        'api.inboxili.com/*' => Http::response(['status' => 'sent', 'message_id' => 'm1'], 200),
    ]);

    $result = app(Inboxili::class)->send([
        'to' => 'ada@example.com',
        'subject' => 'Hi',
        'html_body' => '<p>Hi</p>',
    ]);

    expect($result['message_id'])->toBe('m1');
    Http::assertSent(fn ($r) => $r->hasHeader('Authorization') && $r['to'] === 'ada@example.com');
});

Production notes

  • Keep the key in .env and run php artisan config:cache in production.
  • Run queue:work under a supervisor so retries happen.
  • Webhooks: register a route, exclude it from CSRF, and verify with hash_equals(hash_hmac('sha256', $request->getContent(), $secret), $request->header('X-Inboxili-Signature')).
  • Laravel's built-in notifications (password reset, email verification) go through MAIL_MAILER. To send them with Inboxili, define a custom notification channel that calls this service.

Frequently asked questions

Can I set MAIL_MAILER to Inboxili?
Yes, with the official package: define a mailer with transport inboxili. The API has one recipient and no CC, BCC, reply-to or attachments, so the transport throws on messages that use them rather than dropping them.
How do I send from a queue?
Wrap the service call in a queued job. That also lets Laravel's retry and backoff handle HTTP 429.

Build with Inboxili

Create a workspace, verify a domain, and make your first API call.

Related