darkin1/intercom
A Laravel package that wraps the Intercom API, making it easy to sync users and companies, track events, tag contacts, and manage conversations from your app. Includes configurable credentials, a simple facade, and auto-discovered service provider for quick setup.
1. Install and wire it up
composer require darkin1/intercom
Laravel 5.5+ auto-discovers the service provider. On older 5.x, register Darkin1\Intercom\IntercomServiceProvider in config/app.php and add the facade alias (Intercom → Darkin1\Intercom\Facades\Intercom).
2. Publish and configure
php artisan vendor:publish --provider="Darkin1\Intercom\IntercomServiceProvider"
Then set the env vars the config file reads — almost always a private app access token (Intercom → Settings → Developers → Private apps), not the App ID and not an OAuth token:
INTERCOM_TOKEN=xxxxxxxxxxxxxxxxxxxx
INTERCOM_SECRET=your_identity_verification_secret
3. Where to look first
config/intercom.php — the exact env keys and any base-URI/admin options it exposes.src/IntercomServiceProvider.php — what it binds into the container and under what key (typically intercom), plus whether it binds the raw Intercom\IntercomClient or a subclass.src/Facades/Intercom.php — getFacadeAccessor() tells you the container key directly.4. First use case: identify the logged-in user
// php artisan tinker
$intercom = app('intercom'); // confirm the key from the facade accessor
$intercom->users->create([
'user_id' => (string) auth()->id(),
'email' => auth()->user()->email,
'name' => auth()->user()->name,
]);
users.create is an upsert, so this same call handles both "first login" and "profile changed." That single call is 80% of what most apps need this package for.
The facade is convenient in controllers, but for anything you'll unit test, inject the client:
use Intercom\IntercomClient;
class IntercomSync
{
public function __construct(private IntercomClient $intercom) {}
public function upsert(User $user): void
{
$this->intercom->users->create([
'user_id' => (string) $user->id,
'email' => $user->email,
'name' => $user->name,
'signed_up_at' => $user->created_at->timestamp, // seconds, not ms
'custom_attributes' => [
'plan' => $user->plan,
'company' => optional($user->company)->name,
],
]);
}
}
If the provider only binds the facade key and not the class, add one line to your own AppServiceProvider:
$this->app->bind(\Intercom\IntercomClient::class, fn ($app) => $app->make('intercom'));
The cleanest integration is an observer → queued job. Never call Intercom synchronously inside a request; their p99 latency is not your p99.
class UserObserver
{
public function saved(User $user): void
{
if ($user->wasChanged(['email', 'name', 'plan'])) {
SyncUserToIntercom::dispatch($user)->onQueue('intercom');
}
}
public function deleted(User $user): void
{
ArchiveUserInIntercom::dispatch($user->id)->onQueue('intercom');
}
}
Use saved + wasChanged(), not updated, so first-creation is covered too — and so a no-op save doesn't burn a rate-limit token.
$intercom->events->create([
'event_name' => 'order-placed',
'created_at' => now()->timestamp,
'email' => $user->email,
'metadata' => ['order_id' => $order->id, 'total' => $order->total_cents],
]);
Events drive Intercom's Series/targeting. This is the highest-leverage call after user upsert.
If the JS snippet uses user_hash, it must be HMAC-SHA256 of the user_id keyed with the same secret Intercom shows in settings:
$userHash = hash_hmac('sha256', (string) $user->id, config('intercom.secret'));
Pass $userHash to the Blade view. Mismatch here fails silently in the browser — it does not throw in PHP — so add an assertion in a feature test.
$intercom->messages->create([
'message_type' => 'email',
'subject' => 'Your invoice is ready',
'body' => '<p>...</p>',
'template' => 'plain',
'from' => ['type' => 'admin', 'id' => config('intercom.admin_id')],
'to' => ['type' => 'user', 'id' => (string) $user->id],
]);
from.id must be a real admin ID — fetch it once via $intercom->admins->list() and cache it in config. Using an email here is a common 422.
The singleton binds one token. For per-tenant Intercom workspaces, ignore the singleton and build the client yourself:
$client = new \Intercom\IntercomClient($tenant->intercom_token, ['base_uri' => $tenant->intercom_base_uri]);
This is also the escape hatch if you need a custom Guzzle handler for logging or retries.
Two viable approaches — pick one per test suite:
// Facade mock, for controller/feature tests
Intercom::shouldReceive('users->create')->once()->with(Mockery::on(fn ($p) => $p['user_id'] === '1'));
// Or bind a fake client into the container
$this->app->instance(\Intercom\IntercomClient::class, $fake);
Http::fake() does not work — the SDK talks Guzzle directly, outside Laravel's HTTP layer.
// AppServiceProvider::register()
if ($this->app->environment(['local', 'testing'])) {
$this->app->bind('intercom', fn () => new NullIntercomClient());
}
Otherwise every php artisan migrate --seed and every CI run pollutes your real Intercom workspace with test users.
The maintenance risk is the headline gotcha. Last release May 2021, Laravel 7 ceiling. It will likely install on 8/9/10, but you're relying on transitive intercom/intercom-php/Guzzle versions to be compatible with your PHP. Check composer why intercom/intercom-php after install, and pin the SDK version explicitly in your own composer.json. Budget a migration: the wrapper is thin, so replacing it with ~20 lines binding IntercomClient in a service provider is usually a one-afternoon job — and removes a single-maintainer dependency with 15 stars.
Data region. EU-hosted Intercom workspaces require https://api.eu.intercom.io. If the package config doesn't expose a base URI, you must construct the client manually. Symptom: 404s or "app not found" on otherwise-valid calls.
Rate limits are the most likely production incident. Intercom enforces global and per-endpoint token buckets and returns 429 with Retry-After. Bulked-up user syncs (a backfill, a CSV import) will trip it. Always route through a rate-limited queue: ->onQueue('intercom')->middleware(RateLimited::perMinute(80)), and give the job $tries = 5 with backoff() so 429s drain rather than fail.
users.create is an upsert with dedupe rules that will surprise you. Intercom dedupes on both user_id and email. If a user changes email and you pass the new one, you can create a second lead instead of updating; if you pass only an email that already exists on a different user_id, you get a conflict. Always send a stable user_id, and treat email as a mutable attribute.
Search is exact-match, not full-text. users.search() / companies.search() with a query field require precise operators (field:value, AND/OR). For enumerating large sets, use the scroll API — pagination via page breaks down past a few thousand records.
Exception surface is Guzzle. Expect GuzzleHttp\Exception\ClientException for 4xx and ServerException for 5xx. The useful detail is in $e->getResponse()->getBody() as JSON — Intercom puts a human-readable errors[].message there that the generic exception message omits. Wrap the sync job's handle() in a try/catch that logs the decoded body, or you'll debug blind.
config:cache bites during deploys. The package reads config('intercom.*') at container-resolution time. If you cache config before setting INTERCOM_TOKEN (or you're using a runtime-injected secret), the client boots with a null token and you get 401s. Also restart queue workers after any env change.
Facade alias collisions. Intercom as a root alias can clash with other SDKs or with a model/service named Intercom. If you hit "class not found" on the alias, register it under a distinct key in config/app.php.
Timestamps are Unix seconds. The PHP SDK is seconds; the JS SDK and some Intercom docs are milliseconds. Passing a millisecond value silently sets a year-57000 signed_up_at. ->timestamp on a Carbon instance is correct; ->getTimestampMs() is not.
users.delete is hard delete. For churn, use archive (users.archive($id)) — permanent deletion is irreversible and breaks event history.
Extension point worth knowing: because the provider binds a plain IntercomClient, you can rebind it in your own service provider with a subclass or a pre-configured instance carrying a custom Guzzle client (retry middleware, request logging, EU base URI) without forking the package. That's the cleanest path to modernizing this dependency incrementally — upgrade the SDK underneath while keeping the package's façade and config ergonomics in place.
How can I help you explore Laravel packages today?