Examples
Blog in Blade
A small blog in Blade: a form to write and edit posts, and a list to search, read and delete them. Two components, talking through events.
On this page
Posts
3-
Shipping the new search
Oct 10, 2026
Search used to wait for the server on every keystroke. Now the list filters as you type, in the browser, and the server only hears about it when you save. The code didn't change much: the same PHP class, compiled to run where the user is.
-
Notes from the Laravel meetup
Oct 3, 2026
Three talks, one theme: send less to the server. Queues, caching and components that do their work in the browser. The best question of the night: what still needs a request? Saving, and anything secret.
-
Why we write PHP for the browser
Sep 21, 2026
Our team knows PHP and Blade. Learning a second language for every interactive widget slowed us down. Writing the component once, and letting it run in the browser, gave us both: one language and an app that feels instant.
Write a post, search the list, read, edit and delete. The posts are yours alone: they live in your session, so try anything. A blog in your app would keep them in the database; nothing else would change.
- Searching, reading more, the word count and the checks on the form run in your browser, as you type.
- Publishing, updating and deleting go to the server, which keeps the posts.
- The form and the list are two components. They don't know about each other: they talk through events.
The blog
A Blade component that places the form, and the list below it.
<?php
namespace App\Nitro\Components\Blog\Blade;
use Nitro\Component;
/** The blog: the form, and the list below it. They talk through events. */
class Blog extends Component
{
}
<div class="own-styles w-full space-y-6">
<nitro:blog.blade.post-form />
<nitro:blog.blade.post-index />
</div>
The form
One form writes new posts and edits old ones: postId says which. The rules on
title and body are checked as you type, and again on the server when you publish.
Once the post is saved, post-saved tells the list.
<?php
namespace App\Nitro\Components\Blog\Blade;
use App\Blog\Posts;
use Nitro\Attributes\Computed;
use Nitro\Attributes\On;
use Nitro\Attributes\Server;
use Nitro\Attributes\Validate;
use Nitro\Component;
class PostForm extends Component
{
/** The post being edited; null for a new one. Posts::save() finds it among this reader's own posts. */
public ?string $postId = null;
#[Validate('required|string|max:120')]
public string $title = '';
#[Validate('required|string|max:2000')]
public string $body = '';
public string $notice = '';
#[Computed]
public function words(): int
{
return preg_match_all('/\S+/', $this->body);
}
#[On('edit-post')]
public function edit(array $post): void
{
$this->postId = $post['id'];
$this->title = $post['title'];
$this->body = $post['body'];
$this->notice = '';
}
#[On('post-deleted')]
public function deleted(string $id): void
{
if ($this->postId === $id) {
$this->cancel();
}
}
public function cancel(): void
{
$this->reset('postId', 'title', 'body', 'notice');
}
#[Server]
public function save(): void
{
$this->validate();
$created = $this->postId === null;
$post = Posts::save($this->postId, $this->title, $this->body);
$this->reset('postId', 'title', 'body');
$this->notice = $created ? 'Published.' : 'Updated.';
$this->dispatch('post-saved', $post, $created);
}
}
<form nitro:submit="save" class="space-y-3 rounded-xl bg-white p-4 ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-white/10">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-slate-900 dark:text-white">{{ $postId === null ? 'New post' : 'Edit post' }}</h3>
@if ($postId !== null)
<button type="button" nitro:click="cancel" class="text-xs font-medium text-slate-500 hover:text-slate-900 dark:hover:text-white">Cancel</button>
@endif
</div>
<div>
<input nitro:model="title" maxlength="120" placeholder="Title" aria-label="Title" class="w-full rounded-lg bg-slate-50 px-3 py-2 text-sm font-medium ring-1 ring-slate-200 outline-none placeholder:text-slate-400 focus:bg-white focus:ring-2 focus:ring-browser-500 dark:bg-white/5 dark:ring-white/10 dark:focus:bg-slate-900">
@error('title')
<p class="mt-1 text-xs text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</div>
<div>
<textarea nitro:model="body" rows="6" maxlength="2000" placeholder="Write something…" aria-label="Body" class="w-full resize-y rounded-lg bg-slate-50 px-3 py-2 text-sm leading-6 ring-1 ring-slate-200 outline-none placeholder:text-slate-400 focus:bg-white focus:ring-2 focus:ring-browser-500 dark:bg-white/5 dark:ring-white/10 dark:focus:bg-slate-900"></textarea>
@error('body')
<p class="mt-1 text-xs text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</div>
<div class="flex items-center gap-3">
<span class="text-xs text-slate-500">{{ $this->words }} {{ $this->words === 1 ? 'word' : 'words' }}</span>
@if ($notice !== '')
<span class="text-xs font-medium text-emerald-600 dark:text-emerald-400">{{ $notice }}</span>
@endif
<button nitro:loading.attr="disabled" class="ml-auto rounded-lg bg-browser-600 px-3.5 py-2 text-sm font-semibold text-white shadow-sm hover:bg-browser-500 disabled:opacity-50">{{ $postId === null ? 'Publish' : 'Update' }}</button>
</div>
</form>
The list
The list filters itself as you type in the search box: results is worked out again in the
browser whenever search or posts change. Edit sends the post to the form with
edit-post; delete asks once more, then removes the post on the server and says
post-deleted.
<?php
namespace App\Nitro\Components\Blog\Blade;
use App\Blog\Posts;
use Nitro\Attributes\Computed;
use Nitro\Attributes\On;
use Nitro\Attributes\Server;
use Nitro\Component;
class PostIndex extends Component
{
/** @var list<array{id: string, title: string, body: string, date: string}> */
public array $posts = [];
public string $search = '';
/** The post being read, and the one whose delete waits for a second click. */
public ?string $open = null;
public ?string $confirming = null;
public function mount(): void
{
$this->posts = Posts::all();
}
#[Computed]
public function results(): array
{
$search = mb_strtolower(trim($this->search));
if ($search === '') {
return $this->posts;
}
return array_values(array_filter(
$this->posts,
fn (array $post) => str_contains(mb_strtolower($post['title'].' '.$post['body']), $search),
));
}
public function read(string $id): void
{
$this->open = $this->open === $id ? null : $id;
}
public function edit(array $post): void
{
$this->confirming = null;
$this->dispatch('edit-post', $post);
}
public function confirmDelete(?string $id): void
{
$this->confirming = $id;
}
#[Server]
public function remove(string $id): void
{
Posts::delete($id);
$this->posts = Posts::all();
$this->confirming = null;
$this->dispatch('post-deleted', $id);
}
/** The form saved a post: shown at once, no request. */
#[On('post-saved')]
public function saved(array $post, bool $created): void
{
if ($created) {
array_unshift($this->posts, $post);
return;
}
foreach ($this->posts as $i => $existing) {
if ($existing['id'] === $post['id']) {
$this->posts[$i] = $post;
}
}
}
}
<div class="space-y-3">
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold text-slate-900 dark:text-white">Posts</h3>
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600 dark:bg-white/10 dark:text-slate-300">{{ count($posts) }}</span>
</div>
<label class="relative block">
<svg class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-slate-400" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="4.5"/><path d="m10.5 10.5 3 3" stroke-linecap="round"/></svg>
<input nitro:model="search" type="search" maxlength="100" placeholder="Search posts" aria-label="Search posts" class="w-full rounded-lg bg-white py-2 pr-3 pl-9 text-sm ring-1 ring-slate-200 outline-none placeholder:text-slate-400 focus:ring-2 focus:ring-browser-500 dark:bg-slate-900 dark:ring-white/10">
</label>
@if (count($this->results) === 0)
<p class="rounded-xl border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500 dark:border-white/15">
{{ $search === '' ? 'No posts yet. Write the first one.' : 'No posts match your search.' }}
</p>
@endif
<ul class="space-y-3">
@foreach ($this->results as $post)
<li nitro:key="{{ $post['id'] }}" class="rounded-xl bg-white p-4 ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-white/10">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h4 class="truncate font-semibold text-slate-900 dark:text-white">{{ $post['title'] }}</h4>
<p class="mt-0.5 text-xs text-slate-500">{{ $post['date'] }}</p>
</div>
@if ($confirming === $post['id'])
<div class="flex shrink-0 items-center gap-1">
<button type="button" nitro:click="remove($post['id'])" nitro:loading.attr="disabled" class="rounded-md bg-red-600 px-2.5 py-1 text-xs font-semibold text-white hover:bg-red-500 disabled:opacity-50">Delete</button>
<button type="button" nitro:click="confirmDelete(null)" class="rounded-md px-2 py-1 text-xs font-medium text-slate-500 hover:bg-slate-100 dark:hover:bg-white/10">Keep</button>
</div>
@else
<div class="flex shrink-0 items-center gap-1">
<button type="button" nitro:click="edit($post)" class="rounded-md px-2 py-1 text-xs font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-white/10">Edit</button>
<button type="button" nitro:click="confirmDelete($post['id'])" class="rounded-md px-2 py-1 text-xs font-medium text-slate-600 hover:bg-red-50 hover:text-red-600 dark:text-slate-300 dark:hover:bg-red-500/10">Delete</button>
</div>
@endif
</div>
<p @class(['mt-2 text-sm leading-6 whitespace-pre-line text-slate-600 dark:text-slate-400', 'line-clamp-2' => $open !== $post['id']])>{{ $post['body'] }}</p>
@if (mb_strlen($post['body']) > 140 || str_contains($post['body'], "\n"))
<button type="button" nitro:click="read($post['id'])" class="mt-1 text-xs font-medium text-browser-600 hover:text-browser-500 dark:text-browser-400">{{ $open === $post['id'] ? 'Show less' : 'Read more' }}</button>
@endif
</li>
@endforeach
</ul>
</div>
How they talk
| Event | Sent by | Heard by | When |
|---|---|---|---|
edit-post
|
the list | the form | In the browser, at once |
post-saved
|
the form | the list | With the server's answer, once the post is saved |
post-deleted
|
the list | the form (if it was editing that post) | With the server's answer |
Every listener runs in the browser, so the other component changes the moment the event arrives. See Events.
The posts
Both components ask one small class for the posts. Here it keeps them in your session; in your app it would
be your Post model.
<?php
namespace App\Blog;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/**
* The examples' blog: each reader's own posts, kept in their session. Your app would keep them
* in the database (a Post model); here every reader starts with the same few posts, and what
* they write is theirs alone and gone when the session ends.
*/
final class Posts
{
/** Posts a reader can have: the session stays small. */
public const LIMIT = 20;
/**
* Newest first.
*
* @return list<array{id: string, title: string, body: string, date: string}>
*/
public static function all(): array
{
if (! session()->has('blog.posts')) {
session(['blog.posts' => self::samples()]);
}
return session('blog.posts');
}
/**
* Writes a new post (no id) or one of the reader's own (its id).
*
* @return array{id: string, title: string, body: string, date: string}
*/
public static function save(?string $id, string $title, string $body): array
{
$posts = self::all();
$index = $id === null ? null : array_search($id, array_column($posts, 'id'), true);
if ($id !== null && $index === false) {
throw ValidationException::withMessages(['title' => 'This post was deleted.']);
}
if ($id === null && count($posts) >= self::LIMIT) {
throw ValidationException::withMessages(['title' => 'This blog keeps '.self::LIMIT.' posts. Delete one to write another.']);
}
$post = [
'id' => $id ?? (string) Str::ulid(),
'title' => $title,
'body' => $body,
'date' => $id === null ? now()->format('M j, Y') : $posts[$index]['date'],
];
if ($id === null) {
array_unshift($posts, $post);
} else {
$posts[$index] = $post;
}
session(['blog.posts' => $posts]);
return $post;
}
public static function delete(string $id): void
{
session(['blog.posts' => array_values(array_filter(self::all(), fn (array $post) => $post['id'] !== $id))]);
}
/** @return list<array{id: string, title: string, body: string, date: string}> */
private static function samples(): array
{
$posts = [
['Shipping the new search', "Search used to wait for the server on every keystroke. Now the list filters as you type, in the browser, and the server only hears about it when you save.\n\nThe code didn't change much: the same PHP class, compiled to run where the user is."],
['Notes from the Laravel meetup', "Three talks, one theme: send less to the server. Queues, caching and components that do their work in the browser.\n\nThe best question of the night: what still needs a request? Saving, and anything secret."],
['Why we write PHP for the browser', "Our team knows PHP and Blade. Learning a second language for every interactive widget slowed us down.\n\nWriting the component once, and letting it run in the browser, gave us both: one language and an app that feels instant."],
];
return array_map(fn (array $post, int $daysAgo) => [
'id' => (string) Str::ulid(),
'title' => $post[0],
'body' => $post[1],
'date' => now()->subDays($daysAgo)->format('M j, Y'),
], $posts, [2, 9, 21]);
}
}
The same blog, other views
The PHP is the same on every page of this section; only the views change: Vue, React, Solid, Svelte. To see components in all five working together, see All together.