Backpackerlist - Rebuilding a Candidate Search API from 2,000 Lines to 100
Rewriting a job portal's core candidate-listing API — a 2,000+ line tangle of nested if/else filter logic powering both a list view and a live map view — into a ~100-line Eloquent query builder that fixed an N+1 query problem and added real pagination.
2,000+ → ~100
Filter logic LOC
N+1 → fixed
Queries per listing request
Added
Pagination
2
Views unified on one query
Problem
Backpackerlist's candidate search endpoint had grown into a 2,000+ line procedural block of nested conditionals handling every filter combination (location, job category, interests, availability, and more) by hand, duplicated across two nearly identical endpoints — one for the standard list view, one for showing candidates on a Google Map. The API had no real pagination, loaded every matching candidate's related data in a loop, and was becoming harder to safely extend every time a new filter was added.
Challenges
- Untangling 2,000+ lines of nested if/else filter logic without changing the actual filtering behavior recruiters relied on
- Two separate endpoints (list view and map view) had drifted out of sync despite needing to return equivalent filtered results
- An N+1 query problem was firing on every request as related candidate data was fetched in a loop instead of eagerly
- The listing had no pagination — every filtered request returned the full result set in one response
- Replacing the filter logic without regressing edge-case filter combinations that had accumulated over time
Architecture
Replaced the hand-rolled conditional chains with a single Eloquent query builder pipeline: each filter (location, job category, interests, availability) maps to a scoped query clause that's conditionally applied only when that filter is present in the request, rather than branching through nested if/else blocks. The list view and map view now share the same underlying query builder and only diverge in the final response shape, eliminating the drift between them. Relations that were previously being lazy-loaded inside loops were converted to eager loads, collapsing what had been one query per candidate into a small, fixed number of queries per request.
Filter request parameters mapped to a chain of conditional Eloquent query scopes instead of nested if/else branches
List view and map view endpoints unified onto the same underlying query builder, diverging only at response formatting
Related models switched from lazy-loaded, per-row queries to eager loading (with()) to eliminate the N+1 pattern
Pagination added to the response, replacing the previous full-result-set behavior
Infinite-scroll trigger on the frontend calls the next page automatically as the user reaches the end of the list
Database Design
Candidate, location, job-category, and interest data were already relational but were being queried independently per candidate. Formalizing the Eloquent relationships (belongsTo/belongsToMany where appropriate) let the query builder eager-load everything a listing needed in a small, fixed number of queries regardless of result size, instead of query count scaling with the number of candidates returned.
Scalability
Because filters are now composable query scopes rather than branching logic, adding a new filter became a small, isolated addition instead of a risk to the existing conditional tree. Pagination plus eager loading meant response time and query count no longer scaled with the total number of matching candidates, which mattered as the candidate pool grew.
Outcome
Cut the core listing logic from 2,000+ lines to roughly 100 by replacing nested conditionals with composable query scopes, eliminated the N+1 query pattern via eager loading, and shipped pagination with auto-loading infinite scroll on both the list and map views.
Tech Stack
Have a similar problem you're trying to architect your way through?