Laravel and Vue CRUD with Inertia

Create, edit, and delete posts from a Vue page, with validation errors, search, and pagination. Continues the official Vue starter kit.

For your AI agent

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

Read https://laravel-vuejs.space/laravel-inertia-vue-crud.md
Create, edit, and delete posts from a Vue page, with validation errors, search, and pagination. Continues the official Vue starter kit.
Do not add Vue Router for these screens.
Do not pass $request->all() into create().
Show the diff. Do not commit.

Next: Paginate a list

On this page · 10 sections
  1. Routes
  2. The controller
  3. The list page
  4. The form
  5. A test
  6. What this page does not do
  7. Recap
  8. FAQ
  9. Next steps
  10. Sources

You have a list of posts. Now you need the other 90% of a real screen: create, edit, delete, search, and pages of ten, without an API or a second router. This guide builds all of it with Laravel 13, Inertia 3 and Vue 3, then tests it.

TL;DR

  • Route::resource('posts', PostController::class)->except(['show']).
  • Search + paginate(10)->withQueryString(); the rows move to posts.data.
  • router.get with preserveState puts the search in the URL.
  • One <Form> for create and edit; errors arrive in the slot, not as a 422.

What you'll learn

  • How to wire a resource controller to Inertia pages
  • How to search and paginate with the URL as state
  • How to build one create/edit form with Inertia 3’s <Form>
  • How to test the whole round trip with Pest

Who it's for: Laravel + Vue developers building their first real feature

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

The usual “Laravel Vue CRUD” tutorial still installs Breeze on Laravel 10 and dumps every row into the page. This one continues the setup guide. You already have a Post model, a factory, and an index page. Here you add create, update, delete, search, and pages of ten.

Heads up: no Vue Router

The stack is Laravel 13, Inertia 3, and Vue 3 with <script setup>. Routing stays in Laravel. Do not add Vue Router for these screens. If you are still choosing an architecture, read Inertia, a SPA, or Nuxt first.

  1. Routes: one resource line.
  2. Controller: index with search and pagination, create/store, edit/update, destroy.
  3. List page: search box, rows, pager.
  4. Form page: shared by create and edit.
  5. Test it.

Routes#

routes/web.phpphp
use App\Http\Controllers\PostController;

Route::resource('posts', PostController::class)->except(['show']);

except(['show']) keeps the list, the form, and the destroy action. Add show later with route model binding on slug if the public URL needs it.

The controller#

app/Http/Controllers/PostController.phpphp
use App\Models\Post;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

class PostController extends Controller
{
    public function index(Request $request): Response
    {
        $search = $request->string('search')->trim()->toString();

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

    public function create(): Response
    {
        return Inertia::render('posts/Form');
    }

    public function store(Request $request): RedirectResponse
    {
        $data = $request->validate([
            'title' => ['required', 'string', 'max:255'],
            'excerpt' => ['required', 'string'],
        ]);

        Post::create([
            ...$data,
            'slug' => str($data['title'])->slug(),
            'published_at' => now(),
        ]);

        return to_route('posts.index');
    }

    public function edit(Post $post): Response
    {
        return Inertia::render('posts/Form', [
            'post' => $post->only(['id', 'title', 'excerpt']),
        ]);
    }

    public function update(Request $request, Post $post): RedirectResponse
    {
        $data = $request->validate([
            'title' => ['required', 'string', 'max:255'],
            'excerpt' => ['required', 'string'],
        ]);

        $post->update([
            ...$data,
            'slug' => str($data['title'])->slug(),
        ]);

        return to_route('posts.index');
    }

    public function destroy(Post $post): RedirectResponse
    {
        $post->delete();

        return to_route('posts.index');
    }
}

paginate(10) returns the current page, the total, and prev_page_url / next_page_url. withQueryString() keeps ?search= on those links. Send the search string back as filters so the input does not forget it on the next visit.

Gotcha: mass assignment

The model from the setup guide already lists title, slug, excerpt, and published_at as fillable. Keep it that way. Do not pass $request->all() into create().

Every row, every time, then Searchable pages of ten:

app/Http/Controllers/PostController.phpphp
'posts' => Post::latest('published_at')->get(),
app/Http/Controllers/PostController.phpphp
'posts' => Post::query()
    ->when($search !== '', fn ($query) => $query->where('title', 'like', "%{$search}%"))
    ->latest('published_at')
    ->paginate(10)
    ->withQueryString(),

The list page#

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

interface Post {
  id: number;
  title: string;
  excerpt: string;
}

interface Page {
  data: Post[];
  current_page: number;
  last_page: number;
  prev_page_url: string | null;
  next_page_url: string | null;
}

const props = defineProps<{
  posts: Page;
  filters: { search: string };
}>();

const search = ref(props.filters.search);
let timer = 0;

watch(search, (value) => {
  window.clearTimeout(timer);
  timer = window.setTimeout(() => {
    router.get(
      "/posts",
      { search: value },
      {
        preserveState: true,
        preserveScroll: true,
        replace: true,
      },
    );
  }, 250);
});
</script>

<template>
  <Head title="Posts" />

  <main class="mx-auto max-w-3xl space-y-6 p-8">
    <div class="flex items-center justify-between gap-4">
      <h1 class="text-3xl font-semibold">Posts</h1>
      <Link href="/posts/create" class="underline">New post</Link>
    </div>

    <input
      v-model="search"
      type="search"
      placeholder="Search titles"
      class="w-full border px-3 py-2"
    />

    <article
      v-for="post in posts.data"
      :key="post.id"
      class="rounded-xl border p-6"
    >
      <h2 class="text-xl font-medium">{{ post.title }}</h2>
      <p class="mt-2">{{ post.excerpt }}</p>
      <div class="mt-4 flex gap-4">
        <Link :href="`/posts/${post.id}/edit`" class="underline">Edit</Link>
        <Link
          :href="`/posts/${post.id}`"
          method="delete"
          as="button"
          class="underline"
          >Delete</Link
        >
      </div>
    </article>

    <nav class="flex gap-4">
      <Link v-if="posts.prev_page_url" :href="posts.prev_page_url"
        >Previous</Link
      >
      <span>Page {{ posts.current_page }} of {{ posts.last_page }}</span>
      <Link v-if="posts.next_page_url" :href="posts.next_page_url">Next</Link>
    </nav>
  </main>
</template>

Gotcha: rows moved to posts.data

The index from the setup guide used posts as an array. Pagination wraps that array in data. Update the v-for or the page will render nothing.

router.get writes the search into the URL. That is what you want: a shared link opens the same filter, and the back button returns to the previous term. preserveState keeps the input focused while Inertia replaces the list.

The form#

Inertia 3’s <Form> component posts like a normal form and puts validation errors in the slot. You do not need v-model on each field.

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

defineProps<{
  post?: { id: number; title: string; excerpt: string };
}>();
</script>

<template>
  <Head :title="post ? 'Edit post' : 'New post'" />

  <main class="mx-auto max-w-3xl space-y-6 p-8">
    <Form
      :action="post ? `/posts/${post.id}` : '/posts'"
      :method="post ? 'put' : 'post'"
      #default="{ errors, processing }"
      class="space-y-4"
    >
      <div>
        <label for="title">Title</label>
        <input
          id="title"
          name="title"
          :defaultValue="post?.title ?? ''"
          class="w-full border px-3 py-2"
        />
        <p v-if="errors.title">{{ errors.title }}</p>
      </div>

      <div>
        <label for="excerpt">Excerpt</label>
        <textarea
          id="excerpt"
          name="excerpt"
          :defaultValue="post?.excerpt ?? ''"
          class="w-full border px-3 py-2"
        />
        <p v-if="errors.excerpt">{{ errors.excerpt }}</p>
      </div>

      <button :disabled="processing" class="border px-4 py-2">
        {{ processing ? "Saving…" : "Save" }}
      </button>
      <Link href="/posts" class="ml-4 underline">Cancel</Link>
    </Form>
  </main>
</template>

Heads up: redirect, not 422

A failed validate() call redirects back to this page with the errors in the session. Inertia follows that redirect. It does not show you a 422. errors.title is the first message for that field. Fix the copy in a FormRequest later if you want custom sentences. The rules above are enough to prove the round trip.

useForm is still there for screens that build the payload in code. The <Form> component is the one to reach for when the fields are inputs. File fields are covered in the upload guide.

A test#

tests/Feature/PostsTest.phpphp
use App\Models\Post;

it('creates a post', function () {
    $this->post('/posts', [
        'title' => 'Hello Laravel 13',
        'excerpt' => 'A short excerpt.',
    ])->assertRedirect('/posts');

    $this->assertDatabaseHas('posts', ['title' => 'Hello Laravel 13']);
});

it('rejects an empty title', function () {
    $this->post('/posts', ['title' => '', 'excerpt' => 'Body'])
        ->assertSessionHasErrors('title');
});

it('searches by title', function () {
    Post::factory()->create(['title' => 'Inertia forms']);
    Post::factory()->create(['title' => 'Something else']);

    $this->get('/posts?search=Inertia')
        ->assertOk()
        ->assertInertia(fn ($page) => $page
            ->has('posts.data', 1)
        );
});

Run the CRUD tests

bash
php artisan test --filter=PostsTest

All three should pass. Then break the when() in index and watch the search test fail.

The starter kit uses Pest. These tests hit the web routes, so they use the session and the CSRF middleware that a browser would use. Feature tests do not need a CSRF token.

What this page does not do#

It does not tell the reader the save worked. That sentence is a flash. It does not upload a file, broadcast a “post created” event, or render the list on the server for Google. Those are the upload, Reverb, and deploy guides. Other paginators, live validation, and who may edit a row are the pagination, Precognition, and permissions guides. Keep this controller boring. That is the page people can finish in an afternoon.

Recap#

  • One resource route and one controller cover the screen.
  • The URL holds the search; withQueryString() keeps it on every page.
  • Rows live on posts.data once you paginate.
  • Inertia 3’s <Form> handles both create and edit with server errors.
  • Pest proves create, validation and search.

FAQ#

Do I need Vue Router for CRUD with Inertia?

No. Routes stay in Laravel; Inertia swaps the Vue page on each visit.

Why does my Inertia list render nothing after adding pagination?

A paginator wraps rows in data. Loop over posts.data instead of posts.

Why do I not get a 422 from Laravel validation in Inertia?

A failed validation redirects back with errors in the session; Inertia follows the redirect and puts them in errors.

Should I use useForm or the Form component?

Use <Form> when the fields are inputs. Keep useForm for screens that build the payload in code.

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.