directorytree/imapengine-laravel
Laravel integration for ImapEngine, a simple API for managing IMAP mailboxes without the PHP extension. Includes a service provider, configuration, and facade so you can connect to and work with mailboxes cleanly inside your Laravel app.
Install and publish. The service provider is auto-discovered, so composer require directorytree/imapengine-laravel is the whole install. Then publish the config:
php artisan vendor:publish --tag=imap
Confirm the exact publish tag and facade alias against the package's service provider before you commit them to a runbook — they're the two names you'll type most and the ones most likely to drift between minors.
Configure one mailbox in .env first. The published config is a named-mailbox map plus a default. Keep each connection in its own env block so you can rotate one credential without touching the others:
// config/imap.php
return [
'default' => 'support',
'mailboxes' => [
'support' => [
'host' => env('IMAP_SUPPORT_HOST'),
'port' => env('IMAP_SUPPORT_PORT', 993),
'username' => env('IMAP_SUPPORT_USERNAME'),
'password' => env('IMAP_SUPPORT_PASSWORD'),
'encryption' => env('IMAP_SUPPORT_ENCRYPTION', 'ssl'),
],
],
];
First use case: read the inbox in Tinker. Do this before you write a single event listener. It proves credentials, TLS, and folder naming in about two minutes:
use DirectoryTree\ImapEngine\Laravel\Facades\Imap;
$inbox = Imap::mailbox('support')->inbox();
foreach ($inbox->messages()->unread()->get() as $message) {
dump($message->subject(), $message->from()->email(), $message->text());
}
If that loop returns nothing but the mailbox is visibly unread, you have a folder-resolution problem, not a credentials problem — inbox() on some providers is INBOX vs. a namespaced path.
First run of the real thing:
php artisan imap:watch --mailbox=support --method=idle
Leave it in the foreground the first time so you can watch it connect, idle, and disconnect. --method=long-polling is the fallback if the provider throttles or drops IDLE connections.
Where to look first, in order: the published config (it is the real API surface), the facade and container bindings, the package's Events directory, then imapengine.com/docs for the core library. Roughly 90% of the behavior you'll debug lives in the core library, not this wrapper — read the wrapper's source once (it's small) so you know where the seam is.
Event-driven ingest — the pattern to default to. MessageReceived is dispatched per message; do almost nothing in the listener and hand off to a job. Pass identifiers, not the Message object, so serialization and retries stay predictable:
namespace App\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use DirectoryTree\ImapEngine\Laravel\Events\MessageReceived;
use App\Jobs\IngestEmail;
class QueueInboundEmail implements ShouldQueue
{
public function handle(MessageReceived $event): void
{
IngestEmail::dispatch(
mailbox: $event->mailbox, // added in v1.3.0 — it's the configured name
uid: $event->message->uid(),
);
}
}
The $mailbox property landing in v1.3.0 is what makes multi-connection listeners possible without threading the connection name through manually. If you're pinned below that, you'll be inferring the mailbox from the message's folder — fragile.
Re-fetch by UID inside the job. The job should do its own Imap::mailbox($name)->inbox()->messages()->find($uid) and parse there. Queue workers run in a different process from the watcher, and re-fetching also gives you a natural place to short-circuit on duplicate.
Idempotency at the transport boundary. This is the decision that matters more than any API ergonomics. Persist what you've processed, keyed on UID + UIDVALIDITY, before dispatching downstream work:
Schema::create('processed_messages', function (Blueprint $table) {
$table->id();
$table->string('mailbox');
$table->unsignedInteger('uidvalidity');
$table->unsignedBigInteger('uid');
$table->string('message_id')->nullable()->index();
$table->timestamp('processed_at');
$table->unique(['mailbox', 'uidvalidity', 'uid']);
});
UIDs are only stable within a UIDVALIDITY epoch; when the server bumps UIDVALIDITY the whole sequence can be reassigned. Enforce the unique index in the adapter layer, not the domain layer, so a reconnect can never double-open a ticket.
Background the watcher, don't schedule it. IDLE wants a supervised long-lived process — Supervisor or systemd, one per mailbox:
[program:imap-watch-support]
command=php /var/www/artisan imap:watch --mailbox=support --method=idle
autostart=true
autorestart=true
stdout_logfile=/var/log/imap-watch-support.log
Recycle it on a schedule (stopwaitsecs, or a nightly supervisorctl restart) — long-running PHP processes accumulate memory, and a fresh connection daily costs you nothing.
Long-polling as the low-pressure alternative. If you're watching many mailboxes, or the provider punishes persistent connections, --method=long-polling trades latency for far fewer sockets. Make that trade explicitly per mailbox rather than globally; a shared support inbox and a nightly invoice inbox have different latency needs.
Multiple connections, one handler. Name mailboxes after the business function (support, billing, leads), and resolve the handler from the mailbox name in the listener. That keeps one ingest path and avoids a listener-per-inbox sprawl as you add the second and third mailbox.
Wrap it in your own port on day one. Cheap insurance on a 38-star dependency:
interface InboundMailbox
{
public function unread(): Collection;
public function watch(): void;
}
Bind the ImapEngine implementation behind it. If the library stalls or a provider forces OAuth you can't get here, the swap is contained instead of a rewrite of every call site.
Attachments belong in Storage. Stream each attachment straight to a disk inside the job and store the path, not the bytes. Email attachments are the fastest way to blow up a queue payload and a database row.
Testing. Two layers: point the config at GreenMail or Dovecot in a container for integration coverage (watch semantics are otherwise unmockable), and swap the facade for unit tests of listeners and jobs. Build the GreenMail fixture before you write ingestion logic — you will not reliably simulate a dropped IDLE connection any other way.
OAuth is the unknown that can kill this. The documented auth is host/port/username/password. Microsoft has retired basic-auth IMAP for Exchange Online and Google pushes OAuth or app passwords. Settle this against your actual target mailboxes in week one — a shared inbox you can reach and one you can't is a scope decision, not a bug.
config:cache will pin stale credentials. Rotating an IMAP password in .env on a cached-config deployment does nothing until you re-run php artisan config:cache. This looks exactly like an auth failure and wastes an afternoon.
A quiet mailbox and a broken mailbox look identical. MailboxWatchAttemptsExceeded (v1.1.0) is your only failure signal from the watcher — wire it to real alerting with a runbook, not a log line. Add a separate explicit credential/token-validity check, because auth expiry surfaces as silence, not as an exception.
UID, not message number. Sequence numbers shift the moment anything is deleted from the folder. Never store messages()->get() array indices or message numbers; persist UIDs and treat UIDVALIDITY changes as "resync this folder."
Version-pin the MessageReceived payload. $mailbox arrived in v1.3.0. Listeners written against it will fatal on an older resolve if someone downgrades a lockfile. Pin directorytree/imapengine-laravel and directorytree/imapengine explicitly in composer.json and review bumps — the core library carries the protocol risk, not the wrapper.
Pass CarbonImmutable freely. v1.1.1 replaced Carbon type hints with CarbonInterface, so immutable dates work in query filters. If you're below that patch, date-range queries will type-error on CarbonImmutable.
No retry/backoff is provided. Reconnect logic, backoff, and rate limiting are yours. Keep them in the job layer, not the watcher, so a stuck connection doesn't become a stuck process.
Not a fit for serverless or short-lived request cycles. The watch model needs a persistent process. If your deployment target is Lambda or a purely request-driven runtime, plan on scheduled polling instead of IDLE and accept the latency — or reconsider the whole approach.
Don't try to backfill through this. Connections scale roughly linearly with watched mailboxes, and bulk historical sync will hammer both the provider and the process. Historical import is a separate, throttled, resumable job.
Debugging: drop to the core library. When the wrapper surprises you, instantiate DirectoryTree\ImapEngine\Mailbox directly against the same credentials and reproduce there. If it fails in the core, the Laravel layer is noise. Check the core docs for its debug/logging options before you reach for tcpdump.
The extension-free claim is the real prize. ext-imap is genuinely awkward in slim container images and absent on managed runtimes. Confirm your base image doesn't need it and delete the provisioning step — that's the win that outlasts any ergonomics you get from the fluent API.
Budget for the surface it doesn't cover: MIME parsing depth for malformed mail, credential encryption if you ever store accounts in the database, dedupe, rate limits, and per-provider quirks. "IMAP solved" is not the same as "email solved."
How can I help you explore Laravel packages today?