# Send email from Laravel

Send transactional email from Laravel with emails.sh over HTTP. Queued job, config entry, and why you should not block the request on a send.

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

1. **Create a key** https://emails.sh/dashboard issues a key starting with esh_.
1. **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.
1. **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.
1. **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.

## Install

```bash
composer require guzzlehttp/guzzle
```

## config/services.php

```php
<?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
<?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
<?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
<?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.


Docs: https://emails.sh/docs.md