MailKite
Get your API key
All guides

Send bulk email with PHP

Reviewed

Sending bulk email with PHP works best as a background CLI job: prepare recipients, submit private personalized messages, and save per-recipient outcomes. This guide implements that path with MailKite (which we build). The download runs without credentials by default and deliberately mixes successful sends, a temporary failure, and a suppression so restart behavior is visible.

Inside the complete PHP worker, the live SDK uses this batch:

$batch = [
    'from' => $from,
    'subject' => 'Your update, {{name}}',
    'text' => 'Hi {{name}}, your requested product update is ready.',
    'recipients' => array_map(fn($r) => ['to' => $r['to'], 'templateData' => ['name' => $r['name']]], $chunk),
];
$response = $live ? $mk->sendBatch($batch) : mockBatch($batch, $ledger, $scenario);

This is an excerpt, with the surrounding worker providing $from, $chunk, and the live/mock branch. Each recipients entry becomes a separate message to one address. Don’t send the audience as a shared to list. Individual personalization and result IDs are what let this worker recover without blindly sending duplicates.

Run it as a CLI job

Download php.php to a private working directory and use PHP 7.4 or newer. No Composer dependencies are needed for the mock:

php php.php
php php.php
php php.php --retry-failed

The first command creates php-mock.json. Its 53 synthetic recipients are submitted in chunks of 50 and 3. The local mock accepts 51, returns a temporary refusal for person1, and reports person2 suppressed. The second invocation submits nothing. The explicit retry submits only person1; person2 stays failed.

Look at the ledger rather than interpreting a clean CLI exit as full success. The attempts field remains one for accepted recipients, and the request history gains one single-recipient chunk on retry. These are MOCK results, not deliverability evidence. The example.com fixtures never enter the live sending branch.

Keeping the process outside a browser request matters. PHP-FPM request deadlines and client disconnects aren’t an email queue. A web controller can authorize a notification job and persist it; the background worker should own network sending and retries.

Add the SDK only for live mode

The Packagist release checked October 14, 2026 is 0.20.0. Install in the directory beside the downloaded file:

composer require mailkite/mailkite:0.20.0

Composer checks the SDK’s PHP and extension requirements, including cURL, JSON, and OpenSSL. The live branch is:

require __DIR__ . '/vendor/autoload.php';
$mk = new \MailKite\Client(getenv('MAILKITE_API_KEY'));

Set MAILKITE_API_KEY and MAILKITE_FROM through your server environment. The account email and sending domain need verification; follow authentication and domain setup. Keep credentials in the worker environment, never in generated HTML, public configuration, or ledger files.

php php.php --live explicitly opts into a real SDK call. It uses php-live.json and only test-bulk-guide@mailkite.dev, a controlled project test recipient. This address isn’t your mailbox or an inbox preview. Running this after another language’s live example creates another potential real message because the ledgers are independent.

There is no arbitrary live audience import in the download. That boundary lets you learn the worker locally before integrating a real subscriber table. For an actual audience, review your own job and recipient records rather than simply replacing the mock fixtures and running the CLI.

Validate and personalize before dispatch

The fixture is an array of bare-address/name records. PHP’s FILTER_VALIDATE_EMAIL handles its address validation; names must be present and contain no line breaks. The worker removes case-insensitive duplicate addresses. This doesn’t validate ownership, subscriber intent, or provider eligibility, and it isn’t a general-purpose mailbox parser.

If you ingest CSV, use fgetcsv in an import step that persists validated records before any sending. Record source row numbers and reasons for exclusions. Decide what to do when duplicate addresses have conflicting preferences: taking the first row is fine for this fixed teaching fixture, but not a substitute for your application’s authoritative preference record.

The subject and text use {{name}}, with values supplied in each entry’s templateData. Shared fields are the base message; per-recipient values override shared defaults. If you add HTML, MailKite escapes merge values. Still preview the template and validate links and optional fields yourself. A plaintext part also makes the message useful without HTML rendering.

The job identity contains mode, sender, recipients, and a version. Reusing a ledger for a changed job fails. Changing message copy should update that identity and go through an explicit audience decision; deleting history to bypass the mismatch would hide previous acceptance.

Preserve request intent before sending

The worker selects eligible row indices, chunks them with array_chunk(..., 50), and sets those rows to inflight before invoking the SDK. It increments attempts, records the chunk’s recipients, and saves the ledger first. Saving uses a temporary file plus rename, with restrictive creation permissions.

If the process exits during a call, the next run converts inflight to unknown. It can’t know whether the remote server accepted the message. Marking the row pending would risk duplicate mail; marking it sent would risk silently losing mail. Unknown is useful precisely because neither claim is justified.

This local checkpoint is for one CLI worker. It doesn’t implement process locks, fsync-based crash durability, distributed leases, or an atomic server/local transaction. Keep the working directory private and outside your web root. LOCK_EX on one file write wouldn’t solve the remote acceptance gap or coordinate a whole read/send/write cycle.

To observe the crash boundary, run php php.php --scenario crash --ledger crash.json. Exit code 75 is expected. Run php php.php --ledger crash.json next: the first 50 become unknown and only the remaining three pending records can proceed.

Validate the response before updating rows

A batch HTTP success doesn’t mean every recipient succeeded. The batch API reference defines one ordered outcome per recipient, with status sent, scheduled, or failed. The download validates the number of results and their numeric positions, then checks recipient identity, allowed status, accepted-result IDs, and summary counts.

OutcomeLedger behaviorRecovery
sentStore ID and exclude from future selectionsObserve delivery feedback
scheduledStore scheduled ID and excludeInspect/cancel existing scheduled work
failedPreserve the error codeReview eligibility and retryability
Missing/invalid response or request exceptionMark the entire chunk unknownReconcile before resending

The only retryable failure here is mock_transient, an invented local code. --retry-failed never guesses that a live error is temporary. Suppressions remain held. Even request-level HTTP errors are conservatively unknown; a production adapter can classify documented rejections more precisely after reviewing their acceptance semantics.

Try --scenario timeout, short, reordered, or badstatus with a fresh --ledger file. Each holds the uncertain first chunk and stops that invocation. scheduled tests that parked messages aren’t selected again. The shared tests execute these behaviors in PHP, Node, and Python without contacting MailKite.

Extend into Laravel or an existing mail stack

For Laravel, persist recipient outcomes in a database and dispatch queue jobs with stable job IDs after commit. A retried job must reload eligible rows rather than replay its original recipient array. Add worker leases, a next-attempt timestamp, and a finite retry budget for known temporary errors. Honor cancellation before claiming new chunks; interrupting an active call still leaves uncertainty.

The sample is sequential: one request in flight and at most 50 recipients per call. That limit is not a throughput guarantee. The inspected PHP SDK doesn’t configure a request deadline or automatic retries for sendBatch. A production worker needs explicit deadline handling and capped backoff for rate limits, while preserving unknown outcomes.

PHP integrationUse it forWhat it doesn’t replace
MailKite SDKPrivate API fan-out with inbound reply handlingAudience/job storage
Provider PHP SDK, such as SESAn existing provider-specific pipelineOutcome mapping and feedback integration
Symfony Mailer over SMTPYour app already sends through SMTPSeparate recipient envelopes and checkpoints

See Symfony Mailer for SMTP integration and the bulk API comparison for provider selection. A transport doesn’t supply subscriber acquisition or a campaign approval workflow.

For scheduledAt, preserve each returned ssnd_… ID and follow sending documentation for cancellation. Verify signed bounce/complaint feedback before changing suppression state. Acceptance isn’t inbox placement, and opens aren’t a reliable substitute for delivery evidence.

Continue with the bulk hub or programmatic email for workflow decisions. Sources checked October 14, 2026: Send API, request schema, response schema, and PHP SDK source. The download README includes all run commands and limitations; discuss your queue integration in community discussions.