Table of Contents
Ever had users report that certain records mysteriously disappear when they apply filters, even though the filter options clearly show those values exist? I recently debugged one of these “phantom disappearance” bugs, and the culprit was a sneaky mismatch between display logic and filter logic.
The Symptom
Users could see “Urgent” in the priority filter dropdown. But when they selected it, several projects that should match just vanished from the results. Without the filter? The projects showed up fine.
The Investigation
I traced two separate code paths:
1. Display Logic (Filter Dropdown Options)
// Controller - populating the dropdown
$priorities = collect(['Low', 'Medium', 'High', 'Urgent'])
->concat(
Task::active()
->distinct()
->pluck('priority')
)
->unique()
->values();
The dropdown showed priorities from ALL active tasks in the system. So “Urgent” appeared because somewhere in the database, urgent tasks existed.
2. Filter Logic (What Actually Gets Queried)
// Later in the controller
if ($request->input('priority')) {
$query->whereJsonContains('project_summaries.cached_priorities',
$request->input('priority'));
}
But the filter checked a denormalized JSON field on the project_summaries table, not the live tasks.
The Root Cause
Project #4251 had 6 urgent tasks assigned to it. But its project_summaries.cached_priorities field was an empty array: [].
Why? The refresh job that populated the cache had a bug—it only collected priorities from completed tasks, not active ones. Since this project had zero completed tasks, its cache stayed empty.
Result:
- Display: “Urgent” shows in dropdown (because urgent tasks exist globally)
- Filter:
whereJsonContains(cached_priorities, 'Urgent')on Project #4251 returns FALSE (because its cache is[]) - User Experience: Project disappears when filtering by Urgent
The Fix
Two options:
- Fix the cache population logic – collect priorities from all tasks (active + completed)
- Change the filter to query live data – join directly to the tasks table instead of checking the cache
I went with option 1 (fix the cache) since the denormalized field existed for performance reasons. But I added a verification step: after every refresh, count tasks with each priority and compare to the cached field. Log mismatches to Sentry.
The Lesson
When display logic and filter logic pull from different sources, you will have bugs.
Always verify:
- Where do the filter options come from? (Live query? Cache? Hardcoded list?)
- What field does the actual filter check? (Same source or different?)
- If they’re different sources, is there a sync mechanism? Does it work correctly?
Denormalized/cached fields are fast, but they’re only as good as the code that keeps them up to date. Trust, but verify.
Leave a Reply