A sortable Laravel and Vue table

The sort column is an allow-list. The direction is asc or desc. The database orders the rows, and the existing paginator links keep the query string.

For your AI agent

Copies a prompt. Paste it into the AI agent you already have open.

Read https://laravel-vuejs.space/laravel-vue-datatable.md
The sort column is an allow-list. The direction is asc or desc. The database orders the rows, and the existing paginator links keep the query string.
Do not pass id through just because the URL contains it, unless the table should sort by id.
Do not keep a second sort ref in the component.
Show the diff. Do not commit.

Next: Show a queued Laravel job in Vue

On this page · 8 sections
  1. The allow-list
  2. The header
  3. A test
  4. Where this stops
  5. Recap
  6. FAQ
  7. Next steps
  8. Sources

You click “Title” and the rows sort beautifully, then page two comes back in the old order. The browser sorted ten rows; the database never heard about it. Here is a sortable, searchable table where Laravel does the sorting and Vue only draws the header.

TL;DR

  • Sort in SQL, never in the browser: the table must match every page.
  • Allow-list the column and force the direction to asc or desc.
  • withQueryString() keeps sort, dir and search on page links.
  • The server’s filters prop is the single source for the active sort.

What you'll learn

  • How to sort in the query with an allow-list
  • How to build sort links that keep the filter
  • How to test that a bad column is ignored

Who it's for: Laravel + Vue developers building admin tables

Applies to: Laravel 13 · Inertia 3 · Vue 3 · Pest

A datatable plugin in the browser sorts the ten rows it was given. Page two is still in Laravel’s default order, so the table lies. Sort in the query. Reuse the pagination you already have. This page does not install a jQuery grid.

The list, the search box, and paginate(10) are the CRUD guide. Previous and Next without a total are pagination. The app is Inertia, a SPA, or Nuxt.

The allow-list#

Column injection, then Allow-listed column and direction:

app/Http/Controllers/PostController.phpphp
->orderBy($request->input('sort'), $request->input('dir'))
app/Http/Controllers/PostController.phpphp
$column = in_array($sort, ['title', 'published_at'], true) ? $sort : 'published_at';
$direction = $request->string('dir')->toString() === 'asc' ? 'asc' : 'desc';
// ...
->orderBy($column, $direction)

Gotcha: user input in orderBy

orderBy($request->input('sort')) is a column injection. The visitor picks the column from a list you wrote down. The direction is asc or it is desc. Anything else becomes desc.

app/Http/Controllers/PostController.phpphp
public function index(Request $request): Response
{
    $sort = $request->string('sort')->toString();
    $column = in_array($sort, ['title', 'published_at'], true) ? $sort : 'published_at';
    $direction = $request->string('dir')->toString() === 'asc' ? 'asc' : 'desc';
    $search = $request->string('search')->trim()->toString();

    return Inertia::render('posts/Index', [
        'filters' => [
            'search' => $search,
            'sort' => $column,
            'dir' => $direction,
        ],
        'posts' => Post::query()
            ->when($search !== '', fn ($query) => $query->where('title', 'like', "%{$search}%"))
            ->orderBy($column, $direction)
            ->paginate(10)
            ->withQueryString(),
    ]);
}

Tip: sorted header, unsorted rows

withQueryString() keeps sort, dir, and search on the page links. Without it, Next returns to the default order and the header looks sorted while the rows are not.

published_at and title match the posts table from the setup guide. Add a column to the in_array list only after the migration exists. Do not pass id through just because the URL contains it, unless the table should sort by id.

The header#

The link toggles direction when it is already the active column. It sends the user back to page 1 by leaving page off the URL. A sort change that stays on page 5 shows an empty page often enough to look broken.

resources/js/pages/posts/Index.vuevue
<script setup lang="ts">
import { Link } from "@inertiajs/vue3";

const props = defineProps<{
  filters: { search: string; sort: string; dir: "asc" | "desc" };
}>();

function sortHref(column: string) {
  const dir =
    props.filters.sort === column && props.filters.dir === "asc"
      ? "desc"
      : "asc";
  const params = new URLSearchParams({
    sort: column,
    dir,
  });

  if (props.filters.search) {
    params.set("search", props.filters.search);
  }

  return `/posts?${params.toString()}`;
}
</script>

<template>
  <table>
    <thead>
      <tr>
        <th>
          <Link :href="sortHref('title')" preserve-scroll>Title</Link>
        </th>
        <th>
          <Link :href="sortHref('published_at')" preserve-scroll
            >Published</Link
          >
        </th>
      </tr>
    </thead>
  </table>
</template>

Heads up: one source for the sort state

The body is still v-for="post in posts.data". The header is the only new markup. Mark the active column with the filters.sort string if you want an arrow. The server state is the arrow. Do not keep a second sort ref in the component.

A test#

Try to sort by password

tests/Feature/PostsSortTest.phpphp
it('ignores a sort column that is not allowed', function () {
    Post::factory()->create(['title' => 'A']);
    Post::factory()->create(['title' => 'B']);

    $this->get('/posts?sort=password&dir=asc')
        ->assertOk()
        ->assertInertia(fn ($page) => $page
            ->where('filters.sort', 'published_at')
            ->where('filters.dir', 'desc')
        );
});

password is not on the post, and it must not reach orderBy. The filter prop is the easy assertion. A query exception is the failure mode if the allow-list is missing and the column does not exist.

Where this stops#

Column show and hide, CSV export, and editing a cell in place are different features. They do not belong on this query. Live validation of an edit form is Precognition. Who may open the row is a policy.

Recap#

  • Sort and search in the query; paginate after.
  • Allow-list columns; clamp the direction.
  • Keep the query string on every link.
  • Test the rejected column.

FAQ#

Why does my Vue datatable sort only the current page?

A client-side table sorts the rows it has. Sort in Laravel with orderBy and paginate after, so every page uses the same order.

Is it safe to pass the sort column from the URL to orderBy?

No. Check it against an allow-list of real columns and force the direction to asc or desc.

Why does sorting reset when I go to page 2?

Add withQueryString() to the paginator so sort, dir and search stay on the links.

Next steps#

Sources#

Found this useful?

Ask questions, share what you built.

Join thousands of Laravel and Vue developers in our Facebook group, or get new posts in your Telegram feed. Sharing this article helps too.

Keep reading

Newsletter

Get the best of Laravel & Vue, once a month.

One short email with new guides, release notes worth reading and hand-picked community links. No spam, unsubscribe any time.