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 versionflute— minimum Flute CMS versionmodules— dependencies on other modulesextensions— required PHP extensionscomposer— Composer packagestheme— 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 - Relations —
BelongsTofor author and category,HasManyfor 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>
@endpushpages/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>
@endsectioncomponents/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:
- Module Structure — proper file and directory organization
- Configuration —
module.jsonand configuration files - ORM Entities — working with Cycle ORM and ActiveRecord
- Module Provider — service registration and module setup
- Controllers — handling HTTP requests with route attributes
- Services — encapsulating business logic
- Views — Blade templates with components
- Widgets — creating customizable widgets
- 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.