Skip to Content
ModulesFull Example

Full Module Example

In this section, we will create a complete working “Blog” module for Flute CMS. The module will demonstrate all major system features: controllers, models, views, routes, localization, payments, widgets, and profile integration.

The example is for demonstration purposes and does not cover all classes. Rely on current APIs: bootstrapModule() for resource loading, ActiveRecord for ORM, loadPackage() for admin package.

Module Structure

    • module.json
      • BlogProvider.php
      • BlogController.php
        • Article.php
        • Category.php
        • Comment.php
        • Tag.php
        • ArticleTag.php
    • routes.php

Module Configuration

module.json

{ "name": "Blog", "version": "1.0.0", "description": "Full-featured blog module for Flute CMS", "authors": ["Development Team"], "url": "https://github.com/flute-cms/blog-module", "providers": [ { "class": "Flute\\Modules\\Blog\\Providers\\BlogProvider", "order": 10 } ], "dependencies": { "php": ">=8.2", "flute": ">=1.0.0", "modules": { "Payments": ">=1.0.0", "Notifications": ">=1.0.0" }, "extensions": ["gd", "mbstring"], "composer": { "intervention/image": "^2.7", "laravel/socialite": "^5.5" }, "theme": { "standard": ">=1.0.0" } } }

In module.json we specify:

  • name — unique module name
  • version — module version in SemVer format
  • description — module description
  • providers — array of providers with class and load order
  • dependencies — module dependencies:
    • php — minimum PHP version
    • flute — minimum Flute CMS version
    • modules — dependencies on other modules
    • extensions — required PHP extensions
    • composer — Composer packages
    • theme — theme dependencies

Module Configuration

<?php // Resources/config/blog.php return [ // Basic settings 'enabled' => true, 'name' => 'Blog', 'description' => 'Publishing articles and news', // Display settings 'articles_per_page' => 10, 'excerpt_length' => 150, 'show_author' => true, 'show_date' => true, 'show_tags' => true, 'show_categories' => true, 'show_comments' => true, // Image settings 'images' => [ 'max_size' => 2048, // KB 'allowed_types' => ['jpg', 'jpeg', 'png', 'gif', 'webp'], 'quality' => 85, 'thumbnails' => [ 'small' => ['width' => 300, 'height' => 200], 'medium' => ['width' => 600, 'height' => 400], 'large' => ['width' => 1200, 'height' => 800], ], ], // Comment settings 'comments' => [ 'enabled' => true, 'moderation' => true, 'nested' => true, 'max_depth' => 3, 'per_page' => 20, 'require_auth' => false, ], // SEO settings 'seo' => [ 'title_separator' => ' | ', 'default_title' => 'Blog', 'default_description' => 'Latest articles and news', 'og_image' => '/images/blog-og.jpg', 'twitter_card' => 'summary_large_image', ], // Notification settings 'notifications' => [ 'email' => [ 'new_comment' => true, 'new_article' => false, 'article_published' => true, ], 'database' => [ 'new_comment' => true, 'new_article' => true, ], ], // Caching settings 'cache' => [ 'articles' => 1800, // 30 minutes 'comments' => 900, // 15 minutes 'categories' => 3600, // 1 hour 'tags' => 3600, // 1 hour ], // Payment settings (for premium articles) 'payments' => [ 'enabled' => false, 'premium_price' => 9.99, 'currency' => 'USD', 'gateway' => 'stripe', ], // RSS settings 'rss' => [ 'enabled' => true, 'items_count' => 20, 'cache_time' => 1800, ], ];

For a real module, minimize config: keep only used keys, move the rest to services or reasonable defaults. Override values via config-dev/blog.php or own configuration service.

Database Entities

Article.php

Main entity for storing blog articles:

<?php namespace Flute\Modules\Blog\Database\Entities; use Cycle\Annotated\Annotation\Column; use Cycle\Annotated\Annotation\Entity; use Cycle\Annotated\Annotation\Relation\HasMany; use Cycle\Annotated\Annotation\Relation\BelongsTo; use Cycle\Annotated\Annotation\Table\Index; use Cycle\Annotated\Annotation\Table; #[Entity(table: 'blog_articles')] #[Table(indexes: [ new Index(columns: ['slug'], unique: true), new Index(columns: ['published', 'created_at']), new Index(columns: ['user_id']), new Index(columns: ['category_id']), ])] class Article { #[Column(type: 'primary')] public int $id; #[Column(type: 'string', nullable: false)] public string $title; #[Column(type: 'text', nullable: false)] public string $content; #[Column(type: 'text', nullable: true)] public ?string $excerpt; #[Column(type: 'string', nullable: false, unique: true)] public string $slug; #[Column(type: 'boolean', default: false)] public bool $published = false; #[Column(type: 'datetime', nullable: true)] public ?\DateTime $publish_date; #[Column(type: 'boolean', default: false)] public bool $premium = false; #[Column(type: 'decimal', precision: 10, scale: 2, nullable: true)] public ?float $price; #[Column(type: 'string', nullable: true)] public ?string $image; #[Column(type: 'json', nullable: true)] public ?array $meta = []; #[Column(type: 'integer', default: 0)] public int $views = 0; #[Column(type: 'integer', default: 0)] public int $likes = 0; #[Column(type: 'datetime', default: 'now')] public \DateTime $created_at; #[Column(type: 'datetime', nullable: true)] public ?\DateTime $updated_at; // Relations #[BelongsTo(target: \Flute\Modules\User\Database\Entities\User::class, nullable: false)] public \Flute\Modules\User\Database\Entities\User $author; #[BelongsTo(target: Category::class, nullable: true)] public ?Category $category; #[HasMany(target: Comment::class, nullable: false)] public array $comments = []; #[HasMany(target: Tag::class, through: ArticleTag::class, nullable: false)] public array $tags = []; // Getters and Setters public function getId(): int { return $this->id; } public function getTitle(): string { return $this->title; } public function setTitle(string $title): void { $this->title = $title; $this->updated_at = new \DateTime(); } public function getContent(): string { return $this->content; } public function setContent(string $content): void { $this->content = $content; $this->updated_at = new \DateTime(); } public function getSlug(): string { return $this->slug; } public function setSlug(string $slug): void { $this->slug = $slug; } public function isPublished(): bool { return $this->published; } public function publish(): void { $this->published = true; $this->publish_date = new \DateTime(); } public function unpublish(): void { $this->published = false; $this->publish_date = null; } public function isPremium(): bool { return $this->premium; } public function setPremium(bool $premium): void { $this->premium = $premium; } public function getPrice(): ?float { return $this->price; } public function setPrice(?float $price): void { $this->price = $price; } public function getImage(): ?string { return $this->image; } public function setImage(?string $image): void { $this->image = $image; } public function incrementViews(): void { $this->views++; } public function getViews(): int { return $this->views; } public function getLikes(): int { return $this->likes; } public function incrementLikes(): void { $this->likes++; } public function getUrl(): string { return route('blog.articles.show', ['slug' => $this->slug]); } public function getExcerpt(int $length = null): string { $length = $length ?? config('blog.excerpt_length', 150); if ($this->excerpt) { return Str::limit(strip_tags($this->excerpt), $length); } return Str::limit(strip_tags($this->content), $length); } public function getReadingTime(): int { $words = str_word_count(strip_tags($this->content)); $wordsPerMinute = 200; // Average reading speed return ceil($words / $wordsPerMinute); } public function getMeta(string $key = null) { if ($key === null) { return $this->meta ?? []; } return $this->meta[$key] ?? null; } public function setMeta(string $key, $value): void { if (!is_array($this->meta)) { $this->meta = []; } $this->meta[$key] = $value; } public function hasTag(string $tagName): bool { foreach ($this->tags as $tag) { if ($tag->name === $tagName) { return true; } } return false; } public function getTagNames(): array { return array_map(function($tag) { return $tag->name; }, $this->tags); } public function canEdit(\Flute\Modules\User\Database\Entities\User $user = null): bool { $user = $user ?? user(); return $user->isLoggedIn() && ($user->id === $this->author->id || $user->can('manage_blog')); } public function canDelete(\Flute\Modules\User\Database\Entities\User $user = null): bool { $user = $user ?? user(); return $user->isLoggedIn() && ($user->id === $this->author->id || $user->can('manage_blog')); } }

Note key entity features:

  • Indexes — created via #[Table(indexes: [...])] attribute for query optimization
  • RelationsBelongsTo for author and category, HasMany for comments and tags
  • Access methods — getters and setters for encapsulating logic
  • Business logic — methods publish(), unpublish(), canEdit(), canDelete()

Category.php

Entity for article categories with nesting support:

<?php namespace Flute\Modules\Blog\Database\Entities; use Cycle\Annotated\Annotation\Column; use Cycle\Annotated\Annotation\Entity; use Cycle\Annotated\Annotation\Relation\HasMany; use Cycle\Annotated\Annotation\Relation\BelongsTo; use Cycle\Annotated\Annotation\Table\Index; #[Entity(table: 'blog_categories')] #[Table(indexes: [ new Index(columns: ['slug'], unique: true), new Index(columns: ['parent_id']), ])] class Category { #[Column(type: 'primary')] public int $id; #[Column(type: 'string', nullable: false)] public string $name; #[Column(type: 'string', nullable: false, unique: true)] public string $slug; #[Column(type: 'text', nullable: true)] public ?string $description; #[Column(type: 'string', nullable: true)] public ?string $image; #[Column(type: 'integer', nullable: true)] public ?int $parent_id; #[Column(type: 'integer', default: 0)] public int $sort_order = 0; #[Column(type: 'boolean', default: true)] public bool $active = true; #[Column(type: 'datetime', default: 'now')] public \DateTime $created_at; // Relations #[BelongsTo(target: Category::class, nullable: true)] public ?Category $parent; #[HasMany(target: Category::class, nullable: false)] public array $children = []; #[HasMany(target: Article::class, nullable: false)] public array $articles = []; // Getters and Setters public function getId(): int { return $this->id; } public function getName(): string { return $this->name; } public function setName(string $name): void { $this->name = $name; } public function getSlug(): string { return $this->slug; } public function setSlug(string $slug): void { $this->slug = $slug; } public function getDescription(): ?string { return $this->description; } public function setDescription(?string $description): void { $this->description = $description; } public function getUrl(): string { return route('blog.categories.show', ['slug' => $this->slug]); } public function getArticleCount(): int { return count($this->articles); } public function isActive(): bool { return $this->active; } public function activate(): void { $this->active = true; } public function deactivate(): void { $this->active = false; } public function hasChildren(): bool { return count($this->children) > 0; } public function isChild(): bool { return $this->parent_id !== null; } public function getLevel(): int { $level = 0; $parent = $this->parent; while ($parent) { $level++; $parent = $parent->parent; } return $level; } public function getPath(): array { $path = []; $current = $this; while ($current) { array_unshift($path, $current); $current = $current->parent; } return $path; } }

Comment.php

Entity for comments with nested replies support:

<?php namespace Flute\Modules\Blog\Database\Entities; use Cycle\Annotated\Annotation\Column; use Cycle\Annotated\Annotation\Entity; use Cycle\Annotated\Annotation\Relation\HasMany; use Cycle\Annotated\Annotation\Relation\BelongsTo; use Cycle\Annotated\Annotation\Table\Index; #[Entity(table: 'blog_comments')] #[Table(indexes: [ new Index(columns: ['article_id', 'created_at']), new Index(columns: ['user_id']), new Index(columns: ['approved', 'created_at']), ])] class Comment { #[Column(type: 'primary')] public int $id; #[Column(type: 'text', nullable: false)] public string $content; #[Column(type: 'string', nullable: true)] public ?string $author_name; #[Column(type: 'string', nullable: true)] public ?string $author_email; #[Column(type: 'string', nullable: true)] public ?string $author_ip; #[Column(type: 'string', nullable: true)] public ?string $user_agent; #[Column(type: 'boolean', default: false)] public bool $approved = false; #[Column(type: 'integer', nullable: true)] public ?int $parent_id; #[Column(type: 'datetime', default: 'now')] public \DateTime $created_at; #[Column(type: 'datetime', nullable: true)] public ?\DateTime $updated_at; // Relations #[BelongsTo(target: Article::class, nullable: false)] public Article $article; #[BelongsTo(target: \Flute\Modules\User\Database\Entities\User::class, nullable: true)] public ?\Flute\Modules\User\Database\Entities\User $user; #[BelongsTo(target: Comment::class, nullable: true)] public ?Comment $parent; #[HasMany(target: Comment::class, nullable: false)] public array $replies = []; // Getters and Setters public function getId(): int { return $this->id; } public function getContent(): string { return $this->content; } public function setContent(string $content): void { $this->content = $content; $this->updated_at = new \DateTime(); } public function isApproved(): bool { return $this->approved; } public function approve(): void { $this->approved = true; } public function disapprove(): void { $this->approved = false; } public function getAuthorName(): string { return $this->user ? $this->user->name : ($this->author_name ?? 'Guest'); } public function getAuthorEmail(): ?string { return $this->user ? $this->user->email : $this->author_email; } public function getAuthorAvatar(): ?string { return $this->user ? $this->user->avatar : null; } public function isReply(): bool { return $this->parent_id !== null; } public function hasReplies(): bool { return count($this->replies) > 0; } public function getDepth(): int { $depth = 0; $parent = $this->parent; while ($parent) { $depth++; $parent = $parent->parent; } return $depth; } public function canEdit(\Flute\Modules\User\Database\Entities\User $user = null): bool { $user = $user ?? user(); if (!$user->isLoggedIn()) { return false; } return $user->id === $this->user->id || $user->can('manage_blog_comments'); } public function canDelete(\Flute\Modules\User\Database\Entities\User $user = null): bool { $user = $user ?? user(); if (!$user->isLoggedIn()) { return false; } return $user->id === $this->user->id || $user->can('manage_blog_comments'); } public function getTimeAgo(): string { // Simple date formatting return $this->created_at->format('d.m.Y H:i'); } }

Tag.php

Entity for article tags:

<?php namespace Flute\Modules\Blog\Database\Entities; use Cycle\Annotated\Annotation\Column; use Cycle\Annotated\Annotation\Entity; use Cycle\Annotated\Annotation\Relation\HasMany; use Cycle\Annotated\Annotation\Table\Index; #[Entity(table: 'blog_tags')] #[Table(indexes: [ new Index(columns: ['slug'], unique: true), ])] class Tag { #[Column(type: 'primary')] public int $id; #[Column(type: 'string', nullable: false)] public string $name; #[Column(type: 'string', nullable: false, unique: true)] public string $slug; #[Column(type: 'text', nullable: true)] public ?string $description; #[Column(type: 'string', nullable: true)] public ?string $color; #[Column(type: 'integer', default: 0)] public int $usage_count = 0; #[Column(type: 'datetime', default: 'now')] public \DateTime $created_at; // Relations #[HasMany(target: Article::class, through: ArticleTag::class, nullable: false)] public array $articles = []; // Getters and Setters public function getId(): int { return $this->id; } public function getName(): string { return $this->name; } public function setName(string $name): void { $this->name = $name; } public function getSlug(): string { return $this->slug; } public function setSlug(string $slug): void { $this->slug = $slug; } public function getUrl(): string { return route('blog.tags.show', ['slug' => $this->slug]); } public function getArticleCount(): int { return count($this->articles); } public function incrementUsage(): void { $this->usage_count++; } public function decrementUsage(): void { if ($this->usage_count > 0) { $this->usage_count--; } } }

ArticleTag.php

Pivot table for many-to-many relationship between articles and tags:

<?php namespace Flute\Modules\Blog\Database\Entities; use Cycle\Annotated\Annotation\Column; use Cycle\Annotated\Annotation\Entity; use Cycle\Annotated\Annotation\Relation\BelongsTo; #[Entity(table: 'blog_article_tags')] class ArticleTag { #[Column(type: 'primary')] public int $id; #[BelongsTo(target: Article::class, nullable: false)] public Article $article; #[BelongsTo(target: Tag::class, nullable: false)] public Tag $tag; }

Module Provider

The provider is the module entry point where all services, events, widgets are registered and module setup is performed:

<?php namespace Flute\Modules\Blog\Providers; use Flute\Core\Support\ModuleServiceProvider; use Flute\Modules\Blog\Services\ArticleService; use Flute\Modules\Blog\Services\CommentService; use Flute\Modules\Blog\Widgets\RecentArticlesWidget; use Flute\Modules\Blog\Widgets\CategoriesWidget; use Flute\Modules\Blog\Events\ArticlePublished; use Flute\Modules\Blog\Listeners\SendNotificationListener; class BlogProvider extends ModuleServiceProvider { protected ?string $moduleName = 'Blog'; /** * Register services in DI container */ public function register(\DI\Container $container): void { // Register services as singletons $container->set(ArticleService::class, \DI\autowire()); $container->set(CommentService::class, \DI\autowire()); } /** * Load module after registering all services */ public function boot(\DI\Container $container): void { // Automatically load resources via bootstrapModule() // This will load: entities, configs, translations, routes, components, widgets, SCSS $this->bootstrapModule(); // Additional view loading with namespace $this->loadViews('Resources/views', 'blog'); // Load module SCSS styles $this->loadScss('Resources/assets/scss/blog.scss'); // Register payment gateway (if payments enabled) $this->registerPaymentGateway(); // Register widgets $this->registerWidgets(); // Register events and listeners $this->registerEvents(); // Register console commands $this->registerCommands(); // Integrate with user profile $this->registerProfileIntegration(); // Setup RSS feed $this->setupRssFeed(); } /** * Register payment gateway for premium articles */ protected function registerPaymentGateway(): void { // Check if payments enabled in config if (!config('blog.payments.enabled', false)) { return; } // Use deferred listener to wait for payment system initialization events()->addDeferredListener( \Flute\Core\Modules\Payments\Events\RegisterPaymentFactoriesEvent::NAME, function($event) { $factory = app(\Flute\Core\Modules\Payments\Factories\PaymentDriverFactory::class); $factory->register('blog_premium', \Flute\Modules\Blog\Gateways\PremiumGateway::class); } ); } /** * Register module widgets */ protected function registerWidgets(): void { $widgetManager = app(\Flute\Core\Modules\Page\Services\WidgetManager::class); // Recent articles widget $widgetManager->registerWidget('blog_recent_articles', RecentArticlesWidget::class); // Categories widget $widgetManager->registerWidget('blog_categories', CategoriesWidget::class); } /** * Register events and listeners */ protected function registerEvents(): void { // Deferred listener for article published event events()->addDeferredListener( ArticlePublished::NAME, [SendNotificationListener::class, 'handleArticlePublished'] ); } /** * Register console commands */ protected function registerCommands(): void { if (is_cli()) { // Register console commands $this->registerConsoleCommands(); } } /** * User profile integration * Adds tab with user articles to their profile */ protected function registerProfileIntegration(): void { events()->addDeferredListener( \Flute\Core\Template\Events\TemplateInitialized::NAME, function($event) { $tpl = $event->getTemplate(); // Add tab to profile $tpl->prependTemplateToSection('profile_tabs', 'blog::partials.profile-tab', [ 'user' => user() ]); // Add tab content $tpl->prependTemplateToSection('profile_tab_content', 'blog::partials.profile-content', [ 'user' => user() ]); } ); } /** * Setup RSS feed */ protected function setupRssFeed(): void { if (!config('blog.rss.enabled', true)) { return; } // Register RSS route router()->get('/blog/rss', function() { $articles = rep(\Flute\Modules\Blog\Database\Entities\Article::class) ->select() ->where('published', true) ->orderBy('created_at', 'DESC') ->limit(config('blog.rss.items_count', 20)) ->fetchAll(); // Generate RSS via template return response() ->make(view('blog::rss', compact('articles'))->render()) ->withHeader('Content-Type', 'application/rss+xml'); })->name('blog.rss'); } /** * Register console commands */ protected function registerConsoleCommands(): void { // Register commands for cache clearing, sitemap generation, etc. $command = new \Flute\Modules\Blog\Console\Commands\GenerateSitemapCommand(); // Register in console application } }

Controllers

BlogController.php

Main controller for public blog part:

<?php namespace Flute\Modules\Blog\Controllers; use Flute\Core\Router\Annotations\Get; use Flute\Core\Router\Annotations\Post; use Flute\Core\Support\BaseController; use Flute\Modules\Blog\Services\ArticleService; use Flute\Modules\Blog\Services\CommentService; use Flute\Modules\Blog\Database\Entities\Article; class BlogController extends BaseController { protected ArticleService $articleService; protected CommentService $commentService; public function __construct(ArticleService $articleService, CommentService $commentService) { $this->articleService = $articleService; $this->commentService = $commentService; } /** * Blog home page with article list */ #[Get('/blog', name: 'blog.index')] public function index() { $page = (int) request()->get('page', 1); $perPage = config('blog.articles_per_page', 10); $category = request()->get('category'); $tag = request()->get('tag'); $search = request()->get('search'); // Get articles with filtering $articles = $this->articleService->getArticlesPaginated([ 'page' => $page, 'per_page' => $perPage, 'category' => $category, 'tag' => $tag, 'search' => $search, ]); // Get categories and tags for sidebar $categories = $this->articleService->getCategories(); $tags = $this->articleService->getPopularTags(); return response()->view('blog::pages.index', compact( 'articles', 'categories', 'tags', 'category', 'tag', 'search' )); } /** * View single article */ #[Get('/blog/{slug}', name: 'blog.articles.show')] public function show($slug) { $article = $this->articleService->getArticleBySlug($slug); if (!$article) { return $this->error(__('blog.article_not_found'), 404); } // Check publication (unpublished visible only to authors) if (!$article->published && !$article->canEdit()) { return $this->error(__('blog.access_denied'), 403); } // Check premium access if ($article->premium && !$this->checkPremiumAccess($article)) { return $this->showPremiumGate($article); } // Increment view counter $this->articleService->incrementViews($article); // Get comments and related articles $comments = $this->commentService->getArticleComments($article->id); $relatedArticles = $this->articleService->getRelatedArticles($article); return response()->view('blog::pages.show', compact( 'article', 'comments', 'relatedArticles' )); } /** * Add comment to article */ #[Post('/blog/{slug}/comments', name: 'blog.articles.comments.store')] public function storeComment($slug) { $article = $this->articleService->getArticleBySlug($slug); if (!$article) { return $this->error(__('blog.article_not_found'), 404); } // Check if comments enabled if (!config('blog.comments.enabled', true)) { return $this->error(__('blog.comments_disabled'), 403); } // Validate data $validated = $this->validate(request()->all(), [ 'content' => 'required|string|min-str-len:10|max-str-len:1000', 'parent_id' => 'nullable|integer', ]); if ($validated !== true) { return $this->json($this->errors()->getErrors(), 422); } try { $comment = $this->commentService->createComment([ 'article_id' => $article->id, 'content' => request()->get('content'), 'parent_id' => request()->get('parent_id'), ]); // JSON response for AJAX requests if (request()->expectsJson()) { return $this->json([ 'success' => true, 'comment' => $comment, 'message' => __('blog.comment_added') ]); } $this->flash(__('blog.comment_added'), 'success'); return redirect()->back(); } catch (\Exception $e) { logs('blog')->error('Failed to create comment', [ 'article_id' => $article->id, 'error' => $e->getMessage() ]); if (request()->expectsJson()) { return $this->json(['error' => __('blog.comment_error')], 500); } $this->flash(__('blog.comment_error'), 'error'); return redirect()->back(); } } /** * Like article */ #[Post('/blog/{slug}/like', name: 'blog.articles.like')] public function likeArticle($slug) { $article = $this->articleService->getArticleBySlug($slug); if (!$article) { return $this->error(__('blog.article_not_found'), 404); } if (!$article->published) { return $this->error(__('blog.access_denied'), 403); } try { $this->articleService->likeArticle($article); return $this->json([ 'success' => true, 'likes' => $article->likes, 'message' => __('blog.article_liked') ]); } catch (\Exception $e) { return $this->json(['error' => __('blog.like_error')], 500); } } /** * Check premium access to article */ protected function checkPremiumAccess(Article $article): bool { if (!user()->isLoggedIn()) { return false; } // Check premium purchase $purchase = rep(\Flute\Modules\Payments\Database\Entities\Purchase::class) ->select() ->where('user_id', user()->id) ->where('item_type', 'blog_article') ->where('item_id', $article->id) ->fetchOne(); return $purchase !== null; } /** * Show premium article purchase page */ protected function showPremiumGate(Article $article) { if (!user()->isLoggedIn()) { return redirect(route('auth.login')); } return response()->view('blog::pages.premium', compact('article')); } }

Services

ArticleService.php

Service encapsulates article business logic:

<?php namespace Flute\Modules\Blog\Services; use Flute\Modules\Blog\Database\Entities\Article; use Flute\Modules\Blog\Database\Entities\Category; use Flute\Modules\Blog\Database\Entities\Tag; use Flute\Modules\Blog\Events\ArticlePublished; class ArticleService { /** * Get articles with pagination and filtering */ public function getArticlesPaginated(array $params = []) { $query = rep(Article::class)->select() ->where('published', true) ->load('author') ->load('category') ->load('tags') ->orderBy('created_at', 'DESC'); // Filter by category if (isset($params['category'])) { $query->where('category.slug', $params['category']); } // Filter by tag if (isset($params['tag'])) { $query->where('tags.slug', $params['tag']); } // Search by title and content if (isset($params['search'])) { $search = $params['search']; $query->where(function($q) use ($search) { $q->where('title', 'LIKE', "%{$search}%") ->orWhere('content', 'LIKE', "%{$search}%"); }); } // Filter by author if (isset($params['author_id'])) { $query->where('user_id', $params['author_id']); } $perPage = $params['per_page'] ?? config('blog.articles_per_page', 10); $page = $params['page'] ?? 1; return $query->paginate($perPage, $page); } /** * Get article by slug */ public function getArticleBySlug($slug) { return rep(Article::class)->select() ->where('slug', $slug) ->load('author') ->load('category') ->load('tags') ->fetchOne(); } /** * Get article by ID */ public function getArticleById($id) { return rep(Article::class)->select() ->where('id', $id) ->load('author') ->load('category') ->load('tags') ->fetchOne(); } /** * Create new article */ public function createArticle(array $data) { $article = new Article(); $article->title = $data['title']; $article->content = $data['content']; $article->excerpt = $data['excerpt'] ?? null; $article->slug = $this->generateUniqueSlug($data['title']); $article->user_id = user()->id; $article->created_at = new \DateTime(); // Set category if (isset($data['category_id'])) { $category = rep(Category::class)->findByPK($data['category_id']); if ($category) { $article->category = $category; } } // Premium article settings if (isset($data['premium'])) { $article->premium = $data['premium']; $article->price = $data['price'] ?? null; } // Save article transaction($article)->run(); // Sync tags if (isset($data['tags'])) { $this->syncTags($article, $data['tags']); } // Process and save image if (isset($data['image'])) { $article->image = $this->processImage($data['image']); transaction($article)->run(); } return $article; } /** * Update article */ public function updateArticle($id, array $data) { $article = $this->getArticleById($id); if (!$article) { throw new \Exception('Article not found'); } if (!$article->canEdit()) { throw new \Exception('Access denied'); } $oldPublished = $article->published; // Update fields if (isset($data['title'])) { $article->title = $data['title']; // Auto generate slug if not specified explicitly if (!isset($data['slug']) || empty($data['slug'])) { $article->slug = $this->generateUniqueSlug($data['title'], $article->id); } } if (isset($data['content'])) { $article->content = $data['content']; } if (isset($data['excerpt'])) { $article->excerpt = $data['excerpt']; } if (isset($data['published'])) { $article->published = $data['published']; } if (isset($data['premium'])) { $article->premium = $data['premium']; $article->price = $data['price'] ?? null; } $article->updated_at = new \DateTime(); transaction($article)->run(); // Update category if (isset($data['category_id'])) { if ($data['category_id']) { $category = rep(Category::class)->findByPK($data['category_id']); $article->category = $category; } else { $article->category = null; } transaction($article)->run(); } // Sync tags if (isset($data['tags'])) { $this->syncTags($article, $data['tags']); } // Publication event (if article just published) if (!$oldPublished && $article->published) { events()->dispatch(new ArticlePublished($article)); } return $article; } /** * Delete article */ public function deleteArticle($id) { $article = $this->getArticleById($id); if (!$article) { throw new \Exception('Article not found'); } if (!$article->canDelete()) { throw new \Exception('Access denied'); } // Delete related data $this->deleteArticleTags($article); $this->deleteArticleComments($article); // Delete image if ($article->image) { $this->deleteImage($article->image); } transaction($article, 'delete')->run(); return true; } /** * Increment view counter */ public function incrementViews(Article $article) { $article->incrementViews(); transaction($article)->run(); } /** * Add like */ public function likeArticle(Article $article) { $article->incrementLikes(); transaction($article)->run(); } /** * Get all active categories */ public function getCategories() { return rep(Category::class)->select() ->where('active', true) ->orderBy('sort_order') ->fetchAll(); } /** * Get popular tags */ public function getPopularTags($limit = 20) { return rep(Tag::class)->select() ->orderBy('usage_count', 'DESC') ->limit($limit) ->fetchAll(); } /** * Get related articles */ public function getRelatedArticles(Article $article, $limit = 5) { // First look for articles in same category if ($article->category) { return rep(Article::class)->select() ->where('category_id', $article->category->id) ->where('id', '!=', $article->id) ->where('published', true) ->orderBy('created_at', 'DESC') ->limit($limit) ->fetchAll(); } // If no category, look by tags $tagIds = array_map(function($tag) { return $tag->id; }, $article->tags); if (!empty($tagIds)) { return rep(Article::class)->select() ->where('tags.id', 'IN', $tagIds) ->where('id', '!=', $article->id) ->where('published', true) ->orderBy('created_at', 'DESC') ->limit($limit) ->fetchAll(); } // If nothing found — return popular articles return rep(Article::class)->select() ->where('published', true) ->orderBy('views', 'DESC') ->limit($limit) ->fetchAll(); } /** * Generate unique slug */ protected function generateUniqueSlug($title, $excludeId = null) { $slug = str()->slug($title); $originalSlug = $slug; $counter = 1; while (true) { $query = rep(Article::class)->select()->where('slug', $slug); if ($excludeId) { $query->where('id', '!=', $excludeId); } if (!$query->fetchOne()) { break; } $slug = $originalSlug . '-' . $counter; $counter++; } return $slug; } /** * Sync article tags */ protected function syncTags(Article $article, array $tagNames) { $existingTags = []; $newTagNames = []; foreach ($tagNames as $tagName) { $tag = rep(Tag::class)->select() ->where('name', $tagName) ->fetchOne(); if ($tag) { $existingTags[] = $tag; } else { $newTagNames[] = $tagName; } } // Create new tags foreach ($newTagNames as $tagName) { $tag = new Tag(); $tag->name = $tagName; $tag->slug = str()->slug($tagName); transaction($tag)->run(); $existingTags[] = $tag; } // Assign tags to article $article->tags = $existingTags; transaction($article)->run(); } /** * Delete article tags */ protected function deleteArticleTags(Article $article) { foreach ($article->tags as $tag) { // Decrement usage counter $tag->decrementUsage(); transaction($tag)->run(); } } /** * Delete article comments */ protected function deleteArticleComments(Article $article) { foreach ($article->comments as $comment) { transaction($comment, 'delete')->run(); } } /** * Process and save image */ protected function processImage($file) { $imageConfig = config('blog.images', []); // Validate size if ($file->getSize() > ($imageConfig['max_size'] ?? 2048) * 1024) { throw new \Exception('Image too large'); } // Validate type $extension = strtolower($file->getClientOriginalExtension()); if (!in_array($extension, $imageConfig['allowed_types'] ?? ['jpg', 'png'])) { throw new \Exception('Invalid image type'); } // Save original $filename = time() . '_' . uniqid() . '.' . $extension; $file->move(public_path('uploads/blog'), $filename); // Create thumbnails $this->createThumbnails($filename); return $filename; } /** * Create image thumbnails */ protected function createThumbnails($filename) { $thumbnails = config('blog.images.thumbnails', []); foreach ($thumbnails as $name => $size) { // Create thumbnail using Intervention Image $image = \Intervention\Image\ImageManagerStatic::make(public_path('uploads/blog/' . $filename)); $image->fit($size['width'], $size['height']); $image->save(public_path('uploads/blog/thumbnails/' . $name . '_' . $filename)); } } /** * Delete image and all thumbnails */ protected function deleteImage($filename) { $path = public_path('uploads/blog/' . $filename); if (file_exists($path)) { unlink($path); } // Delete thumbnails $thumbnails = config('blog.images.thumbnails', []); foreach (array_keys($thumbnails) as $name) { $thumbnailPath = public_path('uploads/blog/thumbnails/' . $name . '_' . $filename); if (file_exists($thumbnailPath)) { unlink($thumbnailPath); } } } }

Views

layouts/app.blade.php

Base layout for blog pages:

{{-- Resources/views/layouts/app.blade.php --}} @extends('flute::layouts.app') @section('styles') @parent <link rel="stylesheet" href="{{ asset('css/blog.css') }}"> @endsection @push('content') <div class="blog-container"> <header class="blog-header"> <div class="container"> <div class="d-flex justify-content-between align-items-center"> <div class="blog-brand"> <h1><a href="{{ route('blog.index') }}">{{ __('blog.title') }}</a></h1> <p class="blog-description">{{ __('blog.description') }}</p> </div> @if(config('blog.show_search', true)) <div class="blog-search"> <form action="{{ route('blog.index') }}" method="GET" class="d-flex"> <x-forms.field class="mb-0 flex-grow-1"> <x-input name="search" :value="request()->get('search')" :placeholder="__('blog.search_placeholder')" /> </x-forms.field> <button type="submit" class="btn btn-primary ms-2"> <x-icon path="search" /> </button> </form> </div> @endif </div> </div> </header> <nav class="blog-navigation"> <div class="container"> <ul class="nav nav-pills"> <li class="nav-item"> <a class="nav-link {{ is_active('blog.index') ? 'active' : '' }}" href="{{ route('blog.index') }}"> {{ __('blog.all_articles') }} </a> </li> @php $categories = app(\Flute\Modules\Blog\Services\ArticleService::class)->getCategories(); @endphp @foreach($categories as $category) <li class="nav-item"> <a class="nav-link {{ request()->get('category') === $category->slug ? 'active' : '' }}" href="{{ route('blog.index', ['category' => $category->slug]) }}"> {{ $category->name }} </a> </li> @endforeach </ul> </div> </nav> <main class="blog-content"> <div class="container"> @yield('blog-content') </div> </main> @include('blog::partials.footer') </div> @endpush @push('scripts') <script src="{{ asset('js/blog.js') }}"></script> @endpush

pages/index.blade.php

Page with article list:

{{-- Resources/views/pages/index.blade.php --}} @extends('blog::layouts.app') @section('title', __('blog.articles') . ' - ' . config('blog.name')) @section('blog-content') <div class="row"> <div class="col-lg-8"> @if($articles->count() > 0) <div class="articles-list"> @foreach($articles as $article) <x-blog::article-card :article="$article" class="mb-4" /> @endforeach </div> {{-- Pagination --}} <div class="d-flex justify-content-center mt-4"> {{ $articles->links('blog::partials.pagination') }} </div> @else <div class="text-center py-5"> <div class="mb-4"> <x-icon path="newspaper" class="fa-3x text-muted" /> </div> <h3>{{ __('blog.no_articles') }}</h3> <p class="text-muted">{{ __('blog.no_articles_description') }}</p> @can('create_articles') <a href="{{ route('blog.articles.create') }}" class="btn btn-primary"> {{ __('blog.create_first_article') }} </a> @endcan </div> @endif </div> <div class="col-lg-4"> @include('blog::partials.sidebar') </div> </div> @endsection

components/article-card.blade.php

Article card component:

{{-- Resources/views/components/article-card.blade.php --}} @props(['article']) <article class="card article-card h-100"> @if($article->image) <div class="article-image"> <img src="{{ asset('uploads/blog/' . $article->image) }}" alt="{{ $article->title }}" class="card-img-top" loading="lazy"> @if($article->premium) <div class="premium-badge"> <x-icon path="crown" /> {{ __('blog.premium') }} </div> @endif </div> @endif <div class="card-body"> <div class="article-meta mb-2"> @if(config('blog.show_category', true) && $article->category) <span class="badge bg-primary"> <a href="{{ route('blog.index', ['category' => $article->category->slug]) }}" class="text-decoration-none text-white"> {{ $article->category->name }} </a> </span> @endif @if(config('blog.show_date', true)) <small class="text-muted ms-2"> <x-icon path="calendar" /> {{ $article->created_at->format('d.m.Y') }} </small> @endif </div> <h5 class="card-title"> <a href="{{ $article->getUrl() }}" class="text-decoration-none"> {{ $article->title }} </a> </h5> @if($article->excerpt) <p class="card-text">{{ $article->excerpt }}</p> @endif <div class="article-footer"> @if(config('blog.show_author', true)) <div class="article-author"> <x-icon path="user" /> <a href="{{ route('profile.show', ['id' => $article->author->id]) }}"> {{ $article->author->name }} </a> </div> @endif <div class="article-stats"> @if(config('blog.show_views', true)) <small class="text-muted me-3"> <x-icon path="eye" /> {{ $article->views }} </small> @endif @if(config('blog.show_comments', true)) <small class="text-muted"> <x-icon path="comments" /> {{ $article->comments->count() }} </small> @endif </div> </div> @if($article->tags->count() > 0) <div class="article-tags mt-2"> @foreach($article->tags as $tag) <span class="badge bg-secondary me-1"> <a href="{{ route('blog.index', ['tag' => $tag->slug]) }}" class="text-decoration-none text-white"> {{ $tag->name }} </a> </span> @endforeach </div> @endif </div> <div class="card-footer bg-transparent"> <div class="d-flex justify-content-between align-items-center"> <a href="{{ $article->getUrl() }}" class="btn btn-primary btn-sm"> {{ __('blog.read_more') }} </a> @if(user()->isLoggedIn()) <form action="{{ route('blog.articles.like', ['slug' => $article->slug]) }}" method="POST" class="d-inline like-form"> @csrf <button type="submit" class="btn btn-outline-danger btn-sm"> <x-icon path="heart" /> <span class="likes-count">{{ $article->likes }}</span> </button> </form> @else <span class="text-muted"> <x-icon path="heart" /> {{ $article->likes }} </span> @endif </div> </div> </article>

Widgets

RecentArticlesWidget.php

Widget to display recent articles:

<?php namespace Flute\Modules\Blog\Widgets; use Flute\Core\Modules\Page\Widgets\Contracts\WidgetInterface; use Flute\Modules\Blog\Database\Entities\Article; class RecentArticlesWidget implements WidgetInterface { /** * Widget name */ public function getName(): string { return __('blog.widget.recent_articles'); } /** * Widget icon */ public function getIcon(): string { return '<x-icon path="clock" />'; } /** * Widget settings */ public function getSettings(): array { return [ 'limit' => [ 'type' => 'number', 'label' => __('blog.widget.limit'), 'default' => 5, 'min' => 1, 'max' => 20, ], 'show_excerpt' => [ 'type' => 'boolean', 'label' => __('blog.widget.show_excerpt'), 'default' => false, ], 'show_date' => [ 'type' => 'boolean', 'label' => __('blog.widget.show_date'), 'default' => true, ], 'show_author' => [ 'type' => 'boolean', 'label' => __('blog.widget.show_author'), 'default' => false, ], ]; } /** * Rendering widget */ public function render(array $settings): string|null { $limit = $settings['limit'] ?? 5; $articles = rep(Article::class)->select() ->where('published', true) ->load('author') ->orderBy('created_at', 'DESC') ->limit($limit) ->fetchAll(); if ($articles->count() === 0) { return null; } return view('blog::widgets.recent-articles', [ 'articles' => $articles, 'settings' => $settings, ])->render(); } /** * Widget settings form */ public function renderSettingsForm(array $settings): string|bool { return view('blog::widgets.recent-articles-settings', [ 'settings' => $settings, ])->render(); } /** * Settings validation */ public function validateSettings(array $input): true|array { $validator = validator(); $validated = $validator->validate($input, [ 'limit' => 'required|integer|min:1|max:20', 'show_excerpt' => 'boolean', 'show_date' => 'boolean', 'show_author' => 'boolean', ]); if (!$validated) { return $validator->getErrors()->toArray(); } return true; } /** * Save settings */ public function saveSettings(array $input): array { return $input; } /** * Default widget width */ public function getDefaultWidth(): int { return 12; } /** * Minimal width */ public function getMinWidth(): int { return 6; } /** * Has settings */ public function hasSettings(): bool { return true; } /** * Action buttons */ public function getButtons(): array { return []; } /** * Action handling */ public function handleAction(string $action, ?string $widgetId = null): array { return ['success' => false, 'message' => 'Action not supported']; } /** * Widget category */ public function getCategory(): string { return 'blog'; } }

Translations

messages.php (en)

<?php // Resources/lang/en/messages.php return [ 'title' => 'Blog', 'description' => 'Latest articles and news', 'articles' => 'Articles', 'article' => 'Article', 'all_articles' => 'All Articles', 'create_article' => 'Create Article', 'edit_article' => 'Edit Article', 'delete_article' => 'Delete Article', 'save_article' => 'Save Article', 'publish_article' => 'Publish Article', 'article_created' => 'Article created successfully', 'article_updated' => 'Article updated successfully', 'article_deleted' => 'Article deleted successfully', 'article_published' => 'Article published successfully', 'article_not_found' => 'Article not found', 'no_articles' => 'No articles found', 'no_articles_description' => 'There are no articles in this category yet', 'create_first_article' => 'Create First Article', 'read_more' => 'Read More', 'by' => 'by', 'on' => 'on', 'premium' => 'Premium', 'comments' => 'Comments', 'comment' => 'Comment', 'add_comment' => 'Add Comment', 'comment_added' => 'Comment added successfully', 'comments_disabled' => 'Comments are disabled', 'comment_error' => 'Failed to add comment', 'reply' => 'Reply', 'replies' => 'replies', 'tags' => 'Tags', 'categories' => 'Categories', 'category' => 'Category', 'select_category' => 'Select category', 'search' => 'Search', 'search_placeholder' => 'Search articles...', 'views' => 'Views', 'likes' => 'Likes', 'like' => 'Like', 'article_liked' => 'Article liked', 'like_error' => 'Failed to like article', 'related_articles' => 'Related Articles', 'popular_articles' => 'Popular Articles', 'latest_articles' => 'Latest Articles', 'access_denied' => 'Access denied', 'confirm_delete' => 'Are you sure you want to delete this article?', 'loading' => 'Loading...', 'error_occurred' => 'An error occurred', 'success' => 'Success', 'cancel' => 'Cancel', 'save' => 'Save', 'edit' => 'Edit', 'delete' => 'Delete', 'view' => 'View', 'back' => 'Back', 'next' => 'Next', 'previous' => 'Previous', 'page' => 'Page', 'of' => 'of', 'showing' => 'Showing', 'to' => 'to', 'results' => 'results', 'no_results' => 'No results found', 'filter' => 'Filter', 'sort_by' => 'Sort by', 'date' => 'Date', 'author' => 'Author', 'popularity' => 'Popularity', 'newest' => 'Newest', 'oldest' => 'Oldest', 'most_viewed' => 'Most Viewed', 'most_liked' => 'Most Liked', // Widgets 'widget' => [ 'recent_articles' => 'Recent Articles', 'limit' => 'Number of articles', 'show_excerpt' => 'Show excerpt', 'show_date' => 'Show date', 'show_author' => 'Show author', ], ];

messages.php (ru)

<?php // Resources/lang/ru/messages.php return [ 'title' => 'Блог', 'description' => 'Последние статьи и новости', 'articles' => 'Статьи', 'article' => 'Статья', 'all_articles' => 'Все статьи', 'create_article' => 'Создать статью', 'edit_article' => 'Редактировать статью', 'delete_article' => 'Удалить статью', 'save_article' => 'Сохранить статью', 'publish_article' => 'Опубликовать статью', 'article_created' => 'Статья успешно создана', 'article_updated' => 'Статья успешно обновлена', 'article_deleted' => 'Статья успешно удалена', 'article_published' => 'Статья успешно опубликована', 'article_not_found' => 'Статья не найдена', 'no_articles' => 'Статьи не найдены', 'no_articles_description' => 'В этой категории пока нет статей', 'create_first_article' => 'Создать первую статью', 'read_more' => 'Читать далее', 'by' => 'автор', 'on' => 'от', 'premium' => 'Премиум', 'comments' => 'Комментарии', 'comment' => 'Комментарий', 'add_comment' => 'Добавить комментарий', 'comment_added' => 'Комментарий успешно добавлен', 'comments_disabled' => 'Комментарии отключены', 'comment_error' => 'Не удалось добавить комментарий', 'reply' => 'Ответить', 'replies' => 'ответов', 'tags' => 'Теги', 'categories' => 'Категории', 'category' => 'Категория', 'select_category' => 'Выберите категорию', 'search' => 'Поиск', 'search_placeholder' => 'Поиск статей...', 'views' => 'Просмотры', 'likes' => 'Лайки', 'like' => 'Нравится', 'article_liked' => 'Статья отмечена как понравившаяся', 'like_error' => 'Не удалось отметить статью', 'related_articles' => 'Похожие статьи', 'popular_articles' => 'Популярные статьи', 'latest_articles' => 'Последние статьи', 'access_denied' => 'Доступ запрещен', 'confirm_delete' => 'Вы уверены, что хотите удалить эту статью?', 'loading' => 'Загрузка...', 'error_occurred' => 'Произошла ошибка', 'success' => 'Успешно', 'cancel' => 'Отмена', 'save' => 'Сохранить', 'edit' => 'Редактировать', 'delete' => 'Удалить', 'view' => 'Просмотр', 'back' => 'Назад', 'next' => 'Далее', 'previous' => 'Назад', 'page' => 'Страница', 'of' => 'из', 'showing' => 'Показаны', 'to' => 'по', 'results' => 'результатов', 'no_results' => 'Результаты не найдены', 'filter' => 'Фильтр', 'sort_by' => 'Сортировать по', 'date' => 'Дате', 'author' => 'Автору', 'popularity' => 'Популярности', 'newest' => 'Сначала новые', 'oldest' => 'Сначала старые', 'most_viewed' => 'По просмотрам', 'most_liked' => 'По лайкам', // Widgets 'widget' => [ 'recent_articles' => 'Последние статьи', 'limit' => 'Количество статей', 'show_excerpt' => 'Показывать отрывок', 'show_date' => 'Показывать дату', 'show_author' => 'Показывать автора', ], ];

Summary

This full module example demonstrates all major capabilities of Flute CMS:

  1. Module Structure — proper file and directory organization
  2. Configurationmodule.json and configuration files
  3. ORM Entities — working with Cycle ORM and ActiveRecord
  4. Module Provider — service registration and module setup
  5. Controllers — handling HTTP requests with route attributes
  6. Services — encapsulating business logic
  7. Views — Blade templates with components
  8. Widgets — creating customizable widgets
  9. Localization — multilingual support

The module is ready to use and can be easily extended with additional features like administrative interface, API endpoints, events, and listeners.