skip to content

In a Laravel app, why do pagination links from $activities->links() drop the active filters, and how do withQueryString(), appends() and fragment() fix it?

level: middleimportance: should knowfreq 45%

answer

  1. base path = current URL, no query
  2. only the page key is added
  3. withQueryString() copies request query
  4. appends(['sport' => 'trail'])
  5. fragment('feed') adds #feed

basics

~20 s

A paginator builds its links from the current URL without its query string and adds only the page parameter, so ?sport=trail is lost. withQueryString() copies the whole current query string, appends() adds chosen keys, and fragment() adds a #hash.

solid answer

~40 s

Laravel resolves a paginator's base path from `$request->url()`, which has no query string, and each link is that path plus `?page=N`. On `/activities?sport=trail`, page 2 therefore links to `/activities?page=2` and the filter vanishes. `withQueryString()` appends every value from the current request's query string (except the page key itself), `appends(['sport' => 'trail'])` or `appends('sport', 'trail')` adds only the keys you choose, and `fragment('feed')` adds `#feed` so the browser jumps back to the list. All three return the paginator, so they chain: `Activity::filter($request)->paginate(20)->withQueryString()->fragment('feed')`. `withPath('/me/activities')` changes the base path, and passing a different `$pageName` (`paginate(20, ['*'], 'clubPage')`) lets two paginators share one page without clashing.

code

php · 15 lines
php
<?php

use App\Models\Activity;
use Illuminate\Http\Request;

Route::get('/activities', function (Request $request) {
    $activities = Activity::query()
        ->when($request->query('sport'), fn ($q, $sport) => $q->where('sport', $sport))
        ->latest()
        ->paginate(20)
        ->withQueryString()   // keep ?sport=... in every link
        ->fragment('feed');   // jump back to #feed

    return view('activities.index', ['activities' => $activities]);
});

go deeper

for a junior

Recall that paginator links lose filters unless you call withQueryString() or appends(), and that fragment() adds a #hash.

for a middle

Explain how the paginator builds URLs from its path, query array and page name, and how custom page names separate two paginators.

for a senior

Choose between copying the whole query string and an allow-list with appends(), considering cache keys and unexpected parameters on public pages.

for a principal

Standardise how list endpoints carry filter state in URLs so links stay shareable, cacheable and consistent across the team's pages.

## How a paginator builds its links A Laravel paginator does not look at the request when it renders; it builds every URL from three stored pieces: - the **path**, resolved when the paginator is created from `$request->url()`, which is the current URL **without** its query string; - the **query array**, which starts empty; - the **page name**, `page` by default (or `cursor` for a cursor paginator). `url($page)` merges the query array with `[pageName => $page]`, appends it to the path, and adds a fragment if one is set. Because the query array starts empty, the only parameter a fresh paginator ever writes is the page number. ## The bug A social running app lists activities at `/activities?sport=trail&distance=10k`. The controller filters with those parameters and calls `paginate(20)`. The first page is correct, but the link to page 2 is `/activities?page=2`: the filters are gone, so page 2 shows unfiltered activities. This is the classic pagination bug in Laravel apps, and it happens with all three paginator types. ## The fixes | Method | What it adds | Use when | |---|---|---| | `withQueryString()` | every key in the current request's query string | links should keep all filters and sort options | | `appends(['sport' => 'trail'])` | only the keys you pass | you want an explicit allow-list | | `appends('sport', 'trail')` | one key | adding a single value | | `fragment('feed')` | `#feed` at the end of each URL | the list sits below other content | | `withPath('/me/activities')` | replaces the base path | links should point to another route | Details worth knowing: 1. `appends()` and `withQueryString()` never overwrite the page key; a `page` value in the query string is ignored so it cannot fight the link's own page number. 2. `withQueryString()` copies whatever is in the query string at the time of the call, including parameters you did not expect; `appends()` with an allow-list is stricter. 3. The methods mutate and return the same paginator, so the order of the chain does not matter. 4. Cursor paginators support `withQueryString()`, `appends()` and `fragment()` in the same way; only the key is `cursor` instead of `page`. ## Where the current page comes from The other half of the round trip is reading the position back. When `paginate()` or `simplePaginate()` runs without an explicit page, Laravel asks the request for the page name's input value. Only an integer of 1 or more is accepted; anything else, such as `?page=abc` or `?page=-2`, silently becomes page 1. A cursor paginator reads `?cursor=` and decodes it; a value that is not a valid cursor is treated as no cursor, so the first page is returned. That is why links must carry exactly the key the paginator reads: a link that drops `sport` changes the result set, and a link with the wrong page name is simply ignored. ## Two paginators on one page A profile page might show a user's activities and their clubs, each paginated. Both default to `?page=`, so moving one moves the other. Pass a distinct page name as the third argument: `paginate(10, ['*'], 'clubPage')`. Each paginator then reads and writes its own key, and `withQueryString()` keeps the other paginator's position in the links. ## Rendering In Blade, `{{ $activities->links() }}` renders the default pagination view with these URLs. `links()` returns a rendered view, which is `Htmlable`, so the double-brace echo outputs it without escaping. The URLs themselves are only as correct as the paginator's path and query array, which is why the fix belongs on the paginator in the controller, not in the view. ## What to say in an interview Name the cause (links are built from the path without the query string), give both fixes (`withQueryString()` for everything, `appends()` for an allow-list), and mention `fragment()` and custom page names as the follow-on details. That shows you have actually debugged a filtered, paginated list rather than only read about one.

  • Why might you prefer appends() over withQueryString() on a public list?
    `withQueryString()` copies every key in the current query string into every link, including tracking parameters or junk a visitor added. `appends()` with an explicit array of known filters keeps the links, and any cached pages, limited to the parameters the page actually supports.
  • How do you stop two paginators on one Laravel page from sharing ?page=?
    Give one a different page name through the third argument, such as `paginate(10, ['*'], 'clubPage')`, or the matching argument on `simplePaginate()` and `cursorPaginate()`. Each paginator then reads and writes its own query key.

saying these in an interview costs you the question

  • The paginator copies the request's query string into links by default
  • withQueryString() must be called in the Blade view, not the controller
  • appends() can override the page number in the links
  • fragment() adds a query parameter named fragment
  • Two paginators on one page stay independent automatically