jeffersongoncalves/laravel-hubspot
Laravel HubSpot is a package that brings HubSpot integration to Laravel applications, offering configuration and a straightforward way to connect your app to HubSpot services. It helps developers add HubSpot functionality with minimal setup and clean Laravel conventions.
php artisan vendor:publish), and set a HubSpot private app access token in .env (not the legacy API key). Scope the token to only the object types you actually touch — scopes are per-object (crm.objects.contacts.read/write, crm.objects.deals.*, crm.schemas.custom.read, etc.) and a missing scope surfaces as a 403 wrapped in HubspotException.HubspotException — it carries the raw API body, so dd($e->getResponseBody()) (or $e->response) is the fastest way to understand any failure.$contact = Hubspot::contacts()->create([
'properties' => [
'email' => $user->email,
'firstname' => $user->first_name,
'lastname' => $user->last_name,
],
]);
$user->update(['hubspot_id' => $contact['id']]);
hubspot_id (nullable, indexed) column to every local model you mirror. Everything else — updates, associations, dedupe — gets easier once you have the remote ID stored.contacts, companies, deals, tickets, products, line_items, and custom objects), so learn the shape once and it transfers.Wrap the client, don't call it from controllers. The package is a thin transport layer. Put it behind a repository/service per object type so you own the mapping between your Eloquent models and HubSpot properties:
final class HubspotContactRepository
{
public function upsert(User $user): string
{
$properties = ['email' => $user->email, 'firstname' => $user->first_name];
return $user->hubspot_id
? tap($user->hubspot_id, fn ($id) => Hubspot::contacts()->update($id, compact('properties')))
: Hubspot::contacts()->create(compact('properties'))['id'];
}
}
Search: use the helper for one property, raw payloads for everything else. The single-property filter helper is fine for email-style lookups, but the moment you need multiple criteria, IN/GT/HAS_PROPERTY operators, or OR logic across filterGroups, drop to the raw payload. Raw payloads are your escape hatch — treat the helper as sugar, not the abstraction.
Always paginate with the cursor. Search and list responses return paging.next.after; loop until it's absent rather than assuming one page. Never fetch a full object list into memory to filter in PHP — that burns rate limit and returns stale data.
Idempotent, queued writes. Rely on the automatic 429 retry for throttling, but wrap every write in a queued job with $tries, backoff(), and retryUntil() so transient 5xx and timeouts recover too. Because retries can re-run a write that already succeeded (see gotchas), make the job check hubspot_id before creating.
Two-phase association writes. Create both objects first, then associate:
Hubspot::deals()->associations()->create($dealId, 'contact', $contactId, ['associationCategory' => 'HUBSPOT_DEFINED', 'associationTypeId' => 3]);
Read associations back to hydrate relationship lists in your UI, and cache them briefly — association reads are the most chatty part of a typical sync.
Error mapping at the boundary. Catch HubspotException in the repository and translate known API errors into domain exceptions: 409 CONFLICT on a duplicate email, 404 on a deleted/archived object, 400 VALIDATION_ERROR with errors[].errorType for property mismatches. Don't let raw API bodies reach your controllers or logs unfiltered — they can contain PII.
Inbound flows use form submissions and marketing emails. Form submissions are best consumed from HubSpot webhooks (or polled) and matched to contacts by email; marketing emails are read-oriented (definitions, stats, campaign context) — you cannot send a marketing email through the API, so keep transactional sending in Laravel and use this only for reporting/segmentation.
Test with Http::fake(). Since the client rides on Laravel's HTTP client, you can fake responses per endpoint and assert the payload shape your repository produces. Do this for the 429 path too, to prove retries actually happen.
--max-time/dedicated queue with numprocs), or use batch endpoints where the package exposes them.id.crm.schemas.custom.read to discover it. A schema created this morning may not be immediately available.properties. Sending ['email' => ...] at the top level of a create/update silently does nothing useful. Dates are epoch milliseconds (or ISO depending on property type), enums are case-sensitive internal values, and read-only properties (hs_object_id, createdate) will be rejected on write.HubspotException's body is whatever HubSpot returned. That's usually JSON with status, category, message, errors[] — but 502/503 from their gateway can be HTML. Always guard your parsing: json_decode($e->getResponseBody(), true) ?: [].delete() archives the object; the ID is dead for most operations but an identical email will still 409 on create. Handle "archived contact, same email" explicitly in your dedupe logic.How can I help you explore Laravel packages today?