Send email from Laravel
Laravel already has Mailables and queues, and the piece you are replacing is the transport. This page calls the emails.sh HTTP API from a queued job, so the request returns immediately and the send retries on its own if the network hiccups.
Setup
- 01
Create a key
https://emails.sh/dashboard issues a key starting with esh_.
- 02
Add it to .env and config
Never call env() outside config/*.php. Once you run php artisan config:cache, env() returns null everywhere else, and this is the classic "works locally, silently fails in production" bug.
- 03
Queue the send
php artisan queue:table && php artisan migrate for the database driver, then QUEUE_CONNECTION=database. A queued job means an HTTP call to emails.sh does not sit in the user request.
- 04
Run a worker
php artisan queue:work in development, and a supervisor or systemd unit in production. With QUEUE_CONNECTION=sync the job runs inline and blocks, which defeats the point.
composer require guzzlehttp/guzzle<?php
return [
// env() is only safe here. After config:cache, calls to env() elsewhere
// return null, so everything the app reads goes through config().
'emailssh' => [
'key' => env('EMAILSSH_API_KEY'),
'from' => env('EMAILSSH_FROM', 'Acme <onboarding@emails.sh>'),
],
];The queued job
app/Jobs/SendTransactionalEmail.php (Laravel 11 or 12)
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class SendTransactionalEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
// Wait 10s, then 60s, then 300s. Anything longer than a few minutes on a
// transactional send is worse than failing, so three attempts is enough.
public array $backoff = [10, 60, 300];
public function __construct(
public string $to,
public string $subject,
public string $html,
public ?string $idempotencyKey = null,
) {}
public function handle(): void
{
$response = Http::withToken(config('services.emailssh.key'))
->acceptJson()
->timeout(15)
->post('https://emails.sh/v1/emails', array_filter([
'from' => config('services.emailssh.from'),
'to' => [$this->to],
'subject' => $this->subject,
'html' => $this->html,
'text' => strip_tags($this->html),
'idempotency_key' => $this->idempotencyKey,
]));
if ($response->failed()) {
Log::error('emails.sh send failed', [
'status' => $response->status(),
'body' => $response->body(),
]);
// 4xx will fail the same way on every retry, so stop rather than
// burn the queue on it. 5xx is worth another attempt.
if ($response->clientError()) {
$this->fail(new RuntimeException($response->body()));
return;
}
$response->throw();
}
Log::info('emails.sh queued', ['id' => $response->json('id')]);
}
}The controller that dispatches it
app/Http/Controllers/SignupController.php
<?php
namespace App\Http\Controllers;
use App\Jobs\SendTransactionalEmail;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Str;
class SignupController extends Controller
{
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'email' => ['required', 'email'],
'name' => ['nullable', 'string', 'max:100'],
]);
$name = e($data['name'] ?? 'there');
SendTransactionalEmail::dispatch(
to: $data['email'],
subject: 'Welcome to Acme',
html: "<p>Hi {$name}, your Acme account is ready.</p>",
// Same signup submitted twice sends once. The API deduplicates on
// this value for 24 hours.
idempotencyKey: 'welcome:' . Str::lower($data['email']),
);
return back()->with('status', 'Check your inbox.');
}
}The webhook receiver
routes/web.php, for delivery and bounce events
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
// Exclude this path from CSRF in bootstrap/app.php:
// ->withMiddleware(fn ($m) => $m->validateCsrfTokens(except: ['webhooks/emailssh']))
Route::post('/webhooks/emailssh', function (Request $request) {
$signature = $request->header('x-emailssh-signature', '');
$expected = hash_hmac('sha256', $request->getContent(), config('services.emailssh.webhook_secret'));
if (! hash_equals($expected, $signature)) {
return response()->json(['error' => 'bad signature'], 401);
}
$event = $request->json('type');
$email = $request->json('data.to.0');
match ($event) {
'email.bounced' => Log::warning('Hard bounce, suppress this address', ['email' => $email]),
'email.complained' => Log::warning('Spam complaint, stop sending', ['email' => $email]),
default => Log::info('emails.sh event', ['type' => $event]),
};
return response()->noContent();
});Worth knowing
env() returns null after config:cache
Laravel caches config into a single file in production, and env() outside config/*.php stops working at that point. Read config("services.emailssh.key"), never env("EMAILSSH_API_KEY"), from a job or controller.
A synchronous send makes the user wait
Calling the API in the controller adds the full round trip to your response time, and a timeout becomes a 500 on a signup that otherwise succeeded. Dispatch a job and return.
QUEUE_CONNECTION=sync is not a queue
It runs the job inline in the same request. Dev machines default to it, so a "queued" send that still blocks means you never set the connection or never started a worker.
Escape user input before it goes in html
The html field is rendered as HTML by the recipient client. Run names and any other user string through e() or build the body from a Blade view, or you have injected markup into your own email.
Questions
Can I keep using Mailables and Blade templates?
Yes. Render the view with view("mail.welcome", $data)->render() and pass the string as html. You keep the templates and change only the transport.
Should I use SMTP or the HTTP API?
HTTP. It works on serverless hosts with no outbound port 25/587, gives you an id back for status lookups, and does not hold a connection open.
How do I retry a failed send?
The job above retries three times with backoff. Anything that exhausts them lands in failed_jobs, and php artisan queue:retry all replays them.
Why is my queued email never sending?
No worker is running. php artisan queue:work in another terminal, and check that QUEUE_CONNECTION is not sync.
The rest of the API
POST /v1/emails sends one and POST /v1/emails/batch sends up to 100 in a call. GET /v1/emails/:id returns the delivery events for a message, which is how you answer "did it arrive" without a support ticket. Webhooks push the same events to you as they happen.