azine/mailgunwebhooks-bundle
Symfony bundle that captures Mailgun webhook event data in your database, so you can search, filter, and display delivery logs beyond Mailgun's 48-hour retention, delete old events via CLI, and get alerts for blacklisted sending IPs or spam-related bounces.
Reality check before you start: this is a Symfony bundle, not a Composer package you can use inside a Laravel app. AzineMailgunWebhooksBundle ships a bundle class, Doctrine entities, Twig templates, a DI extension, and console commands wired through AppKernel/bundles.php. Day-to-day leverage as a Laravel developer comes in one of two shapes:
mailgun_event table, point Mailgun webhooks at it, and have your Laravel app read that table (shared DB) or call an internal API for delivery history.Assuming option 1, minimal path:
composer require azine/mailgunwebhooks-bundle
// config/bundles.php
Azine\MailgunWebhooksBundle\AzineMailgunWebhooksBundle::class => ['all' => true],
# config/routes.yaml
azine_mailgun_webhook:
resource: "@AzineMailgunWebhooksBundle/Resources/config/routing/mailgunevent_webhook.yml"
prefix: /
azine_mailgun_admin:
resource: "@AzineMailgunWebhooksBundle/Resources/config/routing/mailgunevent_admin.yml"
prefix: /admin/
# config/packages/azine_mailgun_webhooks.yaml
azine_mailgun_webhooks:
api_key: '%env(MAILGUN_API_KEY)%'
public_api_key: '%env(MAILGUN_PUBLIC_KEY)%'
email_domain: '%env(MAILGUN_DOMAIN)%'
no_reply_email: '[email protected]'
no_reply_name: 'Example Mailer'
spam_alerts:
enabled: true
interval: '60'
alerts_recipient_email: '%env(MAILER_ALERT_TO)%'
Then generate the schema (the bundle's entity needs a table — check for bundled migrations first and otherwise doctrine:migrations:diff):
php bin/console doctrine:schema:update --dump-sql # inspect before --force
First use case, in order:
php bin/console debug:router -e prod | grep mailgunevent_webhook — copy that absolute URL.https://app.mailgun.com/app/webhooks (delivered, bounced, dropped, opened, clicked, complained, unsubscribed)./admin/, find it with debug:router | grep mailgun_overview).Do step 3 in the prod env specifically; dev URLs and trailing slashes are the #1 cause of "webhooks aren't arriving".
Single receiver, many event types. Mailgun posts every event type to the same endpoint; the bundle normalizes them into one MailgunEvent row. Don't create per-event-type routes — configure the same URL everywhere and filter at query time.
React to events with a listener. Every persisted event dispatches a MailgunWebhookEvent carrying the MailgunEvent. This is your hook for anything downstream — Slack on dropped, tagging a Message-Id against a user, pushing complaint events into a suppression list:
class BounceToSlackListener implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [MailgunWebhookEvent::class => 'onWebhook'];
}
public function onWebhook(MailgunWebhookEvent $event): void
{
$mailgunEvent = $event->getMailgunEvent();
// inspect event type / recipient / message headers, dispatch to Slack, tag in CRM, etc.
}
}
Keep listeners fast and idempotent — Mailgun retries, and a slow listener blocks the webhook response (Mailgun times out and retries, which amplifies load).
Troubleshooting as the daily workflow. The highest-value day-to-day use is answering "did this user actually get the password reset / invoice / newsletter?" Search the admin list by recipient and open the single-event detail. Because Mailgun free-tier purges logs after ~48h, this is the only place that answer exists a week later — so deploy this before you need it, not after an incident.
Cron the two commands. Both are designed for a scheduler:
# weekly — keep the table bounded, strtotime-style relative date
php bin/console mailgun:delete-events --date '60 days ago'
# every few hours — blacklist check with retries before alerting
php bin/console mailgun:check-ip-in-blacklist --numberOfAttempts 5
Set repeat_notification_after_days to a non-zero value (e.g. 3) so a persistent listing doesn't page your on-call every run; you only get re-notified when the blacklist set actually changes or the mute window expires. Note the HetrixTools free plan allows roughly 3 checks/day — for most teams this is an hourly-or-slower cron, not every five minutes.
Deliverability monitoring as a separate signal. spam_alerts handles the "Mailgun told us someone complained" path (with the helpdesk ticket config for requesting a clean IP), and hetrixtools_service handles "our sending IP got blocklisted". Wire both to an inbox your team actually reads, and set interval to avoid alert-flapping during a complaint burst.
Reporting/queries. For engagement numbers, query the bundle's repository directly from a controller or a Laravel-side read model: group by event type over a date range. On 5.0.0, replay/deduplication is handled at ingest, so open/click rates aren't inflated by Mailgun's retry deliveries — that's the version where these numbers become safe to put in front of stakeholders.
Integrate with your Laravel app. Simplest: shared database, read-only Eloquent model pointed at mailgun_event. Cleaner: expose a small /internal/events/{messageId} endpoint on the Symfony side and let Laravel call it. Either way, keep the webhook endpoint on the Symfony host so signature keys and the admin route stay in one place.
MailgunWebhookController + signature check + mailgun_events migration probably beats running a Symfony satellite — evaluate honestly before committing to a second runtime.-date in the README is stale. Modern Symfony Console parses -date as a cluster of short options. Use --date '60 days ago'. If the command errors with "option does not exist", this is why./, Mailgun gets redirected to a login page and you'll see zero rows with a 200-ish response — classic silent failure. Verify with curl -X POST from outside your network.api_key) and whether your Mailgun account has a distinct webhook signing key. A forged-event-free store is worth the setup pain — but only once it's actually accepting real traffic.debug:router -e prod, not dev. Dev URLs, ports, and trailing slashes all produce "webhook configured, nothing stored".excluded_404s regexes as the README suggests; otherwise your free-tier 10k limit disappears into /cgi-bin/php probes.Message-Id + event type + timestamp in your reporting queries, or your numbers will drift high. Treat the version pin as a reporting-correctness decision, not a cosmetic one.doctrine:schema:update --force in prod; generate a migration from the bundle's entity and ship it through your normal pipeline.repeat_notification_after_days, interval, and web_view_token before you touch code.How can I help you explore Laravel packages today?