Groundmail

Migrate

Migrate from Mailgun to Groundmail in Laravel

Move a Laravel app from Mailgun to Groundmail: before and after code, env vars, DNS records, webhook changes and prices at 50k, 100k and 1M emails.

By GroundmailCompetitor prices last checked

Short answer

To move a Laravel app from Mailgun, install composer require groundmail/laravel, set MAIL_MAILER=groundmail with a groundmail mailer in config/mail.php (your Mailables, notifications and queued mail stay as they are), and swap MAILGUN_SECRET for GROUNDMAIL_API_KEY. Then verify your sending domain in Groundmail, leaving Mailgun's DNS records in place until you cut over.

Mailgun's webhook events map onto Groundmail's email.* events, signed with HMAC-SHA256 in a Groundmail-Signature header. The free email migration scanner finds every place your repo sends through Mailgun and writes the pull request for your coding agent.

On this page

Before and after: the Laravel mail config

Laravel sends through Mailgun with a mail driver, so the move is a config change: the Groundmail package replaces the driver, and the code that builds and sends your email stays as it is.

Swap the package

composer remove symfony/mailgun-mailer
composer require groundmail/laravel

Before: Mailgun (Laravel)

# .env
MAIL_MAILER=mailgun
MAILGUN_DOMAIN=mg.acme.com
MAILGUN_SECRET=…
MAILGUN_ENDPOINT=api.mailgun.net
MAIL_FROM_ADDRESS=no-reply@acme.com

// config/services.php
'mailgun' => [
    'domain' => env('MAILGUN_DOMAIN'),
    'secret' => env('MAILGUN_SECRET'),
    'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'),
    'scheme' => 'https',
],

After: Groundmail (Laravel)

# .env
MAIL_MAILER=groundmail
GROUNDMAIL_API_KEY=gm_…
MAIL_FROM_ADDRESS=no-reply@acme.com

// config/mail.php, under 'mailers'
'groundmail' => [
    'transport' => 'groundmail',
],

// Your Mailables, notifications and queued mail don't change:
Mail::to($user)->send(new ResetPasswordMail($user));

If you also call Mailgun's API directly

The Groundmail Laravel package maps from, to, cc, bcc, reply-to, subject, HTML, text and attachments for you. If some code also calls Mailgun's API directly, for example to send a stored template, its fields map like this.

MailgunGroundmail
Sending domain in the URL or client callTaken from the from address, which must be on a verified domain
Form fields from, to, subject, html, textJSON fields with the same names
template + h:X-Mailgun-Variables (or t:variables)template: { id: "<alias>", variables: { … } }
h:Reply-Toreply_to
o:tag, v: custom variablestags: up to 10 { name, value } pairs, returned on every webhook
attachment (multipart file)attachments[] with base64 content and filename
api.eu.mailgun.net for EU accountsOne endpoint, api.groundmail.dev

Using Mailgun templates? Send them by alias with template: { id: "<alias>", variables: { … } }. The importer converts your Mailgun templates and publishes them under an alias. More field detail: Mailgun migration docs and Laravel docs.

Environment variables

TodayOn Groundmail
MAIL_MAILER=mailgunMAIL_MAILER=groundmail
MAILGUN_SECRETGROUNDMAIL_API_KEY (a gm_… key)
MAILGUN_DOMAIN, MAILGUN_ENDPOINTRemove after cutover, with the mailgun block in config/services.php
Your webhook secret, e.g. MAILGUN_WEBHOOK_SIGNING_KEYGROUNDMAIL_WEBHOOK_SECRET: the signing secret shown once when you create the webhook

Create the key under API keys in the dashboard: the Send only preset is enough for the app. Leave the Mailgun variables set until you cut over, so rolling back is a config change.

DNS: add Groundmail's records, keep Mailgun's until cutover

Add the domain you send from in the dashboard, or with POST /domains. Groundmail gives you these records; on Cloudflare, GoDaddy and Vercel one click writes them for you.

RecordTypeHostValue
OwnershipTXT_groundmail.example.comToken from the dashboard
DKIMTXT<selector>._domainkey.example.comPublic key from the dashboard
Return pathCNAMErp.example.comrp.groundmail.dev (DNS only, not proxied)
SPFTXTexample.comAdd include:spf.groundmail.dev to your existing SPF record
DMARCTXT_dmarc.example.comKeep yours; the dashboard suggests one if you have none

Ownership, DKIM, the return path and SPF must resolve before the domain verifies.

Mailgun records to remove after cutover

  • The DKIM TXT record (for example smtp._domainkey or k1._domainkey)
  • The email. tracking CNAME and the MX records on your Mailgun subdomain, if you used one
  • Take include:mailgun.org out of your SPF record (or the SPF record on your Mailgun subdomain).

Wait a few days after the last email goes out through the old provider, in case you need to roll back. Background on each record: SPF, DKIM and DMARC for transactional email.

Webhooks: what changes

MailgunGroundmail
DeliveryOne event per POST: signature plus event-dataOne JSON object per POST: type, created_at and data
Checking it came from themHMAC-SHA256 of timestamp + token with your webhook signing key, sent in the bodyGroundmail-Signature: t=…,v1=…: HMAC-SHA256 of t.rawBody with the endpoint's secret
EndpointsSet per domain and per event typePer URL, with any set of events across all your domains

If your endpoint doesn't return a 2xx, Groundmail retries the delivery 4 more times: after 5 seconds, 30 seconds, 2 minutes and 2 minutes. Subscribe an endpoint to these events to replace the ones you handle today:

Mailgun eventGroundmail event
acceptedemail.sent
deliveredemail.delivered
temporary_failemail.delivery_delayed
permanent_failemail.bounced and email.failed (data.bounce_type is hard or block)
complainedemail.complained
openedemail.opened
clickedemail.clicked
unsubscribedcontact.unsubscribed

Check the signature against the raw request body, before you parse it. In Laravel:

Verify a Groundmail webhook (Laravel controller)

use Illuminate\Http\Request;

class GroundmailWebhookController
{
    public function __invoke(Request $request)
    {
        $ok = $this->verifyGroundmailSignature(
            $request->getContent(),
            (string) $request->header('Groundmail-Signature'),
            config('services.groundmail.webhook_secret'), // env('GROUNDMAIL_WEBHOOK_SECRET')
        );
        abort_unless($ok, 401);

        $event = $request->json()->all(); // ['type' => 'email.delivered', 'data' => [...]]

        return response()->noContent();
    }

    private function verifyGroundmailSignature(string $rawBody, string $header, string $secret): bool
    {
        $parts = [];
        foreach (explode(',', $header) as $part) {
            [$key, $value] = array_pad(explode('=', $part, 2), 2, '');
            $parts[$key] = $value;
        }
        $t = $parts['t'] ?? '';
        $v1 = $parts['v1'] ?? '';
        if (!ctype_digit($t) || $v1 === '' || abs(time() - (int) $t) > 300) {
            return false;
        }

        return hash_equals(hash_hmac('sha256', $t . '.' . $rawBody, $secret), $v1);
    }
}

Use Send test event on the webhooks page, or POST /emails/test from CI, to try your handler before real mail flows. Payloads and every event: webhooks.

Mailgun and Groundmail at 50k, 100k and 1M emails

Monthly volumeMailgunGroundmail
Free option100 emails/day, 1 domain3,000 emails/month (100/day), no time limit
50,000 emails/month$35 (Foundation 50k)$20 (Pro)
100,000 emails/month$75 (Foundation 100k)$35 (Pro)
1,000,000 emails/month$700 (Scale 1M)$650 (Scale)

USD per month, monthly billing, cheapest published plan that covers each volume. Mailgun prices as of October 2026 from mailgun.com/pricing; Groundmail prices from our pricing page.

Last checked on each vendor's public page.

Groundmail is $20 against $35 at 50,000 emails a month and $35 against $75 at 100,000. Mailgun's Foundation 100k includes a dedicated IP; Groundmail includes one from 500,000 emails a month.

Plans, overage and what each includes: Mailgun pricing. Everything else side by side: Groundmail vs Mailgun.

Cut over without losing mail

  1. 1

    Put the provider behind a flag

    Keep the Mailgun settings next to Groundmail's and choose between them with an env var, so rolling back is a config change and a restart.

  2. 2

    Send to yourself first

    Trigger a password reset and a receipt to your own addresses, and check the headers show DKIM passing for your domain.

  3. 3

    Move a small share

    Send 5 to 10% of real mail through Groundmail for a day, and watch bounces and complaints on the dashboard next to what you see today.

  4. 4

    Ramp to 100%

    Then switch everything over, and keep Mailgun configured for a week in case you need to switch back.

  5. 5

    Clean up

    Remove the Mailgun package, its env vars and its DNS records, and cancel the plan.

Let the migration tool find every send

Then bring your Mailgun setup across with the importer: paste a Mailgun API key under Import and Groundmail copies your suppressions, templates, sending domains (with the DNS changes) and webhooks (created switched off until your handler is ready). Nothing changes in your Mailgun account, and the key is used for the import only. From a terminal:

Import from Mailgun with the CLI

npm install -g groundmail-cli
groundmail login
export MAILGUN_API_KEY=…
groundmail migrate mailgun --dry-run   # preview, nothing written
groundmail migrate mailgun             # import

Frequently asked questions

How long does it take to move a Laravel app from Mailgun?

The code change is usually under an hour: one package, one mailer setting and one env var. Domain verification takes as long as your DNS host takes to publish the records, and is one click on Cloudflare, GoDaddy and Vercel. Plan a few days of partial sending before you turn Mailgun off.

Can I keep sending over SMTP instead?

Yes. If your app sends through smtp.mailgun.org today, point it at smtp.groundmail.dev on port 587 with STARTTLS (or 2525 if your host blocks 587), username groundmail, and an SMTP credential (gmsmtp_…) from the dashboard as the password. Same verified domains, limits and suppressions as the API. See SMTP.

What happens to my Mailgun suppression list?

The importer copies hard bounces, spam complaints and unsubscribes onto your Groundmail suppression list. Bounces that were blocks on Mailgun's servers are left off, because those addresses are fine.

Why do teams move from Mailgun to Groundmail?

Lower prices from 50,000 to 100,000 emails a month, transactional mail kept separate from marketing mail, and a dedicated server on the same API when you need one.

Sources

Facts on this page about other companies' products, prices and policies come from these public pages. They change, so check them before you decide.

Transactional email, plus everything around it.

Sign-in codes, receipts and alerts kept separate from marketing mail, with inbound, templates and automations included. Start free with 3,000 emails a month, and move to a dedicated IP or server without changing your code.