Tutorial: Build a Complete Page
Every other page in these docs explains one feature. This one puts them together. You will build the Recipe Box: a public gallery of community recipes with photos, pagination, a "save to favorites" button and a form for logged-in users to share their own recipe. By the end you will have used almost every part of the framework in a single, realistic feature, and seen where each piece belongs.
The code follows the same rules as the framework itself: PHP 8.4, strict types, and everything is written to pass PHPStan level 9.
What You Will Use
Here is every feature the Recipe Box touches, and where it shows up.
| Feature | Where it appears | Reference |
|---|---|---|
| Controllers & dependency injection | RecipeController | Controllers, Container |
| Doctrine entities | Recipe (writes) | Doctrine ORM |
| PDO models & pagination | RecipeCatalog (reads) | Models, Pagination |
| Migrations | CreateRecipesTable | Console Commands |
| Named routes & middleware | routes/web.php | Routing, Middleware |
| Twig views, flash messages, CSRF | views/recipes/ | Views, Security |
| Validation | store() | Validation |
| File uploads | store() | File Uploader |
| Image resizing & signed URLs | imageSet() | Image Processing |
| Events & listeners | RecipePublished | Events |
defer() | view counter, JSON export | Deferred Tasks |
| Persistent queue | EmailRecipeConfirmation | Persistent Queue |
| Sessions | favorites, recently viewed | Sessions |
| Cache | popular recipes | Caching |
| Support helpers & Path | Config, Fs, Json, Path | Support Helpers, Path Helper |
| SEO helpers | meta tags, Schema.org, Sluggify | SEO Helpers |
| React Islands | FavoriteButton, ServingScaler | React + Rhapsody |
| Mailer & Logger | the queue job, a listener | Mailer, Logging |
How a recipe gets published
This is the request you will build in Steps 5 to 10. Notice that each tool does a different job: some work happens inside the request, some right after it, and some in a separate process.
POST /recipes
|- CSRF check (global middleware), then the "auth" route middleware
|- Validator -> are the form fields acceptable?
|- FileUploader -> store the photo (checked by real MIME type)
|- Doctrine -> persist() + flush() the Recipe entity
|- EventDispatcher -> RecipePublished: clear caches, write a log line (in the request)
|- defer() -> rebuild the JSON export (after the response)
|- QueueManager -> EmailRecipeConfirmation (a worker process, retried)
'- redirect + flash -> GET /recipes/{slug}
Before You Start
You need a working Rhapsody application (see Installation & Setup) with APP_URL and your database settings in .env. Then run these commands once. Each one is explained in the step that uses it.
# Users table, so recipes have authors (skip if you already ran it)
php rhapsody auth:install
# React bridge for the islands in Step 11 (skip if you already ran it)
php rhapsody react:install
npm install
# Tables for the persistent queue used in Step 10
php rhapsody queue:install --migrate
APP_URL set even on a local install. redirect() and route() both build addresses from APP_URL and APP_BASE_URL, and a few lines in this tutorial rely on that.Project Layout
These are the files you will create or change. Everything else already exists in the skeleton.
app/
Controllers/RecipeController.php Step 6 (generated, then rewritten)
Entities/Recipe.php Step 3
Events/RecipePublished.php Step 8 (generated, then edited)
Jobs/EmailRecipeConfirmation.php Step 10
Listeners/ClearRecipeCaches.php Step 8 (generated, then edited)
Listeners/LogRecipeActivity.php Step 8 (generated, then edited)
Models/RecipeCatalog.php Step 4 (generated, then rewritten)
Providers/EventServiceProvider.php Step 8 (edited)
Services/RecipeExporter.php Step 9
config/config.php Step 1 (edited)
db/migrations/..._create_recipes_table.php Step 2 (generated, then edited)
resources/js/components/FavoriteButton.jsx Step 11 (generated, then edited)
resources/js/components/ServingScaler.jsx Step 11 (generated, then edited)
routes/web.php Step 5 (edited)
views/recipes/index.twig, _card.twig, show.twig, create.twig Step 7
Step 1: Configuration
Put tunable values in configuration, not in controller code. Read them from the environment with Env inside config/config.php, where it is allowed to touch the environment, and read them everywhere else through the typed Config service.
config/config.php (add this key to the array the file returns)
'recipes' => [
'per_page' => Env::int('RECIPES_PER_PAGE', 9),
'export_enabled' => Env::bool('RECIPES_EXPORT', true),
],
.env
RECIPES_PER_PAGE=9
RECIPES_EXPORT=true
Later, $this->config->int('recipes.per_page', 9) returns a real int, using dot notation to reach the nested key. If a key is missing and you gave no default, Config throws immediately instead of letting a null travel through your code.
Step 2: The Database Table
Rhapsody uses Phinx for migrations, so the same file works on MySQL, PostgreSQL, SQL Server and SQLite. Generate the file, then fill in change().
php rhapsody make:migration CreateRecipesTable
Open the new file in db/migrations/ and replace its body:
db/migrations/20261106120000_create_recipes_table.php
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateRecipesTable extends AbstractMigration
{
public function change(): void
{
$this->table('recipes')
->addColumn('author_id', 'string', ['limit' => 255])
->addColumn('title', 'string', ['limit' => 160])
->addColumn('slug', 'string', ['limit' => 190])
->addColumn('summary', 'string', ['limit' => 300])
->addColumn('ingredients', 'text')
->addColumn('steps', 'text')
->addColumn('servings', 'integer', ['default' => 4])
->addColumn('photo', 'string', ['limit' => 100, 'null' => true])
->addColumn('views', 'integer', ['default' => 0])
->addColumn('created_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP'])
->addIndex(['slug'], ['unique' => true])
->addIndex(['author_id'])
->create();
}
}
Your file name will carry its own timestamp. Now create the table:
php rhapsody migrate
author_id holds the user_id of the built-in users table. We use a plain column rather than a database foreign key to keep the tutorial focused, but adding one is a good exercise.
Step 3: The Entity (Writes)
Use a Doctrine entity when you create or change data. The EntityManager tracks the object and writes only what changed. Entities live in app/Entities/, which the framework scans automatically.
app/Entities/Recipe.php
<?php
declare(strict_types=1);
namespace App\Entities;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'recipes')]
class Recipe
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(name: 'author_id', type: 'string', length: 255)]
private string $authorId;
#[ORM\Column(type: 'string', length: 160)]
private string $title;
#[ORM\Column(type: 'string', length: 190, unique: true)]
private string $slug;
#[ORM\Column(type: 'string', length: 300)]
private string $summary;
#[ORM\Column(type: 'text')]
private string $ingredients;
#[ORM\Column(type: 'text')]
private string $steps;
#[ORM\Column(type: 'integer')]
private int $servings;
#[ORM\Column(type: 'string', length: 100, nullable: true)]
private ?string $photo;
#[ORM\Column(name: 'created_at', type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
public function __construct(
string $authorId,
string $title,
string $slug,
string $summary,
string $ingredients,
string $steps,
int $servings,
?string $photo = null,
) {
$this->authorId = $authorId;
$this->title = $title;
$this->slug = $slug;
$this->summary = $summary;
$this->ingredients = $ingredients;
$this->steps = $steps;
$this->servings = $servings;
$this->photo = $photo;
$this->createdAt = new DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getAuthorId(): string
{
return $this->authorId;
}
public function getTitle(): string
{
return $this->title;
}
public function getSlug(): string
{
return $this->slug;
}
public function getPhoto(): ?string
{
return $this->photo;
}
}
The views column from the migration is deliberately not mapped. Doctrine leaves unmapped columns alone, so a new row simply gets the database default of 0, and the view counter in Step 9 can update it with one cheap query instead of loading the whole entity.
Step 4: A Read Model (Reads)
Pages that mostly read data, such as lists, sidebars and counters, are a good fit for a plain PDO model: one explicit query, no object hydration. Generate it, then replace the contents.
php rhapsody make:model RecipeCatalog
app/Models/RecipeCatalog.php
<?php
declare(strict_types=1);
namespace App\Models;
use PDO;
use Rhapsody\Core\BaseModel;
/**
* @phpstan-type RecipeRow array{id: int|string, title: string, slug: string, summary: string, photo: string|null, servings: int|string, views: int|string, created_at: string, author_name: string}
* @phpstan-type RecipeDetail array{id: int|string, author_id: string, title: string, slug: string, summary: string, ingredients: string, steps: string, servings: int|string, photo: string|null, views: int|string, created_at: string, author_name: string}
*/
final class RecipeCatalog extends BaseModel
{
private const string CARD_COLUMNS = 'r.id, r.title, r.slug, r.summary, r.photo, r.servings, r.views, r.created_at, u.name AS author_name';
public function countAll(): int
{
$stmt = $this->db->prepare('SELECT COUNT(*) FROM recipes');
$stmt->execute();
$count = $stmt->fetchColumn();
return is_numeric($count) ? (int) $count : 0;
}
/**
* @return list<RecipeRow>
*/
public function findPaginated(int $limit, int $offset): array
{
$stmt = $this->db->prepare(
'SELECT ' . self::CARD_COLUMNS . '
FROM recipes r
JOIN users u ON u.user_id = r.author_id
ORDER BY r.created_at DESC, r.id DESC
LIMIT :limit OFFSET :offset'
);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
/** @var list<RecipeRow> $rows */
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return $rows;
}
/**
* @return list<RecipeRow>
*/
public function popular(int $limit): array
{
$stmt = $this->db->prepare(
'SELECT ' . self::CARD_COLUMNS . '
FROM recipes r
JOIN users u ON u.user_id = r.author_id
ORDER BY r.views DESC, r.id DESC
LIMIT :limit'
);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
/** @var list<RecipeRow> $rows */
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return $rows;
}
/**
* @return RecipeDetail|null
*/
public function findBySlug(string $slug): ?array
{
$stmt = $this->db->prepare(
'SELECT r.*, u.name AS author_name
FROM recipes r
JOIN users u ON u.user_id = r.author_id
WHERE r.slug = :slug
LIMIT 1'
);
$stmt->execute([':slug' => $slug]);
/** @var RecipeDetail|false $row */
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row === false ? null : $row;
}
public function incrementViews(int $id): void
{
$stmt = $this->db->prepare('UPDATE recipes SET views = views + 1 WHERE id = :id');
$stmt->execute([':id' => $id]);
}
}
The @phpstan-type lines describe the exact shape of a row, so the rest of the code (and PHPStan) knows which keys exist. Every value that came from a visitor is bound as a parameter, never concatenated into the SQL.
LIMIT :limit OFFSET :offset works on MySQL, PostgreSQL and SQLite. SQL Server uses ORDER BY ... OFFSET :offset ROWS FETCH NEXT :limit ROWS ONLY instead. If you target SQL Server, swap that clause in the two paginated queries.Step 5: Named Routes
Give every route a name and refer to it by name from then on. Links keep working if you ever change a URL, or install the site in a subdirectory.
routes/web.php (add these lines)
<?php
use App\Controllers\RecipeController;
use Rhapsody\Core\Routing\Router;
Router::get('/recipes', [RecipeController::class, 'index'])->name('recipes.index');
// Static paths go BEFORE the {slug} wildcard: the first matching route wins,
// so /recipes/new would otherwise be read as a recipe called "new".
Router::get('/recipes/new', [RecipeController::class, 'create'])
->name('recipes.create')
->middleware('auth');
Router::post('/recipes', [RecipeController::class, 'store'])
->name('recipes.store')
->middleware('auth');
Router::get('/recipes/{slug}', [RecipeController::class, 'show'])->name('recipes.show');
Router::post('/recipes/{id}/favorite', [RecipeController::class, 'favorite'])->name('recipes.favorite');
- The router passes the
Requestto every action first, then the route parameters in order:show(Request $request, string $slug). ->middleware('auth')uses the key fromconfig/config.php. Visitors who are not logged in are sent to/loginbefore your controller runs.- CSRF protection is global middleware, so every
POSThere is checked automatically. You only have to send the token (Steps 7 and 11). - If you use the route cache, run
php rhapsody route:clearafter adding routes, or the new URLs will 404.
Step 6: The Controller
Generate the file, then replace its contents with the code below. The class is built in six parts; they all belong in the same file, in this order.
php rhapsody make:controller RecipeController
6.1 Dependencies
Type-hint what you need in the constructor and the container supplies it. BaseController already gives you $this->twig, $this->cache and $this->schema, so only add what is specific to this controller.
app/Controllers/RecipeController.php
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Entities\Recipe;
use App\Events\RecipePublished;
use App\Jobs\EmailRecipeConfirmation;
use App\Models\RecipeCatalog;
use App\Services\RecipeExporter;
use Doctrine\ORM\EntityManager;
use Rhapsody\Core\BaseController;
use Rhapsody\Core\Events\EventDispatcher;
use Rhapsody\Core\Exceptions\HttpException;
use Rhapsody\Core\FileUploader;
use Rhapsody\Core\Helpers\ImageSigner;
use Rhapsody\Core\Helpers\Path;
use Rhapsody\Core\Pagination;
use Rhapsody\Core\Queue\QueueManager;
use Rhapsody\Core\Request;
use Rhapsody\Core\Response;
use Rhapsody\Core\Routing\Router;
use Rhapsody\Core\SEO\Sluggify;
use Rhapsody\Core\Session;
use Rhapsody\Core\Support\Config;
use Rhapsody\Core\Validator;
use Twig\Environment;
/**
* @phpstan-import-type RecipeRow from RecipeCatalog
*/
final class RecipeController extends BaseController
{
public function __construct(
Environment $twig,
private readonly EntityManager $em,
private readonly RecipeCatalog $catalog,
private readonly RecipeExporter $exporter,
private readonly Validator $validator,
private readonly EventDispatcher $events,
private readonly QueueManager $queue,
private readonly Config $config,
) {
parent::__construct($twig);
}
6.2 The list page
This action combines pagination, typed configuration, the cache, and per-page SEO metadata. Request::int() always returns an integer, so a visitor typing ?page=abc simply gets page 1.
app/Controllers/RecipeController.php (continued)
public function index(Request $request): Response
{
$pagination = new Pagination(
$this->catalog->countAll(),
$this->config->int('recipes.per_page', 9),
max(1, $request->int('page', 1)),
);
$recipes = array_map(
$this->withCardImage(...),
$this->catalog->findPaginated($pagination->getLimit(), $pagination->getOffset()),
);
return $this->view('recipes/index.twig', [
'recipes' => $recipes,
'pagination' => $pagination,
'popular' => $this->popular(),
'recent' => $this->recentRecipes(),
], [
'title' => 'Recipe Box: home cooking shared by the community',
'description' => 'Browse home-cooked recipes with photos and ingredient lists that scale to any number of servings.',
'canonical_url' => $request->getCanonicalUrl(),
]);
}
The third argument to view() becomes the page's <title>, meta description and canonical link (see SEO Helpers).
6.3 The recipe page
Three things happen here that make the page fast and findable: the view counter is defer()red so the visitor never waits for the database write, the visit is remembered in the session, and a Schema.org Recipe block is added so search engines can show rich results.
app/Controllers/RecipeController.php (continued)
public function show(Request $request, string $slug): Response
{
$recipe = $this->catalog->findBySlug($slug);
if ($recipe === null) {
throw new HttpException(404, 'That recipe does not exist.');
}
$id = (int) $recipe['id'];
// Counting a view is not worth making the visitor wait: run it after the response.
defer(fn () => $this->catalog->incrementViews($id));
$this->rememberVisit($recipe['slug'], $recipe['title']);
$ingredients = $this->lines($recipe['ingredients']);
$steps = $this->lines($recipe['steps']);
$photo = $recipe['photo'] === null ? null : $this->imageSet($recipe['photo'], 960, 640);
$this->schema->add('Recipe', array_filter([
'name' => $recipe['title'],
'description' => $recipe['summary'],
'image' => $photo['src'] ?? null,
'author' => ['@type' => 'Person', 'name' => $recipe['author_name']],
'datePublished' => substr($recipe['created_at'], 0, 10),
'recipeYield' => $recipe['servings'] . ' servings',
'recipeIngredient' => $ingredients,
'recipeInstructions' => array_map(
static fn (string $step): array => ['@type' => 'HowToStep', 'text' => $step],
$steps,
),
], static fn (mixed $value): bool => $value !== null));
return $this->view('recipes/show.twig', [
'recipe' => $recipe,
'ingredients' => $ingredients,
'steps' => $steps,
'photo' => $photo,
'favorite' => in_array($id, $this->favoriteIds(), true),
], [
'title' => $recipe['title'] . ' | Recipe Box',
'description' => $recipe['summary'],
'canonical_url' => $request->getCanonicalUrl(),
]);
}
Throwing HttpException hands the request to the framework's error handler, so a missing recipe renders your theme's errors/404.twig page.
6.4 Sharing a recipe
This is the heart of the tutorial. Read it top to bottom: validate, upload, save, announce, defer, queue, redirect.
app/Controllers/RecipeController.php (continued)
public function create(): Response
{
return $this->view('recipes/create.twig', ['old' => [], 'errors' => []], [
'title' => 'Share a recipe | Recipe Box',
]);
}
public function store(Request $request): Response
{
// 1. Validate. Typed accessors never throw; they turn bad input into a safe default.
$input = [
'title' => trim($request->string('title')),
'summary' => trim($request->string('summary')),
'ingredients' => trim($request->string('ingredients')),
'steps' => trim($request->string('steps')),
'servings' => $request->int('servings'),
];
$valid = $this->validator->validate($input, [
'title' => 'required|min:3|max:160',
'summary' => 'required|max:300',
'ingredients' => 'required|min:10',
'steps' => 'required|min:10',
'servings' => 'required|numeric|min:1|max:50',
]);
$errors = $this->validator->getErrors();
// 2. Store the photo (optional). Only touch the disk once the text is valid.
$upload = null;
if ($valid && $this->hasPhoto($request)) {
$upload = new FileUploader($request)
->images()
->maxSize('4MB')
->to(Path::public('images/recipes'))
->store('photo');
if ($upload->failed()) {
$valid = false;
$errors['photo'] = [$upload->firstError() ?? 'The photo could not be uploaded.'];
}
}
if (! $valid) {
return $this->view('recipes/create.twig', ['old' => $input, 'errors' => $errors])
->setStatusCode(422);
}
// 3. Save with Doctrine.
$authorId = Session::string('user_id');
$slug = $this->uniqueSlug($input['title']);
$recipe = new Recipe(
authorId: $authorId,
title: $input['title'],
slug: $slug,
summary: $input['summary'],
ingredients: $input['ingredients'],
steps: $input['steps'],
servings: $input['servings'],
photo: $upload?->first()?->filename,
);
$this->em->persist($recipe);
$this->em->flush();
$id = $recipe->getId() ?? throw new \LogicException('Doctrine did not assign an id.');
// 4. Announce it. Listeners react without this controller knowing they exist.
$this->events->dispatch(new RecipePublished($id, $slug, $authorId));
// 5. Quick, best-effort work after the response: defer().
if ($this->config->bool('recipes.export_enabled', true)) {
defer($this->exporter->export(...));
}
// 6. Work that must not be lost: the persistent queue.
$this->queue->dispatch(new EmailRecipeConfirmation($id), queue: 'emails');
// 7. Flash message + redirect to the new page, using its route name.
return redirect(Router::generateUrl('recipes.show', ['slug' => $slug]))
->with('success', 'Your recipe is live!');
}
- Validation errors come back as a list per field, which is what the template in Step 7 expects. The
422status tells browsers and tools the submission was understood but rejected. - The uploader checks the file's real type from its contents, never the name the browser sent, renames it to a random name, and rejects SVG because SVG can carry scripts. See File Uploader.
- The flash message set by
with()lives for exactly one more request, which is the page you redirect to.
6.5 The favorite button's endpoint
One route serves two kinds of client. A React island asks for JSON; a plain form post (or a visitor with JavaScript disabled) gets a redirect. Request::wantsJson() is how the action tells them apart.
app/Controllers/RecipeController.php (continued)
public function favorite(Request $request, string $id): Response
{
$recipeId = (int) $id;
if ($this->em->find(Recipe::class, $recipeId) === null) {
throw new HttpException(404, 'That recipe does not exist.');
}
$favorites = $this->favoriteIds();
$isFavorite = in_array($recipeId, $favorites, true);
$favorites = $isFavorite
? array_values(array_diff($favorites, [$recipeId]))
: [...$favorites, $recipeId];
Session::set('favorites', $favorites);
if ($request->wantsJson()) {
return $this->json(['favorite' => ! $isFavorite, 'count' => count($favorites)]);
}
return redirect(Router::generateUrl('recipes.index'))
->with('success', $isFavorite ? 'Removed from your favorites.' : 'Saved to your favorites.');
}
6.6 Private helpers
Small private methods keep the actions readable. Notice how much of the framework shows up here in a few lines: the cache, the session, signed image URLs and the slug helper.
app/Controllers/RecipeController.php (continued)
/**
* The five most-viewed recipes, cached for ten minutes. The cache stores JSON,
* so we cache plain arrays (not objects).
*
* @return list<RecipeRow>
*/
private function popular(): array
{
/** @var list<RecipeRow> $rows */
$rows = $this->cache->remember('recipes.popular', 10, fn (): array => $this->catalog->popular(5));
return $rows;
}
/**
* Recipe ids this visitor has saved, kept in their session.
*
* @return list<int>
*/
private function favoriteIds(): array
{
$stored = Session::get('favorites', []);
$ids = [];
if (is_array($stored)) {
foreach ($stored as $value) {
if (is_int($value)) {
$ids[] = $value;
}
}
}
return $ids;
}
/**
* Newest first, at most five: slug => title.
*
* @return array<string, string>
*/
private function recentRecipes(): array
{
$stored = Session::get('recent_recipes', []);
$recent = [];
if (is_array($stored)) {
foreach ($stored as $slug => $title) {
if (is_string($title)) {
$recent[(string) $slug] = $title;
}
}
}
return $recent;
}
private function rememberVisit(string $slug, string $title): void
{
$recent = $this->recentRecipes();
unset($recent[$slug]);
Session::set('recent_recipes', array_slice([$slug => $title] + $recent, 0, 5, true));
}
/**
* Resized, cropped WebP URLs for 1x and 2x screens. Every URL is signed, so
* visitors cannot ask the server for arbitrary sizes.
*
* @return array{src: string, srcset: string}
*/
private function imageSet(string $file, int $width, int $height): array
{
$variant = static fn (int $factor): string => ImageSigner::url('recipes/' . $file, [
'w' => $width * $factor,
'h' => $height * $factor,
'fit' => 'crop',
'fm' => 'webp',
'q' => 80,
]);
return [
'src' => $variant(1),
'srcset' => $variant(1) . ' ' . $width . 'w, ' . $variant(2) . ' ' . ($width * 2) . 'w',
];
}
/**
* Swaps a row's photo filename for its image URLs, ready for the template.
*
* @param RecipeRow $row
* @return array<string, mixed>
*/
private function withCardImage(array $row): array
{
return array_merge($row, [
'photo' => $row['photo'] === null ? null : $this->imageSet($row['photo'], 480, 320),
]);
}
/**
* Turns a textarea into a clean list: one entry per non-empty line.
*
* @return list<string>
*/
private function lines(string $text): array
{
$lines = preg_split('/\R/', $text) ?: [];
return array_values(array_filter(
array_map('trim', $lines),
static fn (string $line): bool => $line !== '',
));
}
private function uniqueSlug(string $title): string
{
$base = Sluggify::transform($title);
$slug = $base;
for ($suffix = 2; $this->em->getRepository(Recipe::class)->findOneBy(['slug' => $slug]) !== null; $suffix++) {
$slug = $base . '-' . $suffix;
}
return $slug;
}
private function hasPhoto(Request $request): bool
{
$photo = $request->getFiles()['photo'] ?? null;
return is_array($photo) && ($photo['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE;
}
}
Step 7: The Views
Views go in views/recipes/. They extend the theme's layout, which already prints flash messages and loads the React bridge, so these templates only describe their own content. Twig escapes every {{ }} value automatically.
The list page
views/recipes/index.twig
{% extends "layouts/main.twig" %}
{% block content %}
<div class="flex flex-wrap items-center justify-between gap-4 mb-8">
<h1 class="text-3xl font-bold">Recipe Box</h1>
{% if auth.check %}
<a href="{{ route('recipes.create') }}"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">Share a recipe</a>
{% else %}
<a href="{{ base_url }}/login" class="text-blue-600 dark:text-blue-400 underline">Log in to share a recipe</a>
{% endif %}
</div>
<div class="grid gap-8 lg:grid-cols-4">
<div class="lg:col-span-3">
<div class="grid gap-6 sm:grid-cols-2 xl:grid-cols-3">
{% for recipe in recipes %}
{% include 'recipes/_card.twig' %}
{% else %}
<p class="col-span-full text-gray-500">No recipes yet. Be the first to share one!</p>
{% endfor %}
</div>
<div class="flex justify-center mt-10">
{{ pagination.render()|raw }}
</div>
</div>
<aside class="space-y-8">
<section>
<h2 class="text-lg font-bold mb-2">Most viewed</h2>
<ul class="space-y-1">
{% for item in popular %}
<li><a class="text-blue-600 dark:text-blue-400" href="{{ route('recipes.show', {slug: item.slug}) }}">{{ item.title }}</a></li>
{% endfor %}
</ul>
</section>
{% if recent %}
<section>
<h2 class="text-lg font-bold mb-2">You looked at</h2>
<ul class="space-y-1">
{% for slug, title in recent %}
<li><a class="text-blue-600 dark:text-blue-400" href="{{ route('recipes.show', {slug: slug}) }}">{{ title }}</a></li>
{% endfor %}
</ul>
</section>
{% endif %}
</aside>
</div>
{% endblock %}
pagination.render() returns ready-made HTML, so it needs the |raw filter. It also keeps any other query parameters (such as a future ?sort=) on every page link.
The recipe card
views/recipes/_card.twig
<article class="rounded-lg overflow-hidden shadow bg-white dark:bg-gray-900">
<a href="{{ route('recipes.show', {slug: recipe.slug}) }}" class="block">
{% if recipe.photo %}
<img src="{{ recipe.photo.src }}" srcset="{{ recipe.photo.srcset }}"
sizes="(min-width: 1280px) 22vw, (min-width: 640px) 45vw, 100vw"
width="480" height="320" loading="lazy" alt="{{ recipe.title }}" class="w-full h-auto">
{% endif %}
<div class="p-4">
<h2 class="text-xl font-bold">{{ recipe.title }}</h2>
<p class="mt-1 text-gray-600 dark:text-gray-400">{{ recipe.summary }}</p>
<p class="mt-3 text-sm text-gray-500">by {{ recipe.author_name }} · {{ recipe.views }} views</p>
</div>
</a>
</article>
The recipe page
Two React islands appear here. Everything else is plain server-rendered HTML, which keeps the page fast and search-friendly. The <noscript> list means the ingredients are still readable if JavaScript is blocked.
views/recipes/show.twig
{% extends "layouts/main.twig" %}
{% block content %}
<article class="max-w-3xl mx-auto">
<p class="mb-4"><a class="text-blue-600 dark:text-blue-400" href="{{ route('recipes.index') }}">← All recipes</a></p>
<h1 class="text-4xl font-bold">{{ recipe.title }}</h1>
<p class="mt-2 text-gray-500">
By {{ recipe.author_name }} · {{ recipe.created_at|date('F j, Y') }} · {{ recipe.views }} views
</p>
{% if photo %}
<img src="{{ photo.src }}" srcset="{{ photo.srcset }}" sizes="(min-width: 768px) 48rem, 100vw"
width="960" height="640" alt="{{ recipe.title }}" class="w-full h-auto rounded-lg mt-6">
{% endif %}
<p class="mt-6 text-lg">{{ recipe.summary }}</p>
<div class="mt-4">
{{ react_component('FavoriteButton', {
endpoint: route('recipes.favorite', {id: recipe.id}),
token: csrf_token(),
initial: favorite
}) }}
</div>
<h2 class="text-2xl font-bold mt-10 mb-3">Ingredients</h2>
{{ react_component('ServingScaler', {baseServings: recipe.servings, ingredients: ingredients}) }}
<noscript>
<ul class="list-disc pl-6">
{% for line in ingredients %}<li>{{ line }}</li>{% endfor %}
</ul>
</noscript>
<h2 class="text-2xl font-bold mt-10 mb-3">Method</h2>
<ol class="list-decimal pl-6 space-y-3">
{% for step in steps %}<li>{{ step }}</li>{% endfor %}
</ol>
</article>
{% endblock %}
The share form
Two details matter here. csrf_field() adds the hidden token that the global middleware checks, and enctype="multipart/form-data" is required for the file input.
views/recipes/create.twig
{% extends "layouts/main.twig" %}
{% block content %}
<div class="max-w-2xl mx-auto">
<h1 class="text-3xl font-bold mb-6">Share a recipe</h1>
<form method="post" action="{{ route('recipes.store') }}" enctype="multipart/form-data" class="space-y-5">
{{ csrf_field() }}
<div>
<label for="title" class="block font-bold mb-1">Title</label>
<input id="title" name="title" type="text" maxlength="160" required value="{{ old.title|default('') }}"
class="w-full rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-900 p-2">
{% if errors.title %}<p class="text-red-600 text-sm mt-1">{{ errors.title|first }}</p>{% endif %}
</div>
<div>
<label for="summary" class="block font-bold mb-1">One-line summary</label>
<input id="summary" name="summary" type="text" maxlength="300" required value="{{ old.summary|default('') }}"
class="w-full rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-900 p-2">
{% if errors.summary %}<p class="text-red-600 text-sm mt-1">{{ errors.summary|first }}</p>{% endif %}
</div>
<div>
<label for="servings" class="block font-bold mb-1">Servings</label>
<input id="servings" name="servings" type="number" min="1" max="50" required value="{{ old.servings|default(4) }}"
class="w-32 rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-900 p-2">
{% if errors.servings %}<p class="text-red-600 text-sm mt-1">{{ errors.servings|first }}</p>{% endif %}
</div>
<div>
<label for="ingredients" class="block font-bold mb-1">Ingredients <span class="font-normal text-gray-500">(one per line, quantity first: "1 1/2 cups flour")</span></label>
<textarea id="ingredients" name="ingredients" rows="6" required
class="w-full rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-900 p-2">{{ old.ingredients|default('') }}</textarea>
{% if errors.ingredients %}<p class="text-red-600 text-sm mt-1">{{ errors.ingredients|first }}</p>{% endif %}
</div>
<div>
<label for="steps" class="block font-bold mb-1">Method <span class="font-normal text-gray-500">(one step per line)</span></label>
<textarea id="steps" name="steps" rows="6" required
class="w-full rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-900 p-2">{{ old.steps|default('') }}</textarea>
{% if errors.steps %}<p class="text-red-600 text-sm mt-1">{{ errors.steps|first }}</p>{% endif %}
</div>
<div>
<label for="photo" class="block font-bold mb-1">Photo <span class="font-normal text-gray-500">(JPG, PNG, GIF or WebP, up to 4 MB)</span></label>
<input id="photo" name="photo" type="file" accept="image/jpeg,image/png,image/gif,image/webp">
{% if errors.photo %}<p class="text-red-600 text-sm mt-1">{{ errors.photo|first }}</p>{% endif %}
</div>
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">Publish recipe</button>
</form>
</div>
{% endblock %}
Browsers never re-fill a file input after a failed submit, so a visitor who hits a validation error has to choose the photo again. That is normal browser behaviour. For multi-file uploads with live progress bars, see the bundled rhapsody-uploader.js in the File Uploader docs.
Step 8: Events and Listeners
When a recipe is published, several things should happen: caches go stale, an activity line is logged, and one day maybe a newsletter is queued. The controller should not know about any of that. It announces what happened, and listeners decide what to do about it.
php rhapsody make:event RecipePublished
php rhapsody make:listener ClearRecipeCaches
php rhapsody make:listener LogRecipeActivity
The event
An event is a small data object. Fill in the constructor generated for you with the facts listeners will need:
app/Events/RecipePublished.php
<?php
declare(strict_types=1);
namespace App\Events;
use Rhapsody\Core\Events\Event;
final class RecipePublished extends Event
{
public function __construct(
public readonly int $recipeId,
public readonly string $slug,
public readonly string $authorId,
) {
}
}
The listeners
A listener is a class with a handle() method. The container builds it, so anything it type-hints in its constructor (the cache, the mailer, the entity manager) is injected for you.
app/Listeners/ClearRecipeCaches.php
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Events\RecipePublished;
use Rhapsody\Core\Cache;
final class ClearRecipeCaches
{
public function __construct(private readonly Cache $cache)
{
}
public function handle(RecipePublished $event): void
{
$this->cache->forget('recipes.popular');
}
}
app/Listeners/LogRecipeActivity.php
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Events\RecipePublished;
use Rhapsody\Core\Helpers\Path;
use Rhapsody\Core\Logger;
final class LogRecipeActivity
{
public function handle(RecipePublished $event): void
{
new Logger(Path::logs('recipes.log'))->log(
"Recipe #{$event->recipeId} ({$event->slug}) published by {$event->authorId}",
);
}
}
Register them
Map the event to its listeners in the provider. They run in the order listed.
app/Providers/EventServiceProvider.php (add to the $listen array, with the matching use lines)
RecipePublished::class => [
ClearRecipeCaches::class,
LogRecipeActivity::class,
],
$dispatcher->listen(RecipePublished::class, fn (RecipePublished $event) => ...);. Listeners are matched by the exact event class.Step 9: Work After the Response with defer()
Some jobs are quick and nice-to-have: counting a view, refreshing a file. defer() runs them right after the visitor has their page. You have already used it twice in the controller. Here is the service behind the second one:
app/Services/RecipeExporter.php
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\RecipeCatalog;
use Rhapsody\Core\Helpers\Path;
use Rhapsody\Core\Support\Fs;
use Rhapsody\Core\Support\Json;
final readonly class RecipeExporter
{
public function __construct(private RecipeCatalog $catalog)
{
}
/**
* Writes the newest recipes to storage/exports/recipes.json.
*/
public function export(): void
{
$directory = Path::storage('exports');
if (! is_dir($directory) && ! mkdir($directory, 0755, true) && ! is_dir($directory)) {
throw new \RuntimeException('Unable to create the export directory.');
}
Fs::write(
Path::storage('exports/recipes.json'),
Json::encode($this->catalog->findPaginated(500, 0), JSON_PRETTY_PRINT),
LOCK_EX,
);
}
}
Path::storage()builds a reliable absolute path that works from a web request, a CLI command or a worker alike.Fs::write()andJson::encode()throw a clear exception on failure instead of returningfalse. Inside a deferred task that exception is written tostorage/logs/defer.logand never reaches the visitor.$this->exporter->export(...)is a first-class callable, sodefer()receives the method directly with nothing to wrap.
Step 10: Work That Must Not Be Lost: the Queue
A confirmation email is different from a view counter: if the mail server hiccups, you want a retry, not silence. A queued job is stored in the database first, then run by a worker process, with automatic retries.
app/Jobs/EmailRecipeConfirmation.php
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Entities\Recipe;
use App\Entities\User;
use Doctrine\ORM\EntityManager;
use Rhapsody\Core\Contracts\JobInterface;
use Rhapsody\Core\Mailer;
use Rhapsody\Core\Queue\Attributes\Backoff;
use Rhapsody\Core\Queue\Attributes\Timeout;
use Rhapsody\Core\Queue\Attributes\Tries;
#[Tries(5)]
#[Backoff(30)]
#[Timeout(45)]
final class EmailRecipeConfirmation implements JobInterface
{
public function __construct(public readonly int $recipeId)
{
}
public function handle(EntityManager $em, Mailer $mailer): void
{
$recipe = $em->find(Recipe::class, $this->recipeId);
if ($recipe === null) {
return; // Deleted before the worker got to it: nothing left to do.
}
$author = $em->find(User::class, $recipe->getAuthorId());
if ($author === null) {
return;
}
$name = htmlspecialchars($author->getName(), ENT_QUOTES, 'UTF-8');
$title = htmlspecialchars($recipe->getTitle(), ENT_QUOTES, 'UTF-8');
$mailer->send(
$author->getEmail(),
'Your recipe is live: ' . $recipe->getTitle(),
"<p>Hi {$name},</p><p>“{$title}” has been published on Recipe Box.</p>",
);
}
}
- The constructor holds only an id. The job stores plain values, then loads fresh data inside
handle(). That also avoids emailing about a recipe that has since been deleted. handle()gets its services from the container, exactly like a controller constructor.- The attributes tune retries: up to 5 attempts, waiting 30s, 60s, 120s... between them, and no single attempt may run longer than 45 seconds.
- A job may run twice if a worker dies at the wrong moment, so write
handle()to be safe to repeat.
The controller already dispatches it, onto a queue named emails. Start a worker to process the queue (see the Persistent Queue page for running it under Supervisor or from cron):
php rhapsody queue:work --queue=emails,default
| Tool | Runs | If it fails | Use it for |
|---|---|---|---|
| Controller code | In the request | The visitor sees an error | Anything the action depends on |
| Event listener | In the request | Logged, then skipped | Decoupled side effects |
defer() | Right after the response | Logged to defer.log, not retried | Quick, best-effort work |
| Queue job | In a worker process | Retried with backoff, then kept as a failed job | Anything that must not be lost |
Step 11: React Islands
An island is a small React component dropped into an otherwise server-rendered page. Use one only where the page needs real interactivity: here, a favorite toggle and an ingredient list that rescales as you change the servings. The bridge from react:install finds every island and mounts it, and the theme layout already loads it.
php rhapsody make:react FavoriteButton
php rhapsody make:react ServingScaler
The favorite button
resources/js/components/FavoriteButton.jsx
import React, { useState } from 'react';
export default function FavoriteButton({ endpoint, token, initial = false }) {
const [favorite, setFavorite] = useState(initial);
const [busy, setBusy] = useState(false);
async function toggle() {
setBusy(true);
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
// The CSRF middleware reads "_token" from the request body.
body: JSON.stringify({ _token: token }),
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = await response.json();
setFavorite(Boolean(data.favorite));
} catch (error) {
console.error('[FavoriteButton]', error);
} finally {
setBusy(false);
}
}
return (
<button
type="button"
onClick={toggle}
disabled={busy}
aria-pressed={favorite}
className="rounded border border-blue-500 px-4 py-2 font-bold text-blue-600 dark:text-blue-400 disabled:opacity-50"
>
{favorite ? '♥ Saved' : '♡ Save'}
</button>
);
}
Two things to notice. First, the Accept: application/json header is what makes wantsJson() true in Step 6.5. Second, the token travels in the JSON body as _token, which is where the CSRF middleware looks for it.
The serving scaler
resources/js/components/ServingScaler.jsx
import React, { useState } from 'react';
const QUANTITY = /^(\d+\s+\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)(.*)$/;
function toNumber(raw) {
const mixed = raw.match(/^(\d+)\s+(\d+)\/(\d+)$/);
if (mixed) {
return Number(mixed[1]) + Number(mixed[2]) / Number(mixed[3]);
}
const fraction = raw.match(/^(\d+)\/(\d+)$/);
if (fraction) {
return Number(fraction[1]) / Number(fraction[2]);
}
return Number(raw);
}
function scale(line, factor) {
const match = line.match(QUANTITY);
if (!match) {
return line;
}
return `${Math.round(toNumber(match[1]) * factor * 100) / 100}${match[2]}`;
}
export default function ServingScaler({ baseServings, ingredients }) {
const base = Number(baseServings) || 1;
const [servings, setServings] = useState(base);
const factor = servings / base;
return (
<div>
<div className="mb-3 flex items-center gap-3">
<button type="button" aria-label="Fewer servings"
onClick={() => setServings((n) => Math.max(1, n - 1))}
className="h-8 w-8 rounded border border-gray-400">-</button>
<span aria-live="polite">{servings} {servings === 1 ? 'serving' : 'servings'}</span>
<button type="button" aria-label="More servings"
onClick={() => setServings((n) => n + 1)}
className="h-8 w-8 rounded border border-gray-400">+</button>
</div>
<ul className="list-disc pl-6 space-y-1">
{ingredients.map((line, index) => (
<li key={index}>{scale(line, factor)}</li>
))}
</ul>
</div>
);
}
Run it
# Development: set VITE_DEV_SERVER=true in .env, then
npm run dev
# Production: set VITE_DEV_SERVER=false, then
npm run build
Props travel from Twig to React as JSON in a non-executable <script> block, escaped so user text can never break out of it. Only pass the island what it needs: the CSRF token is fine because it only goes to the page that asked for it.
Sessions at a Glance
You used the session in four different ways without ever starting it: the front controller does that once per request.
| What | How | Where |
|---|---|---|
| Who is logged in | Session::string('user_id') | store(); the auth middleware checks the same key |
| One-time messages | redirect(...)->with('success', ...), shown by the layout | store(), favorite() |
| Favorites | Session::set('favorites', ...) and Session::get() | favorite(), show() |
| Recently viewed | Session::set('recent_recipes', ...) | rememberVisit() |
| CSRF token | csrf_field() and csrf_token() | create.twig, show.twig |
Favorites live in the session, so they belong to one browser and disappear when it closes. To keep them for good, store them in a table keyed by user_id: the controller code barely changes.
Step 12: Try It
- Start the worker in a second terminal:
php rhapsody queue:work --queue=emails,default. - Register or log in, open
/recipes/new, and share a recipe with a photo. - You land on the new recipe with a "Your recipe is live!" message. Click Save and change the servings.
- Check the side effects:
| Look at | You should see |
|---|---|
storage/logs/recipes.log | A line from LogRecipeActivity (Step 8) |
storage/exports/recipes.json | The exported catalog, written by the deferred task (Step 9) |
The worker terminal / storage/logs/queue.log | EmailRecipeConfirmation completing (Step 10) |
/img/recipes/...?w=480&h=320...&s=... in the page source | A signed, resized image URL (Step 6.6) |
| The debug toolbar (development mode) | The matched route, queries, session contents and cache hits for the request |
If something goes wrong
| Symptom | Likely cause and fix |
|---|---|
419 CSRF token mismatch | The form is missing {{ csrf_field() }}, or the island is not sending _token in the JSON body. |
/recipes/new shows a recipe or a 404 | The {slug} route is registered before the static one, or the route cache is stale. Reorder, then run php rhapsody route:clear. |
Images return 403 | The URL was not signed. Build it with ImageSigner::url(), as in imageSet(). |
Images return 404 | The Glide path is relative to public/images/. The file must be at public/images/recipes/.... |
| The islands do not appear | Run npm run dev with VITE_DEV_SERVER=true (or npm run build with it set to false) and check the browser console. |
| Queued mail never arrives | Is a worker running? Did you run queue:install --migrate? Is MAIL_HOST set in .env? Failed jobs show up with php rhapsody queue:failed. |
Before You Go Live
- Set
APP_ENV=productionand a strongAPP_KEY. The key also signs your image URLs and queued jobs. - Build the front end:
VITE_DEV_SERVER=falseandnpm run build. - Cache routes and templates:
php rhapsody route:cacheandphp rhapsody app:cache-warm. Re-runroute:clearthenroute:cachewhenever you add a route. - Keep the queue worker alive with Supervisor, or from cron on shared hosting, and restart it after each deploy so it loads the new code.
- Make the directories writable:
storage/(cache, logs, exports) andpublic/images/recipes/. - Run PHPStan at level 9 before you ship.
Security notes worth repeating
- Uploaded files are checked by content, renamed randomly and never allowed to keep an executable extension. We store recipe photos under
public/images/so the image resizer can reach them. If you would rather keep originals private, upload to the defaultstorage/uploadsinstead and point thesourcesetting inImageControllerthere (see Image Processing). - Twig escapes everything by default. The only
|rawin this tutorial is the pagination HTML, which the framework builds itself. - Every query that includes visitor input uses bound parameters.
Where to Go Next
You now have a pattern you can copy for any feature. A few ways to extend the Recipe Box and practise:
- Edit and delete a recipe, allowing only its author: compare
Session::string('user_id')togetAuthorId()and throwHttpException(403)otherwise. - Move the sort and page size into the URL and let the AJAX pagination helper swap the list without a page reload.
- Add a rate limit to the share form with your own middleware.
- Persist favorites in a table so they follow the user between devices.
- Test it: start from the Unit Testing guide.