Skip to content

CRM · Open source

CRM Sync

A free lead sync for your CRM, in your Filament panel: push records as contacts, companies and deals with field mapping in code; manual, bulk and queued sync; the sync status, and the CRM record and its recent activity on each record, in Filament 4 and 5.

Filament 4 and 5 Laravel 12 and 13 PHP 8.2+ v1.0.0 Free · MIT
CRM Sync: A free lead sync for your CRM, in your Filament panel: push records as contacts, companies and deals with field mapping in code; manual, bulk and queued sync; the sync status, and the CRM record and its recent activity on each record, in Filament 4 and 5.

What it solves

Leads, sign-ups and orders live in your Laravel app; the sales team works in the CRM.

Getting one into the other usually means CSV exports or an API call buried in a controller, with duplicates when it runs twice. Nobody in the panel can tell whether a record reached the CRM, why it failed, or what the sales team has done with it since.

CRM Sync is a free lead sync for HubSpot, in your Filament panel. You map an Eloquent model to HubSpot contacts, companies and deals in code, on the model (SyncsToCrm) or from a service provider (CrmSync::map()). Each sync upserts the mapped objects through the HubSpot CRM v3 API with a private-app token from your own account, stores each record's HubSpot ID and status in a local crm_sync_links table, and then creates default associations through the v4 API. Contacts matched by email use HubSpot's batch upsert; other objects are found by a property you choose, else by the stored ID. A row and page action syncs one record at once, a bulk action queues a selection, and a mapping can queue a sync on every save. A status column shows Synced, Queued, Failed or Not synced from the local table, and two widgets on a View or Edit page show the record's current HubSpot values and its recent notes, emails, calls, meetings and tasks, cached for 5 minutes. HubSpot is the only driver at v1.0.0. It pushes one way only: nothing comes back from HubSpot, there are no webhooks, and deleting a local record does not delete or archive it in HubSpot. The tests use Laravel's HTTP fake with responses in the shape HubSpot's API documents; they never call HubSpot. The release notes say it has not yet been run against a real HubSpot account.

  • Field mapping in code for contacts, companies and deals: CRM property => attribute, dot path or closure, with dates sent as ISO 8601 UTC, booleans as "true"/"false", enums by value and lists joined with semicolons
  • identifyBy(): contacts by email go through HubSpot's batch upsert; any other property is matched with a CRM search before a new record is created; without it, only the stored ID matches
  • Associations to the record's own mapping of another object or to a related model, using HubSpot's default association type; a related record not yet synced is synced first, one level deep
  • A "Sync to HubSpot" row and page action that runs in the request and shows the result, or is queued with ->queue()
  • A bulk action, behind a confirmation, that queues one job per selected record and skips records without a mapping or permission
  • Optional syncOnSave() per mapping, and when() to sync only matching records
  • A status column read from the local table in one query per page: Synced, Queued, Failed (with the error) or Not synced, with the last sync time underneath
  • A record widget with the mapped properties plus a few standard ones per object, and an activity widget with the 10 latest notes, emails, calls, meetings and tasks; both cached for 5 minutes and refreshed after a sync
  • An "Open in HubSpot" action, and a CRM sync page with Test connection, Check mappings and a log of every record's sync with its status, error and retry
  • Queued syncs limited to 4 job starts per second across workers, released for Retry-After on a 429 and retried for up to 6 hours
  • Manual syncs limited to 20 per user per minute; access through authorizeUsing(), viewCrmSync and syncToCrm gates, or the record's update policy
  • The token is read from .env, sent only in the Authorization header, and masked in every error message that is shown or stored
A Leads table in a light Filament panel, with Dashboard, CRM sync, Deals and Leads in the sidebar, and Name, Email, Company and HubSpot columns. Seven leads, among them Jane Cooper of Acme Inc and Guy Hawkins of Vandelay Industries, show Synced in green with a time such as 7 seconds ago underneath. Jenny Wilson, who has no email or company, shows Failed in red with the error: The "email" value is empty, so the record cannot be matched. Each row has View and Edit links and an action menu, and the footer reads Showing 1 to 8 of 8 results.
A Leads table in a light Filament panel, with Dashboard, CRM sync, Deals and Leads in the sidebar, and Name, Email, Company and HubSpot columns. Seven leads, among them Jane Cooper of Acme Inc and Guy Hawkins of Vandelay Industries, show Synced in green with a time such as 7 seconds ago underneath. Jenny Wilson, who has no email or company, shows Failed in red with the error: The "email" value is empty, so the record cannot be matched. Each row has View and Edit links and an action menu, and the footer reads Showing 1 to 8 of 8 results.

Install

Requires PHP 8.2+, Filament 4 and 5, and Laravel 12 and 13.

composer require amjadiqbal/filament-crm-sync

Usage

use AmjadIqbal\FilamentCrmSync\Actions\OpenInCrmAction;
use AmjadIqbal\FilamentCrmSync\Actions\SyncToCrmAction;
use AmjadIqbal\FilamentCrmSync\Actions\SyncToCrmBulkAction;
use AmjadIqbal\FilamentCrmSync\CrmSyncPlugin;
use AmjadIqbal\FilamentCrmSync\Tables\CrmSyncStatusColumn;
use Filament\Actions\ActionGroup;

// Panel provider
$panel->plugin(CrmSyncPlugin::make());

// Resource table
public static function table(Table $table): Table
{
    return $table
        ->columns([
            TextColumn::make('name'),
            TextColumn::make('email'),
            CrmSyncStatusColumn::make(),
        ])
        ->recordActions([
            ActionGroup::make([SyncToCrmAction::make(), OpenInCrmAction::make()]),
        ])
        ->toolbarActions([SyncToCrmBulkAction::make()]);
}

Setup

Install the package and run the migration; the service provider loads its one migration, so no publish step is needed. Create a private app in your HubSpot account (Settings > Integrations > Private apps, or Development > Legacy apps in newer accounts) and put its access token in .env. The README lists the scopes: read and write on contacts, companies and deals, and schema read on the three objects for Check mappings and property labels. Then open the CRM sync page and click Test connection.

  1. 1 composer require amjadiqbal/filament-crm-sync
  2. 2 php artisan migrate (adds the crm_sync_links table)
  3. 3 Set CRM_SYNC_HUBSPOT_TOKEN, and optionally CRM_SYNC_HUBSPOT_PORTAL_ID for Open in HubSpot links without an extra API call.
  4. 4 Add ->plugin(CrmSyncPlugin::make()) to your panel provider.
  5. 5 Implement SyncsToCrm with the InteractsWithCrm trait on a model, or register a mapping with app(CrmSync::class)->map().
  6. 6 Add the status column and actions to the resource, and the widgets to its View or Edit page.
  7. 7 Run a queue worker for bulk and on-save syncs, or set CRM_SYNC_QUEUE=false to run them in the request.
use AmjadIqbal\FilamentCrmSync\Concerns\InteractsWithCrm;
use AmjadIqbal\FilamentCrmSync\Contracts\SyncsToCrm;
use AmjadIqbal\FilamentCrmSync\Mapping\CrmMapping;

class Lead extends Model implements SyncsToCrm
{
    use InteractsWithCrm;

    public function crmMappings(): array
    {
        return [
            CrmMapping::contact()
                ->identifyBy('email')
                ->fields(['firstname' => 'first_name', 'lastname' => 'last_name', 'phone' => 'phone'])
                ->associate('company')
                ->when(fn (Lead $lead) => $lead->marketing_consent),

            CrmMapping::company()
                ->identifyBy('domain', fn (Lead $lead) => parse_url($lead->website, PHP_URL_HOST))
                ->fields(['name' => 'company_name']),
        ];
    }
}

Field mapping

A CrmMapping maps one model to one CRM object. Use HubSpot's internal property names (firstname, not "First name").

  • CrmMapping::contact(), company() and deal() are the HubSpot objects; CrmMapping::object() names an object of a driver you add.
  • fields([...]): CRM property => model attribute, dot path such as company.name, or a closure that receives the model.
  • identifyBy($property, $value = null): the property that finds an existing record, with the attribute, dot path or closure that gives its value (the property name when omitted). Email values are lowercased. A record whose identity value is empty and that has no stored ID fails with: The "email" value is empty, so the record cannot be matched in the CRM.
  • associate($object, $relation = null): after this object syncs, associate it with this model's own mapping of $object, or with the record(s) in $relation (a model or a collection).
  • when(fn ($model) => bool): records that do not match are skipped; the manual action's result says "skipped by its condition". No row is written to the sync log for a skipped record.
  • syncOnSave() queues a sync after every save (off by default). keepNulls() sends empty values, clearing the HubSpot property, instead of leaving it alone.
  • Values are converted before sending: dates to ISO 8601 UTC, booleans to "true" or "false", enums to their value, and lists to semicolon-separated text (HubSpot's multi-select format). Empty strings and null are left out unless keepNulls() is set.

What a sync does

For each mapping of the record, the plugin upserts the object, stores the result on the record's row in crm_sync_links (one row per record, driver and object), then creates the associations.

  • With a stored HubSpot ID, the object is updated with a PATCH. If HubSpot answers 404 (deleted or merged away there), the plugin falls back to the identity match or creates the record again.
  • Without a stored ID: a contact identified by email is sent to the contacts batch upsert endpoint with idProperty email; any other identity is looked up with a CRM search (the oldest match wins) and updated, else a new record is created with the identity property set.
  • A hash of the sent values is stored. A sync that is not forced skips an object whose values have not changed since its last successful sync. Sync on save and app(CrmSync::class)->queue() are not forced; the row action, page action, bulk action and retry always send.
  • Associations use HubSpot's default association between the two objects. A related record with no synced ID is synced first, one level deep; if it still has no ID, the association is skipped with a warning in the result. An association error is reported and shown as a warning; it does not fail the sync.
  • One sync makes one to four requests, by the README's count.
  • From code: app(CrmSync::class)->syncNow($record) returns a SyncResult; app(CrmSync::class)->queue($record) queues it.

Status column and sync page

CrmSyncStatusColumn reads the record's rows in crm_sync_links, loading the rows for the whole table page in one query. Nothing is read from HubSpot when the table renders.

  • Labels: Synced (green), Queued (blue), Failed (red) and Not synced (gray), which is also shown before the first sync. For a record with several objects, Failed wins, then Queued, then Synced.
  • Under the label: the start of the error for a failed record, with the full error on hover, else how long ago it last synced.
  • The CRM sync page shows whether the token is set and counts per status, then a table of every row: record, object, status with its error, the HubSpot ID linked to the record in HubSpot, last synced and attempts, with status and object filters. Failures sort first, then queued.
  • Retry, on one row or a selection, queues a forced sync of that object.
  • Test connection reads one contact and the account details, and shows the portal ID.
  • Check mappings reads each object's property list and reports any mapped property that does not exist or is read-only or calculated. It checks models registered with map() and SyncsToCrm models that already have rows in the sync log; a SyncsToCrm model that has never synced is not checked yet.

The HubSpot record in your panel

Add CrmRecordWidget and CrmActivityWidget to a View or Edit page with getFooterWidgets() or getHeaderWidgets(). Both read HubSpot live through the cache.

  • The record widget shows, for each synced object, the mapped properties plus a few standard ones (contact: email, firstname, lastname, phone, company, lifecyclestage, hs_lead_status; company: name, domain, industry, city, country; deal: dealname, amount, dealstage, pipeline, closedate) with HubSpot's labels. Change the extra ones with CrmSyncPlugin::make()->recordProperties('contact', [...]). It has Sync and Open in HubSpot actions in its header.
  • If HubSpot no longer has the record, the widget says it was deleted there and that syncing again recreates it.
  • The activity widget uses the synced contact, else the company, else the deal. It reads the record's associated notes, emails, calls, meetings and tasks (up to 50 of each), batch-reads their title, body and timestamp, and lists the 10 newest; CrmActivityWidget::limit() changes that, up to 20.
  • An activity type the private app is not allowed to read (403) is left out, so a missing scope only hides those items.
  • Record, activity and property reads are cached for cache_seconds (300 by default, 0 for none), per token. A sync clears the cached reads of the records it touched.
  • Open in HubSpot links to the record in your portal. The portal ID comes from CRM_SYNC_HUBSPOT_PORTAL_ID, else from the account details endpoint, cached for a day.
protected function getHeaderActions(): array
{
    return [SyncToCrmAction::make(), OpenInCrmAction::make()];
}

protected function getFooterWidgets(): array
{
    return [CrmRecordWidget::class, CrmActivityWidget::class];
}

Queue and rate limits

Bulk syncs, retries and sync on save are queued as one SyncRecordToCrm job per record on queue.connection and queue.name. Set CRM_SYNC_QUEUE=false to run them in the request instead. The README notes that HubSpot allows private apps 100 to 190 requests per 10 seconds, depending on the plan.

  • Queueing marks the record's rows as Queued. The job is dispatched after the database commit and loads the latest values of the model when it runs; it is dropped if the model has been deleted.
  • A record has one queued job at a time (a unique lock of up to 10 minutes), and jobs start at most queue.jobs_per_second (4) per second across workers.
  • On a 429, a queued job is released for HubSpot's Retry-After, 10 seconds when absent, or an hour when HubSpot reports the daily limit. It keeps trying for 6 hours, or until 3 unexpected exceptions, and then marks its queued rows Failed.
  • A sync in the request waits for Retry-After when it is at most max_wait_seconds (10), up to twice. Connection errors and 5xx responses are retried twice with a 1 and 2 second pause. Requests time out after 15 seconds.
  • A HubSpot error on one object marks that object Failed with the message and does not stop the other objects.
  • Manual syncs, bulk actions and retries count against 20 per user per minute (a bulk action counts once); change it with CrmSyncPlugin::make()->syncsPerMinute().

Who can use it

The plugin checks two abilities: view (the CRM sync page and both widgets) and sync (the sync and retry actions). In order:

  • authorizeUsing() on the plugin, if set, decides both. The closure receives $ability, $record (or null) and $user, and replaces the gates and policies.
  • Otherwise the viewCrmSync gate (view) or syncToCrm gate (sync) is used when your app defines it.
  • Otherwise, for sync on a record whose model has a policy, the policy's update ability decides.
  • With none of these, every user who can open the panel can view and sync.
  • The row action checks again when it runs. The bulk action and retry skip records the user may not sync and report how many were skipped. ->logPage(false) removes the CRM sync page.
Gate::define('viewCrmSync', fn (User $user) => $user->is_admin);
Gate::define('syncToCrm', fn (User $user, ?Model $record = null) => $user->can('update', $record));

// Or one closure for both abilities:
CrmSyncPlugin::make()->authorizeUsing(fn (string $ability, ?Model $record, $user) => $user->is_admin);

HubSpot API use

The driver uses Laravel's HTTP client against https://api.hubapi.com with the private-app token as a Bearer token. There is no HubSpot SDK and no OAuth flow.

  • Writes: PATCH and POST on /crm/v3/objects/{contacts|companies|deals}, POST /crm/v3/objects/contacts/batch/upsert, and POST /crm/v3/objects/{type}/search for identity matches.
  • Associations: PUT /crm/v4/objects/{type}/{id}/associations/default/{type}/{id}.
  • Reads: GET /crm/v3/objects/{type}/{id} for properties and associated activity, POST /crm/v3/objects/{notes|emails|calls|meetings|tasks}/batch/read, GET /crm/v3/properties/{type}, and GET /account-info/v3/details for the portal ID.
  • Nothing is ever deleted in HubSpot, and nothing is read back into your models.
  • There is no webhook endpoint and no scheduled sync. The README lists two-way sync, scheduled backfills, more objects and more CRMs as part of a planned Pro version.
  • Adding a CRM: implement the CrmDriver contract and register it with app(CrmSync::class)->extend('name', fn (array $config) => new MyDriver($config)).

Token and errors

The token is read from .env through config/crm-sync.php, sent only in the Authorization header, and not stored in the database or shown in the panel.

  • No token: every call stops with "No CRM token is set", and the CRM sync page shows the token as missing.
  • HubSpot errors become readable messages: 401 (token rejected), 403 (missing scope, with HubSpot's message), 404, 409 (for example an email that belongs to another contact) and any other status with HubSpot's message.
  • Every message is passed through a mask that replaces the configured token, anything shaped like a pat- token, and Bearer credentials before it is shown in a notification or stored as last_error.

Credits and trademarks

By Amjad Iqbal. This is an independent project. It is not affiliated with, endorsed by or sponsored by HubSpot, Inc. "HubSpot" is a trademark of HubSpot, Inc. and is used here only to say which CRM the plugin works with. It is also not made or endorsed by Filament.

Hire me

Need this built into your app, or something custom?

I wrote CRM Sync. I can wire it into your Filament panel, extend it for your case, or build the feature around it. Tell me what you need and you'll get a plan and a quote, free.

Support

Found a bug or want a feature? Open an issue on GitHub. For help adding it to your own project, use the quote form above.

More Filament plugins

All plugins
RankForge Lite: Adds meta, Open Graph and X fields to any Filament resource, scores the content for SEO and readability as you type, and previews the Google result before you publish.

RankForge Lite

Adds meta, Open Graph and X fields to any Filament resource, scores the content for SEO and readability as you type, and previews the Google result before you publish.

Change Requests: Holds edits to Filament records as change requests that a reviewer checks field by field, then approves, rejects or rolls back.

Change Requests

Holds edits to Filament records as change requests that a reviewer checks field by field, then approves, rejects or rolls back.

Deleted Records: Lists the records spatie/laravel-deleted-models kept when they were deleted, previews their stored attributes, and restores or permanently deletes them.

Deleted Records

Lists the records spatie/laravel-deleted-models kept when they were deleted, previews their stored attributes, and restores or permanently deletes them.

Stat Cards: Adds progress bars, icon tiles, card and text colours, trends, badges and bar charts to the stats overview widget you already have, by changing one import.

Stat Cards

Adds progress bars, icon tiles, card and text colours, trends, badges and bar charts to the stats overview widget you already have, by changing one import.

Uptime Monitor: Lists the sites spatie/laravel-uptime-monitor watches with their uptime and certificate status, and lets you add, edit, check, turn on and off and delete them from your panel.

Uptime Monitor

Lists the sites spatie/laravel-uptime-monitor watches with their uptime and certificate status, and lets you add, edit, check, turn on and off and delete them from your panel.

XLSX Import: A drop-in replacement for Filament's ImportAction that also reads Excel (.xlsx) and OpenDocument (.ods) workbooks, with a sheet picker, through the Importer classes you already have.

XLSX Import

A drop-in replacement for Filament's ImportAction that also reads Excel (.xlsx) and OpenDocument (.ods) workbooks, with a sheet picker, through the Importer classes you already have.

Billing Console: A free, read-only billing console for apps that bill with Laravel Cashier: customers, subscriptions, invoices and payments, with an MRR and churn summary, in Filament 4 and 5.

Billing Console

A free, read-only billing console for apps that bill with Laravel Cashier: customers, subscriptions, invoices and payments, with an MRR and churn summary, in Filament 4 and 5.

Record Settings: A free way to edit per-user, per-team and per-tenant settings in Filament 4 and 5: a settings page, a settings modal on table rows and settings fields inside resource forms, stored with glorand/laravel-model-settings or in a JSON column.

Record Settings

A free way to edit per-user, per-team and per-tenant settings in Filament 4 and 5: a settings page, a settings modal on table rows and settings fields inside resource forms, stored with glorand/laravel-model-settings or in a JSON column.

Audience Sync: A free plugin that subscribes, unsubscribes, tags and updates your app's records in an email-marketing audience or list, with queued bulk sync, a status column, list and campaign stats, and consent rules that never resubscribe anyone who opted out, in Filament 4 and 5.

Audience Sync

A free plugin that subscribes, unsubscribes, tags and updates your app's records in an email-marketing audience or list, with queued bulk sync, a status column, list and campaign stats, and consent rules that never resubscribe anyone who opted out, in Filament 4 and 5.

Need something built?

Free quote in 60 seconds