One person, four contacts
An event platform for a nonprofit, where the same applicant existed several times over and nobody could tell which record was real.
Role
Contract developer on a small team, inherited codebase
Duration
June 2021 to February 2022
Stack
Laravel, Livewire, Alpine.js, Tailwind CSS, MySQL, Salesforce, FormAssembly, Authorize.Net
Scale
Several thousand users
What was wrong
The site held user accounts. Salesforce held contacts, and the marketing flows that ran off them. In principle each person was one account and one contact. In practice a single person could have several contacts, and the site often couldn't tell which one belonged to them.
None of it was a bug. Every cause was wiring or ordinary human behaviour:
- Salesforce had more than one email field, and a form had been wired to write into the wrong one. A person's email would land somewhere the matching logic wasn't looking, so a new contact got created instead of an existing one being found.
- People submitted a later form with a different email address than the one they'd used before.
- The form platform could prefill a returning applicant's details, but only when it was wired to do so. Where it wasn't, an applicant retyping their own details slightly differently produced another record.
That's the failure mode worth noticing. Nothing threw an error. The system did exactly what it was configured to do, and the data drifted anyway.
Stopping the drift first
Fixing existing records is pointless while new ones are still being created wrongly, so the intake path went first.
Forms moved to FormAssembly, submitting to both Salesforce and to a receiving application I built on the site side, which normalised each submission into the site's own shape. One submission, two destinations, consistent from the start. The email field wiring was corrected so submissions landed where the matching logic would actually find them.
Payments moved at the same time. Card details had been taken over the phone into a point-of-sale terminal, with transaction details then entered by hand into the site and Salesforce — a manual step at exactly the point where mistakes are expensive. FormAssembly's Authorize.Net integration handled payment on submission for this site's event registrations, so the payment arrived with the record rather than being reattached to it afterwards.
Reconciling what was already broken
That left the records already in the system.
The obvious approach is a batch job: sweep everything, match by some rule, merge what looks the same. I didn't do that. Merging contacts is irreversible, the join key was demonstrably unreliable — that was the whole problem — and a bad merge across several thousand users is not something you unwind.
So the work happens per user, at login. Each account carries a flag for whether it has been reconciled. An unreconciled user gets a prompt on their next login asking to sync their data. They agree, it runs immediately, and the page refreshes with their details filled in.
For this audience that lands naturally. Anyone logging in is there to register for an event or check an application, so being asked to complete their own details is what they came to do.
Matching, when the identifier can't be trusted
The sync searches Salesforce for the user, and falls back as evidence gets weaker.
First by email, across every candidate field rather than just the expected one. Contacts had scattered across those fields — that's what created the duplicates — so searching all of them is what finds records the original matching missed.
When no email matched at all, which happens when Salesforce holds an address the person has since stopped using, it fell back to first name, last name and address together.
Everything found for that person is merged through Salesforce's native merge into a single surviving contact, with the most recent value winning where fields disagree. That contact is then associated with the Laravel account and fills in whatever the site was missing.
The risk I accepted
The fallback tier is weaker evidence than the primary one. Email is close to unique. Name and address together are not — two people at one address sharing a full name is uncommon but real.
For that to cause a wrong merge, it would have to coincide with the email match having already failed. Two rare things at once, in a population of a few thousand. We accepted it.
I'd do it differently now. A merge can't be undone, so a low probability against an unrecoverable outcome deserves more than a probability argument — and the fix is cheap. Show the matched contact in the prompt and let the person confirm it's them. The prompt was already there. It just wasn't showing them what they were agreeing to.
Why it runs inline
The sync calls Salesforce during the request, with a spinner, and takes a few seconds.
The reflex is to queue that. I didn't, for two reasons. The page the user is about to see is built from the data being reconciled, so deferring the work means rendering a page you already know is about to change. And a queue would have meant workers, monitoring, failure handling and an intermediate state to explain to users — real infrastructure, on a site that otherwise needed none of it, for an operation each person performs exactly once.
If it ran on every request, or took long enough to look broken, that calculation flips. It ran once per user and finished in seconds.
Where it ended up
New submissions write to both systems consistently. Existing accounts reconcile themselves as people log in. Event payments are captured with the registration rather than keyed in afterwards.
The engagement ran eight months.