Перейти до вмісту

Архітектура власного мультимовного блогу: з чого ми почали

Чому IT Compass написав власний рушій блогу і як він влаштований: структура бази для трьох мов, шар сервісів, DTO та кілька трейтів для повторного використання.

АвторВідділ ІТ
  • 4 хв читання
  • 343 перегляди
Архітектура власного мультимовного блогу: з чого ми почали

Коли ми в 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-асистента для генерації метаданих і про систему автозбереження чернеток.

Продовження буде.

Теги

Поділитися статтею

Яку LLM обрати для бізнесу: GPT, Claude, Gemini чи open-source

Сильні сторони GPT, Claude, Gemini та open-source моделей, підказки, коли обирати кожну, і шість кроків вибору з урахуванням вартості, безпеки даних та інтеграції.

  • 10 хв читання
  • 219 переглядів

AI-агенти в компанії: де вони окупаються, а де вистачить скрипта

Чим AI-агент відрізняється від чат-бота й RPA, у яких процесах він дає вимірювану віддачу і чому більшість пілотів не доходить до продакшну. У кінці тест із п'яти запитань перед стартом проєкту.

  • 9 хв читання
  • 202 перегляди

Чому ШІ посилює хаос у невпорядкованих компаніях

ШІ примножує все, що знаходить у компанії, зокрема безлад у даних і незадокументовані процеси. Розбираємо, чому впровадження провалюються і в якій послідовності діяти, щоб воно окупилося.

  • 9 хв читання
  • 143 перегляди
Повернутися до блогу

Файли cookies

Ми використовуємо необхідні файли cookies та один функціональний, який вмикаємо лише за вашою згодою. Статистику відвідувань рахуємо на сервері, без cookies. Політика cookies