Email testing with PHP
No Composer package required: the helper below uses the bundled cURL extension. Works the same in PHPUnit, Pest or Laravel Dusk.
Before you begin
- A MailFlat accountFree to start, no card. Every plan can open inboxes from the API.
- An account API keyAgents → API keys in the dashboard. It looks like mf_live_… and goes in the X-API-Key header. Keep it in an environment variable, never in the repo. See API keys and authentication.
- PHP 8.0+ (ext-curl)That is the whole toolchain requirement.
The client
There is no PHP package yet, and you do not need one: the API is six endpoints. Paste this helper into your test support directory and every example below works.
<?php
// Nothing to install: the bundled cURL extension is enough.
// Save this as tests/Support/MailFlat.php and require it.
final class MailFlat
{
private const API = "https://mailflat.net/api/v1";
public static function call(string $method, string $path, ?array $body = null): array
{
$ch = curl_init(self::API . $path);
$headers = ["X-API-Key: " . getenv("MAILFLAT_API_KEY")];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
]);
$raw = curl_exec($ch);
curl_close($ch);
return json_decode($raw, true) ?? [];
}
}
Your first inbox and one-time code
The whole loop: open an address, let your app mail it, read the code back, clean up.
<?php
// 1. a real, deliverable address
$created = MailFlat::call("POST", "/inboxes", [
"prefix" => "signup",
"retention_hours" => 2,
]);
$address = $created["address"]; // signup@a7f2c.mailflat.net
// 2. your app sends the code to that address
$app->register($address);
// 3. read it back, no mocking anywhere
$otp = MailFlat::waitForOtp($address);
echo $otp; // "482913"
// 4. done with it
MailFlat::call("DELETE", "/inboxes/$address");
Line by linewhat each step of this PHP example does
- create(...)
- Returns immediately with a real, deliverable address. Nothing is queued or simulated.
- retention_hours
- Optional. Messages purge themselves after it, so a skipped teardown never leaks.
- wait_for_otp
- Polls for you and fails loudly on timeout, instead of returning nothing three lines before the real error.
- delete()
- Optional but tidy. Retention would clean up anyway.
The address is real
Mail actually travels: SMTP, DKIM, the lot. Nothing is stubbed, so a broken template or a misconfigured sender fails here exactly like it would in production.
Waiting for the code
We extract the one-time code server-side and hand it to you as a field, so you never write a regex against an email body.
<?php
// Poll /latest until the server has extracted a code, then fail loudly.
public static function waitForOtp(string $address, int $timeout = 60, int $interval = 3): string
{
$deadline = time() + $timeout;
while (time() < $deadline) {
$res = self::call("GET", "/inboxes/$address/latest");
if (!empty($res["encrypted"])) {
throw new RuntimeException($res["note"]); // E2E inbox: server cannot read it
}
if (!empty($res["email"]["otp_code"])) {
return $res["email"]["otp_code"];
}
sleep($interval);
}
throw new RuntimeException("no OTP arrived for $address within {$timeout}s");
}
Always set a timeout
A poll loop without a deadline turns a missing email into a hung job. Fail loudly instead: the error message should name the address you were waiting on.
Reading every message
<?php
$res = MailFlat::call("GET", "/inboxes/$address/messages");
foreach ($res["emails"] as $msg) { // newest first
echo $msg["subject"], $msg["sender"], $msg["received_at"];
echo $msg["body_text"]; // plain text body
echo $msg["body_html"]; // HTML body
echo $msg["otp_code"] ?? ""; // extracted code, if any
}
// drop one message, keep the inbox
$id = $res["emails"][0]["id"];
MailFlat::call("DELETE", "/inboxes/$address/messages/$id");
| Field | Type | What it is |
|---|---|---|
| subject | string | Subject line |
| sender | string | From address |
| body_text | string | Plain text body |
| body_html | string | HTML body |
| otp_code | string | null | One-time code, extracted by us |
| to_address | string | The exact address it was sent to, tag included |
| tag | string | null | Plus-addressing tag, if the sender used one |
| received_at | ISO 8601 | When it landed |
| is_encrypted | boolean | True on end-to-end encrypted inboxes, where body and code are unavailable |
Sending mail from the inbox
Useful in reverse: point your own inbound pipeline at a MailFlat address and check that it handles what arrives.
<?php
// mail leaves from the inbox address, DKIM-signed by our own MTA
MailFlat::call("POST", "/inboxes/$address/send", [
"to" => "someone@example.com",
"subject" => "Welcome",
"body" => "Plain text body",
"html" => "<p>Optional HTML body</p>",
]);
Cleaning up
<?php
MailFlat::call("DELETE", "/inboxes/$address");
// or leave it: retention_hours on create means it cleans itself up
MailFlat::call("POST", "/inboxes", ["prefix" => "ci", "retention_hours" => 2]);
Two safety nets, use both
Delete in teardown so the list stays readable, and set retention_hours so a crashed run still cleans itself up.
In a test suite
Works with PHPUnit, Pest, Laravel Dusk, Codeception and anything else that gives you a setup and teardown hook.
<?php
// tests/SignupTest.php — a fresh inbox per test, torn down even on failure
use PHPUnit\Framework\TestCase;
final class SignupTest extends TestCase
{
private string $address;
protected function setUp(): void
{
$this->address = MailFlat::call("POST", "/inboxes", [
"prefix" => "ci-" . bin2hex(random_bytes(4)),
"retention_hours" => 2,
])["address"];
}
protected function tearDown(): void
{
MailFlat::call("DELETE", "/inboxes/{$this->address}");
}
public function testUserCanSignUpWithARealCode(): void
{
$this->app->register($this->address);
$otp = MailFlat::waitForOtp($this->address);
$this->assertMatchesRegularExpression("/^\d{6}$/", $otp);
}
}
Line by linewhat each step of this PHP example does
- unique prefix
- One inbox per test, so parallel workers never read each other's mail.
- teardown hook
- Runs even when the test fails — that is exactly when inboxes get left behind.
- real code, real email
- No test-mode backdoor in your app: the path under test is the one your users take.
API reference
| Endpoint | What it does | Returns |
|---|---|---|
| POST /api/v1/inboxes | Open an inbox | { ok, address, api_key, retention_hours } |
| GET /api/v1/inboxes | Every inbox this key opened | { ok, inboxes: [...] } |
| GET /api/v1/inboxes/{address}/latest | Newest message, the polling call | { ok, email: {...} | null } |
| GET /api/v1/inboxes/{address}/messages | Every message, newest first | { ok, emails: [...] } |
| POST /api/v1/inboxes/{address}/send | Send from this address | { ok } |
| DELETE /api/v1/inboxes/{address} | Drop the inbox and its mail | { ok } |
| DELETE /api/v1/inboxes/{address}/messages/{id} | Drop one message | { ok } |
Full endpoint reference, including error shapes and rate limits: Agent API and MCP.