amjadiqbal / filament-crm-sync
A free Filament 4 and 5 plugin that syncs your records to HubSpot as contacts, companies and deals: field mapping, manual and queued sync, sync status, and the CRM record and recent activity in your panel.
Requires
- php: ^8.2
- filament/filament: ^4.0|^5.0
- illuminate/contracts: ^12.0|^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- pestphp/pest-plugin-livewire: ^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
CRM Sync for Filament
A free lead sync for HubSpot, in your Filament panel. Push your records (leads, users, orders, anything Eloquent) to HubSpot as contacts, companies and deals with a field mapping you write once. Sync one record, a selection or every save, see each record's sync status in your tables, and see the HubSpot record and its recent activity on the record's page. It works with Filament 4 and 5.
What it does
- Field mapping in code. Map a model to HubSpot contacts, companies and deals: CRM property => attribute, dot path
(
company.name) or closure. Dates, booleans, enums and lists are converted to the format HubSpot takes. - Idempotent upserts. Each record remembers its HubSpot ID. Contacts are matched by email with HubSpot's batch
upsert; companies and deals by any property you choose (for example
domain), else by the stored ID. Re-syncing never creates duplicates, and unchanged records are skipped. - Associations. Associate the contact with its company, or a deal with the lead it belongs to. A related record that was never synced is synced first.
- Manual and queued sync. A "Sync to HubSpot" row and page action (runs at once and shows the result), a bulk
action that queues the selection, and optional sync on every save. Queued jobs are rate-limited, back off on
HubSpot's 429 responses using
Retry-After, and never run twice at once for the same record. - Status in your tables. A column shows Synced, Queued, Failed or Not synced, with the time or the error.
- The HubSpot record in your panel. Two widgets for a View or Edit page: the record's current HubSpot values, and its recent notes, emails, calls, meetings and tasks. Plus an "Open in HubSpot" action.
- A CRM sync page. Test the connection, check that every mapped property exists and is writable in your HubSpot account, and see every sync with its status and error, with retry.
Your own HubSpot account is used through a private-app token you create. The token is read from your .env, sent
only to HubSpot, and never logged, stored or shown; error messages are masked before they are shown or saved.
Requirements
- PHP 8.2+ (8.3+ for Filament 5)
- Laravel 12 or 13
- Filament 4 or 5
- A HubSpot account where you can create a private app, and a queue worker for queued syncs
Installation
composer require amjadiqbal/filament-crm-sync php artisan migrate
The migration adds one table, crm_sync_links, which stores each record's HubSpot ID and last sync status.
Create the HubSpot token
In HubSpot, go to Settings > Integrations > Private apps (in newer accounts: Development > Legacy apps), create a private app and give it these scopes:
| Scope | For |
|---|---|
crm.objects.contacts.read, crm.objects.contacts.write |
contacts, and reading activity on them |
crm.objects.companies.read, crm.objects.companies.write |
companies |
crm.objects.deals.read, crm.objects.deals.write |
deals |
crm.schemas.contacts.read, crm.schemas.companies.read, crm.schemas.deals.read |
"Check mappings" and property labels |
Activity types the app can't read are left out of the activity widget, so missing scopes there only hide those
items. Copy the access token into .env:
CRM_SYNC_HUBSPOT_TOKEN=pat-na1-... # Optional: for "Open in HubSpot" links without an extra API call CRM_SYNC_HUBSPOT_PORTAL_ID=12345678
Register the plugin
use AmjadIqbal\FilamentCrmSync\CrmSyncPlugin; public function panel(Panel $panel): Panel { return $panel // ... ->plugin(CrmSyncPlugin::make()); }
This adds the CRM sync page. Open it and click Test connection.
Map your models
Implement SyncsToCrm on a model you own:
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') // HubSpot property, and the attribute (same name here) ->fields([ 'firstname' => 'first_name', 'lastname' => 'last_name', 'phone' => 'phone', 'hs_lead_status' => fn (Lead $lead) => $lead->qualified ? 'OPEN_DEAL' : 'NEW', ]) ->associate('company') // this lead's own company mapping, below ->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']), ]; } }
Or map a model you don't own (or prefer to keep clean) from a service provider's boot():
use AmjadIqbal\FilamentCrmSync\CrmSync; use AmjadIqbal\FilamentCrmSync\Mapping\CrmMapping; app(CrmSync::class)->map(Order::class, fn () => [ CrmMapping::deal() ->fields([ 'dealname' => fn (Order $order) => "Order {$order->number}", 'amount' => 'total', 'pipeline' => fn () => 'default', 'dealstage' => fn (Order $order) => $order->paid ? 'closedwon' : 'appointmentscheduled', 'closedate' => 'paid_at', ]) ->associate('contact', 'customer'), // the Order's customer relation, synced with its own mapping ]);
| Mapping method | What it does |
|---|---|
CrmMapping::contact(), company(), deal() |
The HubSpot object (CrmMapping::object('...') for a driver's own objects) |
identifyBy($property, $value = null) |
Finds an existing record before creating one. Contacts by email use HubSpot's upsert; other properties are searched. Without it, only the stored ID matches. |
fields([...]) |
HubSpot property => attribute, dot path or closure |
associate($object, $relation = null) |
Associate with the related model's synced record, or with this model's own mapping of $object |
when(fn ($model) => bool) |
Only sync matching records (others are skipped and logged as skipped) |
syncOnSave() |
Queue a sync on every save (off by default) |
keepNulls() |
Send empty values, clearing the HubSpot property, instead of leaving it alone |
Use HubSpot's internal property names (firstname, not "First name"). Check mappings on the CRM sync page lists
any mapped property your account doesn't have, or that is read-only.
Add the UI to your resources
use AmjadIqbal\FilamentCrmSync\Actions\OpenInCrmAction; use AmjadIqbal\FilamentCrmSync\Actions\SyncToCrmAction; use AmjadIqbal\FilamentCrmSync\Actions\SyncToCrmBulkAction; use AmjadIqbal\FilamentCrmSync\Tables\CrmSyncStatusColumn; use Filament\Actions\ActionGroup; public static function table(Table $table): Table { return $table ->columns([ // ... CrmSyncStatusColumn::make(), ]) ->recordActions([ // ... your View / Edit actions ActionGroup::make([ // keeps the row narrow next to your own actions SyncToCrmAction::make(), // runs now; ->queue() to queue it instead OpenInCrmAction::make(), ]), ]) ->toolbarActions([ SyncToCrmBulkAction::make(), // queues the selection ]); }
On a View or Edit page:
use AmjadIqbal\FilamentCrmSync\Widgets\CrmActivityWidget; use AmjadIqbal\FilamentCrmSync\Widgets\CrmRecordWidget; protected function getHeaderActions(): array { return [SyncToCrmAction::make(), OpenInCrmAction::make()]; } protected function getFooterWidgets(): array { return [CrmRecordWidget::class, CrmActivityWidget::class]; }
The record widget shows the mapped properties plus a few standard ones per object (change them with
CrmSyncPlugin::make()->recordProperties('contact', ['email', 'lifecyclestage'])). The activity widget reads the
contact first, else the company or deal. HubSpot reads are cached for 5 minutes (cache_seconds) and refreshed
after a sync.
You can also sync from code:
app(CrmSync::class)->syncNow($lead); // returns a SyncResult app(CrmSync::class)->queue($lead); // queues it
Queue and rate limits
Bulk syncs, retries and sync-on-save are queued, so run a worker (php artisan queue:work). Set CRM_SYNC_QUEUE=false
to run everything in the request instead.
HubSpot allows private apps 100 to 190 requests per 10 seconds, depending on your plan. A sync makes one to four
requests. Queued syncs start at most queue.jobs_per_second (default 4) per second across workers. On a 429, a
queued job is released for HubSpot's Retry-After (10 seconds when absent, an hour for the daily limit) and keeps
trying for 6 hours; a sync in the request waits up to max_wait_seconds (10) and retries twice. Network errors and
5xx responses are retried twice. Manual syncs are limited to 20 per user per minute
(CrmSyncPlugin::make()->syncsPerMinute(...)).
Publish the config to change these:
php artisan vendor:publish --tag="filament-crm-sync-config"
Authorization
By default, anyone who can use the panel can view the CRM sync page and widgets. Syncing a record needs its policy's
update ability when the model has a policy. To decide yourself:
// Gates, when defined, are used instead: 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 ("view" and "sync"), which replaces gates and policies: CrmSyncPlugin::make()->authorizeUsing(fn (string $ability, ?Model $record, $user) => $user->is_admin);
What it does not do (yet)
- It pushes from your app to HubSpot only. Changes made in HubSpot do not come back, and deleting a local record does not delete or archive it in HubSpot.
- Mapping is code-only; there is no mapping editor in the panel.
- HubSpot is the only driver. The driver contract (
AmjadIqbal\FilamentCrmSync\Contracts\CrmDriver, registered withapp(CrmSync::class)->extend('name', fn (array $config) => new MyDriver($config))) is there so others can be added.
A Pro version is planned with two-way sync, scheduled backfills, more objects and more CRMs.
Testing
composer test
The tests use Laravel's HTTP fake with responses in the shape HubSpot's API documents; they never call HubSpot.
Changelog
See CHANGELOG.md.
Support
- 🐛 Bug or feature request: open an issue
- 🔒 Security issue: please report it privately through GitHub's security tab
Need this customised, or something built?
I'm available for custom development, package integration, and technical consulting.
More packages
This is part of a family of open-source packages. See all of them.
| filament-billing-console | A read-only billing console for Laravel Cashier (Stripe) in Filament |
| filament-uptime-monitor | Uptime and SSL checks managed in Filament, for spatie/laravel-uptime-monitor |
| laravel-webhook-console | Browse, inspect and replay received webhooks, for spatie/laravel-webhook-client |
Credits
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.
License
The MIT License (MIT). See LICENSE.md.


