jeffersongoncalves/laravel-sendgrid
A Laravel package that integrates SendGrid for sending transactional email from your application, providing a simple way to configure SendGrid and deliver mail reliably within Laravel projects.
The package exposes a hosted email platform's full API surface — transactional sends, contact management, campaign reads, stats, bounces, spam reports, and address validation — through a single Laravel integration. Treat it as the delivery + reputation layer of your app, not as a replacement for Laravel's Mail facade until you've verified transport support.
Minimal setup
composer require <vendor>/<package> # pin ^1.0
php artisan vendor:publish --tag=<package>-config
MAIL_SERVICE_API_KEY=SG.xxxxxxxx
MAIL_SERVICE_SANDBOX=false
Add the key to config/services.php (or the published config, whichever the README points to) and never read env() outside config files — otherwise php artisan config:cache will silently hand you null and every send will fail with an auth error that looks like a bad key.
Where to look first
config/<package>.php — base URL, timeout, retry count, default sender identity. The base URL override is the most useful knob: point it at a mock server in tests.src/ facade and its getFacadeAccessor() binding — confirms which concrete client class you'll be injecting.tests/ directory if shipped — the fastest source of truth for payload shapes, since the README usually lags a .0 release.composer.json requirements — confirm the Guzzle/carbon/Laravel version constraints match your app before you build a workflow on it.First use case
Send one transactional email from a queued job, then immediately read it back through the reputation endpoints to close the loop:
MailService::mail()->send([
'to' => [['email' => $user->email, 'name' => $user->name]],
'from' => ['email' => config('mail.from.address'), 'name' => config('mail.from.name')],
'replyTo' => ['email' => '[email protected]'],
'subject' => 'Your invoice is ready',
'html' => view('emails.invoice', compact('invoice'))->render(),
'text' => view('emails.invoice-text', compact('invoice'))->render(),
]);
Then MailService::stats()->get(...) and MailService::bounces()->list() to verify the message actually landed. Doing both on day one surfaces credential, sender-verification, and permission-scope problems before they're buried inside a business feature.
Wrap the client, don't sprinkle the facade. Inject the package's client into one thin application service and let the rest of the codebase depend on your interface:
final class TransactionalMailer
{
public function __construct(private MailServiceClient $client) {}
public function send(Mailable $mailable): string
{
return $this->client->mail()->send($mailable->toPayload())->messageId();
}
}
This gives you one place to add retry logic, redact PII from logs, and swap providers. Bind the interface in a service provider and never reference the facade in controllers or models.
Keep payload construction in dedicated classes. For each transactional message (password reset, receipt, invite) create a small class with a toPayload(): array method holding the subject, template ID, and data mapping. Scattering array literals across jobs makes template changes a grep exercise.
Dynamic templates are the leverage point. Store template IDs in config/mail-templates.php keyed by a domain-meaningful name (invoice.ready, auth.reset), and pass a flat data array. Keep a resources/mail-templates/ folder with the template source under version control — the API is the system of record for rendering, but Git should be the system of record for what the template says. Add a test that asserts every key your code passes exists in the template's expected-data fixture; template variable mismatches fail silently by rendering an empty string.
Queue everything, always. Even "instant" sends belong in a job. Wrap sends in a job with $tries = 3, backoff() returning [10, 60, 300], and a failed() method that records the failure to your own table. Catch the package's exceptions explicitly and re-throw transport-level failures (429, 5xx, timeouts) as retryable, but mark 4xx payload/validation errors as $this->fail() immediately — retrying a malformed payload just burns rate limit.
Use notifications for multi-channel fan-out. Register a custom notification channel that delegates to your TransactionalMailer. Then a PasswordReset notification can decide between mail, SMS, and in-app without the caller knowing which provider is behind the mail leg — and you keep the package swappable.
Contact sync belongs in domain events, not controllers. On UserRegistered / ProfileUpdated, dispatch a queued SyncContactToProvider listener. It should be idempotent — the provider's upsert semantics (not yours) decide duplicates — and it should never be in the request path, because contact APIs are typically slower and stricter than send APIs. Use search() only for admin/support lookups and for reconciliation jobs, not for auth or gating.
Read-only campaigns and stats demand a mirror, not a proxy. campaigns.list/get and stats.get have no write side in this release. If you need dashboards, run a scheduled command that pulls stats into a local email_stats table with a (date, campaign_id) unique index, then query your own database. That gives you history beyond the provider's retention window, joins against your users table, and immunity to their rate limits during business hours.
Bounces and spam reports are a suppression workflow. Poll on a schedule (or consume webhooks if you add them) and write to a suppressed_emails table keyed by address. Distinguish hard from soft bounces — hard bounce or spam complaint means set users.email_undeliverable_at and stop sending marketing mail to that address; soft bounce means retry later. Check this table before every send. Never delete suppressed addresses; the provider's suppression list and yours will drift, and yours is the one your app can read in a single query.
Email validation is a gate, not a verdict. Call it at registration and at checkout for high-value flows, cache the result for 30–90 days keyed by the normalized address, and treat risky/unknown as "allow but flag" rather than "reject". Store the raw verdict so support can reverse a bad call without a second API round-trip.
Testing pattern. Point the configured base URL at a local mock (WireMock/HttpMock or a Laravel route in a test-only provider) and assert on the request body — that catches the payload regressions that actually break in production. Add one optional, env-gated contract test against the provider's sandbox so signature and endpoint drift gets caught before a deploy, and keep it out of the default suite so CI stays fast and offline-safe.
1.0.0 means no upgrade history. Pin the exact minor (^1.0 at most), read every changelog entry before bumping, and wrap the client in your own interface on day one so a 2.0 rename costs you a single adapter file instead of a codebase-wide find-and-replace.env() call added outside config/ — including in your own wrapper — returns null under config:cache. Run php artisan config:cache locally at least once before shipping.mail.send means accepted, not delivered. Most "the package is broken" reports are unverified sender domains or an unverified from-address, which surface later as bounces, not as exceptions.contacts.list(), campaigns.list(), bounces.list() and spam_reports.list() all paginate. Code that assumes the default page size holds everything will pass in staging with 12 contacts and drop 40,000 in production. Iterate with the package's cursor/offset helper or loop until count < limit, and add a test with a mocked two-page response.list and get only. Use it for reporting and for linking your own records to provider campaign IDs.cc/bcc count against your sending quota and appear in bounces. They also leak recipient addresses to each other in some providers' bounce reports — prefer separate sends or a template-driven fan-out for anything user-facing.RequestException bubble. Always try/catch around calls and rethrow as a domain exception carrying the provider's error code and message; log the error body but redact the API key and email bodies.to/cc/bcc/html before the log handler sees them, and make sure your custom Mailer wrapper never dd()s a payload.Http::fake(), which won't intercept a package that instantiates its own Guzzle client. If the facade is macroable, macros are fine for one-off admin tooling but keep them out of the domain layer.cc/bcc accidentally the only recipient, (5) sending on the wrong environment's API key. In that order — it resolves the vast majority of reports.How can I help you explore Laravel packages today?