I had a bug where metrics were being duplicated every time a campaign changed stages. Two metrics would become four, then eight. The data looked fine in the form — the duplicates appeared silently in the database.
“there is a bug somewhere when moving campaign stages that duplicates metrics”
The root cause was a single line of code that looked perfectly reasonable.
Table of contents
Open Table of contents
The bug
My campaign edit page saves metrics using Laravel’s updateOrCreate. The idea is simple: if the metric already exists (by ID), update it. If it doesn’t, create it.
$this->experiment->metricResults()->updateOrCreate(
['id' => $metric['id'] ?? null],
[
'name' => $metric['name'],
'start_value' => $metric['start_value'],
'end_value' => $metric['end_value'],
]
);
This looks correct. If $metric['id'] exists, match on it. If not, pass null and let updateOrCreate create a new record.
The problem: when the ID is null, this always creates a new record. Every single time. Even if the metric was just created a moment ago.
Why SQL id = NULL never matches
I asked:
“I don’t understand this properly, how can metric id be null?”
The answer comes down to how SQL handles NULL. When updateOrCreate receives ['id' => null], it generates a query like:
SELECT * FROM metric_results WHERE id = NULL
In SQL, NULL is not a value — it’s the absence of a value. The expression id = NULL doesn’t check “is this column null?” — it evaluates to NULL (neither true nor false). The correct SQL for checking nulls is id IS NULL, but that’s not what Laravel generates here.
Since id = NULL never matches any row, updateOrCreate always falls through to the “create” branch. Every save creates a new metric instead of updating the one that was just created.
The fix
Guard against null IDs explicitly:
// Before: always creates duplicates when id is null
$this->experiment->metricResults()->updateOrCreate(
['id' => $metric['id'] ?? null],
[
'name' => $metric['name'],
'start_value' => $metric['start_value'],
'end_value' => $metric['end_value'],
]
);
// After: create when no ID, update when there is one
if (!empty($metric['id'])) {
$this->experiment->metricResults()->updateOrCreate(
['id' => $metric['id']],
[
'name' => $metric['name'],
'start_value' => $metric['start_value'],
'end_value' => $metric['end_value'],
]
);
} else {
$this->experiment->metricResults()->create([
'name' => $metric['name'],
'start_value' => $metric['start_value'],
'end_value' => $metric['end_value'],
]);
}
There’s one more piece. After saving all metrics, refresh the data so that newly created records have their database IDs:
// After the loop — refresh with DB IDs to prevent
// duplicates on the next save
$this->getMetrics();
Without this refresh, the next save still sees null IDs for the metrics that were just created, and creates duplicates again.
When this happens
This bug is most likely to appear when:
- Forms allow adding new items dynamically — the user adds a metric in the form, saves, adds another, saves again. The first metric has no ID on the first save.
- Records are created in bulk — looping over an array where some items are new (no ID) and some are existing (have an ID).
- Stage transitions trigger saves — moving a campaign to a new stage re-saves all associated data, and any items without IDs get duplicated.
The pattern is always the same: data that starts without an ID goes through updateOrCreate, which creates instead of matching.
The rule
Never pass null as a match key to updateOrCreate. If you don’t have an ID yet, use create() directly. If you do have an ID, use updateOrCreate() with a real value.
| Scenario | Method | Why |
|---|---|---|
| New record (no ID) | create() | No existing record to match |
| Existing record (has ID) | updateOrCreate(['id' => $id]) | Matches by ID, updates fields |
| Unknown (might be new) | Check first, then branch | updateOrCreate(['id' => null]) never matches |
Key takeaway
updateOrCreate(['id' => null]) silently creates duplicates because SQL id = NULL never matches anything. It’s one of those bugs that looks like a data issue — duplicate rows appearing from nowhere — when it’s actually a logic issue. Always guard against null IDs: use create() for new records and updateOrCreate() only when you have a real ID to match on.