directorytree/imapengine
ImapEngine is a modern PHP IMAP library for reading and managing mailboxes with a clean, expressive API. It supports messages, attachments, flags, searches, folders, and Laravel integration, making email automation and inbox syncing simple and reliable.
Install and wire it up.
composer require directorytree/imapengine
There is no ext-imap requirement — the library talks the protocol over sockets, so it works on hosts where the legacy extension is missing or banned. Requirements are PHP 8.x, ext-openssl for TLS, and illuminate/collections (pulled in automatically; it's a first-class dependency, so keep it aligned with your Laravel version — see Gotchas).
Standalone PHP — the smallest useful connection:
use DirectoryTree\ImapEngine\Mailbox;
$mailbox = Mailbox::make([
'host' => 'imap.example.com',
'port' => 993,
'encryption' => 'ssl',
'username' => config('mail.imap.user'),
'password' => config('mail.imap.pass'),
'timeout' => 30,
'debug' => false,
]);
$inbox = $mailbox->inbox();
Laravel. Publish the package config during install (php artisan vendor:publish, pick the ImapEngine provider's tag) and you get a config file holding named mailbox connections plus a facade/factory to resolve them. Keep credentials in .env, not in config/imap.php, and register one connection per mailbox you actually poll.
Where to look first. In order: the config file (connection shape and defaults), then Mailbox (connect(), inbox(), folders()), then Folder (messages(), idle(), poll(), quota methods), then Message. The query builder hanging off $folder->messages() is where you'll spend 80% of your time — read the docs page on message retrieval and search before writing anything real.
First use case: an inbox list view. Headers only, no bodies, no attachments. Lazy loading (v1.23/1.24) makes this cheap by default:
$messages = $mailbox->inbox()
->messages()
->withHeaders() // subject, from, date, message-id
->withFlags() // \Seen, \Flagged, custom keywords
->unseen()
->get();
foreach ($messages as $message) {
$rows[] = [
'uid' => $message->uid(),
'subject' => $message->subject(),
'from' => $message->from()->name ?? $message->from()->email,
'date' => $message->date(),
'unread' => ! $message->hasFlag('\\Seen'),
];
}
Only call $message->text(), ->html(), or ->attachments() when a user actually opens a message.
Separate list queries from detail queries. This is the single biggest lever. A list view wants withHeaders() + withFlags(); a detail view wants withBody() (and bodyStructure() if you render parts yourself). If you find yourself looping over 200 messages calling ->text(), you've built an N+1 over IMAP round trips. Eager-load on the query or don't load at all.
Incremental sync via UID ranges. Don't re-scan the mailbox each run. Persist the highest UID you've seen per folder, then:
$query = $folder->messages()
->withHeaders()
->withFlags()
->uid($lastSeenUid + 1, INF); // UID sequence range (v1.16.1)
foreach ($query->get() as $message) { /* persist UID, ingest */ }
UID ranges are also the right tool for backfill, chunked so no single command exceeds server limits. Note the v1.24.1 fix around infinite UID ranges — if you're on an older release, be explicit about your upper bound.
Push work to the server. Subject/from/date criteria, seen/unseen, larger()/smaller() (v1.21), and server-side SORT (v1.22) all mean fewer bytes and fewer round trips. Anything the server can filter, don't filter in PHP. LARGER/SMALLER is how you find the 20MB attachment that's stalling your ingestion pipeline.
Bulk, don't loop. Per-message markRead(), move(), copy(), delete() inside a foreach is a command per message. The query-level bulk operations (v1.21.0) collapse that into range-optimized payloads (v1.25.6):
$folder->messages()
->subject('Invoice')
->before('2025-01-01')
->markRead(); // or ->flag(...), ->delete(), ->move($archive), ->copy(...)
Use the per-message methods only for single-item user actions.
Ingestion pipeline shape. Query → filter server-side (size, date, sender) → for each message, walk attachments(), save() (or stream contents()) to object storage, record filename, contentType, contentDisposition, size → then move the message to a processed folder or flag it. Keep the whole thing in a queued job, one message per job, so a poison message doesn't kill the batch. RFC822.SIZE (v1.18) lets you reject oversized messages before downloading parts.
Near-real-time: idle() vs poll(). idle() is the protocol-native option; always pass a timeout callback so you can break out and let the process recycle (v1.16.0). poll() (v1.19.0) is long-polling built on top and is easier to reason about in a worker loop. Either way, run it in a dedicated worker with a supervisor, not inside a web request, and reconnect on exit.
Thread detection uses messageId() and In-Reply-To (now handled as a Message-ID, not an address, per v1.18). In-Reply-To is your cheap grouping key; don't build threading off subject normalization.
Hide the library behind an interface. Define your own MailService / DTOs (InboundMessage, Attachment) and map at the boundary. Nothing in your domain should import DirectoryTree\ImapEngine\*. That buys you the swap to Gmail API / Graph later, and it makes tests trivial because you can fake your own interface.
Testing. Unit-test your adapter against fakes; integration-test the real thing against a disposable Dovecot or a dedicated test mailbox per provider you support. Server behavior, not PHP behavior, is where the bugs live.
$message->subject() on 500 messages is fine; $message->html() on 500 messages is 500 fetches. Decide explicitly per code path.contents() calls leaving saved files empty. If you need bytes twice, buffer them or save() to disk first and work from the file.UIDVALIDITY generation, and a mailbox re-creation invalidates every stored UID. Own that in your schema: store folder + UID + a UIDVALIDITY value you track yourself, and be prepared to full-resync when it changes. The library won't do this bookkeeping for you.illuminate/collections version coupling. v1.25.5 swapped firstWhere() for Collection::first(...) specifically to avoid lower-version breakage. If your app pins an old illuminate/collections, check compatibility before adopting — this is a real conflict surface in older Laravel apps.debug => true logs commands. Useful while integrating a new provider, dangerous in production: keep it behind an env flag, scrub logs, and never ship the connection dump anywhere near credential storage.BackedEnum|string (typed as of v1.15.4). Prefer an enum in your own code and convert at the boundary; keyword strings are case- and backslash-sensitive (\Seen, not Seen).SORT, IDLE, LARGER/SMALLER, quota commands, and BODY[HEADER]-first behavior (v1.18.2 was a server-compat workaround) are all capability-dependent. Feature-detect or gracefully fall back to fetch-order sorting; have a test account on Gmail, M365/Outlook, and Dovecot before you ship.timeout, always disconnect() on shutdown, and wrap work in try/catch: connection failures now surface as a proper ImapConnectionFailedException (v1.22.4) rather than suppressed stream warnings — but they'll still blow up a job. Retry with backoff and treat repeated failures as an alertable state, not a silent skip.size() filters, process one message per job, and let the queue's memory limit be your safety net.ID parameter escaping. Keep the version current, and never concatenate raw user input into search criteria or folder names — use the builder.Mailbox::make(), and the Laravel facade/factory. Everything else — persistence, attachment storage, retry policy, observability, provider migration — is your layer. Budget for it explicitly; the ~$0 license cost is not the total cost.How can I help you explore Laravel packages today?