symfony/google-mailer
Official Symfony Mailer bridge for sending email through Gmail and Google Workspace via the Gmail API. Configure OAuth2 credentials and use it as a Symfony Mailer transport for Google-backed delivery.
This is a transport bridge, not a mailer itself. It plugs Google's Gmail REST API into symfony/mailer, so you get MailerInterface / TransportInterface like any other transport. Reach for it when outbound SMTP is blocked (Cloud Run, GKE egress policies, restrictive corporate firewalls) or when you want sends attributed to a Workspace user's Gmail history.
Prerequisites (do these before touching the code):
https://mail.google.com/ (full) or gmail.send (send-only — prefer this).Install and configure:
composer require symfony/google-mailer symfony/mailer
# .env
MAILER_DSN=gmail+api://[email protected]:YOUR_OAUTH_ACCESS_TOKEN@default
# config/packages/mailer.yaml
framework:
mailer:
dsn: '%env(MAILER_DSN)%'
First send — nothing Google-specific in your code:
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
class Notifier
{
public function __construct(private MailerInterface $mailer) {}
public function ping(): void
{
$this->mailer->send((new Email())
->from('[email protected]')
->to('[email protected]')
->subject('Hello from Gmail API')
->text('It works.'));
}
}
Where to look first: Symfony\Component\Mailer\Bridge\Google\Transport\GmailTransportFactory and GmailTransport. That is the entire package surface.
Use MailerInterface everywhere. The bridge is invisible once configured. Your Email, TemplatedEmail, and NotificationEmail code is identical to the SMTP world, which makes swapping transports later a DSN change.
Prefer refresh tokens over baked-in access tokens. Access tokens expire in ~1 hour. The static DSN approach only works for a smoke test. For production, either:
TransportInterface around the bridge and call Google\Client::fetchAccessTokenWithRefreshToken() before send().A decorator is the least invasive pattern:
final class RefreshingTransport implements TransportInterface
{
public function __construct(
private TransportInterface $inner,
private Google\Client $client,
) {}
public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage
{
if (!$this->client->getAccessToken() || $this->client->isAccessTokenExpired()) {
$this->client->fetchAccessTokenWithRefreshToken();
}
return $this->inner->send($message, $envelope);
}
public function __toString(): string { return 'gmail+api'; }
}
Route through Messenger for resilience. Gmail API calls can be slow and rate-limited; async sending makes user-facing requests snappy and gives you retries for free:
framework:
messenger:
routing:
'Symfony\Component\Mailer\Messenger\SendEmailMessage': async
Keep From == authenticated user. Gmail's API rejects (or rewrites) From addresses that don't match the authenticated account or a verified alias. Centralize this in a from default on Email/TemplatedEmail and assert it in tests.
Testing: don't hit the API. In test env set MAILER_DSN=null://null (or in-memory://) and assert on MessageEvent via mailer.assertEmailIsQueued() / the profiler. If you specifically need to test the Google DSN path, mock the Google\Client and inject it into a transport built by hand.
gmail+api, not gmail. The bridge does not provide SMTP-over-Gmail; gmail:// and gmail+smtp:// are not registered by this package. If you want SMTP you don't need this bridge at all — use smtp://smtp.gmail.com:587 with an app password.From must be the authenticated Gmail address (or a verified alias/Send-As in Workspace). Mismatches produce a cryptic 400 from the Gmail API that looks like a serialization error if you catch it at the Mailer layer — bubble up the Google\Service\Exception on failure.google/apiclient pulls a large tree; if image size matters, run composer require google/apiclient:^2.0 with extra.google/apiclient-services filtered to just Gmail, or use the google/apiclient-services allowlist to trim.SentMessage carries a Gmail message id you can log, but you will not get async bounces. Pair with a webhook consumer on Gmail push notifications if you need that.Google\Client::isAccessTokenExpired()), that scopes include gmail.send or https://mail.google.com/, and that the Gmail API (not just OAuth consent) is enabled on the Cloud project. Three independent places to get it wrong.new GmailTransport($googleClient, $dispatcher) directly, then wrap it in your own TransportInterface. The factory is convenient, not mandatory.default host is conventional, not required. Any host segment is accepted; the email and token come from user/password. If you see auth failures, check your DSN parser isn't mangling tokens containing + or / — URL-encode them.symfony/mailer and symfony/google-mailer to the same ^7.x constraint to avoid TransportFactory interface drift during deploys.How can I help you explore Laravel packages today?