Skip to content

Commit d5068f2

Browse files
markshustclaude
andcommitted
docs: fix tutorials to use entities, Latte views, and marko up
- Replace manual SQL with entity-driven schemas (#[Table], #[Column]) - Replace inline HTML with Latte templates in resources/views/ - Use marko up instead of manual PHP server commands - Note SSE multi-worker support in chat tutorial - Reorder tutorials sidebar: Create a Custom Module first Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 372f204 commit d5068f2

6 files changed

Lines changed: 447 additions & 125 deletions

File tree

docs/astro.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,11 @@ export default defineConfig({
9494
{
9595
label: 'Tutorials',
9696
items: [
97+
{ label: 'Create a Custom Module', slug: 'tutorials/custom-module' },
9798
{ label: 'Build a Blog', slug: 'tutorials/build-a-blog' },
9899
{ label: 'Build a REST API', slug: 'tutorials/build-a-rest-api' },
99100
{ label: 'Build a Real-time Chat', slug: 'tutorials/build-a-chat' },
100101
{ label: 'Build an Admin Panel', slug: 'tutorials/build-an-admin-panel' },
101-
{ label: 'Create a Custom Module', slug: 'tutorials/custom-module' },
102102
],
103103
},
104104
],

docs/src/content/docs/tutorials/build-a-blog.md

Lines changed: 82 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,58 @@ Run the migrations:
4747
marko db:migrate
4848
```
4949

50-
This creates the `posts`, `comments`, and related tables from the blog package's migrations.
50+
The blog package defines its schema using entity attributes --- `#[Table]`, `#[Column]`, and `#[Index]` --- on entity classes like `Post` and `Comment`. When you run `marko db:migrate`, it reads these attributes and auto-generates the migrations. Here is a simplified view of the `Post` entity:
51+
52+
```php title="packages/blog/src/Entity/Post.php"
53+
<?php
54+
55+
declare(strict_types=1);
56+
57+
namespace Marko\Blog\Entity;
58+
59+
use Marko\Database\Attributes\Column;
60+
use Marko\Database\Attributes\Index;
61+
use Marko\Database\Attributes\Table;
62+
use Marko\Database\Entity\Entity;
63+
64+
#[Table('posts')]
65+
#[Index('idx_posts_author_id', ['author_id'])]
66+
#[Index('idx_posts_status', ['status'])]
67+
#[Index('idx_posts_published_at', ['published_at'])]
68+
class Post extends Entity implements PostInterface
69+
{
70+
#[Column(primaryKey: true, autoIncrement: true)]
71+
public ?int $id = null;
72+
73+
#[Column(unique: true)]
74+
public string $slug;
75+
76+
#[Column]
77+
public string $title = '';
78+
79+
#[Column(type: 'TEXT')]
80+
public string $content = '';
81+
82+
#[Column('author_id', references: 'authors.id')]
83+
public int $authorId = 0;
84+
85+
#[Column(type: 'TEXT')]
86+
public ?string $summary = null;
87+
88+
#[Column('published_at')]
89+
public ?string $publishedAt = null;
90+
91+
#[Column('created_at')]
92+
public ?string $createdAt = null;
93+
94+
#[Column('updated_at')]
95+
public ?string $updatedAt = null;
96+
97+
// ...
98+
}
99+
```
100+
101+
You never write SQL or migration files by hand --- the entity attributes are the single source of truth.
51102

52103
## Step 3: Start the Server
53104

@@ -73,7 +124,7 @@ The `marko/blog` package registers these routes automatically:
73124

74125
## Step 5: Customize Templates
75126

76-
Blog templates use Latte and can be overridden by placing files in your app module:
127+
Blog templates use [Latte](https://latte.nette.org/) and can be overridden by placing files in your app module:
77128

78129
```
79130
app/blog/resources/views/
@@ -86,18 +137,28 @@ app/blog/resources/views/
86137

87138
For example, override the post listing:
88139

89-
```html title="app/blog/resources/views/post/index.latte"
90-
<h1>My Blog</h1>
91-
92-
{foreach $posts as $post}
93-
<article>
94-
<h2><a href="/blog/{$post->slug}">{$post->title}</a></h2>
95-
<p>{$post->excerpt}</p>
96-
<time>{$post->createdAt|date:'M j, Y'}</time>
97-
</article>
98-
{/foreach}
140+
```latte title="app/blog/resources/views/post/index.latte"
141+
<main>
142+
<h1>My Blog</h1>
143+
<p n:if="$posts->isEmpty()" class="no-posts">There are no posts yet.</p>
144+
<ul n:if="!$posts->isEmpty()" class="post-list">
145+
{foreach $posts->items as $post}
146+
<li>
147+
<article>
148+
<h2><a href="/blog/{$post->slug}">{$post->title}</a></h2>
149+
<p n:if="$post->summary">{$post->summary}</p>
150+
<time datetime="{$post->publishedAt}">
151+
{$post->getPublishedAt()->format('F j, Y')}
152+
</time>
153+
</article>
154+
</li>
155+
{/foreach}
156+
</ul>
157+
</main>
99158
```
100159

160+
Templates access entity properties directly --- `$post->title`, `$post->slug`, `$post->summary` --- and use getter methods like `$post->getPublishedAt()` for computed values.
161+
101162
## Step 6: Add Authentication
102163

103164
Protect the comment form so only logged-in users can comment:
@@ -136,18 +197,19 @@ declare(strict_types=1);
136197

137198
namespace App\Blog\Plugin;
138199

139-
use Marko\Blog\Repositories\PostRepository;
200+
use Marko\Blog\Entity\Post;
201+
use Marko\Blog\Repositories\PostRepositoryInterface;
140202

141203
class AddReadingTimePlugin
142204
{
143-
public function afterFindBySlug(PostRepository $subject, ?array $result): ?array
205+
public function afterFindBySlug(PostRepositoryInterface $subject, ?Post $result): ?Post
144206
{
145207
if ($result === null) {
146208
return null;
147209
}
148210

149-
$wordCount = str_word_count($result['body']);
150-
$result['reading_time_minutes'] = max(1, (int) ceil($wordCount / 200));
211+
$wordCount = str_word_count($result->content);
212+
$result->readingTimeMinutes = max(1, (int) ceil($wordCount / 200));
151213

152214
return $result;
153215
}
@@ -161,12 +223,12 @@ Register it:
161223

162224
declare(strict_types=1);
163225

164-
use Marko\Blog\Repositories\PostRepository;
226+
use Marko\Blog\Repositories\PostRepositoryInterface;
165227
use App\Blog\Plugin\AddReadingTimePlugin;
166228

167229
return [
168230
'plugins' => [
169-
PostRepository::class => [
231+
PostRepositoryInterface::class => [
170232
AddReadingTimePlugin::class,
171233
],
172234
],
@@ -176,8 +238,8 @@ return [
176238
## What You've Learned
177239

178240
- How to scaffold a Marko project and install packages
179-
- Database setup with migrations
180-
- Template overriding for customization
241+
- Entity-driven database schema with `#[Table]`, `#[Column]`, and `#[Index]` attributes
242+
- Template overriding with Latte for customization
181243
- [Events and observers](/docs/concepts/events/) for reactive behavior
182244
- [Plugins](/docs/concepts/plugins/) for modifying existing functionality
183245

docs/src/content/docs/tutorials/build-a-chat.md

Lines changed: 64 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ cd my-chat
2828
composer require marko/core marko/routing marko/config marko/env \
2929
marko/database marko/database-pgsql \
3030
marko/authentication marko/session marko/session-database \
31-
marko/pubsub marko/pubsub-redis marko/sse
31+
marko/pubsub marko/pubsub-redis marko/sse \
32+
marko/view marko/view-latte marko/dev-server
3233
```
3334

3435
## Step 2: Configure Redis PubSub
@@ -46,20 +47,47 @@ return [
4647

4748
The prefix ensures all chat channels are namespaced under `chat:` in Redis, keeping them separate from other PubSub traffic in your application.
4849

49-
## Step 3: Create the Messages Table
50+
## Step 3: Define the Message Entity
5051

51-
Create a migration for persisting chat messages:
52+
Marko uses entity-driven schemas --- define your table structure as a PHP class with attributes, then run `marko db:migrate` to auto-generate and apply the migration.
5253

53-
```sql title="migrations/001_create_messages.sql"
54-
CREATE TABLE messages (
55-
id SERIAL PRIMARY KEY,
56-
room VARCHAR(100) NOT NULL,
57-
username VARCHAR(100) NOT NULL,
58-
body TEXT NOT NULL,
59-
created_at TIMESTAMP NOT NULL DEFAULT NOW()
60-
);
54+
```php title="app/chat/src/Entity/Message.php"
55+
<?php
56+
57+
declare(strict_types=1);
6158

62-
CREATE INDEX idx_messages_room_id ON messages (room, id);
59+
namespace App\Chat\Entity;
60+
61+
use Marko\Database\Attributes\Column;
62+
use Marko\Database\Attributes\Index;
63+
use Marko\Database\Attributes\Table;
64+
use Marko\Database\Entity\Entity;
65+
66+
#[Table('messages')]
67+
#[Index('idx_messages_room_id', ['room', 'id'])]
68+
class Message extends Entity
69+
{
70+
#[Column(primaryKey: true, autoIncrement: true)]
71+
public ?int $id = null;
72+
73+
#[Column(length: 100)]
74+
public string $room;
75+
76+
#[Column(length: 100)]
77+
public string $username;
78+
79+
#[Column(type: 'TEXT')]
80+
public string $body;
81+
82+
#[Column('created_at')]
83+
public ?string $createdAt = null;
84+
}
85+
```
86+
87+
Then generate and run the migration:
88+
89+
```bash
90+
marko db:migrate
6391
```
6492

6593
The composite index on `(room, id)` ensures efficient lookups when fetching message history and recovering missed messages after reconnection.
@@ -124,6 +152,7 @@ declare(strict_types=1);
124152
namespace App\Chat\Controller;
125153

126154
use App\Chat\Repository\MessageRepository;
155+
use JsonException;
127156
use Marko\Authentication\AuthManager;
128157
use Marko\Authentication\Middleware\AuthMiddleware;
129158
use Marko\PubSub\Message;
@@ -133,7 +162,7 @@ use Marko\Routing\Attributes\Middleware;
133162
use Marko\Routing\Attributes\Post;
134163
use Marko\Routing\Http\Request;
135164
use Marko\Routing\Http\Response;
136-
use JsonException;
165+
use Marko\View\ViewInterface;
137166

138167
#[Middleware(AuthMiddleware::class)]
139168
class ChatController
@@ -142,14 +171,15 @@ class ChatController
142171
private readonly MessageRepository $messageRepository,
143172
private readonly PublisherInterface $publisher,
144173
private readonly AuthManager $authManager,
174+
private readonly ViewInterface $view,
145175
) {}
146176

147177
#[Get('/chat/{room}')]
148178
public function room(string $room): Response
149179
{
150180
$messages = $this->messageRepository->forRoom($room);
151181

152-
return Response::json(data: [
182+
return $this->view->render('chat::message/room', [
153183
'room' => $room,
154184
'messages' => $messages,
155185
]);
@@ -183,7 +213,7 @@ class ChatController
183213
}
184214
```
185215

186-
The `#[Middleware(AuthMiddleware::class)]` attribute at the class level protects every endpoint in this controller. The `PublisherInterface` is injected by the DI container --- since `marko/pubsub-redis` is installed, it resolves to the `RedisPublisher` automatically.
216+
The `#[Middleware(AuthMiddleware::class)]` attribute at the class level protects every endpoint in this controller. The `PublisherInterface` is injected by the DI container --- since `marko/pubsub-redis` is installed, it resolves to the `RedisPublisher` automatically. The `room` method renders a Latte template via `ViewInterface` --- the template name `'chat::message/room'` resolves to `resources/views/message/room.latte` within the chat module.
187217

188218
## Step 6: Build the SSE Streaming Endpoint
189219

@@ -256,14 +286,16 @@ Key design decisions:
256286
- **`timeout: 300`** --- The stream closes after 5 minutes. The client's `EventSource` will automatically reconnect, sending `Last-Event-ID` so no messages are lost.
257287
- **Replay on reconnect** --- Before subscribing to the live stream, `replayMissed` sends any messages the client missed during the disconnection gap.
258288

259-
## Step 7: Add Client-Side JavaScript
289+
## Step 7: Add the Chat View
290+
291+
Marko uses Latte templates stored in `resources/views/` within each module. The template name `'chat::message/room'` in the controller resolves to this file:
260292

261-
```html title="public/chat.html"
293+
```latte title="app/chat/resources/views/message/room.latte"
262294
<!DOCTYPE html>
263295
<html lang="en">
264296
<head>
265297
<meta charset="UTF-8">
266-
<title>Marko Chat</title>
298+
<title>Marko Chat — {$room}</title>
267299
<style>
268300
#messages { height: 400px; overflow-y: auto; border: 1px solid #ccc; padding: 1rem; }
269301
.message { margin-bottom: 0.5rem; }
@@ -272,16 +304,22 @@ Key design decisions:
272304
</style>
273305
</head>
274306
<body>
275-
<h1>Chat Room</h1>
307+
<h1>Chat Room: {$room}</h1>
276308
<div id="status">Connecting...</div>
277-
<div id="messages"></div>
309+
<div id="messages">
310+
{foreach $messages as $message}
311+
<div class="message">
312+
<span class="username">{$message['username']}:</span> {$message['body']}
313+
</div>
314+
{/foreach}
315+
</div>
278316
<form id="send-form">
279317
<input type="text" id="body" placeholder="Type a message..." autocomplete="off" />
280318
<button type="submit">Send</button>
281319
</form>
282320
283321
<script>
284-
const room = 'general';
322+
const room = {$room|json};
285323
const messagesDiv = document.getElementById('messages');
286324
const statusDiv = document.getElementById('status');
287325
@@ -329,6 +367,8 @@ Key design decisions:
329367
</html>
330368
```
331369

370+
The template receives `$room` and `$messages` from the controller. Existing messages are rendered server-side in the `{foreach}` loop, while new messages arrive in real-time via the SSE connection below.
371+
332372
The `EventSource` API handles reconnection automatically. When the SSE stream closes (after the 300-second timeout or a network interruption), the browser reconnects and sends the last received event ID via the `Last-Event-ID` header. The server uses this to replay any missed messages before resuming the live stream.
333373

334374
Note that `source.addEventListener` uses the event name `room.general` --- this matches the `channel` field on the `Message`, which `SseStream` sets as the SSE `event` type via `SseEvent`.
@@ -404,12 +444,12 @@ The replay loop creates `SseEvent` objects with explicit `id` values. When the b
404444

405445
## Step 9: Start the Server and Test
406446

407-
Start the built-in PHP server:
408-
409447
```bash
410-
php -S localhost:8000 -t public
448+
marko up
411449
```
412450

451+
`marko up` (alias for `dev:up`) starts the full development environment automatically --- PHP server, Docker if detected, pub/sub listener if pubsub packages are installed, and frontend build tools if detected. For SSE applications this is important: `marko up` starts PHP with `PHP_CLI_SERVER_WORKERS=4` by default, so the SSE connection does not block all other requests on the single-threaded PHP built-in server. MarkoTalk (the reference chat implementation) uses this same approach.
452+
413453
In separate terminals, test the flow:
414454

415455
```bash

0 commit comments

Comments
 (0)