# 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.

- URL: https://laravel-vuejs.space/laravel-vue-datatable
- Author: Anass Ez-zouaine (https://laravel-vuejs.space/author/anass-ez-zouaine)
- Published: 2026-10-10
- Topics: Laravel, VueJs, Inertia

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](https://laravel-vuejs.space/laravel-inertia-vue-crud). Previous and Next without a total are [pagination](https://laravel-vuejs.space/laravel-vue-pagination). The app is [Inertia, a SPA, or Nuxt](https://laravel-vuejs.space/laravel-vue-in-2026).

## The allow-list

**Column injection:**

`app/Http/Controllers/PostController.php`
```php
->orderBy($request->input('sort'), $request->input('dir'))
```

**Allow-listed column and direction:**

`app/Http/Controllers/PostController.php`
```php
$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.php`
```php
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.vue`
```vue
<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.php`
> ```php
> 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](https://laravel-vuejs.space/laravel-precognition-vue). Who may open the row is a [policy](https://laravel-vuejs.space/laravel-vue-policies).

## 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

**Q: 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.

**Q: 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`.

**Q: 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

- [Laravel and Vue pagination](https://laravel-vuejs.space/laravel-vue-pagination)
- [CRUD with Inertia and Vue](https://laravel-vuejs.space/laravel-inertia-vue-crud)
- [Laravel Precognition with Vue](https://laravel-vuejs.space/laravel-precognition-vue)

## Sources

- [Laravel 13: Queries, ordering](https://laravel.com/docs/13.x/queries#ordering-grouping-limit-and-offset)
- [Laravel 13: Pagination](https://laravel.com/docs/13.x/pagination)
- [Inertia v3: Testing](https://inertiajs.com/docs/v3/advanced/testing)
