akhan619/laravel-ses-tracking
Laravel SES Tracking helps Laravel apps monitor Amazon SES email activity. It handles SES/SNS webhooks for opens, clicks, bounces, and complaints, stores tracking events, and provides routes/models to integrate delivery and engagement data into your application.
Install and publish
composer require akhan619/laravel-ses-tracking
php artisan vendor:publish --provider="Akhan619\LaravelSesTracking\ServiceProvider"
php artisan migrate
Start by reading, in this order:
config/ses-tracking.php (or whatever the published config is named) — this tells you the webhook route/URI, middleware, table names, and the model class used for events. Everything else in this assessment keys off those values.php artisan route:list | grep -i ses — confirm the package registered its SNS endpoint for you, and note the exact URL. If nothing shows up, you must register the route yourself in routes/api.php or routes/web.php.database/migrations/* that were published — check the columns you'll be querying (message id, event type, recipient, timestamps) before you write any listeners.Wire up SNS (one-time, per environment)
SubscriptionConfirmation after validating the signature, so you should see the subscription flip to "Confirmed" without touching the console again.app/Http/Middleware/VerifyCsrfToken.php → $except). This is the single most common reason "the subscription never confirms" — SNS gets a 419 and the package never sees the request.First use case
The 20-minute win is bounce/complaint handling: listen for the package's bounce and complaint events and mark the address undeliverable.
// app/Listeners/MarkAddressUndeliverable.php
public function handle($event): void
{
User::where('email', $event->email)->update(['email_bounced_at' => now()]);
}
Register the listener in EventServiceProvider, send a test email through the tracked configuration set to a known-bad address (e.g. [email protected]), and confirm a row lands in the events table and your listener fires.
Match SES events to your own records. SES payloads identify a send by mail.messageId, not by your Eloquent primary key. Do this once, early:
ses_message_id (indexed) to whatever table represents an outbound email/message.MessageSent event: $event->message->getSymfonySentMessage()->getMessageId() returns the SES message id for the send.where('ses_message_id', $event->message_id) lookup in your listener.Without this, you can only ever report aggregate counts, never "this specific email to this specific user bounced."
Listen, don't poll. The package fires Laravel events for bounces, complaints, opens, and clicks. Treat the persisted models as the audit trail and the events as the trigger. Make listeners ShouldQueue with a dedicated queue so a slow suppression job never delays the SNS response:
class SuppressOnComplaint implements ShouldQueue
{
public $queue = 'ses-events';
public $tries = 3;
}
Separate the "hot path" from the "analytics path." Bounces and complaints should be synchronous-ish and idempotent (suppression lists). Opens and clicks are high-volume and lossy-tolerant — route those to a different listener/queue, and consider disabling the SES open/click event destination entirely in noisy environments.
Filter by configuration set / topic. If you send both transactional and marketing mail, use two configuration sets and two SNS topics. Point them at the same webhook (or two route instances with different config) and branch on the configuration set name in the payload so marketing engagement never touches transactional suppression logic.
Local development. SNS can't reach localhost. Options, in order of convenience:
Keep a handful of real captured payloads (one bounce, one complaint, one click) in tests/Fixtures and assert your listeners against them.
Backfill/replay. Because delivery is at-least-once and SNS retries, build your listeners to tolerate a duplicate event arriving 5 minutes later. Replaying a day's raw payloads should be a safe operation — that's the bar.
Signature validation needs outbound HTTPS. The package fetches the SNS signing certificate to verify each message. On a locked-down VPC (no egress, no NAT), every request fails validation and events silently vanish. Allow egress to sns.*.amazonaws.com and cache the certs; verify the behavior in a sandbox that mirrors production networking.
Confirm the auto-confirm path before you trust it. Auto-confirming subscriptions is safe only because the signature is verified first. Don't defeat that with a config flag to "just get it working" — instead check the order of operations in the package's controller, and if you need to debug, log the raw body rather than disabling verification.
SNS payloads are double-encoded. The outer JSON has a Message field that is itself a JSON string containing the SES event. If you're writing any custom parsing, json_decode($body) twice. If the package throws a null-array error on ingest, this is usually why.
Delivery ≠ delivered to inbox. SES Delivery events mean "accepted by the receiving server." Don't drive UX off them; they're only useful for latency metrics.
Open/click tracking is opt-in and limited. Opens require HTML bodies plus SES open tracking enabled on the configuration set; clicks rewrite links. Neither fires reliably for plain-text mail or for clients that block pixels. Report them as directional signals, never as ground truth.
At-least-once delivery. Add a unique index on the SNS MessageId (or upsert on it) so retries don't create duplicate rows or double-fire suppression webhooks to downstream systems.
Table growth is the real ops cost. Open/click events accumulate fast. Index created_at and your message-id column, and schedule a prune job (or partition) from day one rather than discovering a 40M-row table next quarter.
Response speed matters. SNS has a short timeout and will retry (and eventually disable the subscription) if your endpoint is slow. Keep the controller's work minimal — persist + dispatch — and never do HTTP calls or mail sending inline.
Migrations and existing data. Run the package migrations before subscribing the topic, and review the published migration for column types/lengths that may conflict with existing tables (especially if you already have an email_events table). On a DB with existing data, test the migration on a restored snapshot.
Framework version ceiling. The last release (v1.0.8, 2023-03-30) is a Laravel 10 compatibility release with a single contributor. Before adopting on Laravel 11/12, pin the exact version, check composer.json constraints and CI coverage, and smoke-test the webhook + listeners in staging. If the framework constraint blocks you, the package surface is small enough to pin/fork the persistence and event-dispatching pieces rather than switch approach.
Where to extend. The config's model and event class bindings are the intended extension points — override the model to add relations/scopes, and map to your own events if the built-in event payloads don't carry what you need. Prefer subclassing/binding over editing the request flow, so the SNS response semantics stay intact.
Debugging checklist, in order: route:list for the endpoint → storage/logs for a 419/500 on the webhook → SNS console subscription status → raw payload logged → row in the events table → listener registered and queue worker running. Most failures are steps 1, 2, or 6.
How can I help you explore Laravel packages today?