0% прочитано

Laravel Search Analytics: Why Every /search Request Is Not a New Search

In ICanUp, one search can produce several GET /search requests: the original submit, tab changes, pagination, infinite scroll, or an Inertia partial reload. This article shows how I count only real search intent, track zero-result queries, and avoid persisting sensitive searches.

25 вересня 2026 р. 10 хв читанняLaravel

When I added Search Analytics to ICanUp, the simplest approach seemed to be counting every request to /search.

But one real search can easily generate several HTTP requests. A user enters a query and presses Search. Then they switch from Posts to Tips, scroll for more results, open another page, or trigger an Inertia request that asks only for results.

If every GET becomes a new search event, analytics starts measuring frontend implementation details instead of user search intent.

The main rule in this implementation is simple: Search Analytics counts explicit search intent, not every request to the search URL.

One /search URL Can Have Several Different Causes

At the HTTP level these requests can look very similar, but they mean different things for analytics.

Event

Count as a new search

The user enters a query and presses Search

Yes

The user switches from Posts to News or Tips

No

Infinite scroll loads more results

No

Pagination requests another page

No

Inertia requests only the results prop

No

The user opens an existing /search?q=... URL directly

No

A technical reload or prefetch occurs

No

That became the important distinction for me between a search request and a search event.

The Frontend Explicitly Marks a Real Search Submit

I did not try to infer user intent from the URL alone.

SearchService has a separate submit() method. Only that path adds the X-Icanup-Search-Submit: 1 header.

The normal openIndex() path does not add the marker, while loadMore() explicitly asks Inertia for only the results prop.

JavaScript - Explicit submit is separate from navigation and load more
export default {    submit(filters = {}, options = {}) {        return this.openIndex(filters, {            ...options,            headers: {                ...(options.headers ?? {}),                "X-Icanup-Search-Submit": "1",            },        });    },     openIndex(filters = {}, options = {}) {        return router.get(            route("search.index"),            filters,            {

If the product knows where meaningful user intent occurs, it is better to mark that event explicitly than to reconstruct intent from every HTTP request.

The Backend Does Not Trust One Header Alone

The marker alone is still not enough to count the request.

SearchIndexAction also validates the HTTP method, Inertia context, prefetch state, the page parameter, and the partial-request headers.

This matters because frontend state can evolve and a single header can accidentally propagate into a later technical request.

PHP - Server-side explicit submit guard
private function isExplicitSubmit(    Request $request,): bool {    if (        ! $request->isMethod('GET')        || $request->prefetch()        || (string) $request->header(            'X-Icanup-Search-Submit'        ) !== '1'        || ! $request->headers->has(            Header::INERTIA        )        || $request->query->has('page')    ) {        return false;    }
Even a request carrying X-Icanup-Search-Submit is ignored when it is pagination, an Inertia partial request, prefetch, or an infinite-scroll merge.

Search Analytics Runs Only After the Search Has Been Resolved

I also wanted to avoid executing the search again only so Analytics could learn how many results existed.

SearchService first creates the SearchPage. Only for a real submit does the tracker receive the query, locale, and totalAcrossProviders().

Search remains responsible for search work, while Analytics receives the already resolved information needed for measurement.

PHP - Track the explicit search using the existing SearchPage result
$searchPage = $this->searchService->page(    $type,    $query,); if (    $query !== ''    && $this->isExplicitSubmit($request)) {    $this->searchAnalyticsTracker->track(        query: $query,        locale: $this->application->getLocale(),        totalResults:            $searchPage->totalAcrossProviders(),    );}

Zero Result Means Zero Across All Search Providers

Search in ICanUp spans multiple content types: Posts, Categories, News, and Tips.

Zero results in the active tab does not necessarily mean the user found nothing. Posts may have zero matches while News or Tips still contain useful results.

Search Analytics therefore uses totalAcrossProviders(). zero_result_count increases only when the whole search has no result, not merely when one tab is empty.

PHP - A real submit records one search and one zero-result search
function explicitSearchHeaders(    array $extra = [],): array {    return [        Header::INERTIA => 'true',        Header::VERSION => Inertia::getVersion(),        'X-Icanup-Search-Submit' => '1',        ...$extra,    ];} $this    ->withHeaders(        explicitSearchHeaders()    )    ->get(

Direct GET, Tab Changes and Pagination Do Not Increase the Counter

I did not leave this behavior only as conditions inside the action. It is also part of the regression tests.

A direct URL, type switch, empty navigation, and page 2 can all work as valid Search requests while analytics_search_daily_aggregates remains unchanged.

PHP - Navigation and pagination are not new searches
$this->get(    route(        'search.index',        [            'q' =>                'direct link',             'type' =>                'posts',        ],    ))->assertOk(); $this->get(    route(        'search.index',

An Inertia Partial Request Is Not a New Search Either

Search infinite scrolling uses a partial reload where the frontend asks only for results.

Without an explicit rule, that GET could look like another search request and inflate the statistics every time the user simply continues scrolling.

PHP - Partial results request does not create Search Analytics
$this    ->withHeaders(        explicitSearchHeaders([            Header::PARTIAL_COMPONENT =>                'Public/Search/Index',             Header::PARTIAL_ONLY =>                'results',        ])    )    ->get(        route(            'search.index',            [                'q' =>                    'partial results',

Search Text Needs Its Own Privacy Boundary

A search query is different from an ordinary technical metric. A person can accidentally paste an email address, token, password assignment, API key, or another secret into Search.

The query therefore passes through separate normalization and privacy validation before persistence.

Unit tests cover whitespace normalization, Unicode compatibility characters, case normalization, empty and over-limit values, plus email addresses, JWT-like values, OpenAI-like, GitHub, GitLab and Slack tokens, Bearer tokens, password/API key assignments, and high-entropy values.

If a query is unsuitable for Analytics, Search itself still works. Only the analytics persistence is skipped.

PHP - Sensitive query works but is not persisted
$this    ->withHeaders(        explicitSearchHeaders()    )    ->get(        route(            'search.index',            [                'q' =>                    'person@example.com',                 'type' =>                    'posts',            ],        )    )
Search Analytics should not turn a search box into a secret log. The fact that a user submitted a string to Search does not automatically make that string safe to persist for analytics.

Search Analytics Is Separate From Ordinary Page Views

I do not store a search as another ordinary page-view metric.

Search has its own daily aggregate built around the normalized query, query hash, locale, searches_count, and zero_result_count.

This makes it possible to answer "what are people searching for?" and "what are they failing to find?" without mixing those concepts into general Content Analytics.

A regression test also verifies that a Search event does not create rows in the general analytics_daily_aggregates or analytics_hourly_aggregates tables.

Search is the user-facing capability. Analytics is secondary.

If the analytics repository becomes temporarily unavailable, a working search should not turn into a 500 only because statistics could not be persisted.

This boundary is also covered by a test: the repository throws an exception while the public Search response must still remain successful.

PHP - Search remains available when Analytics storage fails
$repository = Mockery::mock(    SearchAnalyticsRepositoryInterface::class); $repository    ->shouldReceive('increment')    ->once()    ->andThrow(        new RuntimeException(            'Search Analytics storage unavailable.'        )    ); app()->instance(    SearchAnalyticsRepositoryInterface::class,    $repository,

Analytics Should Not Repeat Expensive Search Work

Zero-result analytics needs the total number of matches across every provider.

A naive implementation could search Posts, Categories, News, and Tips again only for Analytics.

The test verifies that the active provider performs search() once and its paginator total is reused instead of issuing another count(). Other providers are counted only where the global total requires it.

PHP - Reuse provider work while calculating the global result total
$posts = new SearchProviderFake(    SearchTypeEnum::POSTS,    4,); $categories = new SearchProviderFake(    SearchTypeEnum::CATEGORIES,    3,); $news = new SearchProviderFake(    SearchTypeEnum::NEWS,    2,); $tips = new SearchProviderFake(

What Finally Counts as One Search

After this case, the contract became much clearer to me.

  1. The user explicitly submits a non-empty search query.
  2. The frontend marks the submit itself rather than every Search navigation.
  3. The backend verifies that the request is not prefetch, pagination, a partial request, or infinite scrolling.
  4. Search runs normally and produces its results.
  5. Analytics receives the normalized query, locale, and global result count.
  6. A sensitive or unsuitable analytics query is not persisted.
  7. If Analytics persistence fails, Search continues to work.

At that point searches_count represents actual search actions instead of the number of technical GET requests that happened to pass through one endpoint.

For product analytics, defining the event correctly matters more than finding a convenient controller location where a counter can be incremented.

Conclusion

Search Analytics in ICanUp did not start with a dashboard or an SQL aggregate. The first problem was defining what a search actually is.

One GET /search can represent a submit, direct link, tab change, pagination, or partial update. The endpoint itself is therefore not a sufficient analytics event.

Once the submit became explicit, the rest of the architecture became simpler: a dedicated tracker, normalized queries, zero-result counting, privacy filtering, separate aggregate storage, and fail-safe behavior.

The final "number of searches" now represents what a person expects that metric to mean, not how many HTTP requests the frontend happened to generate.