Build a Blog
This tutorial walks you through building a fully functional blog with posts, comments, and authentication using Marko.
What You’ll Build
Section titled “What You’ll Build”- A blog with posts and comments
- User authentication (login/register)
- An admin area for managing posts
- Database-backed persistence
Prerequisites
Section titled “Prerequisites”- PHP 8.5+
- Composer 2.x
- PostgreSQL (or MySQL)
Step 1: Create the Project
Section titled “Step 1: Create the Project”composer create-project marko/skeleton my-blogcd my-blogcomposer require marko/blogThe marko/blog package provides post and comment functionality out of the box.
Step 2: Configure the Database
Section titled “Step 2: Configure the Database”Edit your .env:
DB_CONNECTION=pgsqlDB_HOST=localhostDB_PORT=5432DB_DATABASE=my_blogDB_USERNAME=markoDB_PASSWORD=secretRun the migrations:
marko db:migrateThe blog package defines its schema using entity attributes --- #[Table], #[Column], and #[Index] --- on entity classes like Post and Comment. When you run marko db:migrate, it reads these attributes and auto-generates the migrations. Here is a simplified view of the Post entity:
<?php
declare(strict_types=1);
namespace Marko\Blog\Entity;
use Marko\Database\Attributes\Column;use Marko\Database\Attributes\Index;use Marko\Database\Attributes\Table;use Marko\Database\Entity\Entity;
#[Table('posts')]#[Index('idx_posts_author_id', ['author_id'])]#[Index('idx_posts_status', ['status'])]#[Index('idx_posts_published_at', ['published_at'])]class Post extends Entity implements PostInterface{ #[Column(primaryKey: true, autoIncrement: true)] public ?int $id = null;
#[Column(unique: true)] public string $slug;
#[Column] public string $title = '';
#[Column(type: 'TEXT')] public string $content = '';
#[Column('author_id', references: 'authors.id')] public int $authorId = 0;
#[Column(type: 'TEXT')] public ?string $summary = null;
#[Column('published_at')] public ?string $publishedAt = null;
#[Column('created_at')] public ?string $createdAt = null;
#[Column('updated_at')] public ?string $updatedAt = null;
// ...}You never write SQL or migration files by hand --- the entity attributes are the single source of truth.
Step 3: Start the Server
Section titled “Step 3: Start the Server”marko upVisit http://localhost:8000/blog --- you should see the blog index.
Step 4: Explore the Routes
Section titled “Step 4: Explore the Routes”The marko/blog package registers these routes automatically:
| Route | Description |
|---|---|
GET /blog | Post listing |
GET /blog/{slug} | Single post |
POST /blog/{slug}/comment | Add comment |
GET /blog/category/{slug} | Posts by category |
GET /blog/tag/{slug} | Posts by tag |
GET /blog/author/{slug} | Posts by author |
GET /blog/search | Search posts |
Step 5: Customize Templates
Section titled “Step 5: Customize Templates”Blog templates use Latte and can be overridden by placing files in your app module:
app/blog/resources/views/├── post/│ ├── index.latte # Post listing│ └── show.latte # Single post└── comment/ └── form.latte # Comment formFor example, override the post listing:
<main> <h1>My Blog</h1> <p n:if="$posts->isEmpty()" class="no-posts">There are no posts yet.</p> <ul n:if="!$posts->isEmpty()" class="post-list"> {foreach $posts->items as $post} <li> <article> <h2><a href="/blog/{$post->slug}">{$post->title}</a></h2> <p n:if="$post->summary">{$post->summary}</p> <time datetime="{$post->publishedAt}"> {$post->getPublishedAt()->format('F j, Y')} </time> </article> </li> {/foreach} </ul></main>Templates access entity properties directly --- $post->title, $post->slug, $post->summary --- and use getter methods like $post->getPublishedAt() for computed values.
Step 6: Add Authentication
Section titled “Step 6: Add Authentication”Protect the comment form so only logged-in users can comment:
composer require marko/authenticationThe blog package dispatches events you can observe:
<?php
declare(strict_types=1);
use Marko\Blog\Events\Comment\CommentCreated;use App\Blog\Observer\NotifyAuthorOfComment;
return [ 'observers' => [ CommentCreated::class => [ NotifyAuthorOfComment::class, ], ],];Step 7: Extend with Plugins
Section titled “Step 7: Extend with Plugins”Want to add reading time to every post? Use a plugin:
<?php
declare(strict_types=1);
namespace App\Blog\Plugin;
use Marko\Blog\Entity\Post;use Marko\Blog\Repositories\PostRepositoryInterface;
class AddReadingTimePlugin{ public function afterFindBySlug(PostRepositoryInterface $subject, ?Post $result): ?Post { if ($result === null) { return null; }
$wordCount = str_word_count($result->content); $result->readingTimeMinutes = max(1, (int) ceil($wordCount / 200));
return $result; }}Register it:
<?php
declare(strict_types=1);
use Marko\Blog\Repositories\PostRepositoryInterface;use App\Blog\Plugin\AddReadingTimePlugin;
return [ 'plugins' => [ PostRepositoryInterface::class => [ AddReadingTimePlugin::class, ], ],];What You’ve Learned
Section titled “What You’ve Learned”- How to scaffold a Marko project and install packages
- Entity-driven database schema with
#[Table],#[Column], and#[Index]attributes - Template overriding with Latte for customization
- Events and observers for reactive behavior
- Plugins for modifying existing functionality
Next Steps
Section titled “Next Steps”- Build a REST API --- create a JSON API
- Create a Custom Module --- build a reusable Composer package