Module Development Best Practices
This section collects recommendations for developing high-quality modules for Flute CMS. Following these practices ensures reliability, maintainability, and performance of your code.
There is no env() helper in the project. Define configuration directly in Resources/config/*.php and override via config-dev/ if necessary.
Architecture and Code Organization
Module Structure
Proper Directory Organization
- module.json
- YourController.php
Avoid Excessive Nesting
❌ Bad
Controllers/Admin/Management/UserController.php
Services/Handlers/Processors/DataProcessor.phpConfiguration
Keep configs simple: no env-helper; use explicit values and optionally config-dev/ for local overrides.
- Do not duplicate values that are never read
- Check that config keys are actually used in code
- Use reasonable default values
SOLID Principles
Single Responsibility Principle (SRP)
Each class should have a single responsibility.
❌ Bad
class ArticleController
{
public function store()
{
// Validation
// Saving to DB
// Sending email
// Logging
// Caching
}
}Dependency Inversion Principle (DIP)
Dependencies should be injected, not created inside classes.
❌ Bad
class ArticleService
{
public function create()
{
$validator = new ArticleValidator(); // Creating dependency
$repository = new ArticleRepository(); // Creating dependency
}
}Working with Database
ActiveRecord vs rep()
Prefer entity ActiveRecord API (Article::findByPK(1), Article::query()->where(...)->fetchAll()).
rep() is kept for backward compatibility; do not use it in new modules if ActiveRecord is available.
Cycle ORM Best Practices
Use Explicit Relation Loading
Avoid N+1 query problem:
❌ Bad
$articles = rep(Article::class)->findAll();
// N+1 problem when accessing $article->author
foreach ($articles as $article) {
echo $article->author->name; // Additional query
}Use Pagination for Large Lists
❌ Bad
$articles = rep(Article::class)->findAll(); // All recordsUse Transactions Correctly
❌ Bad
// Separate transactions
$article = new Article();
transaction($article)->run();
$tag = new Tag();
transaction($tag)->run();Query Optimization
Use Select to Limit Fields
// Load only needed fields
$articles = Article::query()
->load('author', ['fields' => ['id', 'name']]) // Only id and name
->fetchAll();Cache Heavy Queries
$popularArticles = cache()->callback(
'blog.popular_articles',
function() {
return Article::query()
->where('published', true)
->orderBy('views', 'DESC')
->limit(10)
->fetchAll();
},
3600 // Cache for 1 hour
);Controllers and Routes
Thin Controllers, Fat Models
Controllers should be thin — move business logic to services.
❌ Bad
class ArticleController extends BaseController
{
public function store()
{
$data = request()->all();
// All logic in controller
$article = new Article();
$article->title = $data['title'];
$article->content = $data['content'];
$article->slug = \Illuminate\Support\Str::slug($data['title']);
// ... lots of code
transaction($article)->run();
return redirect()->back();
}
}Extract Validation into Separate Class
<?php
namespace Flute\Modules\Blog\Services;
class ArticleValidator
{
public function validate(array $data): bool
{
$rules = [
'title' => 'required|string|min-str-len:3|max-str-len:255',
'content' => 'required|string|min-str-len:10',
'category_id' => 'nullable|integer',
'tags' => 'nullable|array|max-arr-count:10',
'tags.*' => 'string|max-str-len:50',
'published' => 'boolean',
];
return validator()->validate($data, $rules);
}
public function getErrors(): array
{
return validator()->getErrors()->toArray();
}
}Usage in controller:
class ArticleController extends BaseController
{
public function __construct(
private ArticleService $articleService,
private ArticleValidator $validator
) {}
public function store()
{
$data = request()->only(['title', 'content', 'category_id', 'tags', 'published']);
if (!$this->validator->validate($data)) {
return $this->json($this->validator->getErrors(), 422);
}
$article = $this->articleService->create($data);
return redirect(route('articles.show', ['id' => $article->id]));
}
}Services and Business Logic
Proper Service Organization
Separate Services by Responsibility
// Main service for CRUD operations
class ArticleService
{
public function create(array $data): Article
{
// Create article
}
public function update(int $id, array $data): Article
{
// Update article
}
public function publish(int $id): Article
{
// Publish article
}
}
// Separate service for notifications
class ArticleNotificationService
{
public function sendNewArticleNotification(Article $article): void
{
// Send notifications
}
}
// Separate service for search
class ArticleSearchService
{
public function search(string $query): Collection
{
// Search articles
}
}Use Dependency Injection
class ArticleService
{
public function __construct(
private ArticleRepository $repository,
private ArticleValidator $validator,
private TagService $tagService,
private FileUploadService $fileUpload,
private CacheService $cache
) {}
public function create(array $data): Article
{
$this->validator->validate($data);
$article = $this->repository->create($data);
if (isset($data['tags'])) {
$this->tagService->syncTags($article, $data['tags']);
}
if (isset($data['image'])) {
$article->image = $this->fileUpload->upload($data['image']);
$this->repository->save($article);
}
$this->cache->invalidate('articles');
return $article;
}
}Error and Exception Handling
Create Custom Exceptions
<?php
namespace Flute\Modules\Blog\Exceptions;
class ArticleNotFoundException extends \Exception
{
public function __construct(int $id)
{
parent::__construct("Article with ID {$id} not found", 404);
}
}
class ArticleAccessDeniedException extends \Exception
{
public function __construct(int $id)
{
parent::__construct("Access denied to article {$id}", 403);
}
}
class ArticleValidationException extends \Exception
{
private array $errors;
public function __construct(array $errors)
{
$this->errors = $errors;
parent::__construct('Article validation failed', 422);
}
public function getErrors(): array
{
return $this->errors;
}
}Proper Exception Handling
class ArticleController extends BaseController
{
public function show($id)
{
try {
$article = $this->articleService->findOrFail($id);
if (!$article->published && !$article->canEdit()) {
throw new ArticleAccessDeniedException($id);
}
return response()->view('blog::show', compact('article'));
} catch (ArticleNotFoundException $e) {
return $this->error(__('article.not_found'), 404);
} catch (ArticleAccessDeniedException $e) {
return $this->error(__('access.denied'), 403);
} catch (\Exception $e) {
logs('blog')->error('Error showing article', [
'article_id' => $id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return $this->error(__('internal_error'), 500);
}
}
}Data Validation
Complex Validation
class ArticleValidator
{
public function validate(array $data, bool $isUpdate = false): array
{
$rules = [
'title' => 'required|string|min-str-len:3|max-str-len:255',
'content' => 'required|string|min-str-len:10',
'excerpt' => 'nullable|string|max-str-len:500',
'slug' => 'required|string|min-str-len:3|max-str-len:255|unique:blog_articles,slug',
'category_id' => 'nullable|integer|exists:blog_categories,id',
'tags' => 'nullable|array|max-arr-count:10',
'tags.*' => 'string|max-str-len:50',
'published' => 'boolean',
'premium' => 'boolean',
'price' => 'nullable|numeric|min:0|max:999.99',
];
if ($isUpdate) {
$rules['slug'] .= ',' . $data['id'];
}
$validator = validator();
$validated = $validator->validate($data, $rules);
if (!$validated) {
throw new ArticleValidationException($validator->getErrors()->toArray());
}
return $data;
}
public function validateComment(array $data): array
{
$rules = [
'content' => 'required|string|min-str-len:1|max-str-len:1000',
'parent_id' => 'nullable|integer|exists:blog_comments,id',
];
// For guests require name and email
if (!user()->isLoggedIn()) {
$rules['author_name'] = 'required|string|max-str-len:255';
$rules['author_email'] = 'required|email|max-str-len:255';
}
$validator = validator();
$validated = $validator->validate($data, $rules);
if (!$validated) {
throw new CommentValidationException($validator->getErrors()->toArray());
}
return $data;
}
}File Handling
Secure File Upload
class FileUploadService
{
private array $allowedTypes = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp'
];
private int $maxSize = 2 * 1024 * 1024; // 2MB
public function upload(UploadedFile $file, string $directory = 'uploads'): string
{
$this->validateFile($file);
$filename = $this->generateUniqueFilename($file);
$path = $directory . '/' . $filename;
$file->move(public_path($directory), $filename);
// Create thumbnails for images
if (str_starts_with($file->getMimeType(), 'image/')) {
$this->createThumbnails($path);
}
return $filename;
}
private function validateFile(UploadedFile $file): void
{
if (!$file->isValid()) {
throw new FileUploadException('Invalid file uploaded');
}
if ($file->getSize() > $this->maxSize) {
throw new FileUploadException('File too large');
}
if (!in_array($file->getMimeType(), $this->allowedTypes)) {
throw new FileUploadException('File type not allowed');
}
}
private function generateUniqueFilename(UploadedFile $file): string
{
$extension = $file->getClientOriginalExtension();
$basename = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
// Clean filename
$basename = preg_replace('/[^a-zA-Z0-9-_]/', '', $basename);
$basename = substr($basename, 0, 50); // Limit length
do {
$filename = $basename . '_' . time() . '_' . rand(1000, 9999) . '.' . $extension;
} while (file_exists(public_path('uploads/' . $filename)));
return $filename;
}
private function createThumbnails(string $path): void
{
$sizes = [
'small' => [300, 200],
'medium' => [600, 400],
'large' => [1200, 800],
];
foreach ($sizes as $name => $size) {
$thumbnailPath = str_replace('.', "_{$name}.", $path);
// Use Intervention Image or similar
$image = \Image::make(public_path($path));
$image->fit($size[0], $size[1]);
$image->save(public_path($thumbnailPath));
}
}
}Caching
Caching Strategies
Model Cache
class ArticleCacheService
{
public function getArticle(int $id): ?Article
{
$cacheKey = "article.{$id}";
return cache()->callback($cacheKey, function() use ($id) {
return rep(Article::class)->findByPK($id);
}, 3600);
}
public function invalidateArticle(int $id): void
{
cache()->delete("article.{$id}");
// Invalidate related caches
cache()->delete('articles.popular');
cache()->delete('articles.recent');
}
public function getPopularArticles(): Collection
{
return cache()->callback('articles.popular', function() {
return rep(Article::class)->select()
->where('published', true)
->orderBy('views', 'DESC')
->limit(10)
->fetchAll();
}, 1800); // 30 minutes
}
}View Cache
class ViewCacheService
{
public function renderArticleCard(Article $article): string
{
$cacheKey = "article_card.{$article->id}." . md5($article->updated_at);
return cache()->callback($cacheKey, function() use ($article) {
return view('blog::components.article-card', compact('article'))->render();
}, 3600);
}
public function invalidateArticleCard(int $articleId): void
{
// Delete all cache versions for article card
$pattern = "article_card.{$articleId}.*";
$keys = cache()->getKeys($pattern);
foreach ($keys as $key) {
cache()->delete($key);
}
}
}Events and Listeners
Correct Event Usage
class ArticleService
{
public function publish(Article $article): void
{
$article->published = true;
$article->publish_date = new \DateTime();
transaction($article)->run();
// Dispatch event
events()->dispatch(new ArticlePublished($article));
}
}
class ArticlePublished
{
public const NAME = 'article.published';
public Article $article;
public function __construct(Article $article)
{
$this->article = $article;
}
}
class SendNotificationListener
{
public function handle(ArticlePublished $event): void
{
$article = $event->article;
// Send notifications to subscribers
$this->sendToSubscribers($article);
// Notify author
$this->notifyAuthor($article);
// Logging
logs('blog')->info('Article published', [
'article_id' => $article->id,
'author_id' => $article->author->id
]);
}
}Localization
Translation Organization
Grouping Translations
// Resources/lang/en/messages.php
return [
'article' => [
'created' => 'Article created successfully',
'updated' => 'Article updated successfully',
'deleted' => 'Article deleted successfully',
'not_found' => 'Article not found',
],
'comment' => [
'added' => 'Comment added successfully',
'deleted' => 'Comment deleted successfully',
],
'validation' => [
'title_required' => 'Title is required',
'content_min' => 'Content must be at least :min characters',
],
];Using Parameters
// In code
$message = __('article.created');
// With parameters
$message = __('pagination.showing', [
'from' => $paginator->firstItem(),
'to' => $paginator->lastItem(),
'total' => $paginator->total()
]);
// With parameter in translation
$message = __('comment.replies_count', ['count' => $count]);Security
Access Permission Checks
class ArticlePolicy
{
public function view(User $user, Article $article): bool
{
return $article->published || $user->id === $article->author->id || $user->can('manage_articles');
}
public function update(User $user, Article $article): bool
{
return $user->id === $article->author->id || $user->can('manage_articles');
}
public function delete(User $user, Article $article): bool
{
return $user->id === $article->author->id || $user->can('manage_articles');
}
public function publish(User $user, Article $article): bool
{
return $user->can('publish_articles');
}
}CSRF Protection
class ArticleController extends BaseController
{
public function store()
{
// CSRF token is checked automatically via middleware
// But can be checked manually
if (!$this->isCsrfValid()) {
return $this->error('CSRF token invalid', 403);
}
// Continue processing
}
}Sanitizing User Input
class ContentSanitizer
{
public function sanitize(string $content): string
{
// Remove potentially dangerous tags
$allowedTags = '<p><br><strong><em><a><ul><ol><li><blockquote><code><pre>';
$content = strip_tags($content, $allowedTags);
// Convert dangerous characters
$content = htmlspecialchars($content, ENT_QUOTES, 'UTF-8');
return $content;
}
public function sanitizeTitle(string $title): string
{
// Text only, no HTML
$title = strip_tags($title);
// Limit length
$title = substr($title, 0, 255);
return trim($title);
}
}Testing
Unit Tests
class ArticleServiceTest extends TestCase
{
private ArticleService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = app(ArticleService::class);
}
public function testCreateArticle()
{
$data = [
'title' => 'Test Article',
'content' => 'Test content with enough length',
'published' => true
];
$result = $this->service->create($data);
$this->assertInstanceOf(Article::class, $result);
$this->assertEquals('Test Article', $result->title);
$this->assertTrue($result->published);
}
public function testGetArticleBySlug()
{
// Create article
$article = $this->service->create([
'title' => 'Test Article',
'content' => 'Test content'
]);
// Get by slug
$found = $this->service->getArticleBySlug($article->slug);
$this->assertNotNull($found);
$this->assertEquals($article->id, $found->id);
}
}Flute CMS uses PHPUnit for testing. Create tests in the tests/ directory of your module.
Documentation
PHPDoc Comments
/**
* Article service handles all article-related business logic
*
* @package Flute\Modules\Blog\Services
*/
class ArticleService
{
/**
* Create a new article
*
* @param array $data Article data
* @return Article Created article instance
* @throws ArticleValidationException When validation fails
* @throws ArticleCreationException When creation fails
*/
public function create(array $data): Article
{
// Implementation
}
/**
* Get article by ID
*
* @param int $id Article ID
* @return Article|null Article instance or null if not found
*/
public function find(int $id): ?Article
{
// Implementation
}
}Performance
Optimization Checklist
Use Indexes
Create indexes for frequently used fields in queries.
Load Relations Lazily
Use eager loading (load()) only when necessary.
Cache Results
Cache results of heavy queries.
Use Pagination
Always use pagination for large datasets.
Summary
Following these practices, you will create high-quality, maintainable, and performant modules for Flute CMS:
- Code Organization — proper directory structure and separation of concerns
- Database — efficient queries and proper ORM usage
- Controllers — thin controllers with logic moved to services
- Validation — comprehensive data validation
- Security — access control and input sanitization
- Caching — smart cache usage
- Testing — code coverage with tests
- Documentation — PHPDoc comments and documentation