Pagination, Search & Filtering
paginate(), query scopes, filter forms and keeping the query string.
Nearly every admin screen or listing page is the same feature: a search box, a few filters, a sort order and pages of results. Laravel has a tool for each part, and they combine into one readable query.
All the examples in this lesson run against a real endpoint in this app, GET /api/demo/tasks/search. Try it first: type a word, change a filter, flip the page.
Paginating results
Swap get() for paginate() and Laravel does the rest. It reads ?page= from the request, adds LIMIT/OFFSET, runs a COUNT(*) for the total, and returns a paginator that knows about pages and links:
$tasks = Task::orderBy('id')->paginate(10); // reads ?page= automatically
$tasks->total(); // 20 (from the extra COUNT query)
$tasks->currentPage(); // 1
$tasks->lastPage(); // 2
$tasks->hasMorePages(); // true
In Blade, {{ $tasks->links() }} renders the page links. In an API, return the paginator through a resource and Laravel adds links and meta blocks for you:
Look at the response. data holds three tasks, meta has current_page, last_page and total, and links has ready-made next/prev URLs.
Three kinds of paginator
| Method | Extra query | Gives you | Best for |
|---|---|---|---|
paginate() |
COUNT(*) |
Page numbers, total, last page | Admin tables, numbered pages |
simplePaginate() |
none | Next / previous only | Big tables where counting is slow |
cursorPaginate() |
none | An opaque next_cursor |
Infinite scroll, feeds, very large or fast-changing data |
Our endpoint takes a paginator parameter so you can compare them. Notice that simple has no total, and cursor returns a next_cursor instead of a page number:
OFFSET 10000 makes the database read and throw away 10,000 rows, so deep pages get slower. A cursor says "rows after id 42" instead (WHERE id > 42 LIMIT 3), which stays fast at any depth. The trade-off is that you can't jump straight to page 7.
Adding search with a query scope
Put the search condition on the model as a local scope, so every controller can reuse it. This is the real scope on Task:
app/Models/Task.php
/**
* Case-insensitive match on title or description.
*/
#[Scope]
protected function search(Builder $query, string $term): void
{
$query->where(function (Builder $query) use ($term) {
$query->whereLike('title', "%{$term}%")
->orWhereLike('description', "%{$term}%");
});
}
The inner where(function …) matters. Without it, the OR would escape and combine with your other filters:
-- Without grouping: the priority filter only applies to the description match
WHERE title LIKE '%api%' OR description LIKE '%api%' AND priority = 'high'
-- With grouping (what the scope produces)
WHERE (title LIKE '%api%' OR description LIKE '%api%') AND priority = 'high'
LIKE '%term%' can't use an index, so it scans the whole table. It's fine for thousands of rows. Beyond that, use a full-text index ($table->fullText(['title', 'description']) with whereFullText()) or Laravel Scout with a search engine such as Meilisearch.
Optional filters with when()
Filters are optional. when($value, $callback) only adds the condition when the value is filled, so you avoid a pile of if statements. Here is the whole controller behind the demo:
app/Http/Controllers/Api/TaskSearchController.php
public function __invoke(Request $request): AnonymousResourceCollection
{
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:100'],
'priority' => ['nullable', Rule::enum(TaskPriority::class)],
'status' => ['nullable', Rule::in(['open', 'completed'])],
'sort' => ['nullable', Rule::in(['title', '-title', 'due_on', '-due_on', 'id'])],
'per_page' => ['nullable', 'integer', 'between:1,50'],
'paginator' => ['nullable', Rule::in(['length', 'simple', 'cursor'])],
]);
$paginator = $filters['paginator'] ?? 'length';
// Cursor pagination needs a unique, non-null sort column
$sort = $paginator === 'cursor' ? 'id' : ($filters['sort'] ?? 'id');
$perPage = (int) ($filters['per_page'] ?? 5);
$query = Task::query()
->with(['project', 'assignee'])
->when($filters['q'] ?? null, fn ($query, string $term) => $query->search($term))
->when($filters['priority'] ?? null, fn ($query, string $priority) => $query->priority(TaskPriority::from($priority)))
->when(($filters['status'] ?? null) === 'open', fn ($query) => $query->open())
->when(($filters['status'] ?? null) === 'completed', fn ($query) => $query->completed())
->orderBy(ltrim($sort, '-'), str_starts_with($sort, '-') ? 'desc' : 'asc')
->when($sort !== 'id', fn ($query) => $query->orderBy('id'));
$tasks = match ($paginator) {
'simple' => $query->simplePaginate($perPage),
'cursor' => $query->cursorPaginate($perPage),
default => $query->paginate($perPage),
};
return TaskResource::collection($tasks->withQueryString());
}
The controller does several things worth copying:
- Validate every filter. Sorting and filtering read straight from the URL, so treat the URL as user input.
- Whitelist sort columns with
Rule::in(). Never pass$request->sortstraight intoorderBy(), or users could sort by columns you never meant to expose. - Eager load with
with(). Each page renders the project and assignee, so this avoids an N+1 per row (see Eloquent Relationships). - Add a tie-breaker.
orderBy('id')after the main sort keeps rows with equal titles or dates in a stable order, so no row appears on two pages.
Send an invalid sort and the whitelist answers with a 422:
The smallest version: the main task list
You don't always need a dedicated search endpoint. The app's main listing, GET /api/demo/tasks, has the same shape in fewer lines. It's a title-only search filter alongside the others, with a fixed page size and order:
app/Http/Controllers/Api/TaskController.php
public function index(Request $request): AnonymousResourceCollection
{
$filters = $request->validate([
'priority' => ['nullable', Rule::enum(TaskPriority::class)],
'status' => ['nullable', Rule::in(['open', 'completed'])],
'project' => ['nullable', 'string', 'exists:projects,slug'],
'search' => ['nullable', 'string', 'max:100'],
]);
$tasks = Task::query()
->with(['project', 'assignee', 'tags'])
->when($filters['priority'] ?? null, fn ($query, string $priority) => $query->priority(TaskPriority::from($priority)))
->when(($filters['status'] ?? null) === 'open', fn ($query) => $query->open())
->when(($filters['status'] ?? null) === 'completed', fn ($query) => $query->completed())
->when($filters['project'] ?? null, fn ($query, string $slug) => $query->whereRelation('project', 'slug', $slug))
->when($filters['search'] ?? null, fn ($query, string $search) => $query->whereLike('title', "%{$search}%"))
->orderBy('id')
->paginate(10)
->withQueryString();
return TaskResource::collection($tasks);
}
Searching a single column needs no grouping closure, because there's no OR to leak. Once you match several columns, move the condition into a scope like search() above.
Keeping filters in the URL
withQueryString() copies the current query string into every pagination link. Without it, clicking "page 2" would drop your search term. Compare the links.next value in this response:
Keeping filters in the URL also means a filtered page can be bookmarked, shared, and reached with the back button. In a server-rendered page the filter form is a plain GET form. Illustrative:
<form method="GET" action="{{ route('tasks.index') }}">
<input type="search" name="q" value="{{ request('q') }}" placeholder="Search tasks">
<select name="priority">
<option value="">Any priority</option>
@foreach (App\Enums\TaskPriority::cases() as $priority)
<option value="{{ $priority->value }}" @selected(request('priority') === $priority->value)>
{{ $priority->label() }}
</option>
@endforeach
</select>
<button>Filter</button>
</form>
@foreach ($tasks as $task)
<x-task-row :task="$task" />
@endforeach
{{ $tasks->links() }}
Paginating relationships and collections
You can paginate a relationship query the same way:
$project->tasks()->open()->latest()->paginate(15);
An in-memory collection has no paginate() method. Paginate in the database whenever you can. If you really must page an array, build a LengthAwarePaginator by hand:
$page = LengthAwarePaginator::resolveCurrentPage();
$items = collect($rows)->forPage($page, 15);
$paginator = new LengthAwarePaginator($items, count($rows), 15, $page, [
'path' => $request->url(),
'query' => $request->query(),
]);
Key takeaways
paginate()gives you numbered pages with a total.simplePaginate()skips the count, andcursorPaginate()stays fast at any depth.- Put search conditions in a model scope, and wrap
ORconditions in a closure so they don't leak into other filters. - Use
when()for optional filters. Validate every filter, and whitelist sort columns. - Always add a unique tie-breaker sort and eager load what each row shows.
withQueryString()keeps filters on every page link, so filtered pages can be bookmarked and shared.