Лучшие практики разработки модулей
В этом разделе собраны рекомендации по разработке качественных модулей для Flute CMS. Следование этим практикам обеспечит надежность, поддерживаемость и производительность вашего кода.
В проекте нет хелпера env(). Конфигурацию задавайте напрямую в Resources/config/*.php и при необходимости переопределяйте через config-dev/.
Архитектура и организация кода
Структура модуля
Правильная организация директорий
- module.json
- YourController.php
Избегайте чрезмерной вложенности
❌ Плохо
Controllers/Admin/Management/UserController.php
Services/Handlers/Processors/DataProcessor.phpКонфигурация
Держите конфиги простыми: без env-хелпера; используйте явные значения и опционально config-dev/ для локальных override.
- Не дублируйте значения, которые нигде не читаются
- Проверяйте, что ключи из конфигов реально используются в коде
- Используйте разумные значения по умолчанию
Принципы SOLID
Single Responsibility Principle (SRP)
Каждый класс должен иметь одну ответственность.
❌ Плохо
class ArticleController
{
public function store()
{
// Валидация
// Сохранение в БД
// Отправка email
// Логирование
// Кеширование
}
}Dependency Inversion Principle (DIP)
Зависимости должны инжектироваться, а не создаваться внутри классов.
❌ Плохо
class ArticleService
{
public function create()
{
$validator = new ArticleValidator(); // Создание зависимости
$repository = new ArticleRepository(); // Создание зависимости
}
}Работа с базой данных
ActiveRecord vs rep()
Предпочитайте ActiveRecord-API сущностей (Article::findByPK(1), Article::query()->where(...)->fetchAll()).
rep() оставлен для обратной совместимости; не используйте его в новых модулях, если есть доступ к ActiveRecord.
Cycle ORM best practices
Используйте явную загрузку связей
Избегайте проблемы N+1 запросов:
❌ Плохо
$articles = rep(Article::class)->findAll();
// N+1 проблема при обращении к $article->author
foreach ($articles as $article) {
echo $article->author->name; // Дополнительный запрос
}Используйте пейджинг для больших списков
❌ Плохо
$articles = rep(Article::class)->findAll(); // Все записиПравильно используйте транзакции
❌ Плохо
// Отдельные транзакции
$article = new Article();
transaction($article)->run();
$tag = new Tag();
transaction($tag)->run();Оптимизация запросов
Используйте select для ограничения полей
// Загружать только нужные поля
$articles = Article::query()
->load('author', ['fields' => ['id', 'name']]) // Только id и name
->fetchAll();Кешируйте тяжелые запросы
$popularArticles = cache()->callback(
'blog.popular_articles',
function() {
return Article::query()
->where('published', true)
->orderBy('views', 'DESC')
->limit(10)
->fetchAll();
},
3600 // Кеш на 1 час
);Контроллеры и маршруты
Thin Controllers, Fat Models
Контроллеры должны быть тонкими — бизнес-логику выносите в сервисы.
❌ Плохо
class ArticleController extends BaseController
{
public function store()
{
$data = request()->all();
// Вся логика в контроллере
$article = new Article();
$article->title = $data['title'];
$article->content = $data['content'];
$article->slug = \Illuminate\Support\Str::slug($data['title']);
// ... много кода
transaction($article)->run();
return redirect()->back();
}
}Выделение валидации в отдельный класс
<?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();
}
}Использование в контроллере:
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]));
}
}Сервисы и бизнес-логика
Правильная организация сервисов
Разделяйте сервисы по ответственности
// Основной сервис для CRUD операций
class ArticleService
{
public function create(array $data): Article
{
// Создание статьи
}
public function update(int $id, array $data): Article
{
// Обновление статьи
}
public function publish(int $id): Article
{
// Публикация статьи
}
}
// Отдельный сервис для уведомлений
class ArticleNotificationService
{
public function sendNewArticleNotification(Article $article): void
{
// Отправка уведомлений
}
}
// Отдельный сервис для поиска
class ArticleSearchService
{
public function search(string $query): Collection
{
// Поиск статей
}
}Используйте 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;
}
}Обработка ошибок и исключений
Создавайте пользовательские исключения
<?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;
}
}Правильная обработка исключений
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);
}
}
}Валидация данных
Комплексная валидация
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',
];
// Для гостей требуем имя и 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;
}
}Работа с файлами
Безопасная загрузка файлов
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);
// Создание миниатюр для изображений
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);
// Очистка имени файла
$basename = preg_replace('/[^a-zA-Z0-9-_]/', '', $basename);
$basename = substr($basename, 0, 50); // Ограничение длины
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);
// Используйте Intervention Image или аналог
$image = \Image::make(public_path($path));
$image->fit($size[0], $size[1]);
$image->save(public_path($thumbnailPath));
}
}
}Кеширование
Стратегии кеширования
Кеш моделей
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}");
// Инвалидация связанных кешей
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 минут
}
}Кеш представлений
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
{
// Удаление всех версий кеша для карточки статьи
$pattern = "article_card.{$articleId}.*";
$keys = cache()->getKeys($pattern);
foreach ($keys as $key) {
cache()->delete($key);
}
}
}События и слушатели
Правильное использование событий
class ArticleService
{
public function publish(Article $article): void
{
$article->published = true;
$article->publish_date = new \DateTime();
transaction($article)->run();
// Отправка события
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;
// Отправка уведомлений подписчикам
$this->sendToSubscribers($article);
// Уведомление автора
$this->notifyAuthor($article);
// Логирование
logs('blog')->info('Article published', [
'article_id' => $article->id,
'author_id' => $article->author->id
]);
}
}Локализация
Организация переводов
Группировка переводов
// 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',
],
];Использование параметров
// В коде
$message = __('article.created');
// С параметрами
$message = __('pagination.showing', [
'from' => $paginator->firstItem(),
'to' => $paginator->lastItem(),
'total' => $paginator->total()
]);
// С параметром в переводе
$message = __('comment.replies_count', ['count' => $count]);Безопасность
Проверка прав доступа
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
class ArticleController extends BaseController
{
public function store()
{
// CSRF токен проверяется автоматически через middleware
// Но можно проверить вручную
if (!$this->isCsrfValid()) {
return $this->error('CSRF token invalid', 403);
}
// Продолжение обработки
}
}Очистка пользовательского ввода
class ContentSanitizer
{
public function sanitize(string $content): string
{
// Удаление потенциально опасных тегов
$allowedTags = '<p><br><strong><em><a><ul><ol><li><blockquote><code><pre>';
$content = strip_tags($content, $allowedTags);
// Преобразование опасных символов
$content = htmlspecialchars($content, ENT_QUOTES, 'UTF-8');
return $content;
}
public function sanitizeTitle(string $title): string
{
// Только текст, без HTML
$title = strip_tags($title);
// Ограничение длины
$title = substr($title, 0, 255);
return trim($title);
}
}Тестирование
Unit тесты
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()
{
// Создаём статью
$article = $this->service->create([
'title' => 'Test Article',
'content' => 'Test content'
]);
// Получаем по slug
$found = $this->service->getArticleBySlug($article->slug);
$this->assertNotNull($found);
$this->assertEquals($article->id, $found->id);
}
}Flute CMS использует PHPUnit для тестирования. Создавайте тесты в директории tests/ вашего модуля.
Документирование
PHPDoc комментарии
/**
* 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
}
}Производительность
Чеклист оптимизации
Используйте индексы
Создавайте индексы для часто используемых полей в запросах.
Загружайте связи лениво
Используйте eager loading (load()) только при необходимости.
Кешируйте результаты
Кешируйте результаты тяжелых запросов.
Используйте пейджинг
Для больших наборов данных всегда используйте пагинацию.
Итоги
Следуя этим практикам, вы создадите качественные, поддерживаемые и производительные модули для Flute CMS:
- Организация кода — правильная структура директорий и разделение ответственности
- База данных — эффективные запросы и правильное использование ORM
- Контроллеры — тонкие контроллеры с выносом логики в сервисы
- Валидация — комплексная проверка данных
- Безопасность — проверка прав доступа и санитизация ввода
- Кеширование — грамотное использование кеша
- Тестирование — покрытие кода тестами
- Документирование — PHPDoc комментарии и документация