Skip to Content
ModulesBest Practices

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

Controllers/Admin/Management/UserController.php Services/Handlers/Processors/DataProcessor.php

Configuration

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.

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.

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:

$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

$articles = rep(Article::class)->findAll(); // All records

Use Transactions Correctly

// 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.

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:

  1. Code Organization — proper directory structure and separation of concerns
  2. Database — efficient queries and proper ORM usage
  3. Controllers — thin controllers with logic moved to services
  4. Validation — comprehensive data validation
  5. Security — access control and input sanitization
  6. Caching — smart cache usage
  7. Testing — code coverage with tests
  8. Documentation — PHPDoc comments and documentation