Skip to content

How we designed the architecture of our own multilingual blog

Why IT Compass built its own blog engine, and how it is put together: database structure for three languages, a service layer, DTOs and a few reusable traits.

AuthorIT Department
  • 4 min read
  • 343 views
How we designed the architecture of our own multilingual blog

When we decided to build our own blog at IT Compass, we did not want "just another blog". We wanted a system that could grow with the company. This is the first part of the story: how we laid the architectural foundation.

Why not a ready-made solution?

The obvious question is why we did not take WordPress, Ghost or one of dozens of other CMSs. The reason is practical. We needed full integration with our existing infrastructure, had specific performance requirements, and did not want to hit a ceiling on scaling.

Architectural principles

We held to four principles:

  • Separation of concerns. Clear boundaries between application layers.

  • Scalability. The architecture has to allow horizontal scaling.

  • Multilingual support. From day one, not added later.

  • Performance. We look after it at every level.

Database structure

One of the first decisions was how to store multilingual content:

php

// Main posts table
Schema::create('admin_blog_posts', function (Blueprint $table) {
    $table->id();
    $table->uuid('uuid')->unique();
    $table->foreignId('creator_id');
    $table->foreignId('author_id')->nullable();
    
    // Images of different sizes
    $table->string('image_low_path')->nullable();
    $table->string('image_medium_path')->nullable();
    $table->string('image_full_path')->nullable();
    
    // Statuses and dates
    $table->timestamp('published_at')->nullable();
    $table->timestamp('archived_at')->nullable();
    $table->timestamp('scheduled_at')->nullable();
    
    $table->timestamps();
});

// Separate table for content in different languages
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 metadata
    $table->string('meta_title');
    $table->string('meta_description');
    $table->json('keywords');
    
    $table->timestamps();
    $table->unique(['post_id', 'language']);
});

Service Layer Pattern

Instead of putting business logic in controllers, we moved it into a service layer:

php

class PostService
{
    protected ImageProcessingService $imageService;

    public function __construct(ImageProcessingService $imageService)
    {
        $this->imageService = $imageService;
    }
    
    public function store(PostCreationDTO $dto): bool
    {
        DB::beginTransaction();
        
        try {
            // Image processing
            $imagePaths = $this->processImage($dto->preview_image);
            
            // Creating post
            $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],
            ]));
            
            // Adding content for each language
            $this->attachContents($post->id, $dto->contents);
            
            // Attaching categories and tags
            $this->attachLabels($post->id, $dto->categoryIds, $dto->tagIds);
            
            DB::commit();
            return true;
            
        } catch (Exception $e) {
            DB::rollBack();
            // Cleaning uploaded images
            $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)

DTOs give us type safety and a clear shape for the data passed between layers:

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 = [],
    ) {}
}

Universal label system

Categories, tags and authors share one table. It is an unusual choice, but it kept the model simple:

php

class Label extends Model
{
    // Label types
    public const CATEGORY = "category";
    public const TAG = "tag";
    public const AUTHOR = "author";
    
    // Multilingual names
    protected $fillable = [
        'type',
        'name_uk',
        'name_en', 
        'name_pl',
        'meta', // For tag color or author nickname
    ];
    
    // Dynamic attribute for current language
    public function getNameAttribute(): string
    {
        $lang = app()->getLocale();
        return $this->{"name_{$lang}"};
    }
}

Traits for reusability

Code that repeats across models went into a small set of traits:

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;
    }
}

Migrations with model support

To avoid repeating table names, we extended the base migration class:

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;
    }
}

What the first part comes down to

Getting the architecture right early saves time later. This is what we spent it on:

  • A database structure that handles several languages

  • Clear separation of application layers

  • Type safety through DTOs

  • Components that can be reused

In the next article I will cover the content editor we integrated, the AI assistant that generates metadata, and the auto-save system for drafts.

To be continued.

Tags

Share this article

Back to blog

Cookies

We use essential cookies and one functional cookie, the latter only with your consent. Visit statistics are counted on our server, without cookies. Cookie Policy