База данных и 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)')] |
json | JSON данные | #[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') выключенным — иначе логгер БД замедляет запросы.
Рекомендации:
- Используйте
select()для выбора только нужных полей - Применяйте пагинацию для больших наборов данных
- Кешируйте результаты часто выполняемых запросов
- Избегайте запросов внутри циклов