Skip to Content
Разработка модулейБаза данных и ORM

База данных и Cycle ORM

Flute CMS использует Cycle ORM с паттерном ActiveRecord для работы с базой данных. Каждый объект представляет запись в БД и содержит как данные, так и методы для работы с ними.

ActiveRecord — рекомендуемый подход в Flute CMS. Старый способ через rep() поддерживается для обратной совместимости, но для новых модулей используйте методы ActiveRecord.

Ключевые особенности

  • Автоматическая компиляция схемы из аннотированных сущностей
  • Кеширование схемы в storage/app/orm_schema.php
  • Хелперы: orm(), rep(), transaction()
  • ActiveRecord как основной API

Создание сущностей

Структура файлов

Сущности модуля располагаются в папке database/Entities:

        • Article.php
        • Category.php
        • Tag.php
        • Comment.php

Чтобы сущности загрузились, провайдер должен вызвать loadEntities() (автоматически выполняется в bootstrapModule()).

Базовая сущность

Создадим сущность статьи блога с аннотациями Cycle ORM:

<?php namespace Flute\Modules\Blog\Database\Entities; use Cycle\ActiveRecord\ActiveRecord; use Cycle\Annotated\Annotation\Column; use Cycle\Annotated\Annotation\Entity; use Cycle\Annotated\Annotation\Table\Index; use Cycle\ORM\Entity\Behavior; // Указываем таблицу и уникальный role для ORM #[Entity(table: 'blog_articles', role: 'blog_article')] // Создаём индексы для часто используемых полей #[Index(columns: ['slug'], unique: true)] #[Index(columns: ['status', 'created_at'])] // Автоматическое заполнение дат создания и обновления #[Behavior\CreatedAt(field: 'createdAt', column: 'created_at')] #[Behavior\UpdatedAt(field: 'updatedAt', column: 'updated_at')] class Article extends ActiveRecord { // Первичный ключ — автоинкремент #[Column(type: 'primary')] public int $id; #[Column(type: 'string')] public string $title; // Уникальный slug для URL #[Column(type: 'string')] public string $slug; // Nullable поля указываем явно #[Column(type: 'text', nullable: true)] public ?string $excerpt = null; #[Column(type: 'text')] public string $content; // Enum с дефолтным значением #[Column(type: 'enum(draft,published,scheduled)', default: 'draft')] public string $status = 'draft'; #[Column(type: 'datetime', nullable: true)] public ?\DateTimeImmutable $publishedAt = null; #[Column(type: 'integer', default: 0)] public int $views = 0; // Автоматически заполняемые поля #[Column(type: 'datetime')] public \DateTimeImmutable $createdAt; #[Column(type: 'datetime', nullable: true)] public ?\DateTimeImmutable $updatedAt = null; }

Пояснение к аннотациям:

АннотацияНазначение
#[Entity]Объявляет класс как сущность ORM, указывает таблицу и role
#[Column]Описывает поле таблицы: тип, nullable, default
#[Index]Создаёт индекс для ускорения поиска
#[Behavior\CreatedAt]Автоматически заполняет дату создания
#[Behavior\UpdatedAt]Автоматически обновляет дату изменения

Типы колонок

Cycle ORM поддерживает различные типы данных:

ТипОписаниеПример
primaryАвтоинкремент ID#[Column(type: 'primary')]
stringСтрока (VARCHAR)#[Column(type: 'string')]
textДлинный текст#[Column(type: 'text')]
integerЦелое число#[Column(type: 'integer')]
booleanЛогическое значение#[Column(type: 'boolean')]
datetimeДата и время#[Column(type: 'datetime')]
floatЧисло с плавающей точкой#[Column(type: 'float')]
enumПеречисление#[Column(type: 'enum(a,b,c)')]
jsonJSON данные#[Column(type: 'json')]

Наследование сущностей (Single Table Inheritance)

Когда несколько типов контента хранятся в одной таблице, используйте Single Table Inheritance:

<?php namespace Flute\Modules\Blog\Database\Entities; use Cycle\Annotated\Annotation\Column; use Cycle\Annotated\Annotation\Entity; use Cycle\Annotated\Annotation\Inheritance\DiscriminatorColumn; use Cycle\Annotated\Annotation\Inheritance\SingleTable; // Базовый абстрактный класс #[Entity] #[SingleTable] // Все наследники хранятся в одной таблице #[DiscriminatorColumn(name: 'type')] // Поле для определения типа abstract class Content { #[Column(type: 'primary')] public int $id; #[Column(type: 'string')] public string $title; #[Column(type: 'text')] public string $content; #[Column(type: 'string')] public string $status = 'draft'; #[Column(type: 'datetime')] public \DateTime $created_at; #[Column(type: 'datetime', nullable: true)] public ?\DateTime $updated_at; // Абстрактный метод — каждый наследник определяет свой тип abstract public function getType(): string; } // Статья — один тип контента #[Entity] class Article extends Content { #[Column(type: 'string', nullable: true)] public ?string $excerpt; public function getType(): string { return 'article'; } } // Страница — другой тип контента #[Entity] class Page extends Content { #[Column(type: 'string', nullable: true)] public ?string $slug; public function getType(): string { return 'page'; } }

Когда использовать Single Table Inheritance:

  • Типы имеют много общих полей
  • Нужны полиморфные запросы (получить все типы контента)
  • Количество специфичных полей невелико

Использование:

<?php // Получить все типы контента $allContent = Content::findAll(); // Получить только статьи $articles = Article::findAll(); // Получить только страницы $pages = Page::findAll();

Связи между сущностями

BelongsTo — Принадлежит одному

Статья принадлежит категории и автору:

<?php use Cycle\Annotated\Annotation\Relation\BelongsTo; use Flute\Core\Database\Entities\User; class Article extends ActiveRecord { // ... другие поля // Статья принадлежит одному автору (может быть null) #[BelongsTo(target: User::class, nullable: true)] public ?User $author = null; // Статья принадлежит одной категории #[BelongsTo(target: Category::class, nullable: true)] public ?Category $category = null; }

HasMany — Имеет много

Категория имеет много статей:

<?php use Cycle\Annotated\Annotation\Relation\HasMany; #[Entity(table: 'blog_categories')] class Category extends ActiveRecord { #[Column(type: 'primary')] public int $id; #[Column(type: 'string')] public string $name; #[Column(type: 'string')] public string $slug; // Одна категория содержит много статей #[HasMany(target: Article::class)] public array $articles = []; }

HasOne — Имеет один

Пользователь имеет один профиль:

<?php use Cycle\Annotated\Annotation\Relation\HasOne; #[Entity(table: 'users')] class User extends ActiveRecord { #[Column(type: 'primary')] public int $id; // Один пользователь имеет один профиль #[HasOne(target: UserProfile::class)] public ?UserProfile $profile = null; }

ManyToMany — Многие ко многим

Статья может иметь много тегов, тег может принадлежать многим статьям:

<?php use Cycle\Annotated\Annotation\Relation\ManyToMany; // Промежуточная таблица для связи #[Entity(table: 'blog_article_tags')] class ArticleTag extends ActiveRecord { #[Column(type: 'primary')] public int $id; #[BelongsTo(target: Article::class)] public Article $article; #[BelongsTo(target: Tag::class)] public Tag $tag; } // В сущности Article #[ManyToMany(target: Tag::class, through: ArticleTag::class)] public array $tags = []; // В сущности Tag #[ManyToMany(target: Article::class, through: ArticleTag::class)] public array $articles = [];

Работа с данными

Поиск записей

ActiveRecord предоставляет статические методы для поиска:

<?php use Flute\Modules\Blog\Database\Entities\Article; // Поиск по первичному ключу $article = Article::findByPK(1); // Поиск одной записи по условию $article = Article::findOne(['slug' => 'my-article']); // Получение всех записей $articles = Article::findAll(); // Получение с условиями $published = Article::findAll(['status' => 'published']);

Query Builder

Для сложных запросов используйте query():

<?php // Базовый запрос с условиями $articles = Article::query() ->where('status', 'published') ->where('publishedAt', '>=', new \DateTimeImmutable('-1 month')) ->orderBy('views', 'DESC') ->limit(10) ->fetchAll();

Доступные методы Query Builder:

<?php $query = Article::query() // Условия WHERE ->where('field', 'value') // Равенство ->where('field', '>', 100) // С оператором ->where('field', 'LIKE', '%text%') // Поиск по шаблону // Дополнительные условия ->whereIn('status', ['draft', 'published']) ->whereNull('deletedAt') ->whereNotNull('publishedAt') ->orWhere('featured', true) // Сортировка и лимиты ->orderBy('createdAt', 'DESC') ->limit(20) ->offset(40); // Выполнение запроса $results = $query->fetchAll(); // Все результаты $result = $query->fetchOne(); // Одна запись $count = $query->count(); // Количество $exists = $query->exists(); // Существуют ли записи

Группировка условий

Для сложных условий используйте замыкания:

<?php $articles = Article::query() ->where('status', 'published') ->where(function($q) { // Условия внутри скобок объединяются OR $q->where('featured', true) ->orWhere('views', '>', 1000); }) ->fetchAll(); // SQL: WHERE status = 'published' AND (featured = true OR views > 1000)

Загрузка связей

Используйте load() для предварительной загрузки связей (избегает N+1 проблемы):

<?php // Загрузка статьи со всеми связями $article = Article::query() ->where('id', $id) ->load('author') // Загружаем автора ->load('category') // Загружаем категорию ->load('tags') // Загружаем теги ->load('comments') // Загружаем комментарии ->fetchOne(); // Теперь можно обращаться без дополнительных запросов echo $article->author->name; echo $article->category->name;

Сервис для загрузки данных

Создайте отдельный сервис для работы с загрузкой связей:

<?php namespace Flute\Modules\Blog\Services; use Flute\Modules\Blog\Database\Entities\Article; class ArticleLoaderService { /** * Получение статьи со всеми связями для детальной страницы */ public function getArticleWithRelations(int $id): ?Article { return Article::query() ->where('id', $id) ->load('author') ->load('category') ->load('tags') ->load('comments') ->fetchOne(); } /** * Получение статей для списка с нужными связями */ public function getArticlesForList(int $page = 1, int $perPage = 10) { return Article::query() ->where('status', 'published') ->load('author') ->load('category') ->load('tags') ->orderBy('publishedAt', 'DESC') ->paginate($perPage, $page); } /** * Получение статьи по slug с проверкой публикации */ public function getArticleBySlugWithRelations(string $slug): ?Article { return Article::query() ->where('slug', $slug) ->where('status', 'published') ->load('author') ->load('category') ->load('tags') ->load('comments') ->fetchOne(); } /** * Получение статей конкретного автора */ public function getArticlesByAuthor(int $authorId, int $limit = 10): array { return Article::query() ->where('author.id', $authorId) ->where('status', 'published') ->load('category') ->load('tags') ->orderBy('publishedAt', 'DESC') ->limit($limit) ->fetchAll(); } }

Расширенный Query Builder

Query Builder поддерживает множество методов для построения сложных запросов:

<?php namespace Flute\Modules\Blog\Services; use Flute\Modules\Blog\Database\Entities\Article; class AdvancedQueryService { /** * Демонстрация всех возможностей Query Builder */ public function queryBuilderExamples() { // 1. Различные условия WHERE $query = Article::query() ->where('status', 'published') // Простое равенство ->where('views', '>', 100) // С оператором сравнения ->where('title', 'LIKE', '%важно%') // LIKE поиск ->whereIn('category.id', [1, 2, 3]) // IN условие ->whereNotIn('status', ['deleted']) // NOT IN ->whereNull('deletedAt') // IS NULL ->whereNotNull('publishedAt') // IS NOT NULL ->whereBetween('createdAt', [$start, $end]) // BETWEEN ->whereNotBetween('views', [0, 10]); // NOT BETWEEN // 2. Логические группировки (скобки в SQL) $query = Article::query() ->where('status', 'published') ->where(function($q) { // Эти условия объединяются внутри скобок $q->where('featured', true) ->orWhere('views', '>', 1000); }) ->orWhere('category.slug', 'urgent'); // SQL: WHERE status = 'published' AND (featured = true OR views > 1000) OR category.slug = 'urgent' // 3. Сортировка $query = Article::query() ->orderBy('publishedAt', 'DESC') // По одному полю ->orderBy('views', 'DESC') // Дополнительная сортировка ->orderByRaw('RAND()'); // Случайный порядок // 4. Группировка и агрегация $stats = Article::query() ->select([ 'category.name', 'COUNT(*) as count', 'AVG(views) as avg_views', 'SUM(views) as total_views', 'MAX(views) as max_views' ]) ->groupBy('category.id') ->having('COUNT(*)', '>', 5) ->fetchAll(); // 5. Соединения таблиц $articles = Article::query() ->join('categories', 'categories.id = articles.category_id') ->where('categories.active', true) ->fetchAll(); // 6. Подзапросы $popularCategories = Article::query() ->select('category_id') ->where('views', '>', 1000) ->groupBy('category_id'); $articles = Article::query() ->whereIn('category_id', $popularCategories) ->fetchAll(); // 7. Raw-выражения для сложных случаев $monthlyStats = Article::query() ->selectRaw('DATE_FORMAT(publishedAt, "%Y-%m") as month, COUNT(*) as count') ->where('status', 'published') ->groupByRaw('DATE_FORMAT(publishedAt, "%Y-%m")') ->orderByRaw('month DESC') ->limit(12) ->fetchAll(); } /** * Динамический поиск с множеством фильтров */ public function advancedSearch(array $filters) { $query = Article::query()->where('status', 'published'); // Текстовый поиск по нескольким полям if (!empty($filters['search'])) { $search = $filters['search']; $query->where(function($q) use ($search) { $q->where('title', 'LIKE', "%{$search}%") ->orWhere('content', 'LIKE', "%{$search}%") ->orWhere('excerpt', 'LIKE', "%{$search}%"); }); } // Фильтр по нескольким категориям if (!empty($filters['category_ids'])) { $query->whereIn('category.id', $filters['category_ids']); } // Фильтр по нескольким авторам if (!empty($filters['author_ids'])) { $query->whereIn('author.id', $filters['author_ids']); } // Диапазон дат if (!empty($filters['date_from'])) { $query->where('publishedAt', '>=', new \DateTimeImmutable($filters['date_from'])); } if (!empty($filters['date_to'])) { $query->where('publishedAt', '<=', new \DateTimeImmutable($filters['date_to'])); } // Минимальное количество просмотров if (!empty($filters['min_views'])) { $query->where('views', '>=', (int) $filters['min_views']); } // Фильтр по тегам if (!empty($filters['tags'])) { $query->whereIn('tags.slug', $filters['tags']); } // Безопасная сортировка (только разрешённые поля) $allowedSorts = ['publishedAt', 'views', 'title', 'createdAt']; $sortBy = $filters['sort_by'] ?? 'publishedAt'; $sortOrder = $filters['sort_order'] ?? 'DESC'; if (in_array($sortBy, $allowedSorts)) { $query->orderBy($sortBy, $sortOrder); } return $query; } /** * Получение статистики по статьям */ public function getArticleStatistics(): array { // Статистика по категориям $categoryStats = Article::query() ->select([ 'category.name', 'category.slug', 'COUNT(*) as total_articles', 'SUM(views) as total_views', 'AVG(views) as average_views', 'MAX(views) as max_views' ]) ->where('status', 'published') ->groupBy('category.id') ->orderBy('total_articles', 'DESC') ->fetchAll(); // Статистика по месяцам за год $monthlyStats = Article::query() ->selectRaw('DATE_FORMAT(publishedAt, "%Y-%m") as month, COUNT(*) as count') ->where('status', 'published') ->groupByRaw('DATE_FORMAT(publishedAt, "%Y-%m")') ->orderByRaw('month DESC') ->limit(12) ->fetchAll(); // Топ авторов $topAuthors = Article::query() ->select([ 'author.id', 'author.name', 'COUNT(*) as articles_count', 'SUM(views) as total_views' ]) ->where('status', 'published') ->groupBy('author.id') ->orderBy('articles_count', 'DESC') ->limit(10) ->fetchAll(); return [ 'categories' => $categoryStats, 'monthly' => $monthlyStats, 'top_authors' => $topAuthors ]; } }

Методы выполнения запросов:

<?php $query = Article::query()->where('status', 'published'); // Получение результатов $articles = $query->fetchAll(); // Все результаты как массив $article = $query->fetchOne(); // Первый результат или null $count = $query->count(); // Количество записей (без LIMIT) $exists = $query->exists(); // Проверка существования // Пагинация $paginated = $query->paginate($perPage, $page); // Итерация для больших наборов данных (экономит память) foreach ($query->getIterator() as $article) { // Обработка каждой записи по отдельности }

Сервис запросов с фильтрами

Создайте отдельный сервис для сложных запросов с фильтрами:

<?php namespace Flute\Modules\Blog\Services; use Flute\Modules\Blog\Database\Entities\Article; class ArticleQueryService { /** * Получение статей с множеством фильтров */ public function getArticlesWithFilters(array $filters = []) { $query = Article::query(); // Фильтр по статусу (по умолчанию — опубликованные) if (isset($filters['status'])) { $query->where('status', $filters['status']); } else { $query->where('status', 'published'); } // Фильтр по категории if (isset($filters['category_id'])) { $query->where('category.id', $filters['category_id']); } // Фильтр по автору if (isset($filters['author_id'])) { $query->where('author.id', $filters['author_id']); } // Полнотекстовый поиск по нескольким полям if (isset($filters['search'])) { $search = $filters['search']; $query->where(function($q) use ($search) { $q->where('title', 'LIKE', "%{$search}%") ->orWhere('content', 'LIKE', "%{$search}%") ->orWhere('excerpt', 'LIKE', "%{$search}%"); }); } // Фильтр по диапазону дат публикации if (isset($filters['date_from'])) { $query->where('publishedAt', '>=', $filters['date_from']); } if (isset($filters['date_to'])) { $query->where('publishedAt', '<=', $filters['date_to']); } // Фильтр по тегам (статьи с любым из указанных тегов) if (isset($filters['tags']) && !empty($filters['tags'])) { $query->whereIn('tags.slug', $filters['tags']); } // Сортировка $sortBy = $filters['sort_by'] ?? 'publishedAt'; $sortOrder = $filters['sort_order'] ?? 'DESC'; $query->orderBy($sortBy, $sortOrder); return $query; } /** * Получение статей по категории */ public function getArticlesByCategory(string $categorySlug, ?int $limit = null): array { $query = Article::query() ->where('status', 'published') ->where('category.slug', $categorySlug) ->orderBy('publishedAt', 'DESC'); if ($limit) { $query->limit($limit); } return $query->fetchAll(); } /** * Получение запланированных статей для автопубликации */ public function getScheduledArticles(): array { $now = new \DateTimeImmutable(); return Article::query() ->where('status', 'scheduled') ->where('publishedAt', '<=', $now) ->fetchAll(); } /** * Получение статей по тегу */ public function getArticlesByTag(string $tagSlug, int $limit = 10): array { return Article::query() ->where('status', 'published') ->where('tags.slug', $tagSlug) ->orderBy('publishedAt', 'DESC') ->limit($limit) ->fetchAll(); } /** * Получение популярных статей за период */ public function getPopularArticles(?int $categoryId = null, int $days = 30): array { $query = Article::query() ->where('status', 'published') ->where('publishedAt', '>=', new \DateTimeImmutable("-{$days} days")) ->orderBy('views', 'DESC'); if ($categoryId) { $query->where('category.id', $categoryId); } return $query->limit(10)->fetchAll(); } /** * Получение статей с несколькими тегами (должны быть ВСЕ указанные теги) */ public function getArticlesWithAllTags(array $tagSlugs): array { return Article::query() ->where('status', 'published') ->whereIn('tags.slug', $tagSlugs) ->groupBy('id') ->having('COUNT(DISTINCT tags.id)', '>=', count($tagSlugs)) ->fetchAll(); } /** * Получение похожих статей */ public function getSimilarArticles(Article $article, int $limit = 5): array { return Article::query() ->where('status', 'published') ->where('id', '!=', $article->id) ->where(function($q) use ($article) { // Похожие по категории if ($article->category) { $q->where('category.id', $article->category->id); } // Или по тегам if (!empty($article->tags)) { $tagIds = array_map(fn($tag) => $tag->id, $article->tags); $q->orWhereIn('tags.id', $tagIds); } }) ->orderBy('publishedAt', 'DESC') ->limit($limit) ->fetchAll(); } }

Создание и обновление

Создание записи

<?php // Создаём новый объект $article = new Article(); // Заполняем поля $article->title = 'Новая статья'; $article->slug = 'novaya-statya'; $article->content = 'Текст статьи...'; $article->status = 'draft'; // Устанавливаем связи if (user()->isLoggedIn()) { $article->author = user()->getCurrentUser(); } $category = Category::findByPK($categoryId); if ($category) { $article->category = $category; } // Сохраняем в базу данных $article->save(); // После сохранения доступен ID echo $article->id; // Например: 42

Обновление записи

<?php // Находим запись $article = Article::findByPK($id); if (!$article) { throw new \Exception('Статья не найдена'); } // Изменяем поля $article->title = 'Обновлённый заголовок'; $article->status = 'published'; $article->publishedAt = new \DateTimeImmutable(); // Сохраняем изменения $article->save();

Бизнес-логика в сущности

Добавляйте методы бизнес-логики прямо в сущность:

<?php class Article extends ActiveRecord { // ... поля /** * Проверка, опубликована ли статья */ public function isPublished(): bool { return $this->status === 'published'; } /** * Публикация статьи */ public function publish(): void { $this->status = 'published'; $this->publishedAt = new \DateTimeImmutable(); $this->save(); } /** * Снятие с публикации */ public function unpublish(): void { $this->status = 'draft'; $this->publishedAt = null; $this->save(); } /** * Увеличение счётчика просмотров */ public function incrementViews(): void { $this->views++; $this->save(); } /** * Получение URL статьи */ public function getUrl(): string { return route('blog.articles.show', ['slug' => $this->slug]); } }

Удаление данных

Простое удаление

<?php $article = Article::findByPK($id); if ($article) { // Удаление через transaction helper transaction($article, 'delete')->run(); }

Мягкое удаление (Soft Delete)

Вместо физического удаления помечаем запись как удалённую:

<?php class Article extends ActiveRecord { #[Column(type: 'datetime', nullable: true)] public ?\DateTimeImmutable $deletedAt = null; /** * Мягкое удаление */ public function softDelete(): void { $this->deletedAt = new \DateTimeImmutable(); $this->save(); } /** * Восстановление */ public function restore(): void { $this->deletedAt = null; $this->save(); } /** * Проверка удаления */ public function isDeleted(): bool { return $this->deletedAt !== null; } }

При выборке исключаем удалённые записи:

<?php $articles = Article::query() ->whereNull('deletedAt') ->fetchAll();

Транзакции

Используйте транзакции для атомарных операций — если одна из операций не удаётся, все изменения откатываются.

Базовая транзакция

<?php $transaction = transaction(); try { // Создаём статью $article = new Article(); $article->title = $data['title']; $article->content = $data['content']; $transaction->persist($article); // Создаём теги foreach ($data['tags'] as $tagName) { $tag = Tag::findOne(['name' => $tagName]); if (!$tag) { $tag = new Tag(); $tag->name = $tagName; $tag->slug = slugify($tagName); $transaction->persist($tag); } // Создаём связь $articleTag = new ArticleTag(); $articleTag->article = $article; $articleTag->tag = $tag; $transaction->persist($articleTag); } // Применяем все изменения атомарно $transaction->run(); return $article; } catch (\Exception $e) { // Откатываем ВСЕ изменения при любой ошибке $transaction->rollback(); throw $e; }

Комплексные транзакции с уведомлениями

Пример публикации статьи с отправкой уведомлений подписчикам:

<?php namespace Flute\Modules\Blog\Services; use Flute\Modules\Blog\Database\Entities\Article; use Flute\Modules\Blog\Database\Entities\Notification; class BlogTransactionService { /** * Публикация статьи с уведомлением подписчиков */ public function publishArticle(int $articleId): Article { $transaction = transaction(); try { $article = Article::findByPK($articleId); if (!$article) { throw new \Exception('Статья не найдена'); } // 1. Публикуем статью $article->status = 'published'; $article->publishedAt = new \DateTimeImmutable(); $transaction->persist($article); // 2. Получаем подписчиков категории $subscribers = $this->getCategorySubscribers($article->category->id); // 3. Создаём уведомления для каждого подписчика foreach ($subscribers as $subscriber) { $notification = new Notification(); $notification->user = $subscriber; $notification->type = 'article_published'; $notification->data = json_encode([ 'article_id' => $article->id, 'article_title' => $article->title, 'category_name' => $article->category->name, 'author_name' => $article->author->name ]); $notification->createdAt = new \DateTimeImmutable(); $transaction->persist($notification); } // 4. Обновляем счётчик статей в категории if ($article->category) { $article->category->articleCount = ($article->category->articleCount ?? 0) + 1; $transaction->persist($article->category); } // 5. Обновляем счётчик статей автора $article->author->articlesCount = ($article->author->articlesCount ?? 0) + 1; $transaction->persist($article->author); // Применяем все изменения $transaction->run(); return $article; } catch (\Exception $e) { $transaction->rollback(); logs('blog')->error('Ошибка публикации статьи: ' . $e->getMessage()); throw $e; } } /** * Создание комментария с уведомлением автора */ public function createComment(int $articleId, array $data): Comment { $transaction = transaction(); try { $article = Article::findByPK($articleId); if (!$article) { throw new \Exception('Статья не найдена'); } // 1. Создаём комментарий $comment = new Comment(); $comment->article = $article; $comment->author = user()->getCurrentUser(); $comment->content = $data['content']; $comment->approved = config('blog.comments.auto_approve', false); $comment->createdAt = new \DateTimeImmutable(); $transaction->persist($comment); // 2. Увеличиваем счётчик комментариев $article->commentsCount = ($article->commentsCount ?? 0) + 1; $transaction->persist($article); // 3. Уведомляем автора статьи (если комментирует не он сам) if ($article->author->id !== user()->id) { $notification = new Notification(); $notification->user = $article->author; $notification->type = 'new_comment'; $notification->data = json_encode([ 'article_id' => $article->id, 'article_title' => $article->title, 'comment_id' => $comment->id, 'commenter_name' => user()->getCurrentUser()->name ]); $notification->createdAt = new \DateTimeImmutable(); $transaction->persist($notification); } $transaction->run(); return $comment; } catch (\Exception $e) { $transaction->rollback(); throw $e; } } }

Пагинация

Базовая пагинация

<?php $page = (int) request()->get('page', 1); $perPage = 10; $articles = Article::query() ->where('status', 'published') ->orderBy('createdAt', 'DESC') ->paginate($perPage, $page); // Использование результатов $items = $articles->items(); // Записи текущей страницы $total = $articles->total(); // Всего записей $currentPage = $articles->currentPage(); // Текущая страница $lastPage = $articles->lastPage(); // Последняя страница $hasMorePages = $articles->hasMorePages(); // Есть ли ещё страницы $onFirstPage = $articles->onFirstPage(); // На первой странице?

В контроллере

<?php class ArticleController extends BaseController { public function index() { $page = (int) request()->get('page', 1); $perPage = min((int) request()->get('per_page', 10), 50); // Ограничение макс. 50 $articles = Article::query() ->where('status', 'published') ->load('author') ->load('category') ->orderBy('publishedAt', 'DESC') ->paginate($perPage, $page); return view('blog::articles.index', [ 'articles' => $articles ]); } }

Сервис пагинации с фильтрами

<?php namespace Flute\Modules\Blog\Services; use Flute\Modules\Blog\Database\Entities\Article; class ArticlePaginationService { /** * Получение статей с пагинацией и фильтрами */ public function getPaginatedArticles(array $filters = [], int $page = 1, int $perPage = 10): array { $query = Article::query()->where('status', 'published'); // Применение фильтров if (!empty($filters['category_id'])) { $query->where('category.id', $filters['category_id']); } if (!empty($filters['author_id'])) { $query->where('author.id', $filters['author_id']); } if (!empty($filters['search'])) { $search = $filters['search']; $query->where(function($q) use ($search) { $q->where('title', 'LIKE', "%{$search}%") ->orWhere('content', 'LIKE', "%{$search}%"); }); } // Загрузка связей с ограничением полей (оптимизация) $query->load('author') ->load('category') ->load('tags'); // Сортировка $sortBy = $filters['sort_by'] ?? 'publishedAt'; $sortOrder = $filters['sort_order'] ?? 'DESC'; $query->orderBy($sortBy, $sortOrder); // Пагинация $paginator = $query->paginate($perPage, $page); return [ 'items' => $paginator->items(), 'pagination' => [ 'current_page' => $paginator->currentPage(), 'last_page' => $paginator->lastPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), 'from' => $paginator->firstItem(), 'to' => $paginator->lastItem(), 'has_more_pages' => $paginator->hasMorePages(), ], 'filters' => $filters ]; } /** * Генерация ссылок пагинации */ public function getPaginationLinks($paginator, string $baseUrl = null): array { $links = []; $currentPage = $paginator->currentPage(); $lastPage = $paginator->lastPage(); // Предыдущая страница $links['previous'] = $paginator->onFirstPage() ? null : ($baseUrl ? $baseUrl . '?page=' . ($currentPage - 1) : null); // Следующая страница $links['next'] = $paginator->hasMorePages() ? ($baseUrl ? $baseUrl . '?page=' . ($currentPage + 1) : null) : null; // Номера страниц (показываем 5 страниц вокруг текущей) $pageNumbers = []; $start = max(1, $currentPage - 2); $end = min($lastPage, $currentPage + 2); for ($page = $start; $page <= $end; $page++) { $pageNumbers[] = [ 'page' => $page, 'url' => $baseUrl ? $baseUrl . '?page=' . $page : null, 'active' => $page === $currentPage ]; } $links['pages'] = $pageNumbers; $links['first'] = $baseUrl ? $baseUrl . '?page=1' : null; $links['last'] = $baseUrl ? $baseUrl . '?page=' . $lastPage : null; return $links; } }

Сервисный слой

Выносите сложную логику в сервисы:

<?php namespace Flute\Modules\Blog\Services; use Flute\Modules\Blog\Database\Entities\Article; use Flute\Modules\Blog\Database\Entities\Category; class ArticleService { /** * Получение статей с фильтрами */ public function getArticles(array $filters = [], int $page = 1, int $perPage = 10) { $query = Article::query()->where('status', 'published'); // Фильтр по категории if (!empty($filters['category_id'])) { $query->where('category.id', $filters['category_id']); } // Поиск по тексту if (!empty($filters['search'])) { $search = $filters['search']; $query->where(function($q) use ($search) { $q->where('title', 'LIKE', "%{$search}%") ->orWhere('content', 'LIKE', "%{$search}%"); }); } // Фильтр по дате if (!empty($filters['date_from'])) { $query->where('publishedAt', '>=', new \DateTimeImmutable($filters['date_from'])); } // Сортировка $sortBy = $filters['sort'] ?? 'publishedAt'; $sortOrder = $filters['order'] ?? 'DESC'; $query->orderBy($sortBy, $sortOrder); // Загрузка связей $query->load('author')->load('category'); return $query->paginate($perPage, $page); } /** * Создание статьи */ public function create(array $data): Article { $article = new Article(); $article->title = $data['title']; $article->slug = $this->generateSlug($data['title']); $article->content = $data['content']; $article->excerpt = $data['excerpt'] ?? null; $article->status = $data['status'] ?? 'draft'; if (user()->isLoggedIn()) { $article->author = user()->getCurrentUser(); } if (!empty($data['category_id'])) { $article->category = Category::findByPK($data['category_id']); } if ($article->status === 'published') { $article->publishedAt = new \DateTimeImmutable(); } $article->save(); return $article; } /** * Генерация уникального slug */ protected function generateSlug(string $title): string { $slug = mb_strtolower(trim($title)); $slug = preg_replace('/[^a-zа-яё0-9-]/u', '-', $slug); $slug = preg_replace('/-+/', '-', $slug); $slug = trim($slug, '-'); // Проверка уникальности $original = $slug; $counter = 1; while (Article::findOne(['slug' => $slug])) { $slug = $original . '-' . $counter++; } return $slug; } }

Миграции схемы

Схема ORM хранится в storage/app/orm_schema.php. При проблемах с нераспознанными сущностями удалите этот файл или вызовите forceRefreshSchema().

Принудительное обновление схемы:

<?php // Обновление схемы ORM app(\Flute\Core\Database\DatabaseConnection::class)->forceRefreshSchema();

Лучшие практики

Используйте индексы

Создавайте индексы для полей, по которым часто выполняется поиск или сортировка.

Загружайте связи явно

Всегда используйте load() для предзагрузки связей, чтобы избежать N+1 проблемы.

Выносите логику в сервисы

Сложные запросы и бизнес-логику размещайте в сервисных классах.

Используйте транзакции

Оборачивайте связанные операции в транзакции для обеспечения целостности данных.

Валидируйте данные

Всегда проверяйте входные данные перед сохранением в базу.

Производительность

В продакшене держите config('database.debug') выключенным — иначе логгер БД замедляет запросы.

Рекомендации:

  1. Используйте select() для выбора только нужных полей
  2. Применяйте пагинацию для больших наборов данных
  3. Кешируйте результаты часто выполняемых запросов
  4. Избегайте запросов внутри циклов