hubspot/hubspot-php
Official HubSpot PHP SDK for integrating HubSpot CRM APIs into PHP applications. Provides a clean client for contacts, companies, deals, tickets, marketing, and more, with simple authentication and request handling.
1. Install
composer require hubspot/hubspot-php
The package is a generated OpenAPI client — there is no facade, no config file, and almost no documentation beyond the HubSpot REST reference. Pin the version in composer.json; minor releases have shipped breaking auth changes.
2. Create a Private App in HubSpot (Settings → Integrations → Private Apps), grant the narrowest scopes you need (crm.objects.contacts.read, etc.), and copy the access token. API keys are gone — v5 only speaks OAuth / private-app tokens.
3. Wire it into the container once, as a singleton:
// app/Providers/HubSpotServiceProvider.php
use HubSpot\Factory;
$this->app->singleton(\HubSpot\Client\Crm\Contacts\Api\BasicApi::class, fn () => null);
$this->app->singleton(\HubSpot\Discovery\Discovery::class, function () {
return Factory::createWithAccessToken(config('services.hubspot.token'), [
'config' => ['http_errors' => true],
]);
});
Add HUBSPOT_TOKEN to config/services.php ('token' => env('HUBSPOT_ACCESS_TOKEN')).
4. First call — list a page of contacts and confirm properties come back:
$client = app(\HubSpot\Discovery\Discovery::class);
$page = $client->crm()->contacts()->basicApi()->getPage(
limit: 10,
properties: ['email', 'firstname', 'lastname']
);
foreach ($page->getResults() as $contact) {
dump($contact->getId(), $contact->getProperties());
}
Where to look first: vendor/hubspot/hubspot-php/src/Client/. The namespace mirrors the API path exactly — Client/Crm/Contacts/Api/BasicApi.php is POST/GET /crm/v3/objects/contacts. Open the class, look at the method signature, and you have the query parameters and return type. This is faster than reading HubSpot's docs for anything except semantics. Start with crm()->{object}()->basicApi(), then searchApi() and batchApi() once you outgrow it.
Wrap the client, don't inject it everywhere. The discovery object exposes every endpoint; injecting it into controllers invites sprawl and makes tests painful. Create one thin adapter per object type:
class ContactRepository
{
public function __construct(private \HubSpot\Discovery\Discovery $hubspot) {}
public function find(string $id): ?SimplePublicObject
{
try {
return $this->hubspot->crm()->contacts()->basicApi()
->getById($id, ['email', 'firstname', 'lastname', 'lifecyclestage']);
} catch (\HubSpot\Client\Crm\Contacts\ApiException $e) {
if ($e->getCode() === 404) return null;
throw $e;
}
}
}
Pagination loop. Paging is cursor-based; getPage() returns a paging->next->after you feed back in. Guard against infinite loops and always cap:
$after = null;
do {
$page = $this->hubspot->crm()->contacts()->basicApi()
->getPage(limit: 100, after: $after, properties: ['email']);
foreach ($page->getResults() as $c) { /* ... */ }
$after = $page->getPaging()?->getNext()?->getAfter();
} while ($after !== null);
Use Search for incremental syncs, not full pagination. searchApi()->doSearch() supports filter groups, sorting, and hs_lastmodifieddate filters, which is the cheapest way to build a nightly "what changed since X" job:
$req = (new PublicObjectSearchRequest())
->setFilterGroups([[ (new FilterGroup())->setFilters([
(new Filter())->setPropertyName('hs_lastmodifieddate')
->setOperator('GTE')->setValue((string) $sinceMs),
])]])
->setProperties(['email', 'firstname'])
->setSorts(['hs_lastmodifieddate'])
->setLimit(100);
$res = $this->hubspot->crm()->contacts()->searchApi()->doSearch($req);
Remember to advance $sinceMs past the last seen updatedAt (epoch milliseconds) or you'll re-process the boundary record forever.
Batch for throughput. One-at-a-time writes will burn your rate limit instantly. Use batchApi() — read, create, update, and (in recent versions) upsert by unique property:
$this->hubspot->crm()->contacts()->batchApi()->create(
(new BatchInputSimplePublicObjectInputForCreate())->setInputs($inputs)
);
Chunk to 100 per request for most objects; a batch call counts as a single request.
Associations are a separate API surface. To read associations with a record, pass them into getById's associations array (object type + association type id). To write them, use the v4 endpoint:
$this->hubspot->crm()->associations()->v4()->basicApi()->create(
'contacts', $contactId, 'deals', $dealId,
[(new AssociationSpec())->setAssociationCategory('HUBSPOT_DEFINED')->setAssociationTypeId(4)]
);
Association type IDs are not intuitive — look them up in the HubSpot associations schema rather than guessing.
Retries belong in Guzzle, not your loop. The SDK does not retry 429s. Add a middleware to the client you hand to Factory:
$stack = HandlerStack::create();
$stack->push(Middleware::retry(
fn ($retries, $req, $res) => $res && $res->getStatusCode() === 429 && $retries < 5,
fn ($retries) => (int) ($res?->getHeaderLine('Retry-After') ?: 2 ** $retries) * 1000
));
$client = Factory::createWithAccessToken($token, [
'client' => ['handler' => $stack, 'timeout' => 10],
]);
Test at the repository boundary. The generated classes are concrete and enormous; mocking them is miserable. Either fake your ContactRepository in feature tests or, for integration tests, inject a Guzzle client built with MockHandler so you can assert the request bodies going out.
Webhooks need hand-rolled verification. The package ships no signature helper. Validate X-HubSpot-Signature-v3 (HMAC-SHA256 over method + uri + body + timestamp, compare with X-HubSpot-Request-Timestamp, reject if older than 5 minutes) inside a middleware before the job dispatch, using the private app's client secret.
Factory::create() no longer reads HUBSPOT_API_KEY the way older tutorials show; use Factory::createWithAccessToken() with a private-app token, or implement OAuth via Client/Auth/OAuth. If you're on 4.x and upgrading, this is the migration.\HubSpot\Client\Crm\Contacts\ApiException, ...\Deals\ApiException, and so on — there's no single meaningful base type. getCode() is the HTTP status and getResponseBody() is a PSR-7 stream you must (string) cast. Wrap this once.getById($id) without a properties list returns only a minimal default set (id, createdAt, updatedAt). Always pass the properties you intend to use, spelled exactly as they appear in HubSpot (case-sensitive, snake_case for custom properties).hs_lastmodifieddate windows or by createdate ranges. limit on search defaults to 10 — set it to 100 explicitly.hs_lastmodifieddate is epoch milliseconds; date properties want YYYY-MM-DD. Sending the wrong format returns a validation error that names the property, so log getResponseBody() on 400s.timeout and connect_timeout in the config array.archived is a real boolean flag, not a filter. Passing true to getById/getPage returns only deleted records; it's easy to leave set during a debugging session.src/Client/ is hundreds of thousands of lines. Exclude it from PHPStan/Psalm paths and from Blade/IDE indexing, or your CI gets slow and your editor gets sad.refresh_token per portal, and centralize the refresh in a single service — the SDK won't do it for you.GuzzleHttp\ClientInterface in its constructor, and Factory accepts a 'client' config key. That's your hook for logging, tracing, retries, and base-URI overrides — everything else is generated and shouldn't be patched.How can I help you explore Laravel packages today?