Build a Blog

In this tutorial you will build a blog application with server-rendered HTML views. You will use MvcController, layouts, sections, ViewBag, and form handling to create a complete read-and-write blog.

What you will build: A blog with a post list page, individual post pages, and a form to create new posts. All rendered with Melodic's template engine using layouts and sections.

Step 1: Project Setup

Create the project and install the framework:

mkdir my-blog && cd my-blog
composer require melodicdev/framework

Create the directory structure:

mkdir -p public config src/{Models,Queries,Commands,Services,Controllers} views/{layouts,blog}

Create config/config.json:

{
    "app": {
        "name": "My Blog",
        "debug": true
    },
    "database": {
        "dsn": "sqlite:database.sqlite"
    }
}

Create the SQLite database:

sqlite3 database.sqlite "CREATE TABLE posts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    slug TEXT NOT NULL UNIQUE,
    body TEXT NOT NULL,
    author TEXT NOT NULL DEFAULT 'Anonymous',
    created_at TEXT NOT NULL
);"

Create the entry point at public/index.php. We will fill in services and routes in later steps:

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use Melodic\Core\Application;
use Melodic\Http\Middleware\JsonBodyParserMiddleware;

$app = new Application(dirname(__DIR__));
$app->loadConfig('config/config.json');

$app->addMiddleware(new JsonBodyParserMiddleware());

// Services and routes will be added later

$app->run();

Step 2: Create the Post Model

Create src/Models/Post.php:

<?php

declare(strict_types=1);

namespace App\Models;

use Melodic\Data\Model;

class Post extends Model
{
    public int $id;
    public string $title;
    public string $slug;
    public string $body;
    public string $author;
    public string $created_at;
}

Step 3: Create Queries and Commands

GetAllPostsQuery

Create src/Queries/GetAllPostsQuery.php:

<?php

declare(strict_types=1);

namespace App\Queries;

use App\Models\Post;
use Melodic\Data\DbContextInterface;
use Melodic\Data\QueryInterface;

class GetAllPostsQuery implements QueryInterface
{
    private readonly string $sql;

    public function __construct()
    {
        $this->sql = "SELECT * FROM posts ORDER BY created_at DESC";
    }

    public function getSql(): string
    {
        return $this->sql;
    }

    /**
     * @return Post[]
     */
    public function execute(DbContextInterface $context): array
    {
        return $context->query(Post::class, $this->sql);
    }
}

GetPostBySlugQuery

Create src/Queries/GetPostBySlugQuery.php:

<?php

declare(strict_types=1);

namespace App\Queries;

use App\Models\Post;
use Melodic\Data\DbContextInterface;
use Melodic\Data\QueryInterface;

class GetPostBySlugQuery implements QueryInterface
{
    private readonly string $sql;

    public function __construct(
        private readonly string $slug,
    ) {
        $this->sql = "SELECT * FROM posts WHERE slug = :slug";
    }

    public function getSql(): string
    {
        return $this->sql;
    }

    public function execute(DbContextInterface $context): ?Post
    {
        return $context->queryFirst(Post::class, $this->sql, ['slug' => $this->slug]);
    }
}

CreatePostCommand

Create src/Commands/CreatePostCommand.php:

<?php

declare(strict_types=1);

namespace App\Commands;

use Melodic\Data\CommandInterface;
use Melodic\Data\DbContextInterface;

class CreatePostCommand implements CommandInterface
{
    private readonly string $sql;

    public function __construct(
        private readonly string $title,
        private readonly string $slug,
        private readonly string $body,
        private readonly string $author,
    ) {
        $this->sql = "INSERT INTO posts (title, slug, body, author, created_at)
                      VALUES (:title, :slug, :body, :author, datetime('now'))";
    }

    public function getSql(): string
    {
        return $this->sql;
    }

    public function execute(DbContextInterface $context): int
    {
        return $context->command($this->sql, [
            'title' => $this->title,
            'slug' => $this->slug,
            'body' => $this->body,
            'author' => $this->author,
        ]);
    }
}

Step 4: Create the PostService

Create src/Services/PostService.php:

<?php

declare(strict_types=1);

namespace App\Services;

use App\Commands\CreatePostCommand;
use App\Models\Post;
use App\Queries\GetAllPostsQuery;
use App\Queries\GetPostBySlugQuery;
use Melodic\Service\Service;

class PostService extends Service
{
    /**
     * @return Post[]
     */
    public function getAll(): array
    {
        return (new GetAllPostsQuery())->execute($this->context);
    }

    public function getBySlug(string $slug): ?Post
    {
        return (new GetPostBySlugQuery($slug))->execute($this->context);
    }

    public function create(string $title, string $body, string $author): void
    {
        $slug = $this->generateSlug($title);

        (new CreatePostCommand($title, $slug, $body, $author))->execute($this->context);
    }

    private function generateSlug(string $title): string
    {
        $slug = strtolower(trim($title));
        $slug = preg_replace('/[^a-z0-9]+/', '-', $slug);

        return trim($slug, '-');
    }
}

Step 5: Create the BlogController

The BlogController extends MvcController which provides view(), viewBag, and setLayout(). It receives the ViewEngine through its constructor (auto-wired by the container).

Create src/Controllers/BlogController.php:

<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Services\PostService;
use Melodic\Controller\MvcController;
use Melodic\Http\RedirectResponse;
use Melodic\Http\Response;
use Melodic\View\ViewEngine;

class BlogController extends MvcController
{
    public function __construct(
        ViewEngine $viewEngine,
        private readonly PostService $postService,
    ) {
        parent::__construct($viewEngine);
    }

    public function index(): Response
    {
        $posts = $this->postService->getAll();

        $this->viewBag->title = 'My Blog';
        $this->setLayout('layouts/main');

        return $this->view('blog/index', ['posts' => $posts]);
    }

    public function show(string $slug): Response
    {
        $post = $this->postService->getBySlug($slug);

        if ($post === null) {
            return $this->notFound(['error' => 'Post not found']);
        }

        $this->viewBag->title = $post->title . ' — My Blog';
        $this->setLayout('layouts/main');

        return $this->view('blog/show', ['post' => $post]);
    }

    public function create(): Response
    {
        $this->viewBag->title = 'New Post — My Blog';
        $this->setLayout('layouts/main');

        return $this->view('blog/create');
    }

    public function store(): Response
    {
        $title = $this->request->body('title', '');
        $body = $this->request->body('body', '');
        $author = $this->request->body('author', 'Anonymous');

        if (empty($title) || empty($body)) {
            $this->viewBag->title = 'New Post — My Blog';
            $this->viewBag->error = 'Title and body are required.';
            $this->setLayout('layouts/main');

            return $this->view('blog/create', [
                'oldTitle' => $title,
                'oldBody' => $body,
                'oldAuthor' => $author,
            ]);
        }

        $this->postService->create($title, $body, $author);

        return new RedirectResponse('/blog');
    }
}

Parent constructor: When extending MvcController and adding your own dependencies, call parent::__construct($viewEngine) to ensure the view system is initialized.

Step 6: Create the Layout

Layouts use renderBody() to insert page content and renderSection() for optional named blocks like scripts or styles.

Create views/layouts/main.phtml:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><?= htmlspecialchars($viewBag->title ?? 'My Blog') ?></title>
    <style>
        body { font-family: system-ui, sans-serif; max-width: 800px; margin: 0 auto; padding: 2rem; }
        nav { border-bottom: 1px solid #ddd; padding-bottom: 1rem; margin-bottom: 2rem; }
        nav a { margin-right: 1rem; text-decoration: none; color: #4f46e5; }
        nav a:hover { text-decoration: underline; }
        .post-meta { color: #666; font-size: 0.875rem; }
        .post-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 1.5rem; margin-bottom: 1rem; }
        .post-card h2 a { text-decoration: none; color: #111; }
        .post-card h2 a:hover { color: #4f46e5; }
        footer { border-top: 1px solid #ddd; padding-top: 1rem; margin-top: 3rem; color: #999; font-size: 0.85rem; }
        form label { display: block; margin-bottom: 0.25rem; font-weight: 600; }
        form input, form textarea { width: 100%; padding: 0.5rem; margin-bottom: 1rem; border: 1px solid #ddd; border-radius: 4px; }
        form textarea { min-height: 200px; }
        form button { background: #4f46e5; color: #fff; border: none; padding: 0.5rem 1.5rem; border-radius: 4px; cursor: pointer; }
        .error { background: #fef2f2; border: 1px solid #fca5a5; color: #b91c1c; padding: 0.75rem; border-radius: 4px; margin-bottom: 1rem; }
    </style>
    <?= $this->renderSection('head') ?>
</head>
<body>
    <nav>
        <a href="/blog">Home</a>
        <a href="/blog/new">New Post</a>
    </nav>

    <main>
        <?= $this->renderBody() ?>
    </main>

    <footer>
        Built with Melodic PHP Framework
    </footer>

    <?= $this->renderSection('scripts') ?>
</body>
</html>

Key layout methods:

MethodPurpose
$this->renderBody()Inserts the child view's content into the layout
$this->renderSection('name')Inserts a named section defined by the child view

Step 7: Create the Views

Post List (blog/index.phtml)

Create views/blog/index.phtml:

<h1>Blog</h1>

<?php if (empty($posts)): ?>
    <p>No posts yet. <a href="/blog/new">Write the first one!</a></p>
<?php endif; ?>

<?php foreach ($posts as $post): ?>
    <div class="post-card">
        <h2><a href="/blog/<?= htmlspecialchars($post->slug) ?>"><?= htmlspecialchars($post->title) ?></a></h2>
        <p class="post-meta">
            By <?= htmlspecialchars($post->author) ?> &mdash;
            <?= htmlspecialchars($post->created_at) ?>
        </p>
        <p><?= htmlspecialchars(mb_substr($post->body, 0, 200)) ?><?= mb_strlen($post->body) > 200 ? '...' : '' ?></p>
    </div>
<?php endforeach; ?>

Single Post (blog/show.phtml)

Create views/blog/show.phtml:

<article>
    <h1><?= htmlspecialchars($post->title) ?></h1>
    <p class="post-meta">
        By <?= htmlspecialchars($post->author) ?> &mdash;
        <?= htmlspecialchars($post->created_at) ?>
    </p>
    <div>
        <?= nl2br(htmlspecialchars($post->body)) ?>
    </div>
</article>

<p><a href="/blog">&larr; Back to all posts</a></p>

<?php $this->beginSection('head') ?>
    <meta property="og:title" content="<?= htmlspecialchars($post->title) ?>">
<?php $this->endSection() ?>

Sections in action: The beginSection('head') / endSection() block captures everything between them and makes it available to the layout via renderSection('head'). This lets child views inject content into the <head> or add scripts at the bottom.

Create Post Form (blog/create.phtml)

Create views/blog/create.phtml:

<h1>New Post</h1>

<?php if (isset($viewBag->error)): ?>
    <div class="error"><?= htmlspecialchars($viewBag->error) ?></div>
<?php endif; ?>

<form method="POST" action="/blog">
    <label for="title">Title</label>
    <input type="text" id="title" name="title" value="<?= htmlspecialchars($oldTitle ?? '') ?>" required>

    <label for="author">Author</label>
    <input type="text" id="author" name="author" value="<?= htmlspecialchars($oldAuthor ?? 'Anonymous') ?>">

    <label for="body">Content</label>
    <textarea id="body" name="body" required><?= htmlspecialchars($oldBody ?? '') ?></textarea>

    <button type="submit">Publish</button>
</form>

The form posts back to /blog. On validation failure, the controller re-renders the form with the old values and an error message stored in viewBag->error.

Step 8: Register Services and Routes

Update public/index.php with the complete configuration:

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use App\Controllers\BlogController;
use App\Services\PostService;
use Melodic\Core\Application;
use Melodic\Data\DbContext;
use Melodic\Data\DbContextInterface;
use Melodic\Http\Middleware\JsonBodyParserMiddleware;
use Melodic\View\ViewEngine;

$app = new Application(dirname(__DIR__));
$app->loadConfig('config/config.json');

$app->addMiddleware(new JsonBodyParserMiddleware());

$app->services(function ($container) use ($app) {
    $container->singleton(DbContextInterface::class, function () use ($app) {
        $dsn = $app->config('database.dsn');
        $pdo = new PDO($dsn);
        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

        return new DbContext($pdo);
    });

    // Register the ViewEngine pointing to the views directory
    $container->singleton(ViewEngine::class, function () use ($app) {
        return new ViewEngine($app->getBasePath() . '/views');
    });

    $container->bind(PostService::class, PostService::class);
});

$app->routes(function ($router) {
    $router->group('/blog', function ($router) {
        $router->get('', BlogController::class, 'index');
        $router->get('/new', BlogController::class, 'create');
        $router->post('', BlogController::class, 'store');
        $router->get('/{slug}', BlogController::class, 'show');
    });
});

$app->run();

Route order matters: The /blog/new route must be registered before /blog/{slug}, otherwise the router would match "new" as a slug parameter.

The route group prefixes all routes with /blog:

MethodPathActionDescription
GET/blogindexList all posts
GET/blog/newcreateShow the create form
POST/blogstoreSave a new post
GET/blog/{slug}showShow a single post

Step 9: Test Your Blog

Start the server:

php -S localhost:8080 -t public

Open http://localhost:8080/blog in your browser. You should see the empty blog with a link to create your first post. Click "New Post", fill in the form, and submit. You will be redirected back to the post list where your new post appears.

What You Built

Your blog follows the MVC pattern within the Melodic architecture:

Browser → Router → BlogController → PostService → Query/Command → SQLite
                          ↓
                   ViewEngine → Layout + View → HTML Response
FilePurpose
src/Models/Post.phpPost data model
src/Queries/GetAllPostsQuery.phpFetch all posts
src/Queries/GetPostBySlugQuery.phpFetch a post by slug
src/Commands/CreatePostCommand.phpInsert a new post
src/Services/PostService.phpBlog business logic
src/Controllers/BlogController.phpMVC controller with view rendering
views/layouts/main.phtmlShared layout with nav, footer, sections
views/blog/index.phtmlPost list view
views/blog/show.phtmlSingle post view with head section
views/blog/create.phtmlNew post form with validation feedback

Next steps: Add edit and delete functionality, style the blog with a proper CSS file using beginSection('head'), or protect the create/store routes with authentication.