Коли ми в IT Compass вирішили зробити власний блог, нам був потрібен не «ще один блог». Ми хотіли систему, яка зростатиме разом із компанією. Це перша частина історії: як ми заклали архітектурний фундамент.
Чому не готове рішення?
Природне запитання: навіщо це робити, якщо є WordPress, Ghost і десятки інших CMS. Причини були практичні. Нам потрібна була повна інтеграція з нашою інфраструктурою, ми мали конкретні вимоги до продуктивності й не хотіли впиратися в стелю при масштабуванні.
Архітектурні принципи
Ми трималися чотирьох принципів:
Розподіл відповідальності. Чіткі межі між шарами застосунку.
Масштабованість. Архітектура має допускати горизонтальне масштабування.
Мультимовність. З першого дня, а не доточена пізніше.
Продуктивність. Дбаємо про неї на кожному рівні.
Структура бази даних
Одне з перших рішень стосувалося того, як зберігати контент кількома мовами:
php
// Основна таблиця постів
Schema::create('admin_blog_posts', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('creator_id');
$table->foreignId('author_id')->nullable();
// Зображення різних розмірів
$table->string('image_low_path')->nullable();
$table->string('image_medium_path')->nullable();
$table->string('image_full_path')->nullable();
// Статуси та дати
$table->timestamp('published_at')->nullable();
$table->timestamp('archived_at')->nullable();
$table->timestamp('scheduled_at')->nullable();
$table->timestamps();
});
// Окрема таблиця для контенту різними мовами
Schema::create('admin_blog_posts_contents', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id');
$table->string('language', 2);
$table->string('title');
$table->text('excerpt');
$table->longText('content_html');
$table->longText('content_text');
$table->string('slug')->unique();
// SEO метадані
$table->string('meta_title');
$table->string('meta_description');
$table->json('keywords');
$table->timestamps();
$table->unique(['post_id', 'language']);
});Service Layer Pattern
Замість того щоб тримати бізнес-логіку в контролерах, ми винесли її в шар сервісів:
php
class PostService
{
protected ImageProcessingService $imageService;
public function __construct(ImageProcessingService $imageService)
{
$this->imageService = $imageService;
}
public function store(PostCreationDTO $dto): bool
{
DB::beginTransaction();
try {
// Обробка зображення
$imagePaths = $this->processImage($dto->preview_image);
// Створення поста
$post = Post::create(array_merge($dto->toArray(), [
'image_low_path' => $imagePaths[ImageProcessingService::LOW],
'image_medium_path' => $imagePaths[ImageProcessingService::MEDIUM],
'image_full_path' => $imagePaths[ImageProcessingService::FULL],
]));
// Додавання контенту для кожної мови
$this->attachContents($post->id, $dto->contents);
// Прив'язка категорій та тегів
$this->attachLabels($post->id, $dto->categoryIds, $dto->tagIds);
DB::commit();
return true;
} catch (Exception $e) {
DB::rollBack();
// Очищення завантажених зображень
$this->imageService->deleteImage(
$imagePaths[ImageProcessingService::LOW] ?? null,
$imagePaths[ImageProcessingService::MEDIUM] ?? null,
$imagePaths[ImageProcessingService::FULL] ?? null
);
throw new Exception('Failed to create post: ' . $e->getMessage());
}
}
}Data Transfer Objects (DTO)
DTO дають нам типобезпеку та чітку форму даних, які передаються між шарами:
php
final readonly class PostCreationDTO
{
public function __construct(
public int $creator_id,
public int $author_id,
public UploadedFile $preview_image,
public ?string $scheduled_at = null,
public ?string $published_at = null,
public bool $publish_now = false,
public array $contents = [],
public array $categoryIds = [],
public array $tagIds = [],
) {}
}Універсальна система міток
Категорії, теги й автори живуть в одній таблиці. Рішення нетипове, але модель завдяки йому проста:
php
class Label extends Model
{
// Типи міток
public const CATEGORY = "category";
public const TAG = "tag";
public const AUTHOR = "author";
// Мультимовні назви
protected $fillable = [
'type',
'name_uk',
'name_en',
'name_pl',
'meta', // Для кольору тегів або нікнейму автора
];
// Динамічний атрибут для поточної мови
public function getNameAttribute(): string
{
$lang = app()->getLocale();
return $this->{"name_{$lang}"};
}
}Трейти для повторного використання
Код, що повторюється в моделях, ми зібрали в невеликий набір трейтів:
php
trait Paginable
{
public function scopePagination($query, Request $request, $resourceClass = null)
{
$perPage = $request->get('per_page', $this->perPage ?? 15);
$paginator = $query->paginate($perPage);
if ($resourceClass) {
$paginator->setCollection(
$paginator->getCollection()->map(
fn($item) => new $resourceClass($item)
)
);
}
return $paginator;
}
}Міграції з підтримкою моделей
Щоб не повторювати назви таблиць, ми розширили базовий клас міграцій:
php
class Migration extends ParentMigration
{
protected $model = null;
protected ?string $tableName = null;
protected ?string $tableConnection = null;
public function __construct()
{
$this->model = $this->model ?? null;
$this->tableName = $this->model ? $this->model::table() : null;
$this->tableConnection = $this->model ? $this->model::connection() : null;
}
}Що з цього випливає після першої частини
Добра архітектура на старті заощаджує час пізніше. Ми витратили свій на таке:
Структуру бази, що працює з кількома мовами
Чіткий поділ шарів застосунку
Типобезпеку завдяки DTO
Компоненти, які можна використовувати повторно
У наступній статті розповім про редактор контенту, який ми інтегрували, про AI-асистента для генерації метаданих і про систему автозбереження чернеток.
Продовження буде.



