fain182 / ext-imap-polyfill
Drop-in polyfill for the imap_* functions removed from PHP core in 8.4, built on directorytree/imapengine
Requires
- php: ^8.1
- directorytree/imapengine: ^1.25.5
Requires (Dev)
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.5
- vlucas/phpdotenv: ^5.6
Provides
- ext-imap: *
This package is auto-updated.
Last update: 2026-08-10 21:55:09 UTC
README
A drop-in polyfill for the imap_* functions removed from PHP core in 8.4. Install it and your existing code keeps working — same function names, same arguments, same objects coming back.
PHP 8.4 moved ext-imap onto PECL (RFC), where it still needs c-client — last released in 2011 and disappearing from distributions. This package defines the same functions in pure PHP instead, on top of directorytree/imapengine.
Install
composer require fain182/ext-imap-polyfill
That's the whole migration: no call sites to touch, nothing else to do.
If ext-imap is present — you're still on PHP 8.3, or someone installed the PECL build — the polyfill is a no-op and the real extension keeps handling every call. It's safe to add before you upgrade, not just after. It also declares provide: ext-imap, so dependencies that require the extension install cleanly alongside it.
Tested against the real extension
Matching the manual isn't enough — the point is matching the extension you're replacing, quirks included: the property order inside a stdClass, the fact that imap_fetch_overview() returns [] where its neighbours return false, the exact ValueError text on a bad flag bitmask.
So the test suite doesn't just run against this package. Every integration test runs a second time against the genuine ext-imap, in a PHP 8.3 container, hitting the same servers — if the polyfill and the extension disagree, the build says so. On top of that, a unit suite covers the internals, and the whole thing is re-run against a second IMAP server (Greenmail and Dovecot) to catch behaviour that only holds on one of them.
That's what caught imap_uid() over POP3 returning the server's UIDL cast to an integer — right on one server, nonsense on the other.
POP3 is supported too, and runs through the same parity checks.
Function reference
imap_8bit,
imap_alerts,
imap_append,
imap_base64,
imap_binary,
imap_body,
imap_bodystruct,
imap_check,
imap_clearflag_full,
imap_close,
imap_create,
imap_createmailbox,
imap_delete,
imap_deletemailbox,
imap_errors,
imap_expunge,
imap_fetchbody,
imap_fetchheader,
imap_fetchmime,
imap_fetch_overview,
imap_fetchstructure,
imap_fetchtext (alias of imap_body),
imap_gc,
imap_getacl,
imap_getmailboxes,
imap_get_quota,
imap_get_quotaroot,
imap_getsubscribed,
imap_headerinfo,
imap_headers,
imap_is_open,
imap_last_error,
imap_list,
imap_listmailbox (alias of imap_list),
imap_listsubscribed (alias of imap_lsub),
imap_lsub,
imap_mail,
imap_mailboxmsginfo,
imap_mail_compose,
imap_mail_copy,
imap_mail_move,
imap_mime_header_decode,
imap_msgno,
imap_mutf7_to_utf8,
imap_num_msg,
imap_num_recent,
imap_open,
imap_ping,
imap_qprint,
imap_rename,
imap_renamemailbox,
imap_reopen,
imap_rfc822_parse_adrlist,
imap_rfc822_parse_headers,
imap_rfc822_write_address,
imap_savebody,
imap_search,
imap_setacl,
imap_setflag_full,
imap_set_quota,
imap_sort,
imap_status,
imap_subscribe,
imap_thread,
imap_timeout,
imap_uid,
imap_undelete,
imap_unsubscribe,
imap_utf7_decode,
imap_utf7_encode,
imap_utf8,
imap_utf8_to_mutf7
Notes on individual functions
Your code has already run against the extension, so the only thing worth reading here is where this package answers differently. Everything else behaves as it did — that is what the parity suite is for.
| Function | Divergence |
|---|---|
imap_check, imap_mailboxmsginfo |
the Mailbox host reads back as you wrote it in the spec; the extension reports the name DNS resolved it to |
imap_open with /tls |
negotiates the best TLS version both ends support, so it connects where the extension's /tls fails outright — that one asks for TLS 1.0 and nothing else |
imap_open with /secure, OP_SECURE, /authuser= |
always refused: they ask for an authentication that keeps the password off the wire, and this package only speaks LOGIN. The extension refuses too unless the server offers a SASL mechanism it can use |
imap_timeout |
IMAP_READTIMEOUT and IMAP_WRITETIMEOUT are one value: setting either sets both, since a PHP socket has a single timeout for both directions |
imap_utf8 |
returns precomposed UTF-8 (café, U+00E9) where the extension returns the decomposed form (cafe + U+0301); the two do not compare equal |
What throws, rather than returning false: imap_scan(),
imap_scanmailbox() and imap_listscan(), which speak a command no reachable
server answers; a {host/nntp} spec, since this package has no NNTP; and
imap_mail() on Windows with no sendmail_path set, since it has no SMTP
client either.
Warnings are raised as E_USER_WARNING rather than E_WARNING, which
userland cannot produce. The text is the same; an error handler filtering on
the level will see the difference.
Errors nobody read stay on the stack. The extension prints whatever
imap_errors() never drained at the end of the request, as notices reading
PHP Request Shutdown: ... (errflg=N); a notice raised from userland cannot
carry the location that one does, so this package says nothing instead.
Passing null where a string parameter is declared is a TypeError here
and a deprecation notice in the extension, which then reads it as "":
internal functions are allowed a coercion userland ones are not. For the same
reason, a TypeError from this package names the call site (, called in ...) where the extension's does not.