Przejdź do treści

Architektura własnego, wielojęzycznego bloga: od czego zaczęliśmy

Dlaczego IT Compass zbudowało własny silnik bloga i jak jest zbudowany: struktura bazy dla trzech języków, warstwa serwisów, DTO i kilka wielokrotnie używanych traitów.

AutorDział IT
  • 4 min czytania
  • 343 wyświetlenia
Architektura własnego, wielojęzycznego bloga: od czego zaczęliśmy

Kiedy w IT Compass postanowiliśmy zrobić własny blog, nie chodziło nam o „kolejny blog". Chcieliśmy systemu, który będzie rósł razem z firmą. To pierwsza część historii: jak położyliśmy fundament architektury.

Dlaczego nie gotowe rozwiązanie?

Naturalne pytanie brzmi: po co to robić, skoro są WordPress, Ghost i dziesiątki innych CMS-ów. Powody były praktyczne. Potrzebowaliśmy pełnej integracji z naszą infrastrukturą, mieliśmy konkretne wymagania dotyczące wydajności i nie chcieliśmy trafić na sufit przy skalowaniu.

Zasady architektoniczne

Trzymaliśmy się czterech zasad:

  • Podział odpowiedzialności. Wyraźne granice między warstwami aplikacji.

  • Skalowalność. Architektura musi dopuszczać skalowanie horyzontalne.

  • Wielojęzyczność. Od pierwszego dnia, a nie dobudowywana później.

  • Wydajność. Dbamy o nią na każdym poziomie.

Struktura bazy danych

Jedna z pierwszych decyzji dotyczyła tego, jak przechowywać treści w wielu językach:

php

// Główna tabela postów
Schema::create('admin_blog_posts', function (Blueprint $table) {
    $table->id();
    $table->uuid('uuid')->unique();
    $table->foreignId('creator_id');
    $table->foreignId('author_id')->nullable();
    
    // Obrazy różnych rozmiarów
    $table->string('image_low_path')->nullable();
    $table->string('image_medium_path')->nullable();
    $table->string('image_full_path')->nullable();
    
    // Statusy i daty
    $table->timestamp('published_at')->nullable();
    $table->timestamp('archived_at')->nullable();
    $table->timestamp('scheduled_at')->nullable();
    
    $table->timestamps();
});

// Oddzielna tabela dla treści w różnych językach
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();
    
    // Metadane SEO
    $table->string('meta_title');
    $table->string('meta_description');
    $table->json('keywords');
    
    $table->timestamps();
    $table->unique(['post_id', 'language']);
});

Wzorzec Service Layer

Zamiast trzymać logikę biznesową w kontrolerach, przenieśliśmy ją do warstwy serwisów:

php

class PostService
{
    protected ImageProcessingService $imageService;

    public function __construct(ImageProcessingService $imageService)
    {
        $this->imageService = $imageService;
    }
    
    public function store(PostCreationDTO $dto): bool
    {
        DB::beginTransaction();
        
        try {
            // Przetwarzanie obrazu
            $imagePaths = $this->processImage($dto->preview_image);
            
            // Tworzenie postu
            $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],
            ]));
            
            // Dodawanie treści dla każdego języka
            $this->attachContents($post->id, $dto->contents);
            
            // Przypisywanie kategorii i tagów
            $this->attachLabels($post->id, $dto->categoryIds, $dto->tagIds);
            
            DB::commit();
            return true;
            
        } catch (Exception $e) {
            DB::rollBack();
            // Czyszczenie wgranych obrazów
            $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 dają nam bezpieczeństwo typów i jasny kształt danych przekazywanych między warstwami:

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

Uniwersalny system etykiet

Kategorie, tagi i autorzy siedzą w jednej tabeli. To nietypowe rozwiązanie, ale dzięki niemu model jest prosty:

php

class Label extends Model
{
    // Typy etykiet
    public const CATEGORY = "category";
    public const TAG = "tag";
    public const AUTHOR = "author";
    
    // Wielojęzyczne nazwy
    protected $fillable = [
        'type',
        'name_uk',
        'name_en', 
        'name_pl',
        'meta', // Dla koloru tagów lub nicku autora
    ];
    
    // Dynamiczny atrybut dla bieżącego języka
    public function getNameAttribute(): string
    {
        $lang = app()->getLocale();
        return $this->{"name_{$lang}"};
    }
}

Traity do ponownego użycia

Kod, który powtarza się w modelach, trafił do niewielkiego zestawu traitów:

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

Migracje z wsparciem modeli

Żeby nie powtarzać nazw tabel, rozszerzyliśmy bazową klasę migracji:

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

Co z tego wynika po pierwszej części

Dobra architektura na starcie oszczędza czas później. Na to poświęciliśmy swój:

  • Strukturę bazy obsługującą wiele języków

  • Wyraźny podział warstw aplikacji

  • Bezpieczeństwo typów dzięki DTO

  • Komponenty nadające się do ponownego użycia

W następnym artykule opowiem o edytorze treści, który zintegrowaliśmy, o asystencie AI generującym metadane i o systemie automatycznego zapisu szkiców.

Ciąg dalszy nastąpi.

Tagi

Udostępnij ten artykuł

Wróć do bloga

Pliki cookies

Używamy niezbędnych plików cookies i jednego funkcjonalnego, tego tylko za Twoją zgodą. Statystyki odwiedzin liczymy na serwerze, bez cookies. Polityka cookies