TL;DR
- You can record outgoing mail — sent, delivered-ish, opened, clicked — using Laravel's own mail events. No SaaS ESP required.
-
MessageSending/MessageSentevents give you the send-side hooks; a tracking pixel handles opens; rewritten links handle clicks. - Gate the whole thing behind a runtime toggle so you can switch it off per environment without a deploy.
- Caveat up front: "opened" via pixel is best-effort, not truth. Know what the signal actually means.
The two send-side events
Laravel fires MessageSending before a message hits the transport and MessageSent after. Listen to both and you get a durable record of every outgoing mail — recipient, subject, a generated message id — without touching your mailables.
class StoreMessageSending
{
public function handle(MessageSending $event): void
{
if (! MailTracking::enabled()) {
return; // runtime toggle — no deploy needed to switch off
}
$event->message->getHeaders()->addTextHeader('X-Mail-Id', (string) Str::uuid());
}
}
Stamping your own id in a header on the way out is what lets you correlate the open and click callbacks back to the exact message later. Register both listeners in the provider and you have a mail history table filling itself.
Opens: a pixel, honestly labelled
An "open" is a 1×1 transparent image at the end of the HTML body pointing at a tracked route. When the mail client loads it, your endpoint marks the message opened and returns the pixel.
Route::get('mail/o/{mailId}', function (string $mailId) {
MailHistory::where('mail_id', $mailId)->update(['opened_at' => now()]);
return response(base64_decode(self::PIXEL), 200)
->header('Content-Type', 'image/png');
})->name('mail.open');
Be honest about what this measures. Many clients block remote images, Apple Mail Privacy Protection pre-fetches them (false positives), and plain-text readers never load it. "Opened" is a soft signal, not a fact. Track it, but don't build billing on it.
Clicks: rewrite links through a redirector
For clicks, rewrite outgoing links to pass through a tracking route that records the hit and then 302s to the real destination.
Route::get('mail/c/{mailId}', function (Request $request, string $mailId) {
MailHistory::where('mail_id', $mailId)->update(['clicked_at' => now()]);
return redirect()->away($request->query('u'));
})->name('mail.click');
One security note: validate or sign the u target. An open redirector is a phishing gift. Sign the URL when you rewrite it and reject anything that fails the signature — don't redirect to arbitrary user-supplied URLs.
Why a runtime toggle matters
Tracking is a policy decision, not a code decision. Some environments (or some recipients, legally) shouldn't be tracked. A runtime flag — a system setting, not a .env read at boot — lets you turn it off instantly and audit the change, without shipping a release.
it('records nothing while tracking is disabled', function () {
MailTracking::disable();
Mail::to('user@example.test')->send(new WelcomeMail());
expect(MailHistory::count())->toBe(0);
});
What the signals actually mean
| Signal | How | Trust level |
|---|---|---|
| Sent |
MessageSending fired |
High — you know you handed it to the transport |
| Delivered | Transport accepted (no bounce) | Medium — accepted ≠ inboxed |
| Opened | Tracking pixel loaded | Low — blocked/pre-fetched by many clients |
| Clicked | Redirector hit | High — a human (or bot) followed the link |
Takeaway
You don't need an external mail service to get visibility into outgoing mail — Laravel's mail events plus a pixel and a signed redirector cover most of it. Just label the signals honestly (opens lie, clicks don't), sign your redirect targets, and put the whole thing behind a runtime toggle so tracking stays a choice, not a hard-coded default.
Top comments (0)