marko/blog
WordPress-like blog functionality for Marko --- posts, authors, categories, tags, and threaded comments with email verification. Provides a full content management system with routes, view templates, pagination, search, spam prevention, and a rich event system for extending behavior.
Installation
Section titled “Installation”composer require marko/blogRequired: A view driver (e.g., marko/view-latte) and a database driver (e.g., marko/database-mysql):
composer require marko/blog marko/view-latte marko/database-mysqlQuick Start
Section titled “Quick Start”Once installed with a view and database driver, the blog works automatically:
- Run migrations to create tables:
marko db:migrate - Visit
/blogto see the post list - Visit
/blog/{slug}to view a single post
Configuration
Section titled “Configuration”All configuration is optional with sensible defaults. Add to your config:
return [ 'blog' => [ 'posts_per_page' => 10, // Posts shown per page 'comment_max_depth' => 5, // Maximum reply nesting level 'comment_rate_limit_seconds' => 30, // Seconds between comments from same IP/email 'verification_token_expiry_days' => 7, // Email token validity 'verification_cookie_days' => 365, // Browser token validity after verification 'verification_cookie_name' => 'blog_verified', 'route_prefix' => '/blog', // Must start with /, must not end with / ],];View Templates
Section titled “View Templates”Default Templates (Latte)
Section titled “Default Templates (Latte)”The blog includes Latte templates in resources/views/:
blog::post/index # Post list with paginationblog::post/show # Single post with commentsblog::category/show # Category archiveblog::tag/index # Tag archiveblog::author/show # Author archiveblog::search/index # Search resultsUsing a Different View Engine
Section titled “Using a Different View Engine”The blog uses marko/view interfaces. To use Blade, Twig, or another template engine:
- Install an alternative view driver instead of
marko/view-latte - Create matching templates in your view driver’s expected format
- The template names (e.g.,
blog::post/index) remain the same
Overriding Templates
Section titled “Overriding Templates”To customize templates without modifying the package, create matching templates in your app module:
app/ myblog/ resources/ views/ blog/ post/ index.latte # Overrides blog::post/index show.latte # Overrides blog::post/showTemplates in app/ modules take precedence over package templates.
Security
Section titled “Security”Rate Limiting
Section titled “Rate Limiting”Comments are rate-limited per IP address and email. Configure with comment_rate_limit_seconds (default: 30 seconds).
Honeypot Spam Prevention
Section titled “Honeypot Spam Prevention”The comment form includes a hidden honeypot field. Bots that fill it are silently rejected.
Email Verification
Section titled “Email Verification”First-time commenters receive a verification email. Once verified, a browser token allows future comments without re-verification.
Extending the Blog
Section titled “Extending the Blog”Swapping Implementations (Preferences)
Section titled “Swapping Implementations (Preferences)”Replace any class globally using #[Preference]. This is how you swap implementations for the entire application:
use Marko\Blog\Repositories\PostRepository;use Marko\Core\Attributes\Preference;
#[Preference(replaces: PostRepository::class)]class CustomPostRepository extends PostRepository{ public function findPublishedPaginated( int $limit, int $offset, ): array { // Custom implementation with caching, filtering, etc. return $this->cache->remember('posts', fn () => parent::findPublishedPaginated($limit, $offset)); }}All blog module bindings can be overridden:
| Interface | Default Implementation |
|---|---|
BlogConfigInterface | BlogConfig |
PostRepositoryInterface | PostRepository |
CommentRepositoryInterface | CommentRepository |
CategoryRepositoryInterface | CategoryRepository |
TagRepositoryInterface | TagRepository |
AuthorRepositoryInterface | AuthorRepository |
SlugGeneratorInterface | SlugGenerator |
PaginationServiceInterface | PaginationService |
SearchServiceInterface | SearchService |
HoneypotValidatorInterface | HoneypotValidator |
CommentRateLimiterInterface | CommentRateLimiter |
CommentVerificationServiceInterface | CommentVerificationService |
CommentThreadingServiceInterface | CommentThreadingService |
Hooking Methods (Plugins)
Section titled “Hooking Methods (Plugins)”Modify method behavior without replacing the entire class using #[Plugin]:
use Marko\Blog\Controllers\PostController;use Marko\Core\Attributes\After;use Marko\Core\Attributes\Before;use Marko\Core\Attributes\Plugin;use Marko\Routing\Http\Response;
#[Plugin(target: PostController::class)]class PostControllerPlugin{ #[Before] public function beforeShow( string $slug, ): ?string { // Redirect old slugs if ($slug === 'old-post') { return 'new-post'; }
return null; // Continue with original slug }
#[After] public function afterIndex( Response $result, ): Response { // Add cache headers return $result->withHeader('Cache-Control', 'public, max-age=3600'); }}Reacting to Events (Observers)
Section titled “Reacting to Events (Observers)”React to events without modifying the code that triggers them using #[Observer]:
use Marko\Blog\Events\Post\PostPublished;use Marko\Core\Attributes\Observer;
#[Observer(event: PostPublished::class)]class NotifySubscribers{ public function __construct( private NewsletterService $newsletterService, ) {}
public function handle( PostPublished $event, ): void { $post = $event->getPost(); $this->newsletterService->sendNewPostNotification($post); }}Available Events
Section titled “Available Events”Post Events
Section titled “Post Events”| Event | When Dispatched | Data |
|---|---|---|
PostCreated | New post saved | getPost(), getTimestamp() |
PostUpdated | Existing post modified | getPost(), getTimestamp() |
PostPublished | Post status changed to published | getPost(), getPreviousStatus(), getTimestamp() |
PostScheduled | Post scheduled for future publication | getPost(), getScheduledAt(), getTimestamp() |
PostDeleted | Post removed | getPost(), getTimestamp() |
Comment Events
Section titled “Comment Events”| Event | When Dispatched | Data |
|---|---|---|
CommentCreated | New comment submitted | getComment(), getPost(), getTimestamp() |
CommentVerified | Comment verified via email | getComment(), getPost(), getVerificationMethod(), getTimestamp() |
CommentDeleted | Comment removed | getComment(), getPost(), getTimestamp() |
Taxonomy Events
Section titled “Taxonomy Events”| Event | When Dispatched | Data |
|---|---|---|
CategoryCreated | New category created | getCategory(), getTimestamp() |
CategoryUpdated | Category modified | getCategory(), getTimestamp() |
CategoryDeleted | Category removed | getCategory(), getTimestamp() |
TagCreated | New tag created | getTag(), getTimestamp() |
TagUpdated | Tag modified | getTag(), getTimestamp() |
TagDeleted | Tag removed | getTag(), getTimestamp() |
AuthorCreated | New author created | getAuthor(), getTimestamp() |
AuthorUpdated | Author modified | getAuthor(), getTimestamp() |
AuthorDeleted | Author removed | getAuthor(), getTimestamp() |
Routes
Section titled “Routes”| Method | Route | Description |
|---|---|---|
GET | /blog | Post list with pagination |
GET | /blog/{slug} | Single post with comments |
GET | /blog/category/{slug} | Posts in category |
GET | /blog/tag/{slug} | Posts with tag |
GET | /blog/author/{slug} | Posts by author |
GET | /blog/search | Search results (requires ?q=query) |
POST | /blog/{slug}/comment | Submit comment on post |
GET | /blog/comment/verify/{token} | Verify comment via email link |
CLI Commands
Section titled “CLI Commands”blog:publish-scheduled
Section titled “blog:publish-scheduled”Publishes posts scheduled for the current time. Run via cron every minute:
marko blog:publish-scheduledmarko blog:publish-scheduled --verbose # Show each published postblog:cleanup
Section titled “blog:cleanup”Removes expired verification tokens:
marko blog:cleanupmarko blog:cleanup --verbose # Show token countsAPI Reference
Section titled “API Reference”PostRepositoryInterface
Section titled “PostRepositoryInterface”public function find(int $id): ?PostInterface;public function findBySlug(string $slug): ?PostInterface;public function findPublishedPaginated(int $limit, int $offset): array;public function countPublished(): int;public function findPublishedByCategory(int $categoryId, int $limit, int $offset): array;public function findPublishedByTag(int $tagId, int $limit, int $offset): array;public function findPublishedByAuthor(int $authorId, int $limit, int $offset): array;public function findScheduledPostsDue(): array;public function getCategoriesForPost(int $postId): array;public function getTagsForPost(int $postId): array;public function save(PostInterface $post): void;public function delete(PostInterface $post): void;CommentRepositoryInterface
Section titled “CommentRepositoryInterface”public function find(int $id): ?CommentInterface;public function findVerifiedForPost(int $postId): array;public function findPendingForPost(int $postId): array;public function countForPost(int $postId): int;public function countVerifiedForPost(int $postId): int;public function findByEmail(string $email): array;public function save(CommentInterface $comment): void;public function delete(CommentInterface $comment): void;CategoryRepositoryInterface
Section titled “CategoryRepositoryInterface”public function findBySlug(string $slug): ?Category;public function isSlugUnique(string $slug, ?int $excludeId = null): bool;public function findChildren(Category $parent): array;public function getPath(Category $category): array;public function findRoots(): array;public function getPostsForCategory(int $categoryId): array;public function getDescendantIds(int $categoryId): array;TagRepositoryInterface
Section titled “TagRepositoryInterface”public function findBySlug(string $slug): ?Tag;public function findByNameLike(string $name): array;public function isSlugUnique(string $slug, ?int $excludeId = null): bool;public function getPostsForTag(int $tagId): array;AuthorRepositoryInterface
Section titled “AuthorRepositoryInterface”public function findBySlug(string $slug): ?Author;public function findByEmail(string $email): ?Author;public function isSlugUnique(string $slug, ?int $excludeId = null): bool;CommentThreadingServiceInterface
Section titled “CommentThreadingServiceInterface”Handles comment tree-building and depth calculation. Injected into controllers that need threaded comment display or reply depth validation.
public function getThreadedComments(int $postId): array;public function calculateDepth(int $commentId): int;SearchServiceInterface
Section titled “SearchServiceInterface”public function searchPaginated(string $query, int $limit, int $offset): array;PaginationServiceInterface
Section titled “PaginationServiceInterface”public function paginate(array $items, int $totalItems, int $currentPage, ?int $perPage = null): PaginatedResult;public function calculateOffset(int $page, ?int $perPage = null): int;public function getPerPage(): int;SlugGeneratorInterface
Section titled “SlugGeneratorInterface”public function generate(string $title, ?Closure $uniquenessChecker = null): string;BlogConfigInterface
Section titled “BlogConfigInterface”public function getPostsPerPage(): int;public function getCommentMaxDepth(): int;public function getCommentRateLimitSeconds(): int;public function getVerificationTokenExpiryDays(): int;public function getVerificationCookieDays(): int;public function getRoutePrefix(): string;public function getVerificationCookieName(): string;public function getSiteName(): string;