Skip to content
YourMail

Framework guide

Send email from Laravel and PHP

Why use this

There is no PHP SDK, and you do not need one: sending is a single JSON POST to https://api.yourmail.dev/v1/emails, and there is no SMTP server to configure. Laravel's HTTP client does all of it. This guide covers the plain call and then a queued job around it.

For example

Someone registers on your Laravel app and a queued job sends their welcome email without holding up the response.

1. Keep the key on the server

Add the key to .env and read it through config/services.php. Call env() only from config files: once you run php artisan config:cache, calls elsewhere return null. The key is a server-side secret, so it never belongs in a Blade view or in front-end code.

# .env — server-side only, never committed.
YOURMAIL_API_KEY=yourmail_...

2. The shortest working send

withToken() sends the key as a Bearer token, and post() sends the array as JSON. Supplying both html and text is worth the extra line: some clients prefer the plain-text part, and its absence is a mild spam signal.

use Illuminate\Support\Facades\Http;

$response = Http::withToken(config('services.yourmail.key'))
    ->acceptJson()
    ->timeout(10)
    ->post('https://api.yourmail.dev/v1/emails', [
        'from' => 'Acme <hello@mail.acme.com>',
        'to' => 'customer@example.com',
        'subject' => 'Welcome aboard',
        'html' => '<p>Thanks for signing up.</p>',
        'text' => 'Thanks for signing up.',
    ]);

$response->throw(); // Throws on any 4xx or 5xx.

$id = $response->json('id');

A success returns {"id": "…"}. The from address must be on a domain you have verified; until you have one, send from no-reply@yourmail.dev instead, which delivers to your own inbox in test mode.

3. A queued job

Sending from a job keeps the API call out of the request and gives you the queue's retry machinery. The job decides which failures are worth that machinery.

<?php

namespace App\Jobs;

use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;
use RuntimeException;

class SendWelcomeEmail implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;

    public function __construct(public User $user) {}

    public function handle(): void
    {
        $response = Http::withToken(config('services.yourmail.key'))
            ->acceptJson()
            ->timeout(10)
            ->post('https://api.yourmail.dev/v1/emails', [
                'from' => 'Acme <hello@mail.acme.com>',
                'to' => $this->user->email,
                'subject' => 'Welcome aboard',
                'html' => '<p>Thanks for signing up.</p>',
                'text' => 'Thanks for signing up.',
                // Derived from the user, not generated per attempt: a retry
                // after a lost response returns the original id instead of
                // sending the welcome email twice.
                'idempotencyKey' => "welcome:{$this->user->id}",
                'tags' => [['name' => 'type', 'value' => 'welcome']],
            ]);

        if ($response->successful()) {
            return;
        }

        $type = $response->json('error.type');
        $retryAfter = (int) $response->header('Retry-After');

        // Transient: our side failing, or a rate limit with a short wait.
        // Hand it back to the queue; the idempotency key makes that safe.
        if ($response->serverError()) {
            $this->release(30);
            return;
        }
        if ($type === 'rate_limited' && $retryAfter > 0 && $retryAfter <= 60) {
            $this->release($retryAfter);
            return;
        }

        // Permanent: invalid field, unverified domain, suppressed recipient,
        // quota, or a day-long cap. The same request fails the same way.
        $this->fail(new RuntimeException(
            "YourMail rejected the send ({$type}): ".$response->json('error.message')
        ));
    }
}

tags is an array of ['name' => …, 'value' => …] pairs, not an associative array; a single map is a 400. Tags show up when you filter your message history later. The API also accepts the snake_case spellings reply_to, idempotency_key and scheduled_at if they suit your codebase better.

4. Retry by error type, not attempt count

Every error comes back as {"error": {"type", "message", "docs_url"}}. A 400, 401, 403 or 422 is configuration to fix: an invalid field, a bad key, an unverified domain, a suppressed recipient. It will fail the same way every time, so the job calls fail() rather than letting the queue try again. A 5xx, a connection timeout (which throws, so the queue retries it up to $tries), or a 429 rate_limited with a short Retry-After is transient. A 429 quota_exceeded carries no Retry-After at all: it is a ceiling, so surface it rather than waiting on it.

The other half of a safe retry is the idempotency key. A send is only safe to retry with the same key: without one, a retry after a lost response sends twice. Derive it from something stable, such as the user or order id plus the template name, never a fresh random value per attempt. You can send it as an Idempotency-Key header instead of a body field; a body key wins if you send both.

// Don't do this.
Http::withToken(config('services.yourmail.key'))
    ->retry(5, 1000) // Retries every failure, including a 422 that
                     // will never succeed, and without an idempotency
                     // key a retried 5xx can send the email twice.
    ->post('https://api.yourmail.dev/v1/emails', $email);

The full list of error types, and which are safe to retry, is in the errors reference.

Sending a lot at once

Dispatching one job per recipient for a few hundred people works but is slow and wasteful. Use the batch endpoint instead: up to 100 messages per request, each with its own recipients and body.

Switching from another provider?

Last updated