Data tables become easier to reason about when the URL owns their state.
Search terms, filters, sort order, and page numbers should not live only inside Vue component memory. They should also exist in the query string.
That single decision gives your Laravel and Vue SPA several useful properties:
- Refreshing keeps the current view.
- Back and forward navigation restores previous results.
- URLs can be copied and shared.
- Server-side filtering remains predictable.
- The browser history reflects meaningful navigation.
- Deep links work without extra client-side state management.
Inertia 3.x makes this pattern natural. The Vue client sends a GET visit with query parameters. Laravel reads those parameters, builds the query, and returns updated props.
The URL becomes the contract between both sides.
The core flow: URL to query to props
Consider an orders page with this URL:
/admin/orders?search=acme&status=paid&sort=created_at&direction=desc&page=2
The browser describes the complete table state.
Vue sends the URL parameters through an Inertia visit. Laravel reads them from the request. The database query applies each constraint. The response returns the matching orders and the active filters.
When the user refreshes the page, Laravel receives the same request again. Nothing needs to be reconstructed from a separate client store.
This approach works well with Laravel’s pagination tools and Inertia’s manual visits.

Laravel: read and apply query parameters
Start with a controller that treats query parameters as input.
<?php
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\Request;
use Inertia\Inertia;
class OrderController extends Controller
{
public function index(Request $request)
{
$search = $request->string('search')->trim()->toString();
$status = $request->string('status')->toString();
$sort = $request->string('sort', 'created_at')->toString();
$direction = $request->string('direction', 'desc')->toString();
$allowedSorts = [
'created_at',
'total',
'status',
];
$sort = in_array($sort, $allowedSorts, true)
? $sort
: 'created_at';
$direction = $direction === 'asc' ? 'asc' : 'desc';
$orders = Order::query()
->with('customer')
->when($search, function ($query, $search) {
$query->where(function ($query) use ($search) {
$query
->where('reference', 'like', "%{$search}%")
->orWhereHas('customer', function ($query) use ($search) {
$query->where('name', 'like', "%{$search}%");
});
});
})
->when($status, fn ($query, $status) =>
$query->where('status', $status)
)
->orderBy($sort, $direction)
->paginate(20)
->withQueryString();
return Inertia::render('Orders/Index', [
'orders' => $orders,
'filters' => [
'search' => $search,
'status' => $status,
'sort' => $sort,
'direction' => $direction,
],
]);
}
}
The when() method keeps optional filters readable. Each filter applies only when its corresponding value exists.
The sort column uses an allow-list. Never pass an unchecked request value directly into orderBy(). Query values are user input, even when they come from a familiar admin screen.
The important pagination detail is withQueryString():
->paginate(20)
->withQueryString();
Laravel automatically understands the page query parameter. withQueryString() adds the other current parameters to generated pagination URLs.
Without it, clicking page two could remove the active search and status filter. With it, the next URL retains the entire table state.
You can also move filters into local Eloquent scopes:
public function scopeSearch($query, ?string $search)
{
return $query->when($search, function ($query, $search) {
$query->where('reference', 'like', "%{$search}%");
});
}
public function scopeStatus($query, ?string $status)
{
return $query->when($status, fn ($query, $status) =>
$query->where('status', $status)
);
}
The controller then becomes:
$orders = Order::query()
->search($search)
->status($status)
->orderBy($sort, $direction)
->paginate(20)
->withQueryString();
Use scopes when filters are reused across screens. Keep them in the controller when the query is specific to one table.
Vue: send the complete table state
On the Vue side, keep a small local object for the controls. Send every active value whenever the table changes.
<script setup>
import { ref, watch } from 'vue'
import { Link, router, usePage } from '@inertiajs/vue3'
const props = defineProps({
orders: Object,
filters: Object,
})
const page = usePage()
const search = ref(props.filters.search ?? '')
const status = ref(props.filters.status ?? '')
const sort = ref(props.filters.sort ?? 'created_at')
const direction = ref(props.filters.direction ?? 'desc')
function visitTable(overrides = {}) {
router.get(route('orders.index'), {
search: search.value || undefined,
status: status.value || undefined,
sort: sort.value,
direction: direction.value,
page: overrides.page ?? 1,
}, {
preserveState: true,
preserveScroll: true,
replace: true,
only: ['orders', 'filters'],
})
}
</script>
preserveState: true keeps the current page component instance. That helps preserve input focus and local control state during GET visits.
replace: true replaces the current history entry. This is useful for search fields, where every keystroke should not create another back-button step.
only requests only the props that changed. It is optional, but useful when the page includes expensive or unrelated data.
Most importantly, pass the full state to router.get(). Inertia does not automatically merge every existing query parameter into a new visit. If the request includes only status, your search and sort values may disappear.
Debounce search without losing the URL
A search field should not send a request for every keystroke. Add a small debounce helper:
<script setup>
let searchTimer
function debounceSearch() {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
visitTable({ page: 1 })
}, 300)
}
</script>
<template>
<input
v-model="search"
type="search"
placeholder="Search orders..."
@input="debounceSearch"
/>
</template>
Reset the page to one when the search or a filter changes. A user searching from page seven should not receive an empty result because page seven no longer exists for the new query.
For a production component, you can debounce several filters through a shared watcher:
watch([search, status], () => {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
visitTable({ page: 1 })
}, 300)
})
Keep explicit actions for filters that should not apply immediately. A status dropdown can visit on change, while a group of advanced filters might use an Apply button.

Sortable headers: make the URL explain the result
A sortable column needs two pieces of state: the column and its direction.
<script setup>
function toggleSort(column) {
if (sort.value === column) {
direction.value = direction.value === 'asc' ? 'desc' : 'asc'
} else {
sort.value = column
direction.value = 'asc'
}
visitTable({ page: 1 })
}
</script>
<template>
<button type="button" @click="toggleSort('created_at')">
Created
<span v-if="sort === 'created_at'">
{{ direction === 'asc' ? '↑' : '↓' }}
</span>
</button>
<button type="button" @click="toggleSort('total')">
Total
<span v-if="sort === 'total'">
{{ direction === 'asc' ? '↑' : '↓' }}
</span>
</button>
</template>
A URL such as this now explains the table:
/admin/orders?status=paid&sort=total&direction=asc&page=1
That clarity helps during support conversations and QA testing. A teammate can copy the URL and see the same result set.
Keep local controls aligned with the URL
Local refs are useful for responsive controls. They should not become a second source of truth.
The server already returns the normalized filter state through the filters prop. That gives Vue a reliable value after every visit. It also handles invalid or missing values that Laravel replaced with defaults.
<script setup>
import { watch } from 'vue'
import { usePage } from '@inertiajs/vue3'
const page = usePage()
watch(
() => page.props.filters,
(filters) => {
search.value = filters.search ?? ''
status.value = filters.status ?? ''
sort.value = filters.sort ?? 'created_at'
direction.value = filters.direction ?? 'desc'
},
{ deep: true }
)
</script>
You can also inspect page.url when you need to derive state directly from the current URL:
const query = new URLSearchParams(new URL(page.url, window.location.origin).search)
const currentSearch = query.get('search') ?? ''
const currentPage = query.get('page') ?? '1'
In most table components, returning a filters prop is simpler. The URL remains canonical, Laravel normalizes it, and Vue receives the exact state used by the query.
Pagination: use Laravel’s generated URLs
Laravel’s paginator returns useful metadata and links. Render those links with Inertia’s Link component:
<template>
<nav v-if="orders.links.length > 3" aria-label="Pagination">
<Link
v-for="link in orders.links"
:key="link.label"
:href="link.url ?? '#'"
:class="{ active: link.active }"
preserve-state
preserve-scroll
v-html="link.label"
/>
</nav>
</template>
Because the paginator used withQueryString(), each URL includes the current search, filter, and sort parameters.
Inertia’s Link component intercepts the navigation and performs an Inertia visit instead of a full page reload. Its preserve-state and replace props mirror the options available through router.get().
For pagination, you usually want a new history entry. Users expect the back button to return them to the previous page of results. Use replace for high-frequency updates such as typing, not automatically for every navigation.
Inertia 3.x notes
Inertia 3.x uses a fetch-based client for visits. router.get(), router.visit(), and Link all support the same core navigation options.
Use router.get() for navigation that should update the page props and URL:
router.get(route('orders.index'), filters, {
preserveState: true,
replace: true,
})
If you need a non-navigation lookup, such as loading a customer preview or validating a reference number, use your project’s useHttp composable or another fetch wrapper. Keep that separate from table navigation. A lookup should not replace the current Inertia page or change the browser URL unless that behavior is intentional.
Inertia’s Link component also accepts query strings directly:
<Link
href="/admin/orders?status=paid"
preserve-state
>
Paid orders
</Link>
For dynamic table state, router.get() is usually easier because it serializes an object and keeps one visit function in charge of search, filters, sorting, and pagination.
The result: a table that behaves like the web
An orders table does not need a large client-side state system to feel polished.
Let Laravel own filtering and pagination. Let the URL describe the current view. Let Vue manage interaction details such as debounce timers and focused inputs. Let Inertia connect the two without a full document reload.
This creates a stable pattern for admin tables, product catalogs, customer directories, and reporting screens.
It also reflects what makes Laravel useful as a PHP web framework: the server remains clear and authoritative, while Vue provides a responsive interface. The same conventions support teams using modern PHP developer tools or planning to build a REST API with PHP.
When table state lives in the URL, refresh, sharing, and back-button navigation stop being edge cases. They become the default behavior.