tritech/threadwire-laravel
Laravel integration for Threadwire by Tritech, providing familiar service provider wiring and configuration to add Threadwire functionality to your app. It follows Laravel conventions so setup stays simple and your code remains clean and maintainable.
Threadwire wraps a chat/messaging provider's HTTP API behind a Laravel facade, so most work is: publish config, set credentials, call the facade.
composer require threadwire/laravel # adjust to the real package name
php artisan vendor:publish --tag="threadwire-config"
Then in .env:
THREADWIRE_TOKEN=...
THREADWIRE_INSTANCE_ID=...
Look first at config/threadwire.php (base URL, timeout, default instance) and the facade class itself — it's the authoritative list of what's callable. As of v1.1.0 the two entry points worth learning first are:
use Threadwire\Laravel\Facades\Threadwire;
// Poll for webhook events, oldest first
$page = Threadwire::events(['types' => ['message.received']]);
foreach ($page->events as $event) {
// $event->id, $event->type, $event->instance_id, ...
}
$page->next_after; // pass back as 'after' on the next call
$page->has_more;
// A chat's history from the linked phone, newest first
$history = Threadwire::chatHistory($chatId);
First use case: local development and any deployment without a public webhook URL. Instead of exposing /webhooks/threadwire, run a poller and feed the same event handler you'd have used for the webhook. This also becomes your recovery path the first time a webhook is silently dropped.
Cursor-based catch-up poller. Persist the cursor, loop while has_more, and never assume a page is the whole backlog.
// app/Console/Commands/PollThreadwireEvents.php
public function handle(): int
{
$cursor = Cache::get('threadwire:cursor'); // nullable on first run
$guard = 0;
do {
$page = Threadwire::events(array_filter([
'after' => $cursor,
'types' => config('threadwire.poll_types'),
'instance_id'=> config('threadwire.instance_id'),
]));
foreach ($page->events as $event) {
dispatch(new HandleThreadwireEvent($event))->onQueue('threadwire');
}
$cursor = $page->next_after ?? $cursor;
Cache::put('threadwire:cursor', $cursor, now()->addYear());
} while ($page->has_more && ++$guard < 50);
return self::SUCCESS;
}
Schedule it every minute (or every five) rather than tight-looping:
$schedule->command('threadwire:poll')->everyMinute()->withoutOverlapping();
One handler, two transports. The strongest pattern here is treating the webhook and the poller as interchangeable producers. Normalise both into a single job keyed by the event id, so webhook delivery and events() retrieval converge:
final class HandleThreadwireEvent implements ShouldQueue
{
public function __construct(public array $event) {}
public function handle(): void
{
$key = "threadwire:event:{$this->event['id']}";
if (! Cache::add($key, true, now()->addDays(7))) {
return; // already processed via webhook (or vice versa)
}
match ($this->event['type']) {
'message.received' => $this->onMessage($this->event),
default => null,
};
}
}
Backfill after downtime. events() oldest-first with a cursor is exactly the shape you want for replaying an outage window. Store the cursor on a per-instance basis if you run multiple instances, and add a --from option that accepts a known-good after value so operators can rewind deliberately.
Building thread views with chatHistory(). Because history is newest-first and anchored to the linked phone, treat it as a view hydration call, not a sync source: reverse for chronological rendering, and merge it with your own stored messages keyed by provider message id rather than replacing your table.
$messages = collect(Threadwire::chatHistory($chat->provider_id))
->sortBy('timestamp')
->values();
Multi-instance fan-out. Run a poller per instance_id with its own cursor, or one poller with no instance_id filter and a match on the event's instance. The second is fewer API calls; the first isolates failures and rate-limit budgets.
Test with Http::fake(). Both new methods are plain HTTP underneath, so you can assert cursor handling without touching the network:
Http::fake([
'*/v1/events*' => Http::sequence()
->push(['events' => [/* ... */], 'next_after' => 'c2', 'has_more' => true])
->push(['events' => [], 'next_after' => 'c2', 'has_more' => false]),
]);
$this->artisan('threadwire:poll')->assertSuccessful();
Http::assertSent(fn ($r) => str_contains($r->url(), 'after=c1'));
next_after, not after. after is your input cursor; the page tells you where to resume. Storing the value you sent re-fetches the same page forever.has_more is not optional. Always loop on it. A single call is a partial view of the backlog, and code that assumes otherwise will "lose" events that were merely on page 2.events() every 60s gives you minute-level latency and burns rate limit. Keep the webhook as the primary path where you can expose a URL.dispatchAfterResponse/queued jobs over inline handling so a slow handler doesn't block the loop.chatHistory() is newest-first and appears unbounded by cursor. Reverse before rendering, and be wary of dumping a very long thread into a single response — paginate in your UI layer or slice defensively rather than assuming the array is small.types must match the provider's event names verbatim (message.received, not message_received or MessageReceived). Keep the canonical list in config so a typo is a one-line fix, not a hunt.types array ≠ "no filter." Depending on the client's query-string serialisation, an empty array may serialise to nothing (fine) or to types[]= (returns nothing). Wrap optional filters in array_filter().created_at. Don't sort locally on timestamps you generated, and don't compare next_after values numerically — they're opaque.dd(Threadwire::events(['after' => $cursor])) once at the start of a session to confirm the page shape (events/next_after/has_more as properties vs. array keys) — the response object is the most common source of silent null cursors.instance_id in config is applied when you omit the argument. A wrong default instance is the classic cause of "the API returns zero events."How can I help you explore Laravel packages today?