pravnyadv / hubspot-php
A framework-free PHP SDK for the HubSpot CRM and dated APIs, with OAuth refresh, retries, pagination, and webhook signature verification.
Requires
- php: ^8.3
- guzzlehttp/guzzle: ^7.8 || ^8.0
- psr/http-message: ^1.1 || ^2.0
- psr/log: ^2.0 || ^3.0
Requires (Dev)
- laravel/pint: ^1.18
- pestphp/pest: ^4.0
- phpstan/phpstan: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-09 12:57:35 UTC
README
A framework-free PHP SDK for the HubSpot CRM and dated API versions, including 2026-09 and later. Install it with Composer as pravnyadv/hubspot-php.
We built this framework-free HubSpot PHP SDK for teams adopting HubSpot's date-versioned APIs. CRM, account, and CMS resources default to 2026-09, with per-family version pinning. It also includes OAuth refresh, retries, cursor pagination, typed exceptions, webhook verification, async requests, and plain object or array responses. Endpoint paths are mapped from HubSpot's OpenAPI catalog.
Responses are plain stdClass objects by default, with associative arrays available when preferred. The client is framework-free and supports PHP 8.3+, Guzzle 7 or 8, and per-family API version pinning.
Install
composer require pravnyadv/hubspot-php
PHP 8.3+, Guzzle 7 or 8.
Usage
use HubSpot\HubSpot; $client = HubSpot::withAccessToken('pat-na1-...'); $contact = $client->crm()->contacts()->get('123', ['email']); echo $contact->properties->email;
Resources are grouped by product area:
$client->crm()->objects('contacts') // generic, any object type incl. custom p_* $client->crm()->contacts() / deals() / tickets() / lists() / owners() / properties() ->associations() / pipelines() / schemas() / timeline() / imports() / exports() / users() $client->cms()->pages() / sourceCode() / blogPosts() / hubdb() / domains() / urlRedirects() $client->marketing()->forms() / emails() / campaigns() / subscriptions() $client->commerce()->paymentLinks() / priceBooks() $client->automation()->flows() / sequences() / actions() $client->conversations()->inbox() / customChannels() / visitorIdentification() $client->events() $client->settings() $client->files() $client->account() $client->scheduler()
Anything without a typed method is still reachable: $client->request('GET', '/crm/objects/2026-09/tickets/456').
Records that may not exist
get() throws NotFoundException on a 404. find() returns null instead, and still throws on every other failure, so a rate limit or outage never looks like a missing record.
$contact = $client->crm()->contacts()->find('jane@example.com', ['email'], idProperty: 'email');
Pagination
foreach ($client->crm()->owners()->all() as $owner) { // walks the `after` cursor echo $owner->id, PHP_EOL; }
Arrays instead of objects
use HubSpot\ResponseFormat; $client = HubSpot::withAccessToken('pat-na1-...', responseFormat: ResponseFormat::Assoc); // now $contact['properties']['email']
Pin one family to a different version
$client->crm()->lists('2026-03')->get('789'); // just lists; everything else stays on 2026-09
Search builder
use HubSpot\Search\SearchRequest; $request = SearchRequest::create() ->where('email', 'HAS_PROPERTY') ->where('lastname', 'EQ', 'Doe') ->limit(50); $client->crm()->contacts()->search($request->toArray());
OAuth
Auto-refreshes; you persist each refreshed token via the callback.
use HubSpot\HubSpot; use HubSpot\Auth\OAuthAuth; use HubSpot\Auth\TokenSet; $oauth = HubSpot::oauth(); $url = $oauth->authorizationUrl($clientId, $redirect, ['crm.objects.contacts.read']); $tokens = $oauth->exchangeAuthorizationCode($clientId, $secret, $_GET['code'], $redirect); $auth = (new OAuthAuth($oauth, $clientId, $secret, $tokens)) ->onTokenRefreshed(fn (TokenSet $t) => $db->save($t->accessToken, $t->refreshToken, $t->expiresAt)); $client = HubSpot::withOAuth($auth);
Webhooks
$ok = HubSpot::webhooks($clientSecret)->isValidV3($method, $url, $rawBody, $signature, $timestamp);
Concurrency
use GuzzleHttp\Promise\Utils; $results = Utils::settle([ $client->crm()->timeline()->createBatchAsync($batchA), $client->crm()->timeline()->createBatchAsync($batchB), ])->wait(); // or capped, for a large fan-out: $results = $client->pool( array_map(fn ($b) => fn () => $client->crm()->timeline()->createBatchAsync($b), $batches), concurrency: 5, );
Errors
Every failed request throws an ApiException subclass picked by status, so you catch what you actually handle:
| Status | Exception | Extra |
|---|---|---|
| 400, 422 | ValidationException |
|
| 401 | AuthenticationException |
also OAuth refresh failures |
| 403 | ForbiddenException |
->missingScopes() |
| 404 | NotFoundException |
|
| 409 | ConflictException |
->existingId() |
| 429 | RateLimitException |
->retryAfter |
| 5xx | ServerException |
|
| none | ConnectionException |
DNS, timeout; status 0 |
Each one has ->status, ->body, and ->error: HubSpot's error body parsed into a HubSpotError (category, subCategory, correlationId, context, errors), the same shape across every API. getMessage() is HubSpot's own message, with the first field error pulled out of validation failures.
Transient failures retry with backoff, honoring Retry-After. A 429 retries on any method; a 5xx or dropped connection only on idempotent methods (never a POST/PATCH), so a create is never duplicated. Tune with maxRetries (0 turns it off).
Handling failures
The client throws on failure; it never returns null or false instead. Catch only what you can act on, and let the rest reach your job or error handler, where a retry is usually right.
// A record that may not exist needs no try/catch. $contact = $client->crm()->contacts()->find($id); // A failure you can act on: catch just that one. try { $client->crm()->contacts()->create(['email' => $email]); } catch (ConflictException $e) { $client->crm()->contacts()->update($e->existingId() ?? throw $e, $properties); }
Avoid catch (ApiException) { return null; }. It turns a rate limit or an outage into "not found", and whatever acts on that null then does the wrong thing quietly. In a queue job, catch RateLimitException and re-queue it after ->retryAfter seconds.
Logging and context
Pass any PSR-3 logger and whatever ids you trace by. Every attempt is logged once (debug on success, info on a 4xx, warning on a 429, 5xx or dropped connection) with method, path, status, duration and HubSpot's correlation id. Bodies, query strings and the token are never logged, though a path can hold an id you looked up by, such as an email with idProperty: 'email'.
$client = HubSpot::withAccessToken($token, logger: $logger, context: ['portal_id' => $portalId]); $job = $client->withContext(['request_id' => $requestId]); // a copy for one job
The same context is on every exception through $e->context(), which Laravel merges into the log entry when it reports the exception.
Middleware
Guzzle middleware added here runs once per attempt, inside the retries, so a rate limiter counts every request HubSpot receives. Exceptions it throws reach you untouched.
$client = HubSpot::withAccessToken($token, middleware: ['rate_limit' => $limiter]);
Keeping paths current
Paths come from HubSpot's OpenAPI catalog, not guesswork. After a HubSpot release, run composer refresh-specs to regenerate docs/verified-paths.md, then bump Client::DEFAULT_VERSION.
Development
composer test # Pest, offline composer analyse # PHPStan level 6 composer lint # Pint
Releases are automated via release-please.
License
MIT.