PHP Receive Email: How to Receive and Parse Incoming Email in PHP and Laravel

On October 02, 2026
19min read
Veljko Ristić Content Manager @Mailtrap
This is a symbolic graphic representation of receiving email with PHP and Laravel for an article that covers the topic in detail.

PHP cannot receive email. It has no listen_for_smtp(), and no amount of Composer will give you one: PHPMailer, Symfony Mailer and the mail() function all send, and none of them can read a single incoming message. What PHP can do is react to email that something else has accepted, and there are exactly three somethings:

  1. A managed inbound API that POSTs a webhook to your app
  2. An IMAP mailbox you poll
  3. A mail server you run that pipes each message into a script

I built all three ways to receive email in PHP (on 8.4 and 8.5) and I timed them. If you searched for “PHP receive email” and landed on a sending tutorial first, that’s normal; half of what ranks for the phrase is about the other direction.

The webhook path took a hosted inbox from zero to a parsed message in my Laravel app in under a minute of setup. The IMAP path taught me that the extension every tutorial still uses no longer compiles on the official Docker images. The pipe path was the fastest of the lot, by two orders of magnitude. It was also the easiest to break silently.

If your PHP app has to ingest email (e.g. a CRM that files replies against a contact, a helpdesk that turns emails into tickets, an AI agent that reads its own inbox), this is the guide.

Sending is a different job; if you need to send email with PHP, that lives in our PHP email sending guide.

How to receive email in PHP: three ways, compared

Pick the managed inbound API unless you already own the mailbox or already run the mail server. It’s the only option that hands you parsed JSON, and the only one that needs no DNS, no daemon and no MIME parser to start. Here is the whole decision in one table:

ApproachReal-timeMailboxMail
server
AuthWho parses MIMEBest for
Mailtrap Inbound Email API (webhook)Yes, webhook within ~30 s, batchedNo, hosted inboxNo;
custom domain optional
API token + HMAC-SHA256 signatureMailtrap (JSON)Apps and agents that treat mail as an event
IMAP (ImapEngine or webklex)No, you pollYesNoPassword (Gmail needs 2-Step Verification for an app password) or
XOAUTH2;
Microsoft 365 password login is dead
YouReading a mailbox you already own
MTA pipe-to-scriptYes, on STDIN in ~30 msNoYes, you run itNone (local)YouSelf-hosted, full control

What I measured (on 18 September 2026):

  • Webhook: 20 messages sent 1.5 s apart arrived as two POSTs, one carrying 12 events and one carrying 8. Three isolated sends waited 16 to 25 s. Storage is quick (median 183 ms from send call to received_at); the webhook is a periodic flush on top of it.
  • Pipe: Postfix 3.10 handed 10 of 10 messages to a PHP script in 21 to 116 ms, median 30 ms.
  • IMAP install: ext/imap failed to build on every official Debian-based php:*-cli image (8.3, 8.4, 8.5). It built on the Alpine variants in 8 to 9 seconds. ImapEngine installed on all six in 3 to 5 seconds with no extension at all.

Prerequisites

  • PHP 8.2 or newer; 8.4 recommended. PHP 8.4 removed ext/imap, the PHP IMAP extension, from core and moved it to PECL under the unbundle_imap_pspell_oci8 RFC. There was no 8.3 deprecation step; it went from bundled to gone. The --with-imap and --with-imap-ssl configure flags went with it, and so did the imap_open() and imap_close() calls every older tutorial starts with. Only the mailbox section cares, and I’ll show you how to avoid the extension entirely.
  • Composer. Every code block below starts with composer require.
  • Per path: a Mailtrap account for the API path (Inbound Email is included with Email API/SMTP; it is not sold separately). A mailbox you can log into for IMAP. A domain and shell access on a mail server for the pipe path.

The code is on PHP 8.4 with the official railsware/mailtrap-php SDK, version 3.15.0. The repository now lives at github.com/mailtrap/mailtrap-php; the Packagist name hasn’t changed.

Receive inbound email in PHP with the Mailtrap Inbound Email API

One API call gives you a hosted address like support-1a2b3c4d@inbound-mailtrap.io. Mail sent to it is accepted, parsed and stored; a webhook tells your app which messages arrived, and you fetch the parsed JSON. There is no MX record to set and no SMTP server to run, and when you’re ready for production you connect your own domain and get a catch-all.

The notification is deliberately thin. It carries event IDs, message IDs and the sender, and nothing else. Your handler fetches the rest. That design keeps the signature check cheap and the retry story sane. It’s also the part the tutorials I read skip, so I’ll spend real time on it.

Create an inbound email address and register the webhook

You need an account-level API token. A domain-scoped token can read inbound messages but returns a 403 on inbox creation; that one cost me ten minutes.

Here’s the command and the code:

composer require railsware/mailtrap-php

Then

<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Mailtrap\Config;
use Mailtrap\MailtrapInboundClient;
use Mailtrap\MailtrapSendingClient;
use Mailtrap\DTO\Request\Inbound\CreateInboundFolder;
use Mailtrap\DTO\Request\Inbound\CreateInboundInbox;
use Mailtrap\DTO\Request\Webhook\CreateWebhook;
use Mailtrap\Helper\ResponseHelper;

$token     = getenv('MAILTRAP_API_TOKEN');
$accountId = (int) getenv('MAILTRAP_ACCOUNT_ID');

$inbound = new MailtrapInboundClient(new Config($token));

// Inboxes live inside folders. One call each.
$folder = ResponseHelper::toArray(
    $inbound->folders()->create(new CreateInboundFolder('crm'))
);
$inbox = ResponseHelper::toArray(
    $inbound->inboxes((int) $folder['id'])->create(new CreateInboundInbox('support'))
);

echo "Send mail to: {$inbox['address']}\n";   // support-<8 hex>@inbound-mailtrap.io

// The webhook is registered on the account, scoped to this inbox.
$sending = new MailtrapSendingClient(new Config($token));
$response = ResponseHelper::toArray(
    $sending->webhooks($accountId)->createWebhook(new CreateWebhook(
        url: 'https://example.com/hooks/mailtrap',
        webhookType: 'inbound_receiving',
        inboundInboxId: (int) $inbox['id'],
    ))
);
$webhook = $response['data'] ?? $response;   // see note below

// Store this. You need it to verify every delivery.
echo "Signing secret: {$webhook['signing_secret']}\n";

Two things about that code that the docs won’t tell you:

  1. The inbox create call returns id, name, address and domain_id. domain_id is populated on hosted inboxes too, so you can’t use it to tell a hosted inbox from a custom-domain one.
  2. createWebhook() wraps its response in a data key; the inbound endpoints don’t. Hence the ?? $response fallback. I found this by creating a webhook whose secret I couldn’t read. Then I deleted it, and did it all over again.

The signing secret is not show-once. You can view and reset it later on the Email API/SMTP webhooks page in the UI. Keep it out of the repo anyway.

Webhook retry notes:

  • If your handler returns anything other than a 2xx, or takes too long, Mailtrap re-sends the same delivery every five minutes, roughly three dozen times over about three hours, then pauses the webhook and emails you.
  • I measured this with a handler that returned 500 on purpose. The scheduler doesn’t take a 500 personally; it just comes back in 300 seconds, for hours.
  • Two things follow. First, your handler receives the same batch again, so it must be idempotent: key on event_id or message_id and skip what you’ve already processed. Second, a paused webhook stays paused until you re-enable it.

Verify the inbound email webhook signature in PHP

Every delivery carries a Mailtrap-Signature header: a lowercase hex HMAC-SHA256 of the raw request body, keyed with the signing secret. The SDK ships a helper that does the constant-time comparison for you. The only thing you have to get right is the input.

<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Mailtrap\Helper\WebhookSignature;

$body      = (string) file_get_contents('php://input');   // the raw bytes, untouched
$signature = $_SERVER['HTTP_MAILTRAP_SIGNATURE'] ?? '';
$secret    = getenv('MAILTRAP_INBOUND_SIGNING_SECRET');

if (! WebhookSignature::verify($body, $signature, $secret)) {
    http_response_code(401);
    exit('bad signature');
}

$payload = json_decode($body, true, flags: JSON_THROW_ON_ERROR);

foreach ($payload['events'] as $event) {
    if ($event['event'] !== 'inbound.message_received') {
        continue;
    }
    // $event['inbox_id'], $event['message_id'], $event['from'], $event['event_id'], $event['timestamp']
    handle((int) $event['inbox_id'], (string) $event['message_id']);
}

http_response_code(200);

Note the loop. A delivery looks like this, and there can be many events in it:

{
  "events": [
    {
      "event": "inbound.message_received",
      "event_id": "6f1d0c0e-…",
      "timestamp": 1758200000123,
      "inbox_id": 2845,
      "message_id": "1832234567890123456",
      "from": "Ada Lovelace <ada@example.com>"
    }
  ]
}

But, no subject, no body, no attachments. timestamp is epoch milliseconds and equals the message’s received_at.

Now the trap. Most PHP webhook handlers, and every framework request object, hand you a decoded array. If you decode the body, do something with it, and then re-encode it to verify… you guessed it, the signature fails. Not sometimes. Every time. I created a receiver to verify each delivery two ways:

Verified againstResult over 3 deliveries
php://input as received3/3 pass
json_encode(json_decode($body))0/3 pass
json_encode(json_decode($body), JSON_PRETTY_PRINT)0/3 pass

Simply, the bytes are not the same: whitespace, key order, escaped slashes, float formatting.

But, there’s a silver lining – HMAC doesn’t care that the JSON is semantically identical. The SDK’s own docblock says “do not parse and re-serialize the JSON”, and now you know why. Practically, in Laravel, that means $request->getContent(), never json_encode($request->all()).

Fetch the parsed email message, attachments and thread in PHP

With a verified message_id and inbox_id in hand, you can move on to fetch the message:

use Mailtrap\Config;
use Mailtrap\MailtrapInboundClient;
use Mailtrap\Helper\ResponseHelper;

function handle(int $inboxId, string $messageId): void
{
    static $client = null;
    $client ??= new MailtrapInboundClient(new Config(getenv('MAILTRAP_API_TOKEN')));

    $message = ResponseHelper::toArray(
        $client->messages($inboxId)->getById($messageId)
    );

    $from    = $message['from'];          // "Ada Lovelace <ada@example.com>"
    $subject = $message['subject'];
    $text    = $message['text_body'];     // not "text"
    $html    = $message['html_body'];     // not "html"
    $thread  = $message['thread_id'];

    foreach ($message['attachments'] as $attachment) {
        // $attachment['download_url'] is a signed S3 URL that expires in 3600 s.
        $bytes = file_get_contents($attachment['download_url']);
        file_put_contents('/var/app/uploads/' . basename($attachment['filename']), $bytes);
    }

    // ... file it against the CRM contact, open a ticket, feed the agent
}

The response has 22 fields. The ones you’ll use: id, inbox_id, from, to, cc, bcc… thread_id, attachments, raw_message_url, and the list goes on.

Yet, there are three gotchas:

  • Body fields are text_body and html_body. I’ve seen more than one handler read $message['text'] and log an empty string forever. Don’t be me 😀
  • attachments is an empty array when there are none, not a missing key. (The official CLI drops the key entirely, which is a CLI quirk, actively fixed at the time of writing. It’s not the API.)
  • Attachment URLs are signed and expire after an hour (X-Amz-Expires=3600). Download them in the handler or re-fetch the message later. There is no separate attachments endpoint; I tried both plausible paths and got 404s.

raw_message_url is the full RFC 5322 message, also signed for an hour. You’ll want it if you already have a MIME pipeline (see the Laravel driver below) or need a header that’s not exposed in the parsed fields.

Threads and replies. The CRM’s next question is always “and how do I reply in the thread?” Every message carries a thread_id; the threads endpoint lists them with message_count, last_activity_at, senders and recipients. A reply goes through the message:

use Mailtrap\Mime\MailtrapEmail;

$reply = (new MailtrapEmail())
    ->subject('Re: ' . $message['subject'])
    ->text('Thanks, we have opened ticket #4821 and will be in touch.');

$client->messages($inboxId)->reply($messageId, $reply);   // 201; lands in the same thread

I sent 25 replies to one message and the thread’s message_count read 26 afterwards. replyAll() and forward() exist too; forward() insists on at least one to recipient. Replies from a hosted inbound-mailtrap.io address are for the test loop; once you connect your own domain, replies go out from an address you control, which is also what their deliverability rests on.

Inbound email limits: message size and hosted-inbox replies

  • Message size. The cap is plan-specific: 10 MB encoded (about 7.5 MB of real payload after base64) on the lower tiers, up to 30 MB on Business and Enterprise. Note that I did not test it plan by plan. Over the cap, Mailtrap’s SMTP server answers the sender with 552 5.3.4 Message exceeds max size of xy bytes, nothing is stored, and no webhook fires. You won’t know it happened unless the sender tells you. Log your bounces on the sending side if oversized mail is likely.
  • Replies from a hosted inbox. My test account had sent 25 replies from an earlier hosted inbox. When I created a fresh inbox and replied once, the API answered 403 with Sending usage of this inbox has reached its limit. The counter is not per inbox, and deleting the old inbox did not reset it. The API reference describes that 403 as the inbox’s monthly sending limit, so it may reset; the landing page says “in total”, and I did not wait a month to find out. (The earlier 25 all returned 201, so enforcement trails the count a little; so don’t build on that.) The good news is that the storage isn’t capped the same way: I sent 25 messages to one hosted inbox and all 25 were stored. Therefore, connect a domain before you build a support workflow on top of replies.
  • Not included. No WebSocket or long-poll event stream, and no draft-and-approve step. If you need a human to sign off before a reply goes out, that’s your queue’s job.

Receive email in Laravel with laravel-mailbox and the Mailtrap driver

I used the beyondcode/laravel-mailbox 6.0 (March 2026, Laravel 10 through 13) as the standard way to route inbound email in Laravel. It ships drivers for Mailgun, SendGrid, Postmark, MailCare and a log driver. It does not ship a Mailtrap driver yet.

Important note: The driver is currently under my public GitHub repo (not official Mailtrap), it’s undergoing QA testing, so it should be treated as a for-shows beta.

Anyway, its MailboxManager extends Laravel’s Illuminate\Support\Manager, so extend() works, and I figured, why not build a driver.

composer require beyondcode/laravel-mailbox veljkorailsware/laravel-mailbox-mailtrap
php artisan vendor:publish --provider="BeyondCode\Mailbox\MailboxServiceProvider"
MAILBOX_DRIVER=mailtrap
MAILTRAP_API_TOKEN=…
MAILTRAP_INBOUND_SIGNING_SECRET=…
MAILTRAP_INBOUND_INBOX_ID=2845

The package registers the driver from its service provider, so there is nothing to call:

// inside the package's service provider
$this->app->resolving(MailboxManager::class, function (MailboxManager $manager) {
    $manager->extend('mailtrap', fn () => new MailtrapDriver());
});

Point the webhook at https://your-app.test/laravel-mailbox/mailtrap. Here’s what the driver does per delivery (in order of actions):

  1. Verifies the signature over $request->getContent()
  2. Loops the events
  3. Fetches each message with the SDK
  4. Downloads raw_message_url
  5. Hands the raw MIME to InboundEmail::fromMessage()

That last step means every method on InboundEmail works as it would with Postmark: from(), subject(), text(), html(), attachments(), reply().

Then, route mail like any other laravel-mailbox app, and you’re pretty much done.

use BeyondCode\Mailbox\Facades\Mailbox;
use BeyondCode\Mailbox\InboundEmail;

// routes/web.php or a service provider
Mailbox::to('{user}@inbound-mailtrap.io', function (InboundEmail $email, string $user) {
    Ticket::openFromEmail($email, mailbox: $user);
});

But, there’s a specific quirk that cost me the delivery the first time, so pay attention 😀 Mailbox::from() matches the sender’s address. For hosted-inbox routing you want Mailbox::to(). My first handler used from(), matched nothing, and laravel-mailbox politely stored nothing, because only_store_matching_emails defaults to true.

Technical Note: With the driver in place, and everything configured properly, the first live delivery reached my handler 8 seconds after the send call. The number you get is likely to vary with the batch window described above, and we’ll do our best to QA it into more like racecar speed.

For PHPUnit integration tests, treat that window as a polling problem: send to the inbox, poll the messages endpoint with a timeout, and assert on what the handler did. A fixed sleep() is how a green suite turns flaky.

Tip: If you want a catch-all on your own domain, create the inbox with domain_id set once the domain is verified and its MX record is in place (the steps are in the catch-all section below ⬇️), and the pattern route becomes Mailbox::to('{user}@yourdomain.com', ...).

Build an AI agent email inbox in PHP

Agent Inbox is the same API as Mailtrap Inbound. It uses the same endpoints, same JSON, same webhook, same SDK calls. The difference lies in the shape of the app. You get one inbox per agent, the webhook is the trigger that wakes the agent, then the thread endpoint loads the context, and reply() to answer in-thread.

Everything above applies unchanged. If you’re choosing between providers for that use case, our inbound email API comparison puts the four main ones side by side; the short version is in the comparison table further down.

Read email from an IMAP mailbox in PHP

IMAP is the right choice when the mailbox already exists and you can’t or won’t redirect its email. For instance, a shared support@ box on Google Workspace, a legacy Exchange mailbox, or a client’s own account.

Then you poll the mailbox, pull unread messages, process them, and mark them seen. There is no real-time story here, and no amount of sleep(5) makes one.

The first decision is which library, and in 2026 it’s been made for you. On PHP 8.4 the bundled extension is gone, and here’s what happened when I tried to bring it back on the six official Docker images:

ImageOSpecl install imapImapEngine 1.25.6webklex/php-imap 6.2.0
php:
8.3-cli
8.4-cli
8.5-cli
Debian 13Fails. libc-client-dev no longer exists in Debian 13Installs, 3 to 5 sRefuses: ext-zip missing
php:
8.3-cli-alpine
8.4-cli-alpine 8.5-cli-alpine
Alpine 3.24Builds in 8 to 9 s (PECL imap 1.0.3), with apk add imap-dev krb5-dev openssl-dev and -D ‘with-kerberos=”yes” with-imap-ssl=”yes”‘Installs, 3 to 5 sRefuses: ext-zip missing

Curiously, you can’t build the extension on the image you’re most likely running. On the image that can build it, you need the exact -D flags or it fails with the same error, headers or not.

The RFC that removed the extension gave the reasons: the underlying c-client library last shipped in 2007, the year of the first iPhone, and has aged with less grace.

In plain English, it has no thread safety; and it can’t do the OAuth authentication Gmail and Microsoft now require. This is the “can’t install it” pain in the r/PHPhelp thread that ranks first for this query; only the distro has changed.

Therefore, use ImapEngine. It’s pure PHP, it installed on all six images with no system packages, and it speaks XOAUTH2.

The webklex/php-imap is the older pure-PHP option the RFC itself pointed at; it works once you build ext-zip, but its last release was April 2025, so I’d start with ImapEngine. Skip anything whose composer.json says ext-imap.

composer require directorytree/imapengine

PHP IMAP authentication: Gmail and Microsoft 365 with OAuth 2.0

Microsoft 365: basic authentication is disabled in every tenant and cannot be re-enabled, by you or by Microsoft support. That covers IMAP, POP and the rest. If your code logs into Exchange Online with a password, it stopped working already. XOAUTH2 is the only door.

Gmail: an app password still works, but only with 2-Step Verification turned on, Google calls it “not recommended”, and a Workspace admin can switch it off for the whole organisation. Fine for a prototype. For anything that has to survive a policy change, use OAuth 2.0 and pass the access token as the password.

ImapEngine handles both with one config key. Port 993 with 'encryption' => 'ssl' is IMAP over implicit TLS, which is what Gmail and Microsoft 365 both expect:

use DirectoryTree\ImapEngine\Mailbox;

// Password or app password
$mailbox = new Mailbox([
    'host'       => 'imap.gmail.com',
    'port'       => 993,
    'encryption' => 'ssl',
    'username'   => 'support@example.com',
    'password'   => getenv('IMAP_APP_PASSWORD'),
]);

// OAuth 2.0: the access token goes in "password"
$mailbox = new Mailbox([
    'host'           => 'outlook.office365.com',
    'port'           => 993,
    'encryption'     => 'ssl',
    'username'       => 'support@example.com',
    'password'       => $accessToken,
    'authentication' => 'oauth',   // sends AUTHENTICATE XOAUTH2
]);

Getting the token is the usual OAuth dance (consent screen, refresh token, the works) with Google’s or Microsoft’s identity endpoints; the IMAP scope is https://mail.google.com/ for Gmail and https://outlook.office.com/IMAP.AccessAsUser.All for Microsoft 365. The connection is TLS on 993 either way; only the login step differs. I verified the XOAUTH2 command in ImapEngine’s source; I did not have a Microsoft 365 tenant to run it against, so treat that half as read, not tested.

Read unread emails with PHP IMAP, process them, mark them seen

$inbox = $mailbox->inbox();

$messages = $inbox->messages()
    ->unseen()
    ->withHeaders()
    ->withBody()
    ->oldest()
    ->get();

foreach ($messages as $message) {
    $from    = $message->from()?->email();
    $subject = $message->subject();
    $text    = $message->text();
    $html    = $message->html();

    foreach ($message->attachments() as $attachment) {
        file_put_contents('/var/app/uploads/' . $attachment->filename(), $attachment->contents());
    }

    // Need the raw RFC 5322 message for your own parser? Cast it.
    $raw = (string) $message;

    process($from, $subject, $text ?? strip_tags((string) $html));

    $message->markSeen();
}

Production notes:

  1. Fetching leaves messages unread by default, which is why markSeen() is explicit; a crash between fetch and mark means you’ll see the message again next run, which is the behaviour you want. And withBody() pulls full bodies over the wire; for a triage pass that only needs subjects, drop it and fetch bodies for the messages you keep.
  2. POP3 exists, and it exists for one thing: download and delete. If that’s your workflow, IMAP vs POP3 covers the trade. Everyone else wants IMAP.

Pipe incoming email to a PHP script on your own mail server

If you run the mail server, you can skip the mailbox entirely: the MTA hands each message to your script on STDIN the moment it’s accepted. This is the fastest path by a wide margin.

In my Postfix container the script started 21 to 116 ms after I injected the message through Postfix’s sendmail -i binary, median 30 ms, and 10 of 10 arrived. It’s also the path with the most ways to fail without a single error in your logs, so I’ll show the failures too.

Postfix pipe to a PHP script, plus cPanel forwarders

The whole configuration is one alias line and one script.

# /etc/aliases
support: "|/usr/local/bin/handle-mail.php"
newaliases
postconf -e "default_privs=nobody"
chmod 755 /usr/local/bin/handle-mail.php
#!/usr/bin/env php
<?php
declare(strict_types=1);

require '/var/app/vendor/autoload.php';   // absolute: the script runs as "nobody" from an unknown cwd

// Postfix passes the raw RFC 5322 message on STDIN and the envelope in env vars.
$raw       = stream_get_contents(STDIN);
$sender    = getenv('SENDER');              // envelope from
$recipient = getenv('ORIGINAL_RECIPIENT');  // the address before alias expansion

try {
    $message = \ZBateson\MailMimeParser\Message::from($raw, false);   // see the MIME section
    store($sender, $recipient, $message);
} catch (\Throwable $e) {
    error_log('handle-mail: ' . $e->getMessage());
    exit(75);   // EX_TEMPFAIL: Postfix keeps the message and retries
}

exit(0);        // accepted; Postfix logs status=sent

For shared hosting with cPanel, the same thing is a form: Email, Forwarders, Add Forwarder, “Pipe to a Program”, and you enter the script path relative to your home directory. Leave /usr/bin/php out; cPanel runs the file directly, so the shebang has to be right.

MTAWhere the pipe livesVerified
Postfix/etc/aliases entry name: “|/path/script.php“, then newaliasesMeasured 2026-09-18, Postfix 3.10.13
cPanel (Exim)Forwarders, “Pipe to a Program”, path relative to homecPanel docs, updated 2026-07-08

Sendmail reads the same /etc/aliases syntax (Postfix inherited it from Sendmail), and Exim’s pipe transport is what the cPanel form wraps. I measured Postfix only; treat the other two as the same mechanism with their own config files.

Read piped email from STDIN: exit codes, shebang and permissions

Error handling in a pipe script is the exit code, nothing else. Postfix expects sysexits.h conventions from the command: zero means delivered, 75 (EX_TEMPFAIL) means “try again later”, which is what you want when the database is down. Anything else is a permanent failure and the sender gets a bounce. Postfix also honours RFC 3463 enhanced status codes printed on stdout, and it kills your script if it runs past command_time_limit, so do the heavy work in a queue and return fast.

Now the failure modes, because this is where I lost the first ten messages. The script runs as nobody (that’s default_privs).

My handler wrote its log to a root-owned file. The file_put_contents returned false, the script exited 0, and Postfix logged status=sent (delivered to command: ...) for all ten, and the log stayed empty. Postfix was telling the truth: it delivered to the command.

The command just couldn’t write anything down. The same silence covers a script that isn’t executable, a shebang that points at a PHP binary the nobody user can’t run (and the CLI binary reads its own php.ini, so an extension enabled for PHP-FPM isn’t necessarily loaded here), and a vendor/autoload.php path that’s relative to the wrong directory.

Before you trust the pipe, run the script by hand as the delivery user:

printf 'From: a@example.com\nTo: support@example.com\nSubject: probe\n\nhello\n' \
  | su -s /bin/sh nobody -c /usr/local/bin/handle-mail.php; echo "exit=$?"

If that prints exit=0 and your side effect appears, the alias will work.

Parse email in PHP: MIME headers, body and attachments

The mailbox and pipe paths hand you a raw RFC 5322 message. The webhook path already returns parsed fields, so if that’s you, skip to the catch-all section. Everyone else needs a MIME parser, and the right one in 2026 is zbateson/mail-mime-parser 4.0. It’s pure PHP, and it’s the parser inside both laravel-mailbox and ImapEngine, so you’re already running it.

The php-mime-mail-parser is the other name you’ll see; it’s fine, but it needs the mailparse PECL extension compiled first, which puts you back in the C-extension business the section above just got you out of.

composer require zbateson/mail-mime-parser

Parse email headers in PHP

use ZBateson\MailMimeParser\Message;
use ZBateson\MailMimeParser\Header\HeaderConsts;

$message = Message::from($raw, false);

$fromEmail = $message->getHeader(HeaderConsts::FROM)?->getEmail();
$fromName  = $message->getHeader(HeaderConsts::FROM)?->getPersonName();
$subject   = $message->getSubject();                        // RFC 2047 decoded for you
$messageId = $message->getHeaderValue(HeaderConsts::MESSAGE_ID);
$inReplyTo = $message->getHeaderValue(HeaderConsts::IN_REPLY_TO);

// Repeated headers (Received, DKIM-Signature) come back as a list
foreach ($message->getAllHeadersByName('Received') as $hop) {
    // ...
}

The parser decodes RFC 2047 encoded words (=?UTF-8?B?...?=) in subjects and display names, which is the first thing hand-rolled header parsing gets wrong. Threading in a CRM hangs off Message-ID, In-Reply-To and References; store all three.

Decode the email body: plain text vs HTML, transfer encoding, charset

The parser reads each part’s Content-Type header, including its charset parameter, undoes the transfer encoding, and hands you strings:

$text = $message->getTextContent();   // decoded: quoted-printable/base64 undone, charset converted to UTF-8
$html = $message->getHtmlContent();
$body = $text ?? strip_tags((string) $html);

getTextContent() and getHtmlContent() return null when the part is absent. Plenty of marketing tools send HTML-only mail with no plain text part, and a ticketing system that reads only the text part then files an empty ticket. Fall back to the HTML content with the tags stripped, as the snippet does.

Extract email attachments in PHP and save them

foreach ($message->getAllAttachmentParts() as $part) {
    $name = $part->getFilename() ?: bin2hex(random_bytes(8));
    $safe = preg_replace('/[^A-Za-z0-9._-]/', '_', $name);
    $part->saveContent('/var/app/uploads/' . $safe);
    // $part->getContentType() for the MIME type; never trust the extension
}

Inline images (Content-Disposition: inline with a Content-ID, and an image Content-Type) are attachments too. Decide up front whether your app wants them.

Normalise incoming email to one shape for all three paths

This is the idea that lets the rest of your app ignore which path delivered the mail. Whatever the source, produce one shape:

final readonly class IncomingMail
{
    public function __construct(
        public string $from,
        public array $to,
        public string $subject,
        public ?string $text,
        public ?string $html,
        public ?string $messageId,
        public ?string $inReplyTo,
        public array $attachments,   // [['filename' => …, 'contentType' => …, 'path' => …], …]
        public \DateTimeImmutable $receivedAt,
    ) {}
}

Write one constructor from a Mailtrap message array (from, text_body, html_body, rfc_message_id, …) and one from a parsed Message.

Everything downstream, the ticket opener, the CRM matcher, the agent, takes an IncomingMail and never sees a header again. When you migrate from an IMAP poller to the webhook, you change one file.

Route a catch-all email address to one PHP handler

A catch-all means every address at a domain (invoice-4821@, ticket-77@, whatever@) lands in one place, and your code reads the local part to decide what to do.

There are three ways to do it listed in the order I’d pick them:

  1. Mailtrap custom domain. Verify the domain, enable inbound receiving, add the MX record, create an inbox with domain_id. Every address at the domain is now one inbox and one webhook. The local part arrives in the to field of the parsed message; in Laravel, Mailbox::to('{ref}@yourdomain.com', ...) captures it as $ref.
  2. MTA catch-all alias. In Postfix, virtual_alias_maps with @yourdomain.com support sends everything to the support alias, which pipes to your script. The original address is in ORIGINAL_RECIPIENT.
  3. A catch-all mailbox over IMAP. Your mail host’s catch-all setting, then the poller above, reading Delivered-To or the first To address per message.

Auto-replies are a send job, not a receive one. Whichever path you use, hand the reply to your sender (Our send email in Laravel covers that side, and the Laravel email API comparison helps if you haven’t picked one) and do it from a queue, or your handler will be waiting on SMTP while the retry clock runs. Deliverability for those replies is the sending side’s problem, SPF, DKIM and a domain with a reputation, and nothing on the receive path changes it.

Inbound email API comparison for PHP: Mailtrap vs Mailgun, Postmark and SendGrid

The four managed inbound APIs a PHP developer is likely to evaluate, on the axes that decide how your handler is written:

Mailtrap Inbound EmailMailgun RoutesPostmark InboundSendGrid Inbound Parse
Webhook bodyThin JSON event batch; fetch the parsed message via APIx-www-form-urlencoded or multipart with parsed fields; not JSONJSON, the richest field set of the fourmultipart/form-data; not JSON
AuthenticationHMAC-SHA256 over the raw body, Mailtrap-Signature headerHMAC-SHA256 of timestamp + token with the API key, sent in the payloadHTTP basic auth in the URL; no HMACUnsigned
RetriesEvery 5 min, roughly three dozen attempts over ~3 h, then pauses (measured, one run)Up to 8 h total; stops on 200 or 406 (interval documentation is inconsistent)1m, 5m, 10m ×3, 15m, 30m, 1h, 2h, 6h; 10 retries; stops on 403About 3 days (vendor docs)
Size cap10 MB encoded on lower tiers, 30 MB on Business+ (vendor statement)Not published on the receiving docs35 MB attachments cumulative30 MB total
Plan gatingIncluded with all plansAll plans include it (1 route Free, 5 Basic)Pro and Platform onlyAll tiers
Raw MIME availableraw_message_url, signed, 1 hbody-mime field, when the forward URL ends in mimeRawEmail field in the JSONemail field, when “POST the raw, full MIME message” is on

Competitor facts were checked against each vendor’s documentation on 2026-08-18, the raw-MIME row on 2026-09-21. Two design consequences for PHP specifically.

  1. Mailgun and SendGrid POST form data, so php://input is not JSON and $_POST is where the fields are. Postmark and Mailtrap POST JSON, so $_POST is empty and you read the body.
  2. Only Mailtrap and Mailgun sign what they send. Postmark authenticates with basic-auth credentials in the webhook URL, and SendGrid’s posts are unsigned. So, in both cases the URL itself is the secret: keep it long and out of your logs.

Wrapping up: which way to receive email in PHP

Three questions settle it.

  1. Do you already own the mailbox? Then IMAP with ImapEngine, over OAuth, and accept polling.
  2. Do you already run the mail server? Then pipe to a script, test it as nobody before you trust it, and exit 75 when you can’t cope.
  3. Neither? Then the managed inbound API: one call for an inbox, a thin signed webhook that you verify against php://input, and a parsed message you fetch by ID.

That is how I’d receive email in PHP in 2026, and it’s the path I’d point a new Laravel project at on day one, with the driver above.

The sending half of the story, the half PHP is actually good at, with PHPMailer or an API SDK, is in the PHP email sending guide, with the PHP email API comparison if you haven’t picked a provider.

FAQ

Can a PHP application receive email?

Not directly; PHP has no SMTP listener, and PHPMailer is a sending library. It receives email through one of three intermediaries: a managed inbound API that calls a webhook, an IMAP mailbox it polls, or a mail server that pipes messages to a script. That holds for plain PHP, Laravel, Symfony and WordPress alike; the framework changes the routing, not the intermediary.

How do I read an inbox in PHP 8.4 now that ext/imap is gone?

Use a pure-PHP client. ImapEngine installs with no system dependencies and supports XOAUTH2; webklex/php-imap works too but needs ext-zip. If you must have the extension, it’s on PECL, but it no longer builds on the Debian-based official Docker images.

IMAP or POP3 for receiving email in PHP?

IMAP. POP3 is a download-and-delete protocol with no folders, no flags and no server-side search. It fits exactly one workflow: pull everything and empty the box.

How do I receive email in PHP without a mail server?

A managed inbound API (Mailtrap gives you a hosted @inbound-mailtrap.io address with one call) or a hosted mailbox you read over IMAP. Both need zero infrastructure on your side.

How do I trigger PHP in real time when an email arrives?

Webhook or MTA pipe. Both avoid polling. The pipe is fastest (tens of milliseconds) but you run the server; the notification arrives within about 30 seconds and you run nothing.

How do I read Gmail or Microsoft 365 mail in PHP now?

Over IMAP with XOAUTH2. Microsoft 365 no longer accepts passwords at all; Gmail still accepts an app password if 2-Step Verification is on, but OAuth is the path that survives policy changes.

How do I receive email in Laravel?

Install laravel-mailbox and a driver for your provider, then declare handlers with Mailbox::to(). It ships Mailgun, SendGrid, Postmark and MailCare drivers; the Mailtrap driver in this article adds one more.

Article by Veljko Ristić Content Manager @Mailtrap

Linguist by trade, digital marketer at heart, I’m a Content Manager who’s been in the online space for 10+ years. From ads to e-books, I’ve covered it all as a writer, editor, project manager, and everything in between. Now, my passion is with email infrastructure with a strong focus on technical content and the cutting-edge in programming logic and flows. But I still like spreading my gospels while blogging purely about marketing.