Build a Blog
This tutorial walks you through building a blog from scratch using Marko’s core packages. You’ll define entities, routes, templates, and extend behavior with plugins.
What You’ll Build
Section titled “What You’ll Build”- A Post entity with database persistence
- A repository with query builder
- Controllers with attribute-based routing
- Latte templates for rendering
- A plugin to add reading time to posts
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-blogStep 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=secretStep 3: Define the Post Entity
Section titled “Step 3: Define the Post Entity”Create an entity with database attributes. Marko reads #[Table], #[Column], and #[Index] to auto-generate migrations --- no SQL by hand.
<?php
declare(strict_types=1);
namespace App\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_slug', ['slug'])]#[Index('idx_posts_published_at', ['published_at'])]class Post extends Entity{ #[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] public bool $published = false;
#[Column] public ?string $publishedAt = null;
#[Column] public ?string $createdAt = null;}Run the migration:
marko db:migrateStep 4: Create a Repository
Section titled “Step 4: Create a Repository”Extend the base Repository class and use the query builder for custom queries:
<?php
declare(strict_types=1);
namespace App\Blog\Repository;
use App\Blog\Entity\Post;use Marko\Database\Repository\Repository;
class PostRepository extends Repository{ protected const string ENTITY_CLASS = Post::class;
public function findBySlug(string $slug): ?Post { /** @var Post|null */ return $this->findOneBy(['slug' => $slug]); }
/** * @return array<Post> */ public function findPublished(): array { return $this->query() ->whereNotNull('published_at') ->where('published', '=', true) ->orderBy('published_at', 'DESC') ->getEntities(); }}The base Repository gives you find(), findAll(), findBy(), findOneBy(), save(), and delete() out of the box. The query() method returns a fluent query builder with automatic entity hydration via getEntities().
Step 5: Add Routes and Controllers
Section titled “Step 5: Add Routes and Controllers”<?php
declare(strict_types=1);
namespace App\Blog\Controller;
use App\Blog\Repository\PostRepository;use Marko\Routing\Attributes\Get;use Marko\Routing\Http\Response;use Marko\View\ViewInterface;
class PostController{ public function __construct( private PostRepository $postRepository, private ViewInterface $view, ) {}
#[Get('/blog')] public function index(): Response { return $this->view->render('blog::post/index', [ 'posts' => $this->postRepository->findPublished(), ]); }
#[Get('/blog/{slug}')] public function show(string $slug): Response { $post = $this->postRepository->findBySlug($slug);
if ($post === null) { return new Response('Post not found', 404); }
return $this->view->render('blog::post/show', [ 'post' => $post, ]); }}Step 6: Create Templates
Section titled “Step 6: Create Templates”<main> <h1>Blog</h1> <ul n:if="$posts"> {foreach $posts as $post} <li> <article> <h2><a href="/blog/{$post->slug}">{$post->title}</a></h2> <time datetime="{$post->publishedAt}">{$post->publishedAt}</time> </article> </li> {/foreach} </ul> <p n:if="!$posts">No posts yet.</p></main><article> <h1>{$post->title}</h1> <time datetime="{$post->publishedAt}">{$post->publishedAt}</time> <div class="content">{$post->content|noescape}</div></article>Step 7: Start the Server
Section titled “Step 7: Start the Server”marko upVisit http://localhost:8000/blog to see your blog.
Step 8: Extend with a Plugin
Section titled “Step 8: Extend with a Plugin”Add reading time to every post without modifying the repository:
<?php
declare(strict_types=1);
namespace App\Blog\Plugin;
use App\Blog\Entity\Post;use App\Blog\Repository\PostRepository;use Marko\Core\Attributes\After;use Marko\Core\Attributes\Plugin;
#[Plugin(target: PostRepository::class)]class AddReadingTimePlugin{ #[After] public function findBySlug(?Post $result): ?Post { if ($result === null) { return null; }
$wordCount = str_word_count($result->content); $result->readingTimeMinutes = max(1, (int) ceil($wordCount / 200));
return $result; }}Now $post->readingTimeMinutes is available in your templates.
What You’ve Learned
Section titled “What You’ve Learned”- Entity-driven database schema with
#[Table],#[Column], and#[Index]attributes - Repositories with built-in CRUD and fluent query builder
- Attribute-based routing with
#[Get]on controller methods - Latte templates for rendering views
- Plugins for modifying existing functionality without editing source
Next Steps
Section titled “Next Steps”- Database guide --- deep dive into entities, migrations, and querying
- Build a REST API --- create a JSON API
- Create a Custom Module --- build a reusable Composer package