resend/resend-php
The official PHP client for the Resend email API. Install via Composer on PHP 8.1+, then send email in a few lines with a simple client interface inspired by OpenAI PHP.
Install the package (v1.17.0 or later):
composer require resend/resend-php
Initialize the client (e.g., in config/services.php):
'resend' => [
'api_key' => env('RESEND_API_KEY'),
'api_url' => env('RESEND_API_URL', 'https://api.resend.com'),
],
Then bind a singleton in app/Providers/AppServiceProvider.php:
public function register()
{
$this->app->singleton(Resend::class, function ($app) {
return Resend::client($app['config']['services.resend.api_key'], [
'api_url' => $app['config']['services.resend.api_url'],
]);
});
}
Bind in register(), not boot() — resolving config in boot() works, but register() makes the binding available to other providers and to queued jobs resolved early.
First call — sanity check the key, the quota, and (new in v1.17.0) the inbox surface:
use Resend\Resend;
$resend = app(Resend::class);
// Account-level usage & quota (v1.16.0)
$usage = $resend->usage->get();
// New in v1.17.0: inbox endpoints. Start with a read so you learn the
// response shape before you provision anything.
$inboxes = $resend->inboxes->list();
// Then a real send
$response = $resend->emails->send([
'from' => '[email protected]',
'to' => '[email protected]',
'subject' => 'Welcome!',
'html' => '<strong>Welcome to our platform!</strong>',
]);
src/Resend.php / the client() factory — the container shape for every service.src/Services/ — the service list (emails, contacts, domains, templates, events, usage, and now inboxes). Read the newest service first: inboxes for v1.17.0, then usage from v1.16.0 — together they tell you what you can receive and how much you can send before you write a line of application code.src/Exceptions/ — the exception hierarchy you'll be catching in jobs.reply+token@ threading, forwarded receipts, and "email-to-create-a-record" workflows all become in-app features instead of a third-party parsing service.usage daily into your admin panel so "why did the newsletter silently fail?" stops being a support ticket.Note: the other changelog entry (a
setup-phpdigest bump from Renovate) is CI housekeeping and has no runtime impact.
Email Sending
emails->send() with from, to, subject, plus html/text.$resend->emails->send([
'from' => '[email protected]',
'to' => '[email protected]',
'template_id' => 'abc123',
'template_data' => ['name' => 'John'],
]);
Inboxes (v1.17.0) — the first inbound primitive in the SDK.
inboxes service follows the same conventions as the rest of src/Services/, so expect the familiar create / get / list / remove shape. Dump the response once in tinker before writing against field names.$inbox = $resend->inboxes->create([
'name' => "support-{$tenant->id}",
]);
$tenant->update(['resend_inbox_id' => $inbox->id]);
Storage::disk('s3')->put("inbound/{$event->id}.json", $request->getContent());
ProcessInboundEmail::dispatch($event->id);
reply_to (e.g. reply+{ticket}@inbox.example.com) and match it back when the message lands in the inbox. This is the pattern that makes an inbox useful rather than just a mailbox.Usage & Quota (v1.16.0) — still the day-to-day ops workhorse.
usage service exposes account-level usage/quota data, so you can answer "how much headroom do I have left this period?" from inside the app instead of the dashboard.$usage = $resend->usage->get();
// Shape varies by plan — log it once, then code against the real keys.
Log::debug('resend.usage', (array) $usage);
if ($remaining < $recipientCount) {
// defer, downscale, or alert — do not fire the batch
return;
}
Cache::remember('resend.usage', now()->addMinutes(10), ...)) — it is not a per-request call.usage to confirm the batch actually consumed quota (a good proxy for "did it really send?").Contacts & Segments
contacts->create(), contacts->upsert() for bulk property sync.Webhooks & Events
$verifier = new \Resend\WebhookSignature($request->header('X-Resend-Signature'));
if (! $verifier->verify($request->getContent(), config('services.resend.webhook_secret'))) {
abort(400);
}
Templates & Domains
templates->create() / update() for copy changes without deploys.Resend into constructors; never new Resend(...) in a controller.class SendWelcomeEmail implements ShouldQueue
{
use Queueable;
public function __construct(public int $userId) {}
public function handle(Resend $resend): void
{
$resend->emails->send([...]);
}
}
MAIL_MAILER=resend in config/mail.php rather than hand-rolling a Mailable. Use the SDK directly only for features the transport doesn't expose (templates, batch, usage, inboxes).ResendChannel keeps notification logic in Notification classes where it belongs.Schedule::command('resend:usage-check')->hourly(); — cheap and catches quota exhaustion before customers do.resend:inboxes-sync command that persists newly created inbox ids, so provisioning is retryable and reconcilable rather than fire-and-forget.API key handling — .env only, config/services.php for the mapping, and never env() outside config files (it returns null once config is cached).
Assuming a usage shape — usage returns plan-dependent data. Dump it once in tinker, then write your alerting against the actual keys. Don't hardcode a field name you only saw in a blog post. The same discipline applies to the brand-new inboxes responses.
Calling usage per request — it's an API call with the same rate limits as everything else. Cache it; 5–10 minutes of staleness is irrelevant for a quota gauge.
Inbox provisioning is not free or idempotent — creating an inbox on every page load, or in a retried job without a guard, will litter your account with duplicates. Persist the id, check for it first, and make the command safe to re-run.
Don't poll inboxes for new mail — a Schedule::everyMinute() loop over inboxes->list() burns rate limit for nothing. Drive receipt off webhooks and use list()/get() only for reconciliation and backfill.
Inbound payloads are big — attachments and full MIME bodies can be megabytes. Store the raw JSON in S3 and keep parsed metadata in the DB; don't json_encode a full message into a text column and don't log it.
Idempotency — for payments/receipts, send an idempotency key so a retried job doesn't double-charge a customer's inbox:
$resend->emails->send([
// ...
'headers' => ['Idempotency-Key' => "receipt-{$order->id}"],
]);
Template data drift — template variables change when marketing edits a template. Validate keys before sending, or a rename becomes a silent 422 in production.
Rate limits / 429s — back off and retry with jitter; don't retry() in a tight loop. Let the queue do the waiting.
Quota exhaustion looks like a bug — once you're over the plan limit, sends fail while your code looks correct. The usage service is exactly the tool that stops this from being a mystery.
Webhook latency — delivery can lag by minutes, inbound and outbound alike. Never use webhooks as your source of truth for a synchronous user action.
\Exception:
try {
$resend->emails->send([...]);
} catch (\Resend\Exception\RateLimitException $e) {
$this->release($e->getRetryAfter());
} catch (\Resend\Exception\ResendException $e) {
Log::error('resend.failed', ['message' => $e->getMessage()]);
throw $e;
}
success — Resend's error bodies carry the actual field-level reason.php artisan tinker → app(Resend::class)->inboxes->list(). Thirty seconds here saves a refactor when the response shape isn't what you assumed.TXT/MX propagation with dig before opening a support ticket — and before you expect an inbox address to receive anything.Resend::client($key, [
'http_client' => new \GuzzleHttp\Client(['timeout' => 30]),
]);
Resend in your own Mailer class that combines send + usage-check + logging. Keeps controllers clean and gives you one place to instrument.InboxProvisioner class around inboxes->create() that handles the persist-then-verify dance and returns a domain object, so onboarding code never touches the SDK directly.$this->mock(Resend::class, function ($mock) {
$mock->shouldReceive('emails->send')->once();
$mock->shouldReceive('usage->get')->andReturn([...]);
$mock->shouldReceive('inboxes->create')->once()->andReturn((object) ['id' => 'inbox_123']);
});
usage — still the cheapest reliability win available.inboxes behind a single feature flag while the endpoint surface is young — v1.17.0 is its first release, so keep the blast radius to one code path you can switch off.emails->list() for auditing what actually went out, filtered by created_at.contacts->upsert() for CRM-style syncs; it's far cheaper than a create/update loop.schedule option for timezone-correct reminders — but check quota at schedule time and at send time.usage, v1.17.0 gives you inboxes; both are additive, so upgrading is safe — but the assessment's "where to look first" changes each time, and that's the five minutes that keeps you current.How can I help you explore Laravel packages today?