helpscout/api-laravel
A Laravel integration for the Help Scout API, providing configuration, a service provider, and a facade for working with mailboxes, conversations, customers, and other Help Scout resources from your PHP applications.
This package is a thin Laravel bridge over the official helpscout/api PHP SDK. It does three things and nothing more: boots a service provider, binds HelpScout\Api\ApiClient into the container, and exposes a facade. Everything you can do with it you can do with the SDK — so the first place to look is the SDK's docs, not this package's.
1. Install and publish
composer require helpscout/api-laravel
php artisan vendor:publish --provider="HelpScout\Laravel\HelpScoutServiceProvider"
(Confirm the exact provider FQCN from the package's composer.json — the package predates Laravel's auto-discovery conventions in places, and with ~9 stars you should not assume the README is current. If auto-discovery doesn't fire, register the provider manually.)
2. Credentials
Create an app in Help Scout under Your Profile → My Apps and take the client ID/secret. Then:
HELPSCOUT_CLIENT_ID=xxxxxxxx
HELPSCOUT_CLIENT_SECRET=xxxxxxxx
// config/helpscout.php
return [
'client_id' => env('HELPSCOUT_CLIENT_ID'),
'client_secret' => env('HELPSCOUT_CLIENT_SECRET'),
];
3. First call — prove auth works
php artisan tinker
>>> app(HelpScout\Api\ApiClient::class)->mailboxes()->list();
If that returns mailboxes, you're done. If it throws an auth error, the problem is the token exchange (client credentials grant against https://api.helpscout.net/v2/oauth2/token), not Laravel.
First real use case: pull a mailbox's open conversations and dump them to a command.
$client = app(\HelpScout\Api\ApiClient::class);
$page = $client->conversations()->list(['mailbox' => 12345, 'status' => 'open']);
foreach ($page->getItems() as $conversation) {
dump($conversation->getId(), $conversation->getSubject());
}
Note the mailbox filter is required for most conversation queries — Help Scout will reject unscoped list calls.
The container binding is a singleton around a stateful HTTP client with an expiring token. Don't spray app(ApiClient::class) across controllers; wrap it, and give yourself a seam to fake later.
namespace App\Services;
use HelpScout\Api\ApiClient;
class HelpScoutService
{
public function __construct(private ApiClient $client) {}
public function createTicket(string $email, string $subject, string $body): int
{
$customer = $this->client->customers()->create(
(new \HelpScout\Api\Customers\Customer())
->setFirstName('Website')
->setLastName('Visitor')
->setEmails([['type' => 'work', 'value' => $email]])
);
$conversation = (new \HelpScout\Api\Conversations\Conversation())
->setMailboxId((int) config('helpscout.mailbox_id'))
->setType('email')
->setSubject($subject)
->setCustomer($customer);
$thread = (new \HelpScout\Api\Conversations\Threads\CustomerThread())
->setCustomer($customer)
->setText($body);
$conversation->setThreads([$thread]);
return $this->client->conversations()->create($conversation)->getId();
}
}
Register it as a singleton alongside the binding, then type-hint HelpScoutService in controllers/jobs.
Conversation creation touches customer lookup, conversation create, and thread create — three round trips that can partially fail. Push it onto a job with $tries = 3 and backoff(); a duplicate ticket is far better than a 500 in the user's face after a network blip.
class SyncTicketToHelpScout implements ShouldQueue
{
use InteractsWithQueue, Queueable;
public int $tries = 3;
public function backoff(): array { return [10, 60, 300]; }
}
Where the binding is registered you can do HelpScout::conversations()->get($id) if the alias ships in config/app.php. It's the same singleton underneath, so there's no behavioural difference — but be consistent. Mixing HelpScout:: and app(ApiClient::class) in one codebase makes grepping impossible.
Mailboxes and users change almost never. Cache::remember('helpscout.mailboxes', 3600, fn () => $client->mailboxes()->list()) will remove an API call from nearly every request path and, more importantly, remove it from your rate budget.
Help Scout list endpoints are page-numbered, not cursor-based. A naive while (true) loop will page forever if you aren't reading the returned pagination metadata:
$page = 1;
do {
$result = $client->conversations()->list($filters + ['page' => $page]);
foreach ($result->getItems() as $c) { /* ... */ }
$page++;
} while ($result->getPagination() && $page <= $result->getPagination()->getTotalPages());
For a full backfill, drive it from a scheduled command with a stored high-water mark, not from a request.
Help Scout posts to a URL you register in the Help Scout UI — this package gives you nothing here. Verify the X-HelpScout-Signature header (base64 HMAC-SHA1 of the raw body with your webhook secret) before decoding JSON, then dispatch a job:
$expected = base64_encode(hash_hmac('sha1', $request->getContent(), config('helpscout.webhook_secret'), true));
abort_unless(hash_equals($expected, $request->header('X-HelpScout-Signature')), 403);
Webhooks are the right answer for "react to a customer reply" — polling conversations is the wrong one.
Because the binding is a plain class, you can swap it:
$this->mock(HelpScoutService::class, fn ($m) => $m->shouldReceive('createTicket')->once()->andReturn(99));
If you go through the facade, HelpScout::shouldReceive('conversations->create') works but is brittle; the mocked chain has to match every call. Prefer mocking your own wrapper.
The singleton holds a token that expires. OAuth client-credentials tokens from Help Scout are short-lived (two hours). A Horizon worker that keeps a resolved ApiClient alive for a long time will eventually hit a 401. Either resolve the client per job (app(ApiClient::class) inside handle() rather than in the constructor), or catch the auth exception at the top of your job and re-resolve. This is the single most common production failure with this wrapper.
env() inside config is fine; env() inside your service is not. If anyone adds env('HELPSCOUT_CLIENT_ID') to a service class, config:cache will silently hand you null. Keep all env reads in config/helpscout.php.
Know the exception taxonomy before you retry anything. The SDK throws HelpScout\Api\Exception\* types — you care about the distinction between a 4xx validation problem (retrying is pointless, alert and drop) and a 429/5xx (retry with backoff). Catch \Throwable and inspect the status code on the response rather than relying on a generic retry. Help Scout returns useful validation detail in the body; log it, because the exception message alone is thin.
Respect the rate limit. The Mailbox API enforces per-app limits and the SDK does not back off for you. When you see X-RateLimit-Remaining dropping near zero, or your first 429, slow down — RateLimiter::for() in Laravel keyed on the mailbox ID is a clean place to enforce it. Bulk imports without throttling will get you temporarily blocked.
mailboxId must be an integer. Passing the string from an env var straight through produces a confusing validation error. Cast at the config boundary.
Attachments are a two-step dance. Upload via the attachments endpoint, get an attachment reference, then include it in the thread. You can't inline base64 in the conversation create call — plan for the extra round trip and handle orphans.
Timestamps are UTC ISO-8601 in and out. If your app stores local time, convert before setting setCreatedAt(); Help Scout will accept the value and you'll spend an afternoon wondering why thread ordering looks wrong.
No sandbox. There's no test tenant. Use a dedicated development mailbox and guard write paths with an if (app()->isProduction()) — or better, a config flag — before you email real customers.
Thin wrapper means no escape hatch. There's no way to configure Guzzle timeouts or middleware through this package. If you need custom HTTP behaviour (timeouts are the usual one — the default is effectively "wait forever"), skip the package entirely and construct ApiClient yourself in a service provider. You lose the facade and gain control; at ~9 stars and zero dependents, that's a reasonable trade, and it means an abandoned package isn't your problem.
Treat the SDK version as the real dependency. Pin helpscout/api in composer.json and read its CHANGELOG when bumping — method names (threads(), create(), the thread subclasses) shift between SDK majors, and this wrapper adds no abstraction to absorb that.
How can I help you explore Laravel packages today?