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.



