HTMX with Laravel
Laravel is a PHP web framework known for its elegant syntax, powerful ORM (Eloquent), and Blade templating engine. HTMX integrates smoothly with Laravel because Laravel already generates HTML on the server through Blade templates — exactly the model HTMX expects. This topic builds a complete task manager using Laravel and HTMX.
Project Setup
# Install Laravel (requires Composer and PHP 8.1+) composer create-project laravel/laravel htmx-laravel-app cd htmx-laravel-app php artisan serve
Detect HTMX Requests in Laravel
Create a simple helper macro on the Request object to detect HTMX requests throughout the application:
// app/Providers/AppServiceProvider.php
use Illuminate\Http\Request;
public function boot(): void
{
Request::macro('isHtmx', function () {
return $this->header('HX-Request') === 'true';
});
}
Now $request->isHtmx() is available in every controller.
Base Blade Layout
<!-- resources/views/layouts/app.blade.php -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>@yield('title', 'My App')</title>
<script src="https://unpkg.com/htmx.org@2.0.0"></script>
</head>
<body hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'>
@yield('content')
</body>
</html>
The hx-headers attribute injects Laravel's CSRF token into every HTMX request automatically. Laravel's VerifyCsrfToken middleware validates the X-CSRF-TOKEN header on every POST, PUT, PATCH, and DELETE request.
Migration and Model
# Create migration and model php artisan make:model Task -m
// database/migrations/xxxx_create_tasks_table.php
public function up(): void
{
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->boolean('done')->default(false);
$table->timestamps();
});
}
// app/Models/Task.php
class Task extends Model
{
protected $fillable = ['title', 'done'];
}
php artisan migrate
Controller
// app/Http/Controllers/TaskController.php
namespace App\Http\Controllers;
use App\Models\Task;
use Illuminate\Http\Request;
class TaskController extends Controller
{
public function index(Request $request)
{
$tasks = Task::latest()->get();
if ($request->isHtmx()) {
return view('tasks.partials.list', compact('tasks'));
}
return view('tasks.index', compact('tasks'));
}
public function store(Request $request)
{
$validated = $request->validate(['title' => 'required|max:255']);
$task = Task::create($validated);
return view('tasks.partials.item', compact('task'));
}
public function edit(Task $task)
{
return view('tasks.partials.edit_form', compact('task'));
}
public function update(Request $request, Task $task)
{
$validated = $request->validate(['title' => 'required|max:255']);
$task->update($validated);
return view('tasks.partials.item', compact('task'));
}
public function destroy(Task $task)
{
$task->delete();
return response('', 200);
}
}
Routes
// routes/web.php
use App\Http\Controllers\TaskController;
Route::get('/tasks', [TaskController::class, 'index'])->name('tasks.index');
Route::post('/tasks', [TaskController::class, 'store'])->name('tasks.store');
Route::get('/tasks/{task}/edit', [TaskController::class, 'edit'])->name('tasks.edit');
Route::put('/tasks/{task}', [TaskController::class, 'update'])->name('tasks.update');
Route::delete('/tasks/{task}', [TaskController::class, 'destroy'])->name('tasks.destroy');
Blade Views
Main Page: resources/views/tasks/index.blade.php
@extends('layouts.app')
@section('title', 'Task Manager')
@section('content')
<h2>Task Manager</h2>
<form hx-post="{{ route('tasks.store') }}"
hx-target="#task-list"
hx-swap="afterbegin"
hx-on:htmx:after-request="if(event.detail.successful) this.reset()">
@csrf
<input type="text" name="title" placeholder="New task..." required>
<button type="submit">Add Task</button>
</form>
<ul id="task-list">
@foreach ($tasks as $task)
@include('tasks.partials.item', ['task' => $task])
@endforeach
</ul>
@endsection
Partial: resources/views/tasks/partials/item.blade.php
<li id="task-{{ $task->id }}">
{{ $task->title }}
<button hx-get="{{ route('tasks.edit', $task) }}"
hx-target="#task-{{ $task->id }}"
hx-swap="outerHTML">
Edit
</button>
<button hx-delete="{{ route('tasks.destroy', $task) }}"
hx-target="#task-{{ $task->id }}"
hx-swap="outerHTML"
hx-confirm="Delete '{{ $task->title }}'?">
Delete
</button>
</li>
Partial: resources/views/tasks/partials/edit_form.blade.php
<li id="task-{{ $task->id }}">
<form hx-put="{{ route('tasks.update', $task) }}"
hx-target="#task-{{ $task->id }}"
hx-swap="outerHTML">
@csrf
@method('PUT')
<input type="text" name="title" value="{{ $task->title }}" required>
<button type="submit">Save</button>
</form>
</li>
Validation Error Handling
Laravel returns a 422 response when validation fails. Return the form with errors so HTMX can swap it in:
public function store(Request $request)
{
try {
$validated = $request->validate(['title' => 'required|max:255']);
} catch (\Illuminate\Validation\ValidationException $e) {
return response()->view(
'tasks.partials.add_form',
['errors' => $e->errors()],
422
);
}
$task = Task::create($validated);
return view('tasks.partials.item', compact('task'));
}
Using HX-Trigger Response Header
After adding a task, tell the client to also refresh the task count:
public function store(Request $request)
{
$validated = $request->validate(['title' => 'required|max:255']);
$task = Task::create($validated);
return response()
->view('tasks.partials.item', compact('task'))
->withHeaders(['HX-Trigger' => 'task-added']);
}
<!-- Task count updates automatically when 'task-added' fires -->
<span hx-get="/tasks/count"
hx-trigger="task-added from:body"
hx-target="this">
{{ $tasks->count() }} tasks
</span>
Key Takeaway
Laravel and HTMX work together elegantly. Add a CSRF token globally via hx-headers on the body and it flows into every request automatically. Use $request->isHtmx() (via a custom macro) to return partial Blade views for HTMX requests and full layouts for direct visits. Blade's @include directive maps directly to HTMX partials. Laravel's route model binding, Eloquent ORM, and validation all work without modification alongside HTMX-driven frontends.
