jeffersongoncalves/laravel-instantly
Laravel integration for Instantly.ai that lets you manage cold email campaigns, leads, and accounts from your PHP app. Offers a simple, configurable API client with Laravel-friendly facades, service provider bindings, and published config.
The package is a thin Laravel wrapper around the Instantly.ai v1 REST API, exposing five resource areas: campaigns, leads, accounts, analytics, and blocklist. Treat it as an HTTP client with Laravel ergonomics (config, facade, container binding) rather than an ORM — there are no Eloquent models, migrations, or persisted state.
Minimal setup:
composer require <vendor>/instantly-laravel
php artisan vendor:publish --tag="instantly-config"
INSTANTLY_API_KEY=your_key_here
# INSTANTLY_BASE_URL=https://api.instantly.ai/api/v1
First use case to validate the integration end-to-end is lead ingestion into an existing campaign. It exercises auth, a read (list campaigns to grab an ID) and a write (create a lead), so it proves the wiring before you build anything else:
use Instantly;
$campaigns = Instantly::campaigns()->all(); // confirm auth + base URL
$campaign = Instantly::campaigns()->find($campaignId);
Instantly::leads()->create([
'campaign' => $campaignId,
'email' => '[email protected]',
'first_name' => 'Jane',
'last_name' => 'Doe',
'company_name'=> 'Acme',
]);
Where to look first, in order:
config/instantly.php — base URL, timeout, API key resolution. Confirm the key is read from config() and not env() directly (the latter breaks under config:cache).src/ — they are the source of truth for available methods; the README typically lags a release.InstantlyClient rather than resolving from the container inline.Collections — this determines how much mapping code you write.Method names vary between SDK releases; the safest first step is a quick php artisan tinker session against a non-production API key to enumerate what each resource exposes before designing around it.
Wrap it, don't sprinkle it. The facade is convenient but couples your domain code to the SDK's array shapes. Put a small adapter in app/Services/Instantly/ that returns your own DTOs. When v1 changes (or you move to v2), you change one class instead of forty call sites.
final class InstantlyLeadSync
{
public function __construct(private InstantlyClient $client) {}
public function push(LeadData $lead, string $campaignId): void
{
$this->client->leads()->create([
'campaign' => $campaignId,
'email' => $lead->email,
...$lead->toInstantlyPayload(),
]);
}
}
Keep campaign IDs out of code. Store them in config/instantly.php (per environment) or in a campaigns table with a instantly_id column. The package has no local concept of campaigns, so you own that mapping — and you will need it for analytics correlation and reconciliation.
Queue every write. Lead creation and blocklist updates are network calls with real latency and real rate-limit exposure. Dispatch jobs:
ProcessInstantlyLead::dispatch($lead)->onQueue('outreach');
Use ->delay() or a rate-limited queue (Redis + RateLimited middleware, or a Bus::batch() with allowFailures()) for imports of more than a handful of records. A failed_jobs table plus retryUntil handling for 429s is cheaper than debugging a timed-out CSV import in a request cycle.
Bulk imports. If the API exposes bulk lead creation, prefer one bulk call over N single calls — rate limits are usually scoped per request. Chunk by ~100–500 records, hash the payload, and keep a local synced_at/payload_hash column so re-runs are idempotent on your side.
Pagination. Analytics and lead listings are paginated. Don't pull everything into memory to count records. Either use a lazy cursor/chunk()-style loop if the resource provides one, or loop pages explicitly with a hard page cap and log when you hit it, so a silent truncation never becomes a wrong dashboard number.
Analytics should be cached, not live. Dashboard widgets hitting Instantly on every page load is the most common way teams hit rate limits. Cache per (campaign, date range, granularity) for 5–15 minutes:
return Cache::remember(
"instantly:analytics:{$campaignId}:{$from}:{$to}",
now()->addMinutes(10),
fn () => Instantly::analytics()->campaign($campaignId, $from, $to),
);
Blocklist as a pre-flight check. The blocklist resource is most valuable before you push a lead, not after a bounce. Either check on submit (acceptable for low volume, cache the result set briefly) or mirror the blocklist into a local table on a schedule and check locally — the latter is far cheaper and lets you join against it in SQL.
Testing. The SDK talks HTTP, so Http::fake() at the transport layer is the correct seam — but only if the package uses the Laravel HTTP client and doesn't swallow the fake. Assert on the outgoing request, not just the return value:
Http::fake(['api.instantly.ai/*' => Http::response(['id' => 'lead_123'], 200)]);
Instantly::leads()->create([...]);
Http::assertSent(fn ($req) => str_contains($req->url(), '/lead')
&& $req['campaign'] === 'camp_abc');
If the package wraps its own Guzzle client, you'll need a MockHandler injection or a fake at your adapter boundary instead. Decide which before you write tests across the codebase.
Integration tips. Register a typed config object via a service provider binding so tests can swap the API key and base URL trivially. Add a instantly log channel so you can trace request IDs from the SDK separately from app noise.
env() calls. If the package's service provider or config file calls env('INSTANTLY_API_KEY') outside the config file, everything returns null after php artisan config:cache. Verify by running config:cache locally before deploying — this is the single most common "works on my machine" failure with SDK packages.Carbon::setTestNow().200 with per-item errors in the body. Check for an errors/leads_uploaded-style key rather than trusting the status code — otherwise bad emails disappear without a trace.^1.0) and keep your adapter thin. Expect breaking changes inside the 1.x line as the upstream API evolves — the package cannot be more stable than the API it wraps.config('instantly') after config:cache; call the endpoint with curl using the same key to confirm whether the problem is the package or the credentials; enable Laravel's HTTP client logging (Http::globalRequestMiddleware) to see the exact payload the package builds — field-name mismatches (campaign vs campaign_id) are the usual culprit on writes.How can I help you explore Laravel packages today?