Examples
Blog in Solid
The same blog with Solid views: a form to write and edit posts, and a list to search, read and delete them. The PHP classes are the Blade blog's; each view reads the component from its props.
On this page
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. Each of them is a component with a Solid view; a Solid view can't place other components inside itself, so Blade composes them.
<?php
namespace App\Nitro\Components\Blog\Solid;
use Nitro\Component;
/** The blog: the form, and the list below it, each with a Solid view, placed in Blade. They talk through events. */
class Blog extends Component
{
}
<div class="own-styles w-full space-y-6">
<nitro:blog.solid.post-form />
<nitro:blog.solid.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\Solid;
use App\Blog\Posts;
use Nitro\Attributes\Computed;
use Nitro\Attributes\On;
use Nitro\Attributes\Server;
use Nitro\Attributes\Validate;
use Nitro\Attributes\View;
use Nitro\Component;
#[View(solid: 'blog/post-form')]
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);
}
}
import { Show } from 'solid-js';
export default function PostForm(props) {
function submit(event) {
event.preventDefault();
props.save();
}
return (
<form class="space-y-3 rounded-xl bg-white p-4 ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-white/10" onSubmit={submit}>
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-slate-900 dark:text-white">{props.postId === null ? 'New post' : 'Edit post'}</h3>
<Show when={props.postId !== null}>
<button type="button" class="text-xs font-medium text-slate-500 hover:text-slate-900 dark:hover:text-white" onClick={() => props.cancel()}>Cancel</button>
</Show>
</div>
<div>
<input value={props.title} onInput={(e) => props.set('title', e.currentTarget.value)} 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" />
<Show when={props.errors.title}>{(messages) => <p class="mt-1 text-xs text-red-600 dark:text-red-400">{messages()[0]}</p>}</Show>
</div>
<div>
<textarea value={props.body} onInput={(e) => props.set('body', e.currentTarget.value)} 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" />
<Show when={props.errors.body}>{(messages) => <p class="mt-1 text-xs text-red-600 dark:text-red-400">{messages()[0]}</p>}</Show>
</div>
<div class="flex items-center gap-3">
<span class="text-xs text-slate-500">{props.words} {props.words === 1 ? 'word' : 'words'}</span>
<Show when={props.notice}>
<span class="text-xs font-medium text-emerald-600 dark:text-emerald-400">{props.notice}</span>
</Show>
<button disabled={props.save.processing} 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">{props.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\Solid;
use App\Blog\Posts;
use Nitro\Attributes\Computed;
use Nitro\Attributes\On;
use Nitro\Attributes\Server;
use Nitro\Attributes\View;
use Nitro\Component;
#[View(solid: 'blog/post-index')]
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;
}
}
}
}
import { For, Show } from 'solid-js';
export default function PostIndex(props) {
return (
<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">{props.posts.length}</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 value={props.search} onInput={(e) => props.set('search', e.currentTarget.value)} 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>
<Show when={props.results.length === 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">
{props.search === '' ? 'No posts yet. Write the first one.' : 'No posts match your search.'}
</p>
</Show>
<ul class="space-y-3">
<For each={props.results}>
{(post) => (
<li 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>
<Show
when={props.confirming === post.id}
fallback={
<div class="flex shrink-0 items-center gap-1">
<button type="button" 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" onClick={() => props.edit(post)}>Edit</button>
<button type="button" 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" onClick={() => props.confirmDelete(post.id)}>Delete</button>
</div>
}
>
<div class="flex shrink-0 items-center gap-1">
<button type="button" disabled={props.remove.processing} class="rounded-md bg-red-600 px-2.5 py-1 text-xs font-semibold text-white hover:bg-red-500 disabled:opacity-50" onClick={() => props.remove(post.id)}>Delete</button>
<button type="button" class="rounded-md px-2 py-1 text-xs font-medium text-slate-500 hover:bg-slate-100 dark:hover:bg-white/10" onClick={() => props.confirmDelete(null)}>Keep</button>
</div>
</Show>
</div>
<p class="mt-2 text-sm leading-6 whitespace-pre-line text-slate-600 dark:text-slate-400" classList={{ 'line-clamp-2': props.open !== post.id }}>{post.body}</p>
<Show when={post.body.length > 140 || post.body.includes('\n')}>
<button type="button" class="mt-1 text-xs font-medium text-browser-600 hover:text-browser-500 dark:text-browser-400" onClick={() => props.read(post.id)}>{props.open === post.id ? 'Show less' : 'Read more'}</button>
</Show>
</li>
)}
</For>
</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: Blade, Vue, React, Svelte. To see components in all five working together, see All together.