hubspot/api-client
Official HubSpot PHP API client for CRM, CMS, and marketing APIs. Offers generated endpoint wrappers, OAuth and private app auth, and PSR-friendly HTTP handling for Laravel/PHP integrations.
The SDK is framework-agnostic — there is no Laravel service provider or facade. Treat it as a Guzzle-backed transport that you wrap yourself.
composer require hubspot/api-client
403 at runtime, not at boot.config/services.php under hubspot.token, sourced from .env. Never inline it.AppServiceProvider:use HubSpot\Factory;
$this->app->singleton(\HubSpot\Client\Crm\Contacts\Api\ContactsApi::class, fn () =>
Factory::createWithAccessToken(config('services.hubspot.token'))
);
Where to look first: the GitHub README gives a copy-paste snippet per API group. Then read src/Discovery/Discovery.php — it is the map. $client->crm()->contacts(), ->companies(), ->deals(), ->tickets(), ->objects() (custom objects), ->associations()->v4(), ->lists(), plus marketing(), files(), conversations(), automation(). Under each group, the leaf is coreApi(), searchApi(), batchApi(), or gdprApi(). Method signatures live in src/Client/Crm/<Group>/Api/*Api.php; request payload shapes live in the matching Model/ directory.
First use case — create then read a contact:
use HubSpot\Client\Crm\Contacts\Model\SimplePublicObjectInput;
$contacts = $client->crm()->contacts()->coreApi();
try {
$contact = $contacts->create(new SimplePublicObjectInput([
'email' => '[email protected]',
'firstname' => 'Ada',
'lastname' => 'Lovelace',
]));
$id = $contact->getId(); // string
$props = $contact->getProperties(); // array<string,string|null>
} catch (\HubSpot\Client\Crm\Contacts\ApiException $e) {
report($e); // see Gotchas: getCode() is the HTTP status
}
Wrap it, don't sprinkle it. The SDK types (SimplePublicObject, CollectionResponseWithTotalSimplePublicObject, ApiException) are verbose and version-sensitive. Define a narrow interface (HubspotContacts::upsert(), findByEmail(), search()) and one SDK-backed implementation. Your jobs and controllers depend on the interface, not on HubSpot\Client\.... This is also the only realistic way to fake it in tests.
Upsert instead of "search then create". getById() accepts an ID property, so email-as-key is one request, not two:
try {
$contact = $contacts->getById($email, ['email', 'lifecyclestage'], 'email');
} catch (ApiException $e) {
if ($e->getCode() === 404) {
$contact = $contacts->create(/* ... */);
} else {
throw $e;
}
}
Paging. List endpoints return getPaging()->getNext()->getAfter(). Loop until getPaging() is null or after is empty. Cursors are opaque and expire — never construct or persist them.
$after = null;
do {
$page = $contacts->getPage(100, $after, ['email', 'firstname']);
foreach ($page->getResults() as $c) { /* ... */ }
$after = $page->getPaging()?->getNext()?->getAfter();
} while ($after);
Bulk syncs should use batch endpoints, not loops. $client->crm()->contacts()->batchApi()->read(...) takes up to 100 IDs per call; batchApi()->create/update/upsert likewise. A 5,000-record nightly sync drops from 5,000 requests to ~50. That single change is usually the difference between hitting rate limits and not.
Search API for anything filtered. Build PublicObjectSearchRequest with setFilterGroups() (OR of ANDs — the nesting is the opposite of what most people assume), setProperties(), setSorts(), setLimit(), and call searchApi()->doSearch(). Use it instead of pulling every record and filtering in PHP.
Queue the writes, throttle the worker. Wrap calls in jobs and add middleware:
public function middleware(): array
{
return [(new RateLimited('hubspot'))->releaseAfter(10)];
}
Define hubspot in RateLimiter::for() at roughly your account's 10-second budget (100 req/10s is the common private-app ceiling; check your tier). For a single high-volume process, add the SDK's retry middleware so 429s back off on the Retry-After header rather than failing the job.
Cache the slow-changing metadata. Owners, pipelines/stages, and property definitions change rarely and are needed on every UI render. Cache::remember('hubspot.owners', 3600, ...) removes three API calls per page load.
Always request properties explicitly. Omitting it returns only default properties; custom fields silently come back missing.
Associations are v4. $client->crm()->associations()->v4()->basicApi()->create(...) with a PublicAssociation-style payload. The v3 association endpoints still exist in older SDK versions and are easy to follow by mistake when copying snippets from blog posts.
Webhooks are yours to handle. The SDK is a client; it does not receive or validate webhooks. Implement the X-HubSpot-Signature-v3 check yourself (HMAC over method + URI + body + timestamp, using the client secret) and return 200 fast, doing the work in a job.
composer.lock and read the diff on upgrade.new SimplePublicObjectInput() can throw ArgumentCountError. Recent versions require the properties array in the constructor. Use new SimplePublicObjectInput(['email' => ...]) or named arguments, and check the constructor in Model/ when a payload "suddenly" breaks after composer update.ApiException::getCode() returns the HTTP status, not a HubSpot error code. Use getResponseBody() (string or stream depending on Guzzle config — check is_string() before json_decode) and getResponseHeaders(). Log x-hubspot-correlation-id from the headers; HubSpot support asks for it first and you cannot recover it later.MISSING_SCOPES category means the private app needs the scope added and the token reissued (old tokens keep working but without the new scope). 429 means back off. Don't retry 403s.doSearch() results. For read-after-write, always use getById(); reserve search for asynchronous/list views. Also: search caps at 200 per page and 10,000 total results per query — narrow the filters rather than paging deeper.'42', '2024-01-01', not true or a DateTime). Send '' to clear a property; null typically means "leave unchanged".timeout, connect_timeout, custom handler/middleware stack) through the Factory when you build the client in the service provider.MockHandler with queued responses through the factory's handler config, which exercises your real serialization/deserialization — worth doing for the upsert and paging wrappers specifically.app()->isLocal() and scrub the Authorization header, or you will leak contact data into shared log storage.How can I help you explore Laravel packages today?