An AJAX CRUD lets users create, edit and delete records without a page reload. In Laravel 13 you build it with a controller that returns JSON, the browser's Fetch API for requests, and a small script that updates the page. No jQuery is needed. This tutorial builds a task list with create, update and delete, including validation errors and the CSRF token.
Targets: Laravel 13 (PHP 8.3 to 8.5), Blade and plain JavaScript.
Step 1: Create the model and migration
php artisan make:model Task -m Schema::create('tasks', function (Blueprint $table) { $table->id(); $table->string('title'); $table->boolean('done')->default(false); $table->timestamps(); }); // app/Models/Task.php protected $fillable = ['title', 'done']; protected function casts(): array { return ['done' => 'boolean']; } Run php artisan migrate.
Step 2: Write a JSON controller
php artisan make:controller TaskController <?php namespace App\Http\Controllers; use App\Models\Task; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\View\View; class TaskController extends Controller { public function index(): View { return view('tasks', ['tasks' => Task::latest()->get()]); } public function store(Request $request): JsonResponse { $data = $request->validate(['title' => ['required', 'string', 'max:255']]); return response()->json(Task::create($data), 201); } public function update(Request $request, Task $task): JsonResponse { $data = $request->validate([ 'title' => ['sometimes', 'required', 'string', 'max:255'], 'done' => ['sometimes', 'boolean'], ]); $task->update($data); return response()->json($task); } public function destroy(Task $task): JsonResponse { $task->delete(); return response()->json(null, 204); } } When a request sends the header Accept: application/json, Laravel returns validation errors as a 422 JSON response instead of a redirect. The script below sends that header.
Step 3: Add the routes
use App\Http\Controllers\TaskController; use Illuminate\Support\Facades\Route; Route::get('/tasks', [TaskController::class, 'index']); Route::post('/tasks', [TaskController::class, 'store']); Route::put('/tasks/{task}', [TaskController::class, 'update']); Route::delete('/tasks/{task}', [TaskController::class, 'destroy']); These are web routes, so they use sessions and CSRF protection. Add ->middleware('auth') if tasks belong to users, and then scope the queries to the current user.
Step 4: Build the Blade page
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="csrf-token" content="{{ csrf_token() }}"> <title>Tasks</title> </head> <body> <form id="task-form"> <input type="text" id="title" placeholder="New task"> <button type="submit">Add</button> <p id="error" style="color: red;"></p> </form> <ul id="task-list"> @foreach ($tasks as $task) <li data-id="{{ $task->id }}"> <span>{{ $task->title }}</span> <button class="delete">Delete</button> </li> @endforeach </ul> <script src="/js/tasks.js"></script> </body> </html> The CSRF token sits in a meta tag so JavaScript can read it. Put the script file at public/js/tasks.js, or move the code into resources/js and load it with Vite.
Step 5: Write the JavaScript
const token = document.querySelector('meta[name="csrf-token"]').content; async function api(url, method, body) { const response = await fetch(url, { method, headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': token, }, body: body ? JSON.stringify(body) : undefined, }); if (response.status === 204) return null; const data = await response.json(); if (!response.ok) throw data; return data; } const form = document.getElementById('task-form'); const input = document.getElementById('title'); const list = document.getElementById('task-list'); const error = document.getElementById('error'); form.addEventListener('submit', async (event) => { event.preventDefault(); error.textContent = ''; try { const task = await api('/tasks', 'POST', { title: input.value }); const li = document.createElement('li'); li.dataset.id = task.id; li.innerHTML = '<span></span> <button class="delete">Delete</button>'; li.querySelector('span').textContent = task.title; list.prepend(li); input.value = ''; } catch (e) { error.textContent = e.errors?.title?.[0] ?? 'Something went wrong.'; } }); list.addEventListener('click', async (event) => { if (!event.target.classList.contains('delete')) return; const li = event.target.closest('li'); await api('/tasks/' + li.dataset.id, 'DELETE'); li.remove(); }); The helper sends JSON and the CSRF header on every call, and throws the error body for non-2xx responses, so validation messages reach the form. The title is inserted with textContent, never innerHTML, so a title containing HTML cannot inject markup. Event delegation on the list means delete buttons added later also work.
How do I edit a task?
Add an Edit button that swaps the span for an input, then call api('/tasks/' + id, 'PUT', { title: newTitle }) and update the span with the returned title. The update method above already supports it.
How do I check that it works?
- Open
/tasks, add a task and confirm it appears without a reload. - Submit an empty title and confirm the validation message shows.
- Delete a task, then refresh to confirm it is gone from the database.
Common mistakes
- 419 error: the CSRF header is missing. Send
X-CSRF-TOKENfrom the meta tag. - Redirects instead of JSON errors: add the
Accept: application/jsonheader. - Inserting server text with innerHTML: use
textContent. - Forgetting authorization: check that the task belongs to the user in the controller.
- Parsing a 204 response as JSON: an empty response has no body, so skip
json().
FAQ
Why Fetch instead of jQuery or Axios?
Fetch is built into every modern browser, so you do not need extra libraries. Axios is also a good choice if you like its interceptors.
Should the routes be in api.php?
For a same-site Blade page, web routes with CSRF are the simplest. Use api.php with tokens for separate frontends or mobile apps.
Can Livewire do this with less code?
Yes. Livewire removes the manual JavaScript, at the cost of learning its component model.
References
Originally published on yasiramin.dev.
