Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Filament Crm Sync Laravel Package

amjadiqbal/filament-crm-sync

Filament CRM Sync connects your Filament admin panels with external CRM systems, helping keep contacts, companies, and deals synchronized. Configure field mappings and run syncs from the dashboard to reduce manual data entry and maintain consistent records across tools.

View on GitHub
Deep Wiki
Context7

Getting Started

  1. Install and migrate

    composer require amjadiqbal/filament-crm-sync
    php artisan migrate
    

    The migration adds the column(s) that store the HubSpot record ID per synced model — without it the upsert logic has nothing to match on and every sync looks "new".

  2. Register the plugin on the panel (not a service provider):

    ->plugin(CrmSyncPlugin::make())
    
  3. Token: create a HubSpot private app, grant at minimum crm.objects.contacts (read/write), plus companies and deals if you map associations, then set CRM_SYNC_HUBSPOT_TOKEN in .env. Run php artisan config:clear (or re-cache) — the package reads it through config, so a cached config will silently keep the old value.

  4. Where to look first, in this order:

    • the plugin class — it registers the row/bulk actions, the status column, the record-page section and the "CRM Sync" page;
    • the SyncsToCrm trait / mapping API — this is where 90% of your work happens;
    • the published config (if publishable) — queue name, rate limit, retry behaviour;
    • the driver contract — only relevant if you plan to add Pipedrive/Salesforce yourself.
  5. First use case — one model, one contact:

    class Lead extends Model implements SyncsToCrm
    {
        use \Amjadiqbal\CrmSync\Concerns\SyncsToCrm;
    
        public function crmMapping(): array
        {
            return [
                'email'      => 'email',
                'firstname'  => 'first_name',
                'lastname'   => 'last_name',
                'company'    => 'company_name',
                'lifecyclestage' => fn () => 'lead',
            ];
        }
    }
    

    Then hit Sync to HubSpot on a single row in the Filament table. Use the row action (synchronous, instant result) for the first few records so you see API errors in the UI, then move to the bulk/queued action once the mapping is right.

Implementation Patterns

Two places to define mappings — pick per model, don't mix.

  • SyncsToCrm on the model: best for models you own, keeps the mapping next to the schema, discoverable.
  • CrmSync::map(Model::class, [...]) in a service provider: best for vendor/legacy models, for keeping CRM vocabulary out of your domain layer, or when one model needs several mappings (e.g. a Person that becomes both a contact and a company).

Mapping values are three kinds — use the least clever one that works:

  • plain attribute name ('email');
  • dot path into a relation ('company.name') — must be eager-loadable, and it will be resolved lazily if you forget with();
  • closure returning a scalar — fine for constants and small transforms, but see the dirty-detection gotcha below.

Upsert keys.

  • Contacts: HubSpot upserts by email. Treat email as required and unique in your mapping. If you have leads without email, gate the sync (canSyncToCrm(): bool) rather than sending a blank and creating junk.
  • Companies and deals: matched on a property you nominate. Use a property that is genuinely stable and unique in HubSpot (a domain for companies, an external ID custom property for deals) — not name.

Associations. When a contact references a company, the related record is synced first. Model this explicitly in your mapping and, for bulk backfills, seed companies before contacts so the "sync related first" path doesn't double the API call count on the first pass.

Choose the trigger deliberately:

  • Row action — support/debugging, ad-hoc re-sync. Instant feedback, blocks the request on API latency.
  • Bulk action — the only sane option for backfills; queues the selection.
  • On save — convenient, but dangerous with imports, seeders, updateOrCreate loops and console commands. If you enable it, pair it with a saved guard (only when relevant fields are dirty) and make sure your queue worker runs.

Queue setup. Give the sync its own queue so a slow HubSpot doesn't starve your mail/notification jobs:

php artisan queue:work --queue=crm-sync

Keep --tries low: the job already backs off on 429 using Retry-After, and a high try count on a genuinely failing payload just burns your rate limit. Add ShouldBeUnique (or the package's equivalent) keyed on model + ID so double-clicking bulk sync doesn't queue the same record twice.

Serialise models by ID, not by model. Any custom job or listener you write around this package should carry $model->getKey() and re-fetch in handle() — a serialised Eloquent model rehydrates stale attributes and will happily push old data over newer HubSpot data.

Use the built-in page as your ops console. The CRM Sync page (connection test, mapping check, sync log, retry) is the first place to look when something goes wrong; wire it into your panel's navigation for whoever owns CRM hygiene, and restrict it with canAccess / policies since it exposes sync history.

Testing. Http::fake() with HubSpot-shaped responses (including a 429 with a Retry-After header) covers the happy path and the backoff. Assert on the queued job rather than on API calls where possible, and keep one integration-style test that runs a full sync against a fake so mapping regressions surface.

Gotchas and Tips

The release has never been run against a real HubSpot account. That is the headline risk. Fixtures and a mock are good evidence of internal consistency, not of HubSpot's actual validation rules. Before production: point it at a free HubSpot developer test account, sync one of each object type, and verify contacts, companies, deals, associations and the activity feed all land as expected. Budget a day for this, not an hour.

HubSpot property names are internal names, not labels. firstname, not "First Name". Custom properties must already exist in the portal — the API will reject or silently ignore unknown ones depending on endpoint. Enumerated properties (lifecycle stage, deal stage, pipeline) require the internal value, not the display label, and deal stages are pipeline-scoped: a hard-coded 'appointment scheduled' will break the moment someone edits the pipeline. Resolve IDs/values from config, or map them from your own enum.

Email is the identity. Changing a mapped email in your app creates a second contact in HubSpot rather than renaming the first — the stored HubSpot ID is retained but the upsert key has changed. Decide up front whether email is immutable for synced records; if not, plan a merge step (manual, in HubSpot).

Skipping unchanged records depends on dirty detection. If most of your mapping is closures or dot paths, Laravel may not see the model as dirty when the underlying data changes (or may see it as dirty when nothing changed). Symptom: nothing syncs, or everything syncs. Fix: keep stable scalar attributes in the mapping, and reach for a forced/manual re-sync when you know the state is stale.

Dot-path mappings and N+1. 'company.name' on a queued sync of 5,000 leads is 5,000 extra queries. Eager-load on the bulk selection path, and prefer denormalising the handful of CRM-relevant fields onto the synced model.

Errors are masked and the token is hidden — good for security, annoying for debugging. When a sync fails with a generic message, check in this order: the sync log entry on the CRM Sync page, storage/logs/laravel.log (the package shouldn't log the token, but the HTTP status and HubSpot error body usually appear there), then the mapping check on the CRM Sync page, then re-run a single record with the row action so you get the synchronous path. Don't dd() the config array in a shared environment.

Scopes are a silent failure mode. Missing crm.objects.companies.write on the private app yields a 403 on association, not on the contact — so the contact syncs, the company doesn't, and the association is skipped. Verify all required scopes at setup, not per-incident.

Rate limits are per-token and shared with everything else using that private app. If the same token also serves a marketing tool or a Zap, your backoff tuning fights theirs. Use a dedicated private app per integration, and consider a lower concurrency / longer interval in the package config than you think you need.

Bulk backfills: run them off-peak, in batches, and check the first batch. Queue the whole selection, then let it settle and inspect the sync log for the first ~100 records before walking away. Failed jobs + a retried bulk action can double-sync records if uniqueness isn't enforced on the queued job.

Filament version drift. Filament 4 and 5 are both supported and both tested in the browser, but action/table APIs shifted between majors — if you copy snippets from Filament 3 docs into the panel you'll get confusing errors unrelated to this package. You must be on Filament 4+; v3 is not supported.

No delete/archive propagation is described. Assume deleting a record locally leaves the HubSpot record intact. If GDPR/deletion semantics matter to you, implement it explicitly (call HubSpot yourself on deleted) and don't assume the package covers it.

Extension point for other CRMs: the driver contract is published but Pipedrive/Salesforce are not implemented. Adding one means implementing the contract and binding it — a reasonable path, but you're writing and maintaining that driver yourself, including its own rate-limit and upsert semantics, which are not transferable from the HubSpot driver.

Upgrades: v1.0.0 is the first release, so there's no migration story yet. Pin the version in composer.json and read changelogs before bumping — this package writes to your database and to a third-party system, so a silent behaviour change in mapping resolution is expensive to discover after the fact.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log