mailjet/mailjet-apiv3-php
Official PHP wrapper for the Mailjet API v3. Send transactional email and SMS, manage contacts and campaigns through a simple, PSR-18 compatible client. Requires PHP 8.1+ and installs easily via Composer.
1. Install and pin.
composer require mailjet/mailjet-apiv3-php
Requires PHP 8.1+. The package now depends on PSR-18/PSR-17 abstractions and lets php-http/discovery auto-install a client. In a Laravel app you almost certainly already have Guzzle, so discovery will resolve to it — but declare it explicitly in composer.json rather than relying on transitive resolution, and pin the wrapper with a caret constraint you've actually tested ("mailjet/mailjet-apiv3-php": "^2.0"). With 274 stars and a small maintainer base, an unpinned * is an unnecessary risk in a package that touches outbound email.
2. Put credentials in config, never in code.
// config/services.php
'mailjet' => [
'key' => env('MJ_APIKEY_PUBLIC'),
'secret' => env('MJ_APIKEY_PRIVATE'),
'url' => env('MJ_MAILJET_URL', 'api.mailjet.com'), // api.us.mailjet.com for US accounts
],
3. Bind a configured singleton. The client is stateless per call and safe to reuse, so bind it once; but bind two if you need both the Email API (v3) and the Send API (v3.1) — version is a constructor option, not a per-call one.
// AppServiceProvider::register()
$this->app->singleton(\Mailjet\Client::class, fn () => new \Mailjet\Client(
config('services.mailjet.key'),
config('services.mailjet.secret'),
true,
['version' => 'v3.1', 'url' => config('services.mailjet.url')],
));
If you need v3 resources (contacts, lists, messages, stats) alongside v3.1 sends, register two named singletons (mailjet.send, mailjet.rest) or a small factory that caches by version.
4. Where to look first. src/Mailjet/Resources.php is the real API surface — it maps PHP constants like Resources::$Email, Resources::$Contact, Resources::$ContactManagecontactslists to REST paths. Skim it before writing anything; it answers "does this wrapper even cover my endpoint?" faster than the docs. Then read the Response class, because every method returns one and your error handling depends on its shape.
5. First use case: a queued transactional send on Send API v3.1.
$body = ['Messages' => [[
'From' => ['Email' => '[email protected]', 'Name' => config('app.name')],
'To' => [['Email' => $user->email, 'Name' => $user->name]],
'Subject' => 'Your receipt',
'TextPart' => $text,
'HTMLPart' => $html,
]]];
$response = app(\Mailjet\Client::class)->post(Resources::$Email, ['body' => $body]);
if (! $response->success()) {
throw new \RuntimeException('Mailjet: '.$response->getStatus().' '.$response->getReasonPhrase());
}
Note the shape difference immediately: v3.1 uses From/To objects and Messages[]; the legacy v3 send uses a flat FromEmail/To/Text-part body. Pick v3.1 and don't mix.
Wrap the client — don't spray $mj->post(Resources::$X) through controllers. A thin MailjetService with intent-named methods (sendTransactional, upsertContact, addToLists, messagesFor) gives you one place for logging, retry policy, and response normalisation, and makes the package swappable later. Inject \Mailjet\Client into it; never instantiate the client inside jobs.
Send from a queued job, not a request. Sending to 50 recipients or making 5 sequential API calls inside a controller will dominate response time. A SendMailjetMessage job with $tries = 3, backoff() returning [10, 60, 300], and retryUntil() is the normal shape. Because the wrapper does not throw on API-level failures, your job must translate:
public function handle(MailjetService $mailjet): void
{
$result = $mailjet->sendTransactional($this->payload);
if ($result->retryable()) { // 429, 5xx, connection error
$this->release($result->backoffSeconds());
} elseif (! $result->ok()) {
$this->fail(new \RuntimeException($result->error())); // 400/401/403 — retrying is pointless
}
}
Treat 4xx as terminal and 429/5xx/transport errors as retryable. This single distinction prevents the classic bug where a malformed payload gets retried three times and burns your send quota.
Batch aggressively. Send API v3.1 accepts multiple entries in Messages[] (up to ~50). For a digest or a queue drain, chunk collect($recipients)->chunk(50) and issue one call per chunk, checking success() per chunk. It cuts latency and rate-limit exposure roughly proportionally, and the per-message To[0].MessageUUID/MessageID in getData() lets you correlate failures back to individual recipients — keep those UUIDs on your own record.
Contact and list management via actions. Subscription state is not a boolean field; it's a POST to Resources::$ContactManagecontactslists with ContactsLists[] entries:
['ContactsLists' => [
['ListID' => $newsletterId, 'Action' => 'addnoforce'], // respect prior unsub
['ListID' => $transactionalId, 'Action' => 'addforce'], // override
]]
addforce silently overrides an opt-out, which is a compliance problem for marketing lists. Use addnoforce for anything marketing-related and reserve addforce for lists you're contractually entitled to write to. Custom contact properties go through Resources::$ContactData with a PUT, and the body is a flat map (['first_name' => ..., 'last_name' => ...]), not a nested Data array.
Prefer stored templates for anything a marketer might edit. v3.1 supports TemplateID + TemplateLanguage + Variables + Subject. Blade-rendered HTMLPart is faster to ship and works great for receipts, but every copy change becomes a deploy. A common split: Blade for transactional/system mail, Mailjet templates with Variables for lifecycle/marketing mail.
Pagination is manual. There is no cursor helper. get(Resources::$Message, ['filters' => $filters]) returns one page; loop with Limit (max 1000, default 10) and Offset until fewer than Limit rows come back. Cap the loop — an unbounded sync against a stats endpoint will happily run for minutes in a worker.
Testing: three layers, pick deliberately.
false disables the HTTP call entirely — useful for "does the app boot / does the job construct" tests, but it returns an empty/optimistic response, so don't assert on business outcomes with it.MockHandler/MockClient as the 5th constructor argument and assert on the request URI, method, and JSON body. This is what you want for payload-shape tests (it catches From vs FromEmail mistakes).SandboxMode => true flag in a real staging send if you need end-to-end validation without delivery.Webhooks land in a normal controller. Register the callback URL through the API (Resources::$Eventcallbackurl) so it's reproducible across environments, then handle open/click/bounce/spam/blocked events in a route that's excluded from CSRF and returns 200 fast, pushing the actual work onto a queue. Verify the payload shape yourself — the wrapper gives you no verification or parsing helpers here.
Inject your own PSR-18 client when you need cross-cutting behaviour. This is the cleanest extension point. Wrapping Guzzle with a logging or retry middleware gives you request/response observability for every Mailjet call without touching the wrapper:
$stack = HandlerStack::create();
$stack->push(Middleware::retry($decider, $delay));
$stack->push(Middleware::log($logger, new MessageFormatter()));
$mj = new \Mailjet\Client($key, $secret, true, ['version' => 'v3.1'], new \GuzzleHttp\Client(['handler' => $stack, 'timeout' => 15]));
Set an explicit timeout. The default has no upper bound in some configurations, and an unresponsive Mailjet endpoint will hold a queue worker indefinitely.
The $CALL argument defaults to true — and it's positional and third. new Client($key, $secret) sends. If you meant to pass options and passed false by reflex, you'll spend an hour wondering why nothing reaches the API. Conversely, false in production means emails silently vanish with a success-looking response.
Version is a constructor option, and the default is v3. Copy-pasting a v3.1 Messages[] payload into a default-constructed client produces a 400 that reads like a validation error rather than a version error. Always construct the client with the version you're targeting and keep exactly one client per version in the container.
success() is not "the email was delivered." It means the API accepted the request (2xx). Delivery, bounces, and blocks come back later via webhooks or the message/statistics resources. Don't write if ($response->success()) { $order->update(['email_sent' => true]); } and call it observability — record the MessageUUID and reconcile.
API errors don't throw; transport errors do. You need both: check success()/getStatus() for HTTP 4xx/5xx coming back as a Response, and wrap the call in try/catch for Psr\Http\Client\ClientExceptionInterface (or Guzzle's ConnectException) for DNS/TLS/timeout failures. A try around only the API call, with no success() check, is the most common bug in this package's usage.
PUT is PATCH. Only the fields you send are modified. This is convenient, but it means you cannot clear a field by omitting it — and it means a partial payload built from a $request->validated() that happens to be missing keys will quietly leave stale values in place.
DELETE returns 204 with no body. getData() will be empty/null. Assert on getStatus() === 204, not on data.
Default Limit is 10. Every unfiltered get() gives you ten rows and looks like "we only have ten contacts." Always pass Limit/Offset explicitly in sync code, and remember Sort is a string like 'ArrivedAt DESC', not an array.
Filters are query-string keys, so types matter. 'IsExcludedFromCampaigns' => 'false' is a string in the docs; sending PHP false may serialise to an empty value. When a filter appears to be ignored, dump the actual request through Guzzle logging before assuming an API bug.
US-hosted accounts need ['url' => 'api.us.mailjet.com']. Symptom is a 401 with valid credentials. If auth fails and you're confident the keys are right, this is the first thing to check, not the last.
SSL_ERROR_SYSCALL / intermittent TLS failures are an environment (IPv6 routing) issue, not a wrapper issue. Inject a Guzzle client with ['force_ip_resolve' => 'v4'] as the 5th constructor argument.
Never call env() from application code. With php artisan config:cache — which you should be running — env() returns null outside config files. All the README examples use getenv(); translate that to config('services.mailjet.*') on day one or your first deploy will fail with a 401 that works fine locally.
No idempotency key. A job retried after a timeout may send twice. Mitigate by persisting the MessageUUID you got back and by making your own send-log table the gate (check-then-send inside a transaction, or a unique constraint on notification_id). Don't rely on the API to deduplicate.
Attachments are base64 strings in the payload. A few MB of PDF in a queued job is fine; base64-encoding a 20 MB file means ~27 MB strings in memory and in your queue payload if you serialise it. Store attachments and pass a path, encoding inside the job just before the call.
Rate limits are real and returning 429. Mailjet enforces per-endpoint limits. If you fan out sends from a Bus::batch, add RateLimited/WithoutOverlapping middleware or a Redis RateLimiter::attempt(), and respect Retry-After in your backoff rather than a fixed sleep.
This is not a Laravel mail transport. It doesn't plug into Mail::to()->send() and it doesn't give you Mailables, Markdown rendering, or assertSent. If your need is "send transactional mail from Laravel," the lower-friction path is symfony/mailjet-mailer via Laravel's SMTP/API transport config, and you keep Mailables, fakes, and previews. Reach for this wrapper when you actually need the wider REST surface — contact and list management, templates with variables, message/statistics retrieval, webhook registration. Deciding this early avoids wrapping the API by hand and then discovering you've rebuilt half a mail driver.
Extension points, ranked by value: injected PSR-18 client (middleware, timeouts, proxies, retries) → injected PSR-17 factories (only if you're using a non-Guzzle stack and discovery misbehaves) → your own service wrapper (response normalisation, metrics). There is no event system, no pluggable retry, and no config object — so anything cross-cutting belongs in the HTTP client or your wrapper, not in a subclass of Client.
How can I help you explore Laravel packages today?