jeffersongoncalves/laravel-customer-io
A Laravel package that integrates Customer.io, giving you a clean, configurable API to track events, identify users, and send transactional messages. It wraps Customer.io's App API with an expressive, framework-friendly interface so you can add customer messaging to any Laravel app with minimal setu...
This package is a thin Laravel wrapper around Customer.io's APIs — primarily the Track API (identify / track / delete / suppress) and, where relevant, the App API (transactional messages, broadcasts, segments). The 1.0.0 tag is the first stable release; there is no prior migration path, so you can adopt it directly.
Install and wire up:
composer require jeffersongoncalves/laravel-customer-io
php artisan vendor:publish --tag=customer-io-config # confirm the actual tag in the README
Set your credentials in .env:
CUSTOMER_IO_SITE_ID=...
CUSTOMER_IO_API_KEY=... # Track API key (write key)
CUSTOMER_IO_APP_API_KEY=... # App API key (Bearer), only if you use transactional/broadcast endpoints
CUSTOMER_IO_REGION=us # or eu — this changes the base URL
Look at these three files first, in this order:
config/customer-io.php — env keys, base URL/region, HTTP timeout and retry options, and the concrete client binding.track vs trackEvent, identify vs createCustomer) vary between Customer.io wrappers.First use case — identify on login, track a conversion:
use JeffersonGoncalves\CustomerIo\Facades\CustomerIo;
// Usually in a login listener or a dedicated service, not in the controller
CustomerIo::identify($user->id, [
'email' => $user->email,
'name' => $user->name,
'created_at' => $user->created_at->timestamp,
]);
CustomerIo::track($user->id, 'subscription_started', [
'plan' => $plan->slug,
'mrr' => $plan->monthly_price,
]);
The single most important decision you make on day one is what you pass as customer_id. Use your application's primary key (or a UUID), never the email address — see the Gotchas section.
Centralize the client behind your own service. Wrap the facade in something like App\Services\CustomerIo\CustomerTracker so your domain code stops depending on the package's method signatures directly. At 1.0.0 the surface is still young, and this gives you one place to absorb future renames.
Fire-and-forget from domain events, not from controllers. The idiomatic Laravel integration is a listener on your own events:
class SyncCustomerToCustomerIo implements ShouldQueue
{
public function handle(OrderPlaced $event): void
{
CustomerIo::track((string) $event->order->user_id, 'order_placed', [
'order_id' => $event->order->id,
'total' => $event->order->total_cents,
'currency' => $event->order->currency,
]);
}
}
Making the listener ShouldQueue gives you retries and backoff for free — more valuable than most of what the package itself can offer, since HTTP calls to Customer.io are the fragile part.
Pre-login anonymous tracking. If you track browsing behaviour before authentication, most Customer.io clients expose an anonymous variant. The pattern that works:
// pre-login: generate and persist a stable anonymous id (cookie or session)
CustomerIo::trackAnonymous($anonymousId, 'viewed_pricing', ['source' => 'nav']);
// after login: stitch the anonymous history onto the real customer
CustomerIo::identify($user->id, ['email' => $user->email]);
CustomerIo::track($user->id, 'signed_up', ['anonymous_id' => $anonymousId]);
Confirm the exact stitching convention Customer.io expects for your account before relying on it — the merge semantics matter more than the method name.
Transactional and broadcast calls stay server-side. App API usage (triggering a transactional message, sending a broadcast) should live in a queued job with an explicit timeout, never inline in a request cycle:
CustomerIo::sendTransactional('order_shipped', $user->id, ['tracking_url' => $url]);
Testing. Fake the HTTP layer rather than the facade where possible — it verifies you're sending the payload you think you are:
Http::fake(['track.customer.io/*' => Http::response(['status' => 'ok'], 200)]);
$this->post('/orders', [...])->assertRedirect();
Http::assertSent(fn ($request) =>
str_contains($request->url(), '/customers/'.$user->id.'/events')
&& $request['name'] === 'order_placed'
);
If you'd rather assert at the domain level, CustomerIo::shouldReceive('track')->once() works too — but then a silent payload regression goes unnoticed.
Feature-flag it in non-production. Customer.io has no sandbox in the way Stripe does; staging events land in your real workspace and can trigger real campaigns. Guard the calls:
if (app()->isProduction() || config('services.customer_io.force_send')) { ... }
Region mismatch is the classic first-day failure. An EU workspace hit on the US base URL returns confusing auth or 404 errors rather than a clear "wrong region." Verify CUSTOMER_IO_REGION before you debug anything else.
Three distinct keys, three distinct scopes. site_id + api_key authenticate the Track API; app_api_key authenticates the App API. Mixing them produces 401s that look like bad credentials. Read the config file and map each env var to the endpoint family it belongs to.
customer_id is effectively immutable. Email is the tempting choice and the wrong one — users change email, and Customer.io will treat the new address as a new person unless you explicitly update attributes. Use your PK; update email as an attribute and let Customer.io handle the identity merge.
Never fire inside an open transaction. If the surrounding DB transaction rolls back, Customer.io has already recorded the event and you now have phantom conversions. Use DB::afterCommit() or dispatch the event from a queued listener, which runs post-commit by default.
Config caching bites. After adding env vars in production, php artisan config:cache must be re-run, and any env() call made outside the config file returns null once cached. Keep all env reads inside config/customer-io.php.
Treat every call as network I/O. Set an explicit timeout in config (a few seconds, not the default 30), and remember that Track API calls in a web request path directly add to your p95. Anything not required for the response belongs in a queue.
Expect silent success. Customer.io's Track API commonly returns 200 with a body that indicates a partial failure. Don't assume a non-exception means the person was created — log and, for critical flows, verify downstream.
Deletion and suppression are not the same thing. delete removes a person; suppress stops messaging while keeping the profile. For GDPR requests you likely want delete, plus a suppressed flag on your own model so you don't re-identify them on the next login.
Watch rate limits and batch where you can. Per-event calls from a bulk import or a backfill will throttle. For backfills, prefer Customer.io's bulk/import path (or their official PHP SDK directly) over looping this wrapper.
At 1.0.0, pin the version. "jeffersongoncalves/laravel-customer-io": "^1.0" in composer.json, and read the changelog before each minor bump. A first stable release of a thin wrapper frequently sees argument-name and signature adjustments in early minors.
Extension point: if the wrapper doesn't expose something you need (device registration, segments, exports), resolve the underlying client or use Guzzle directly against the documented endpoint rather than forking the package — the wrapper's value is the config and DI wiring, not API coverage.
How can I help you explore Laravel packages today?