Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Imap Laravel Package

ddeboer/imap

A modern PHP IMAP library for reading mailboxes and messages. Connect to servers, list folders, fetch and search messages, inspect headers, bodies and attachments, flag or move mail, and work with UTF-8 folder names through a clean, object-oriented API.

Deep Wiki
Context7

Getting Started

ddeboer/imap is an object-oriented wrapper around PHP's ext-imap. It's not a Laravel package — there's no service provider, no config file, no facade. You get a small set of classes (Imap, Mailbox, Message, Attachment) that wrap the procedural imap_* functions into something you can actually test and reason about.

1. Install the extension first, not the package.

# Debian/Ubuntu
sudo apt-get install php-imap
# or, on PHP 8.4+, ext-imap left core and lives on PECL:
pecl install imap

composer require ddeboer/imap

Verify with php -m | grep imap before writing a line of code. Half of all "why doesn't this work" reports are a missing extension or a disabled imap in php.ini.

2. Open a connection.

use Ddeboer\Imap\Server;

$server = new Server('imap.example.com', 993, 'ssl'); // or 'tls' on 143
$connection = $server->authenticate('[email protected]', 'password');
$mailbox = $connection->getMailbox('INBOX');

Or skip the Server object with the DSN form, which is easier to keep in .env:

use Ddeboer\Imap\Imap;

$connection = Imap::fromString(
    '{imap.example.com:993/imap/ssl}INBOX',
    config('mail.imap.username'),
    config('mail.imap.password'),
);

3. Read your first message.

foreach ($mailbox->getMessages() as $message) {
    dump(
        $message->getUid(),
        (string) $message->getSubject(),
        (string) $message->getFrom(),
        $message->getDate(),
        $message->getBodyText(),
    );
}

Where to look first: the Mailbox class is the real API surface — getMessages(), getMail($uid), getStatus(), getMailbox() for subfolders. Message is a lazy value object: nothing is fetched until you call a getter, and flags/attachments/move operations all live on it. Read those two files before anything else; the rest of the library is filters and search operators.

First real use case: an inbound-email-to-ticket pipeline. Poll INBOX, walk messages, persist to Eloquent, move the message to Processed, expunge. That exercises connection handling, paging, persistence, and flag management — the four things that break in production.


Implementation Patterns

Configuration and connection setup

Keep the DSN in config, not in code. A single config/imap.php with per-account entries lets you support "support@", "billing@", and per-tenant mailboxes without duplicating connection logic:

// config/imap.php
return [
    'accounts' => [
        'support' => [
            'dsn'      => env('IMAP_SUPPORT_DSN'),   // {host:993/imap/ssl}INBOX
            'username' => env('IMAP_SUPPORT_USER'),
            'password' => env('IMAP_SUPPORT_PASS'),
        ],
    ],
];
class ImapConnectionFactory
{
    public function for(string $account): Imap
    {
        $cfg = config("imap.accounts.{$account}");
        return Imap::fromString($cfg['dsn'], $cfg['username'], $cfg['password']);
    }
}

Bind that factory as a singleton in a service provider and inject it. Resist the temptation to bind the Imap connection itself as a singleton — see Gotchas.

Poll-and-persist with a queued job

The pattern that scales is: a thin scheduled command discovers new UIDs, dispatches one job per message, and each job does its own fetch-and-store. That keeps memory flat and gives you retries per message.

// app/Console/Commands/PollMailbox.php
public function handle(ImapConnectionFactory $factory): void
{
    $mailbox = $factory->for('support')->getMailbox('INBOX');

    foreach ($mailbox->getMessages(SearchCriteria::unseen()) as $message) {
        ProcessInboundEmail::dispatch(
            account: 'support',
            uid: $message->getUid(),
            mailboxName: 'INBOX',
        )->onQueue('mail');
    }
}
// app/Jobs/ProcessInboundEmail.php
public function handle(ImapConnectionFactory $factory): void
{
    $connection = $factory->for($this->account);
    $mailbox = $connection->getMailbox($this->mailboxName);
    $message = $mailbox->getMail($this->uid);

    if (InboundEmail::where('message_id', $message->getMessageId())->exists()) {
        return; // idempotent: re-delivery is normal
    }

    $email = InboundEmail::create([
        'message_id' => $message->getMessageId(),
        'in_reply_to' => $message->getInReplyTo(),
        'subject'    => (string) $message->getSubject(),
        'from'       => (string) $message->getFrom(),
        'body_text'  => $message->getBodyText(),
        'body_html'  => $message->getBodyHtml(),
        'received_at'=> $message->getDate(),
    ]);

    foreach ($message->getAttachments() as $attachment) {
        $email->attachments()->create([
            'filename' => $attachment->getFilename(),
            'path'     => Storage::disk('mail')->putFile(
                "inbound/{$email->id}", $attachment->getDecodedContent(), 'private'
            ),
            'size'     => $attachment->getSize(),
        ]);
    }

    $mailbox->move($message->getUid(), 'Processed');
    $mailbox->expunge();
}

Search instead of filtering in PHP

Never getMessages() the whole folder and if your way through it. Mailbox::getMessages() accepts a search expression, and the Search\ namespace has filters (Unseen, Since, From, Subject, Text) plus logical operators (AndExpression, OrExpression, NotExpression). Push the filtering to the IMAP server:

use Ddeboer\Imap\Search\LogicalOperator\AndExpression;
use Ddeboer\Imap\Search\Filter\{Unseen, Since, From};

$criteria = new AndExpression(
    new Unseen(),
    new Since(new \DateTimeImmutable('-7 days')),
);

foreach ($mailbox->getMessages($criteria) as $message) { /* ... */ }

For folders with tens of thousands of messages this is the difference between a two-second job and a timed-out worker.

Streaming large folders

Mailbox::getMessages() returns an iterator backed by a generator, so messages are fetched lazily and released as you move on. Don't iterator_to_array() it — that defeats the whole design and will OOM on a busy folder.

foreach ($mailbox->getMessages($criteria) as $message) {
    $this->handle($message);
    unset($message); // let the cycle collector reclaim it
}

Subfolders and mailbox discovery

getMailbox($name) with a slash works only if you quote what the server expects. Use the built-in traversal instead of guessing names:

foreach ($connection->getMailboxes('INBOX') as $child) {
    dump($child->getName(), $child->getStatus()->getMessages());
}

This matters for Gmail ([Gmail]/Sent Mail) and Dovecot (configurable separator), where hardcoded folder names silently fail.

Flags and workflow state

Read/write flags on Message or Mailbox, but be aware that your database should be the source of truth for workflow state, not \Flagged. IMAP flags are a shared, mutable namespace — users mark things read in their mail client. Use flags for the things the mail client understands (\Seen, \Flagged, \Answered) and your own status column for everything else.

Testing against a real server

Unit-test your mappers with hand-built Message fakes, but keep one integration suite against a throwaway IMAP server — behaviour differs by server implementation (Dovecot vs Gmail vs Exchange all have quirks around expunge, charset, and folder separators). Docker one-liner:

docker run -d -p 3143:3143 -p 3993:3993 greenmail/standalone

Point a testing DSN at {localhost:3993/imap/ssl/novalidate-cert}INBOX. The novalidate-cert flag in the DSN is exactly what it sounds like — necessary for self-signed certs, and worth having in your test config.


Gotchas and Tips

ext-imap left PHP core in 8.4. If you're upgrading, this package will stop autoloading correctly until you pecl install imap. It's also not thread-safe, which is why the connection object should never be shared.

Never cache or inject the Imap connection as a singleton. In queue workers, Octane, or long-running daemons the underlying socket goes stale after a few minutes of idle time and you get cryptic "connection dropped" errors on the next command. Open the connection inside the job, do the work, let it be garbage-collected. The cost of a fresh authenticate is a few hundred milliseconds; the cost of debugging stale sockets is an afternoon.

UIDs are not stable identifiers. A UID is only unique within a mailbox and an UIDVALIDITY epoch. If the server re-creates the mailbox (which some providers do on migration), UIDs reset and your getMail($storedUid) fetches the wrong message. Persist (mailbox, uidvalidity, uid) if you need to re-fetch, and treat a UIDVALIDITY change as "all my stored UIDs are meaningless." Message::getMessageId() is the safer business key — that's why the dedupe check in the job above uses it.

Deletes need two steps. $mailbox->deleteMessage($uid) only sets \Deleted. Nothing actually disappears until $mailbox->expunge(). Forgetting the expunge is the single most common bug report against this library, and the symptom (messages reappearing after a client sync) looks like a caching problem. There's also move(), which does copy + delete + expunge for you.

Don't assume reading a message marks it seen — or that it doesn't. The library aims to peek rather than fetch, but this varies by code path and version. Set \Seen explicitly when you want it and assert on it in tests; don't build UI assumptions on the default.

Attachments are loaded fully into memory. getContents() / getDecodedContent() return the entire attachment as a PHP string. A 25 MB PDF from a supplier's invoice scanner, times a few workers, will hit your memory_limit. Either raise the limit explicitly on the mail queue or switch to a library with streaming support for those mailboxes. Also: getDecodedContent() (transfers-encoding-aware) is what you want for anything not plain 7-bit — getContents() on a base64 attachment gives you base64 text.

Encoding is a live issue. Subjects and display names arrive RFC 2047-encoded. The package casts them to string via getSubject() / getFrom(), but the result depends on imap_mime_header_decode doing the right thing with the declared charset. If you see =?UTF-8?Q?...?= leaking into your database, check the server's charset negotiation before blaming the package. Normalise on write: mb_convert_encoding($subject, 'UTF-8', 'UTF-8') after stripping control characters.

No IDLE, no push, no OAuth. This is a polling library. If you need near-real-time delivery you're either polling every 30–60 seconds (fine for most transactional inboxes, unkind to Gmail) or using a provider webhook. Similarly, Gmail and Microsoft both require OAuth2/XOAUTH2 now; this package authenticates with a plain username and password. App passwords work for Google Workspace if your admin allows them, otherwise plan for a separate OAuth token → AUTHENTICATE XOAUTH2 layer, which you'll have to build yourself.

Timeouts are global, not per-call. imap_timeout(IMAP_OPENTIMEOUT, ...) etc. affect every connection in the process. Set them once in a service provider bootstrapping method and understand they're inherited by anything else touching IMAP in that request.

Error handling is exception-based but granularity is coarse. AuthenticationFailedException, MailboxNotFound, ImapException are the ones you'll actually catch. Catch ImapException broadly around connection and mailbox resolution (retry with backoff — transient server errors are common at provider maintenance windows), and let anything thrown from a specific message fetch fail the individual job rather than the batch.

Empty-body messages return null, not ''. getBodyText() on a multipart/alternative message with only HTML gives you null. Laravel's Text/Str::of() handle it, but strict-mode code and strip_tags(null) deprecations on PHP 8.1+ will bite you.

Expunge affects the whole folder, not just your message. On a shared mailbox, expunge() can remove messages another process or user just flagged for deletion. If that's a risk, prefer move() to an archive folder and let a separate, rarely-run command handle expunging.

Testing against Gmail will make you hate IMAP. Gmail's \All folder, its aggressive rate limits, and its tendency to return different results for the same search query twice. Test against Dovecot or GreenMail for logic, and treat Gmail as a production-only integration you smoke-test manually.

The Server/Imap factory is the extension point. Most classes are final, so you can't meaningfully subclass Message to add accessors. Wrap instead: write your own InboundEmail DTO and a MessageMapper that takes a Message and returns it. That keeps the library on the edge of your architecture — one class to swap if you ever migrate to a streaming or pure-PHP implementation — rather than letting Ddeboer\Imap\Message leak into your controllers.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource