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_...// config/services.php
return [
// ...
'yourmail' => [
'key' => env('YOURMAIL_API_KEY'),
],
];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')
));
}
}// After the user is created, e.g. in your registration controller:
SendWelcomeEmail::dispatch($user);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?
- AWS SES: YourMail vs AWS SES
- Resend: migration guide · YourMail vs Resend
- Postmark: migration guide · YourMail vs Postmark
- SendGrid: migration guide · YourMail vs SendGrid
Last updated