Skip to content

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.

How to use this page: follow the steps in order and you will end with a working feature. If you only need one technique, every step stands on its own and links to the full reference page for that feature.

What You Will Use

Here is every feature the Recipe Box touches, and where it shows up.

Feature Where it appears Reference
Controllers & dependency injectionRecipeControllerControllers, Container
Doctrine entitiesRecipe (writes)Doctrine ORM
PDO models & paginationRecipeCatalog (reads)Models, Pagination
MigrationsCreateRecipesTableConsole Commands
Named routes & middlewareroutes/web.phpRouting, Middleware
Twig views, flash messages, CSRFviews/recipes/Views, Security
Validationstore()Validation
File uploadsstore()File Uploader
Image resizing & signed URLsimageSet()Image Processing
Events & listenersRecipePublishedEvents
defer()view counter, JSON exportDeferred Tasks
Persistent queueEmailRecipeConfirmationPersistent Queue
Sessionsfavorites, recently viewedSessions
Cachepopular recipesCaching
Support helpers & PathConfig, Fs, Json, PathSupport Helpers, Path Helper
SEO helpersmeta tags, Schema.org, SluggifySEO Helpers
React IslandsFavoriteButton, ServingScalerReact + Rhapsody
Mailer & Loggerthe queue job, a listenerMailer, 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
Tip: Keep 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.

Portability: 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 Request to every action first, then the route parameters in order: show(Request $request, string $slug).
  • ->middleware('auth') uses the key from config/config.php. Visitors who are not logged in are sent to /login before your controller runs.
  • CSRF protection is global middleware, so every POST here is checked automatically. You only have to send the token (Steps 7 and 11).
  • If you use the route cache, run php rhapsody route:clear after 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 422 status 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;
    }
}
Why images are signed: every distinct set of resize options creates a new cached file. Signing means only sizes your own code asked for can be generated. That is also why the URLs are built here in PHP and handed to the template ready to use: a URL assembled in JavaScript could not be signed.

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 }} &middot; {{ 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') }}">&larr; All recipes</a></p>

    <h1 class="text-4xl font-bold">{{ recipe.title }}</h1>
    <p class="mt-2 text-gray-500">
        By {{ recipe.author_name }} &middot; {{ recipe.created_at|date('F j, Y') }} &middot; {{ 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,
],
Tip: You can also attach a listener in code, for example inside a service: $dispatcher->listen(RecipePublished::class, fn (RecipePublished $event) => ...);. Listeners are matched by the exact event class.
Warning: A listener that throws is logged and skipped; it never breaks the request. That makes listeners the right place for side effects (logging, cache clearing, notifications) and the wrong place for anything the user's action depends on, such as saving the recipe itself. That belongs in the controller, as in Step 6.4.

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() and Json::encode() throw a clear exception on failure instead of returning false. Inside a deferred task that exception is written to storage/logs/defer.log and never reaches the visitor.
  • $this->exporter->export(...) is a first-class callable, so defer() receives the method directly with nothing to wrap.
Warning: Deferred tasks cannot change the response and cannot write to the session (it is already closed), and they are lost if the server stops while they wait. Keep them small, and capture plain values such as an id.

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>&ldquo;{$title}&rdquo; 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 codeIn the requestThe visitor sees an errorAnything the action depends on
Event listenerIn the requestLogged, then skippedDecoupled side effects
defer()Right after the responseLogged to defer.log, not retriedQuick, best-effort work
Queue jobIn a worker processRetried with backoff, then kept as a failed jobAnything 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 inSession::string('user_id')store(); the auth middleware checks the same key
One-time messagesredirect(...)->with('success', ...), shown by the layoutstore(), favorite()
FavoritesSession::set('favorites', ...) and Session::get()favorite(), show()
Recently viewedSession::set('recent_recipes', ...)rememberVisit()
CSRF tokencsrf_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

  1. Start the worker in a second terminal: php rhapsody queue:work --queue=emails,default.
  2. Register or log in, open /recipes/new, and share a recipe with a photo.
  3. You land on the new recipe with a "Your recipe is live!" message. Click Save and change the servings.
  4. Check the side effects:
Look at You should see
storage/logs/recipes.logA line from LogRecipeActivity (Step 8)
storage/exports/recipes.jsonThe exported catalog, written by the deferred task (Step 9)
The worker terminal / storage/logs/queue.logEmailRecipeConfirmation completing (Step 10)
/img/recipes/...?w=480&h=320...&s=... in the page sourceA 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 mismatchThe form is missing {{ csrf_field() }}, or the island is not sending _token in the JSON body.
/recipes/new shows a recipe or a 404The {slug} route is registered before the static one, or the route cache is stale. Reorder, then run php rhapsody route:clear.
Images return 403The URL was not signed. Build it with ImageSigner::url(), as in imageSet().
Images return 404The Glide path is relative to public/images/. The file must be at public/images/recipes/....
The islands do not appearRun 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 arrivesIs 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=production and a strong APP_KEY. The key also signs your image URLs and queued jobs.
  • Build the front end: VITE_DEV_SERVER=false and npm run build.
  • Cache routes and templates: php rhapsody route:cache and php rhapsody app:cache-warm. Re-run route:clear then route:cache whenever 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) and public/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 default storage/uploads instead and point the source setting in ImageController there (see Image Processing).
  • Twig escapes everything by default. The only |raw in 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') to getAuthorId() and throw HttpException(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.

Related Documentation