lob/lob-php
Official PHP client for Lob's direct mail automation API. Send letters, postcards, self-mailers, and checks programmatically, verify addresses, and track mailpieces from Laravel or any PHP app using this Composer-installable SDK.
composer require lob/lob-php
Where to look first
composer.json constraint → determine the major version. The 3.x line is a hand-written resource client (Lob\Lob, $lob->postcards()->create()); 4.x is an autogenerated SDK (builder pattern, typed models, named arguments). The two are not API-compatible, and most blog posts you'll find describe 3.x.README.md + the generated classes under vendor/lob/lob-php/src/ — for 4.x the method signatures are your real documentation; the Lob REST docs map 1:1 but parameter casing differs (address_line1 → addressLine1).Minimal Laravel wiring
// config/services.php
'lob' => [
'key' => env('LOB_API_KEY'), // test_... or live_...
'version' => env('LOB_API_VERSION'), // e.g. 2020-02-11, pins API behaviour
],
// app/Providers/AppServiceProvider.php
$this->app->singleton(\Lob\Lob::class, fn () => \Lob\Lob::builder()
->setApiKey(config('services.lob.key'))
// ->setVersion(config('services.lob.version'))
->build());
First use case: address verification at signup/checkout, then mailing. It's the cheapest call, needs no assets, and exercises auth, error handling and serialization end to end.
$check = $sdk->usVerifications->verify(
primaryLine: '185 Berry St Ste 6100',
city: 'San Francisco', state: 'CA', zipCode: '94107',
);
if (str_starts_with($check->deliverability, 'deliverable')) {
// safe to mail
}
Wrap the SDK, never sprinkle it. Autogenerated SDKs change shape between minor releases; isolate every call in app/Services/Lob/LobGateway.php behind an interface (verifyAddress(), sendPostcard(), cancelPiece(), getPiece()). Bind the interface in the container, and you get free fakes in tests plus a one-file diff when you upgrade.
Verify on write, mail on queue. Persist normalized address fields when the user enters them, run verification there, and store the deliverability verdict. Mailing belongs in a queued job — Lob latency is variable and a 5xx must not roll back a user's checkout.
class SendWelcomePostcard implements ShouldQueue
{
use InteractsWithQueue, Queueable;
public int $tries = 5;
public array $backoff = [10, 60, 300, 900];
public function handle(LobGateway $lob): void
{
$piece = $lob->sendPostcard($this->order);
// Store the Lob ID immediately — it's your only handle for
// tracking, cancellation and webhook reconciliation.
$this->order->update([
'lob_id' => $piece->id,
'lob_type' => 'postcard',
'status' => $piece->status,
]);
}
}
Idempotency is non-negotiable. Mail pieces are billed and irreversible once mailed. Pass an idempotency key derived from your domain object ("postcard-{$order->id}") on every create call; the SDK exposes it as a request option/parameter. Retries then return the original piece instead of printing a second one.
Use metadata as your join key. Lob stores arbitrary key/value pairs on every piece and echoes them back on list/get and in webhooks. Put order_id, tenant_id, campaign there — it makes reconciliation trivial and avoids a lookup table.
Reuse uploaded assets. Upload PDF/HTML once via the uploads endpoint, keep the returned href, and pass that on every create instead of re-uploading per piece. Same for Lob-hosted HTML templates: send merge_variables, keep the template in your repo, version it by name.
Webhooks are the workflow backbone. One route, verify, persist raw payload, dispatch:
Route::post('/webhooks/lob', function (Request $request, LobGateway $lob) {
abort_unless($lob->verifyWebhook($request->getContent(), $request->header('Lob-Signature')), 403);
LobEvent::create(['type' => $request->input('event_type.id'), 'payload' => $request->all()]);
ProcessLobEvent::dispatch($request->input('event_type.id'), $request->input('body.id'));
return response()->noContent();
});
Typical lifecycle to model: *.created → *.rendered_pdf / *.rendered_thumbnails → *.mailed → *.in_transit → *.in_local_area → *.processed_for_delivery → *.delivered, plus *.returned_to_sender and *.re-routed.
Paginate with cursors, not pages. List endpoints take limit (max 100) plus after/before. Walk with after until the result set is smaller than limit; don't build offset pagination on top of it.
Testing. Test-mode keys (test_...) give watermarked proofs and don't bill. Point the unit tests at your LobGateway interface; for contract tests use the sandbox key in a separate CI env and gate on LOB_API_KEY being present.
$lob->postcards()->create(['to' => [...]]) and $sdk->postcards->create(to: ...) are different worlds. Read the installed version's src/ before copying a snippet from anywhere, including the vendor README of an older tag.address_line1 usually surfaces as a 422 with a nested field error — log the full error body, not just getMessage().$request->getContent() before any middleware touches it; hash_equals for the comparison. If your verification keeps failing, check for a proxy/TrimStrings middleware mangling the payload, and confirm the secret is the webhook's, not your API key.delivered can arrive before in_transit after a retry. Treat events as hints and reconcile against GET /v1/postcards/{id} before making irreversible state transitions.cancel only works before mailed; after that you eat the cost. Guard sends behind a config flag and a deliverability check, and add a "review queue" for anything the verifier flags as deliverable_incorrect_unit / deliverable_missing_unit — those are fixable, undeliverable is not.send_date accepts immediate or ISO8601, and scheduling has a lead-time window; a date in the past silently becomes immediate. Validate in a form request rather than letting Lob 422 you.Retry-After; map it to a job release with that delay rather than burning a retry.Http::withBasicAuth($key, '')->baseUrl('https://api.lob.com/v1') in the gateway for that one call, and delete it after the next SDK bump.Lob-Request-Id (or the 4xx body's error_id / message) on every failed call — Lob support asks for it, and it's the fastest path from "customer says nothing arrived" to an answer.if (app()->isProduction() && ! str_starts_with($key, 'live_')) throw ...) — a misconfigured env means either watermarked mail or an accidental five-figure invoice.How can I help you explore Laravel packages today?