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.



