innoge/laravel-msgraph-mail
Send Laravel mail through Microsoft Graph (Office 365/Outlook) with a native mail transport. Configure your Azure app credentials, then use Laravel's Mail facade as usual—no SMTP needed. Supports attachments, HTML mail, and modern Graph authentication for reliable, scalable delivery.
1. Install and let auto-discovery wire the transport
composer require innoge/laravel-msgraph-mail
There is nothing to publish. The service provider registers a microsoft-graph transport driver via package discovery. All configuration lives in your app's config/mail.php.
2. Register the Azure app (do this before writing any code)
You need an Entra ID (Azure AD) app registration configured for the client-credentials flow:
Mail.Send as an Application permission (not Delegated) + admin consent granted.Mail.ReadWrite (application) as well if you ever send attachments that push the payload past ~3 MB.3. Add the mailer block
'mailers' => [
'microsoft-graph' => [
'transport' => 'microsoft-graph',
'client_id' => env('MICROSOFT_GRAPH_CLIENT_ID'),
'client_secret' => env('MICROSOFT_GRAPH_CLIENT_SECRET'),
'tenant_id' => env('MICROSOFT_GRAPH_TENANT_ID'),
'from' => [
'address' => env('MAIL_FROM_ADDRESS'),
'name' => env('MAIL_FROM_NAME'),
],
'save_to_sent_items' => env('MAIL_SAVE_TO_SENT_ITEMS', false),
],
],
The from block is optional — omit it entirely and the global mail.from is used. If you do include it, both address and name are expected.
4. Flip the default mailer
MAIL_MAILER=microsoft-graph
MICROSOFT_GRAPH_TENANT_ID=00000000-0000-0000-0000-000000000000
MICROSOFT_GRAPH_CLIENT_ID=00000000-0000-0000-0000-000000000000
MICROSOFT_GRAPH_CLIENT_SECRET=super-secret-value
[email protected]
MAIL_FROM_NAME="Your App"
Then php artisan config:clear and send a test:
Mail::raw('Graph is wired up.', fn ($m) => $m->to('[email protected]')->subject('Hello Graph'));
That single line is the whole first use case — if it lands, everything else (Mailables, Notifications, queued mail) works unchanged. There is no package-specific send API.
Where to look first when it breaks: the mailer entry in config/mail.php (most failures are a missing/renamed key), then config:clear, then the Azure permission blade. The package's own troubleshooting matrix maps error codes (AADSTS*, ErrorAccessDenied, ErrorInvalidUser) to fixes.
Because it's a Laravel mail transport, nothing in your application code changes. Mailables, Notifications, Mail::to()->send(), Markdown mail, and Blade views all work as-is. Migrating from the deprecated smtp.office365.com path is purely config + Azure work.
Graph is a remote HTTP API; a slow request should never block a user-facing response. Standard queued Mailable pattern:
class InvoiceReady extends Mailable implements ShouldQueue
{
public $tries = 5;
public $backoff = [10, 30, 60, 120];
public function __construct(public Invoice $invoice) {}
public function build()
{
return $this->subject('Your invoice is ready')
->markdown('mail.invoice-ready');
}
}
This is also the recommended answer to cURL error 28 — retry with backoff rather than inflating timeouts.
Client secrets expire and cause the classic AADSTS7000215 fire drill. Certificate auth signs a JWT client assertion and never expires (until the cert does):
'microsoft-graph' => [
'transport' => 'microsoft-graph',
'client_id' => env('MICROSOFT_GRAPH_CLIENT_ID'),
'tenant_id' => env('MICROSOFT_GRAPH_TENANT_ID'),
'client_certificate' => [
'certificate' => env('MICROSOFT_GRAPH_CERTIFICATE'), // PEM content or file path
'private_key' => env('MICROSOFT_GRAPH_PRIVATE_KEY'), // PEM content or file path
'passphrase' => env('MICROSOFT_GRAPH_KEY_PASSPHRASE'),
],
'from' => ['address' => env('MAIL_FROM_ADDRESS'), 'name' => env('MAIL_FROM_NAME')],
],
Requires ext-openssl. If both secret and certificate are configured, the certificate wins — a useful staging/production split, and a nasty surprise if you set both by accident.
Define more than one Graph mailer (e.g. billing, notifications) when different from-addresses are needed. In 2.x save_to_sent_items is read from the mailer's own config entry, so it works under any key name — in 1.x it only worked on the literal microsoft-graph key.
Mail::mailer('billing')->to($customer)->send(new InvoiceReady($invoice));
// or per-mailable
$mailable->mailer('billing');
->from() changes the Graph URL target (/users/{address}/sendMail), so the alternate address must also be a real mailbox the app is allowed to send as.
$this->from('[email protected]', 'Billing');
Sent Items can be toggled per mailable without touching config:
use Symfony\Component\Mailer\Header\MetadataHeader;
$this->withSymfonyMessage(fn ($message) => $message->getHeaders()
->add(new MetadataHeader('save-to-sent-items', 'true')));
Useful for compliance-relevant mail (invoices, legal notices) while letting noise stay out of Sent Items.
Attachments under the payload threshold are sent inline in sendMail. Beyond ~3 MB the package transparently switches to a draft + chunked upload session, and cleans up the draft on failure. That path needs Mail.ReadWrite:
$this->attach($path, ['as' => 'statement.pdf', 'mime' => 'application/pdf']);
Beyond ~150 MB Graph's upload session limit is hit (ErrorMessageSizeExceeded) — send a signed download link instead of an attachment.
Nothing package-specific is required:
Mail::fake();
$this->post('/invoices/1/send');
Mail::assertQueued(InvoiceReady::class);
Mail::fake() swaps the transport entirely, so tests never touch Graph and never need Azure credentials. Keep your test env's MAIL_MAILER as array or log so a stray unmocked send fails loudly rather than calling Microsoft.
php artisan config:cache after adding the mailer block, or the new key won't exist — the top cause of "Configuration key ... is missing".php artisan cache:clear whenever you rotate a secret, upload a new certificate, or grant a new Graph permission. Cached tokens live for expires_in - 60s and embed the roles they were issued with.| Symptom | Real cause | Fix |
|---|---|---|
Configuration key tenant_id/client_id/client_secret/from.address ... missing |
Mailer block absent (very common in apps upgraded from Laravel 8/9 where config/mail.php predates this driver), empty env, or stale config cache |
Add the full block, php artisan config:clear |
AADSTS7000215: Invalid client secret |
Secret expired, or you copied the secret's ID instead of its Value | New secret, copy the Value — or move to certificate auth |
AADSTS700016 / AADSTS90002 |
Wrong client_id, or wrong tenant_id (domain vs GUID) |
Copy both GUIDs from the app's Overview page |
AADSTS700027: invalid signature |
Certificate not uploaded under Certificates & secrets → Certificates, or cert/key mismatch | Verify thumbprints match |
403 ErrorAccessDenied |
Mail.Send added as Delegated instead of Application, no admin consent, or an Exchange ApplicationAccessPolicy restricting the app to a subset of mailboxes |
Re-add as Application, grant consent, review access policies |
MissingMailReadWritePermission |
Mail > ~3 MB, Mail.ReadWrite not granted |
Grant + consent, then cache:clear (cached token lacks the role) — or shrink the attachment |
| Permission granted but still 403 | Token cached from before the grant | php artisan cache:clear |
404 ErrorInvalidUser |
From address is an alias, a distribution list, or doesn't exist in the tenant | Use the mailbox's primary SMTP address |
| Mail sends but isn't in Sent Items | save_to_sent_items defaults to false |
Enable in mailer config or via the metadata header |
cURL error 28 |
Transient Graph/DNS slowness | Queue + retry/backoff; higher timeouts don't fix it |
Header forwarding is narrow. Only custom headers prefixed X- reach Graph. Symfony metadata/tag headers and any X--less custom header are silently dropped — no exception, which makes it easy to ship a "header-driven" feature that never arrives. Recipient display names are also dropped; only addresses are transmitted, and Microsoft substitutes tenant-known names on delivery.
Inline images vs attachments. Attachment filenames come through with real names and extensions where the framework exposes them; inline-image content IDs are handled separately and are stable. Don't try to correlate the two.
No publishable config is by design. Resist the urge to vendor:publish. Everything is in config/mail.php; treat the mailer block as first-class source-controlled config, not a generated file.
Watch the 2.x boundary. If you're on 1.x: save_to_sent_items only works on a mailer literally keyed microsoft-graph, large mails hard-fail at ~4 MB with ErrorMessageSizeExceeded (no upload sessions), and certificate auth doesn't exist. Check UPGRADE.md before copying config snippets from blog posts.
Debugging the actual Graph response. The package surfaces Graph's error body inside the exception message — log $e->getMessage() rather than a generic "mail failed". Boost's last-error and read-log-entries MCP tools are handy here if available; otherwise tail -f storage/logs/laravel.log while firing a synchronous test send is faster than reasoning about it.
Extension points are thin on purpose. There's no driver subclass hook worth using; do per-message customisation via withSymfonyMessage() headers and per-context routing via additional mailer keys. If you need heavier control (custom payload shaping, batch sending), call the Graph API directly rather than fighting the transport.
One mailbox, one app, one tenant. For multi-tenant SaaS, remember the token cache key is tenant + client — separate tenants need separate mailer entries (and separate credentials), or you'll get confusing cross-tenant ErrorInvalidUser responses.
How can I help you explore Laravel packages today?