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.

Leave a Comment

Your email address will not be published. Required fields are marked *