mailerlite/mailerlite-php
Official PHP SDK for the MailerLite API, offering fluent methods to manage subscribers, campaigns, groups, segments, fields, forms, automations, and webhooks in your PHP applications.
1. Install and satisfy PSR discovery
composer require mailerlite/mailerlite-php
If Composer/composer runtime throws Could not find resource using any discovery strategy, you have no PSR-7/PSR-18 implementation installed. The README's fix is:
composer require php-http/curl-client guzzlehttp/psr7 php-http/message
In a typical Laravel app guzzlehttp/psr7 is often already present transitively, but do not assume it — install the trio explicitly, or inject your own PSR-18 client (see below).
2. Put the key in config, never in env() calls
// config/services.php
'mailerlite' => [
'key' => env('MAILERLITE_API_KEY'),
],
3. Bind it once in the container
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->singleton(\MailerLite\MailerLite::class, function () {
return new \MailerLite\MailerLite([
'api_key' => config('services.mailerlite.key'),
]);
});
}
4. Where to look first
subscribers, campaigns, groups, segments, fields, forms, automations, webhooks, batch, plus campaignLanguages / timeZones and e-commerce.https://developers.mailerlite.com/docs/*. Treat the SDK as a thin transport and the MailerLite API docs as the source of truth for every $data array.src/ once: each resource is a small class with create/find/get/update/delete plus a few resource-specific methods. There are no DTOs or models — you pass arrays, you get the decoded response back.5. First use case: newsletter signup
use MailerLite\MailerLite;
$mailerLite = app(MailerLite::class);
$subscriber = $mailerLite->subscribers->create([
'email' => $request->validated('email'),
'fields' => ['name' => $request->validated('name')],
]);
$mailerLite->groups->assignSubscriber(
config('services.mailerlite.default_group_id'),
$subscriber['id'] ?? $subscriber->id
);
Do this inside a queued job, not in the request cycle — see below.
Wrap the SDK, don't sprinkle it. The SDK has no Laravel idioms (no facade, no config, no events). Keep it behind one application service so a future SDK swap or a switch to MailerLite's HTTP API is a single-file change:
namespace App\Services;
use MailerLite\MailerLite;
class Newsletter
{
public function __construct(private MailerLite $client) {}
public function subscribe(string $email, array $fields = [], ?string $groupId = null): array
{
$subscriber = $this->client->subscribers->create([
'email' => $email,
'fields' => $fields,
]);
if ($groupId) {
$this->client->groups->assignSubscriber($groupId, $this->id($subscriber));
}
return $subscriber;
}
private function id(array|object $r): string
{
return is_array($r) ? $r['id'] : $r->id;
}
}
Resolve once per request/job, not per call. The binding above is a singleton; injecting MailerLite into a job constructor is fine, but note that queue jobs serialize dependencies — inject the Newsletter wrapper or resolve from the container inside handle().
Job-per-write for anything user-triggered. Signup, order confirmation, contact-form opt-in: dispatch a job with $tries = 3, backoff = [10, 60, 300]. Any network blip otherwise becomes a 500 on a marketing form.
class SyncSubscriberToMailerLite implements ShouldQueue
{
public int $tries = 3;
public array $backoff = [10, 60, 300];
public function __construct(public string $email, public array $fields = []) {}
public function handle(Newsletter $newsletter): void
{
$newsletter->subscribe($this->email, $this->fields, config('services.mailerlite.default_group_id'));
}
public function middleware(): array
{
return [(new RateLimited('mailerlite'))->releaseAfter(60)];
}
}
Register the limiter in a service provider — MailerLite's API is rate-limited (roughly 120 req/min), and a single bulk import can blow through it:
RateLimiter::for('mailerlite', fn () => Limit::perMinute(60));
Bulk import: chunk, then use the Batch API. One HTTP call per subscriber will not survive a CSV of 20k rows. Chunk the collection (batch accepts a limited number of operations per request; keep to ~25–50) and hand it to the batch endpoint rather than looping subscribers->create:
User::query()
->whereNotNull('email')
->chunkById(500, function ($users) {
$users->chunk(40)->each(function ($slice) {
SyncUserBatch::dispatch($slice->map->only('email')->values()->all());
});
});
Assigning to groups requires the subscriber ID, not the email. Common pitfall in a "move user to VIP group" flow: you must find()/lookup first. Cache the email→id mapping if you do this often, or store mailerlite_id on your users table when you first sync.
Webhooks: route + verify + queue.
// routes/api.php
Route::post('/webhooks/mailerlite', MailerLiteWebhookController::class)
->withoutMiddleware(['auth']);
In the controller, reject anything whose secret/path token doesn't match config('services.mailerlite.webhook_secret'), then SyncSubscriberFromWebhook::dispatch($request->all()) and return 204. Never do SDK calls inside the webhook request itself — MailerLite retries and you'll double-process.
Campaigns from an admin panel. Two-step: create, then schedule. Validate the payload shape against the API docs first — the SDK does not validate anything, so a missing language_id or emails[].from only surfaces as an exception at send time.
$campaign = $mailerLite->campaigns->create([...]);
$mailerLite->campaigns->schedule($campaignId, ['delivery' => 'instant']);
Cache the reference data used to build these payloads (campaignLanguages->get(), timeZones->get(), fields->get()) — they change rarely and cost a round trip each time.
Reporting. For a dashboard, campaigns->get(['filter' => ['status' => 'sent']]) plus campaigns->getSubscriberActivity($id, ['type' => 'opened']) gives you openers. Poll these on a schedule into your own tables; don't call the API from a page render.
E-commerce / events. orders, products, customers resources let you push purchase events for automation triggers. Fire these from your OrderPaid listener via a job, keyed on your internal order ID so retries are idempotent on MailerLite's side.
Multi-tenant. If each tenant has its own MailerLite account, don't use the singleton binding — use contextual binding or a factory keyed by tenant, and resolve the client inside the job from the tenant context.
Testing. Laravel's Http::fake() has zero effect here: the SDK speaks PSR-18 directly. Two workable options:
$this->mock(Newsletter::class, ...)) — the pragmatic default for feature tests.ClientInterface that returns canned PSR-7 responses, and inject it into new MailerLite(['api_key' => 'test']). This is the only way to test your request-payload construction.Either way, never let the test suite hit the live API.
MailerLite alias. Everything above is code you write. Budget an hour for the wrapper.$response is under-specified. Most methods hand back the decoded API payload, but the type varies by resource. dd() once on your first create() call and write a tiny accessor ($this->id($response) above) rather than guessing — this is the single biggest source of "undefined index" bugs with this SDK.Could not find resource using any discovery strategy) never mentions MailerLite. Bookmark the fix.MailerLiteException subclass hierarchy to catch granularly. Handle Psr\Http\Client\ClientExceptionInterface for transport failures and inspect the response body/status for 4xx. A 422 from a bad field name will look exactly like a 500 unless you log the body.$backoff).get() returns one page. Loop with the page/limit filter keys per the API docs, and cap the loop — a runaway while (true) against a paginated endpoint is an easy way to get throttled.unAssignSubscriber (capital A), getSignups, getSubscriberActivity, forms->get('popup', []) where the first argument is the form type. Don't guess method names — copy from the README verbatim.subscribers->create(['fields' => ['favourite_colour' => ...]]) silently drops or errors on unknown keys. Create the field via fields->create() first, and cache the field list instead of checking per request.new MailerLite(['api_key' => ..., 'client' => $yourPsr18Client]) lets you plug in a client with logging, timeouts, or retry middleware. Do set an explicit timeout — the default curl client's is not generous and queued jobs can hang.composer.json ("mailerlite/mailerlite-php": "^2.0") rather than relying on ^ floating into a breaking resource signature change. Read the changelog before upgrading — the README shows the v2 API shape.env() outside config breaks under config:cache. With a singleton registered in AppServiceProvider, reading env('MAILERLITE_API_KEY') directly will return null in production. Always route through config().campaigns->cancel() immediately after schedule(['delivery' => 'instant']) is a race you will lose. Add a delay for cancel-and-replace flows, or model it as an explicit admin action.User::deleted listener, resolve the ID via your stored mailerlite_id column — don't search by email at deletion time (the email may already be anonymised).How can I help you explore Laravel packages today?