meil / meil-php
Official PHP client for the meil.ro email API: transactional email and newsletters.
Requires
- php: >=8.1
- ext-curl: *
- ext-json: *
Requires (Dev)
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.5 || ^11.0 || ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
The official PHP client for the meil.ro API: transactional email and newsletters.
- PHP 8.1 or newer. It needs only
ext-curlandext-json, so it runs on shared hosting and inside WordPress. - Answers are plain associative arrays. They are described with PHPDoc array shapes, so your editor and PHPStan know every key.
- Safe retries: sends always carry an
Idempotency-Key, so a retry never sends twice.
composer require meil/meil-php
$meil = new Meil\Meil('meil_...'); // or set MEIL_API_KEY and call new Meil\Meil()
Keys are the API's own field names (reply_to, template_id, audience_id), so the API docs and this package read the same.
Sending email
$sent = $meil->emails->send([ 'from' => 'Magazinul Tău <comenzi@magazin.ro>', 'to' => 'ana@example.com', // a string, "a@x.ro, b@y.ro", or an array; at most 50 recipients with cc and bcc 'subject' => 'Comanda 2451 a fost expediată', 'html' => '<p>Salut Ana, comanda ta e pe drum.</p>', ]); echo $sent['id'];
With a saved template and its placeholders:
$meil->emails->send([ 'from' => 'Magazinul Tău <comenzi@magazin.ro>', 'to' => 'ana@example.com', 'template_id' => 'tpl_id', 'variables' => ['name' => 'Ana', 'order_id' => 2451], ]);
With attachments. Pass the file's raw bytes as content, and the SDK base64-encodes them. Do not encode them yourself. You can attach up to 20 files, 7 MB in total.
$meil->emails->send([ 'from' => 'Magazinul Tău <facturi@magazin.ro>', 'to' => 'ana@example.com', 'subject' => 'Factura pentru comanda 2451', 'heading' => 'Mulțumim pentru comandă', // no html: built in your domain's branding 'body' => 'Factura este atașată.', 'button' => ['label' => 'Vezi comanda', 'url' => 'https://magazin.ro/comenzi/2451'], 'attachments' => [ ['filename' => 'factura-2451.pdf', 'content' => file_get_contents('/path/factura-2451.pdf')], ], ]);
The answer is ['id' => ...]. It can also have unsubscribe_url, and suppressed when some recipients on your suppression list were skipped.
Idempotency keys
emails->send() and broadcasts->create() always send an Idempotency-Key. If you give none, the SDK makes a random UUID for the call, which makes its own retries safe. To make your own retries safe too (a cron job or queue worker that runs twice, say), pass a key that is unique to the send:
$meil->emails->send($params, ['idempotency_key' => "order-{$order->id}-confirmation"]);
For 24 hours, the same key with the same body returns the first answer and sends nothing new. The same key with a different body throws a ConflictException with the error name invalid_idempotent_request.
Errors
Failed calls throw. Every exception is a Meil\Exception\MeilException (a RuntimeException) with these methods:
getErrorName(): the API's stable error name, such as"rate_limit_exceeded";getStatusCode(): the HTTP status;getMessage(): English text for people;getId(): when there is one.
use Meil\Exception\MeilException; use Meil\Exception\RateLimitException; use Meil\Exception\ValidationException; try { $meil->emails->send($params); } catch (ValidationException $e) { if ($e->getErrorName() === 'send_rejected') { error_log('Refused by the mail provider; logged as ' . $e->getId()); } else { error_log('Invalid request: ' . $e->getMessage()); } } catch (RateLimitException $e) { error_log('Rate limit or quota: ' . $e->getErrorName()); // daily_quota_exceeded, monthly_quota_exceeded, ... } catch (MeilException $e) { error_log($e->getStatusCode() . ' ' . $e->getErrorName() . ': ' . $e->getMessage()); }
Exception (Meil\Exception\…) |
When |
|---|---|
AuthenticationException |
401, a missing or invalid key |
PermissionException |
403: key scope, plan, suspended account, recipient_suppressed |
NotFoundException |
404, also for objects of a domain your key is not locked to |
ConflictException |
409: idempotency conflicts |
ValidationException |
422, including send_rejected |
RateLimitException |
429, with getRetryAfter() in seconds when the API sends one |
ServerException |
5xx, including Cloudflare error pages (error name invalid_response) |
ConnectionException |
no answer: connection_error or timeout, status null |
Retries
By default the SDK retries up to 2 times. Set max_retries to change that; 0 turns retries off.
It retries:
- 429
rate_limit_exceeded, after theRetry-Afterthe API sends; - 409
concurrent_idempotent_requests; - network errors and timeouts;
- 502, 503, 504, and Cloudflare's 520 to 524, with exponential backoff.
Quotas (daily_quota_exceeded, monthly_quota_exceeded, previous_broadcast_pending) are never retried. GET, PATCH and DELETE calls are retried. A POST is retried only when it carries an Idempotency-Key, which means only emails->send() and broadcasts->create().
The SDK waits between retries with usleep(), so a web request that hits a rate limit can take a few extra seconds. For a page that must answer quickly, set 'max_retries' => 0.
$meil = new Meil\Meil('meil_...', [ 'max_retries' => 2, 'timeout' => 30, // seconds per attempt 'base_url' => 'https://api.meil.ro', ]); $meil->emails->get($id, ['timeout' => 5, 'max_retries' => 0]); // per call
Broadcasts
$result = $meil->broadcasts->create([ 'audience_id' => 'aud_id', 'from' => 'Magazinul Tău <noutati@magazin.ro>', 'subject' => 'Noutăți în martie', 'html' => '<h1>Salut {{first_name|acolo}}</h1> … <a href="{{unsubscribe_url}}">Dezabonare</a>', 'send' => true, // or 'scheduled_at' => new DateTimeImmutable('2026-11-01 09:00', new DateTimeZone('Europe/Bucharest')) on paid plans; or neither for a draft ], ['idempotency_key' => 'newsletter-2026-03']); if ($result['status'] === 'under_review') { // HTTP 202: the broadcast exists and waits for a person at meil.ro to look at it. // Not an error. Check on it later with $meil->broadcasts->get($result['id']). echo 'Held for review: ' . $result['id'] . ' ' . $result['message']; } elseif ($result['status'] === 'draft') { echo 'Draft saved: ' . $result['id']; } else { echo "{$result['status']}: {$result['recipients']} recipients"; }
$meil->broadcasts->send($id) sends a draft. It can also return the under_review result.
Other broadcast methods: get, list, update, delete, schedule($id, $date) and unschedule.
Contacts
$contact = $meil->contacts->add([ 'audience_id' => 'aud_id', 'email' => 'ana+news@example.com', 'first_name' => 'Ana', 'properties' => ['city' => 'Cluj'], ]); // An existing contact keeps its own values; clashes are listed in $contact['kept_properties']. $meil->contacts->update('ana+news@example.com', ['properties' => ['city' => 'Iași', 'old_field' => null]]); // null removes $meil->contacts->remove('aud_id', 'ana+news@example.com'); // off one audience $meil->contacts->delete('ana+news@example.com'); // everywhere, with its data
The SDK URL-encodes the email addresses in paths for you.
Paging
emails->list() and audiences->listContacts() return one page: ['object' => 'list', 'has_more' => bool, 'data' => [...]]. To get everything, loop over the generator. Pages are fetched as you go:
foreach ($meil->audiences->iterateContacts('aud_id', ['limit' => 1000]) as $contact) { echo $contact['email'], $contact['unsubscribed'] ? ' (unsubscribed)' : '', "\n"; } foreach ($meil->emails->iterate() as $email) { if ($email['last_event'] === 'bounced') { echo implode(', ', $email['to']), "\n"; } }
The other lists (audiences->list(), broadcasts->list(), templates->list()) return everything at once.
All methods
| Resource | Methods |
|---|---|
emails |
send, list, iterate, get |
audiences |
create, list, get, delete, listContacts, iterateContacts |
contacts |
add, get, update, delete, remove |
broadcasts |
create, list, get, update, delete, send, schedule, unschedule |
templates |
create, list, get, update, delete |
content |
check |
A key's scope decides what it may call. email_access keys get emails and the content check; broadcast_access keys get everything except audiences and contacts. The SDK does not check scopes: the API answers with a PermissionException (insufficient_permission).
Timestamps
Timestamps in answers (created_at, scheduled_at, sent_at, ...) are UTC strings formatted "YYYY-MM-DD HH:MM:SS", returned as the API sends them. To parse one: new DateTimeImmutable($email['created_at'], new DateTimeZone('UTC')).
WordPress and shared hosting
The package has no Composer dependencies. Without Composer, require the files with a small PSR-4 autoloader for the Meil\ namespace, which maps to src/:
spl_autoload_register(static function (string $class): void { if (str_starts_with($class, 'Meil\\')) { require __DIR__ . '/meil-php/src/' . str_replace('\\', '/', substr($class, 5)) . '.php'; } });
To send through something other than cURL (WordPress's HTTP API, a PSR-18 client), implement Meil\Transport\TransportInterface and pass it as the transport option.
Development
composer install composer test # fake transport, no network composer analyse # PHPStan, level 8 MEIL_API_KEY=... MEIL_TEST_DOMAIN=yourdomain.ro composer test:live # real API; mails only the SES simulator
License
MIT