To jest część 10 serii o migracji holas.pl z WordPressa na niestandardowy generator stron statycznych oparty na Symfony. Część 9 opisuje system wielojęzyczności.


Część 5 opisywała jak Claude Code był używany podczas początkowego budowania — AGENTS.md jako instrukcja obsługi dla AI, generowanie serwisów i szablonów z konwencji, tłumaczenie treści. Ten wpis opisuje co dzieje się później: strona działa, build przechodzi, ale architektura ma niedociągnięcia. Trzy rundy poprawek jakości, ekstrakcja komponentów i reorganizacja namespace'ów — wszystko napędzane cyklami przeglądów z pomocą AI.

Dlaczego iterować?

Pierwsza działająca wersja priorytetyzuje wysyłkę. Opublikuj treść, niech build przejdzie, wdróż na Raspberry Pi. Dług techniczny narasta naturalnie po drodze: metody zwracają tablice asocjacyjne zamiast value objects, serwisy wstrzykiwane są jako klasy konkretne zamiast interfejsów, szablony rosną w monolity.

W zespole code review wyłapuje te wzorce. W solowym projekcie nie ma nikogo do przeglądania pull requestów. AI wypełnia tę lukę — nie jako pieczątka akceptacji, ale jako systematyczny reviewer czytający każdy plik i raportujący problemy z numerami linii.

Cykl iteracji:

  1. Poproś AI o audyt pod kątem konkretnych wzorców (strict types, naruszenia SOLID, problemy DRY)
  2. AI czyta każdy plik PHP, raportuje problemy ze ścieżkami plików i numerami linii
  3. Zaplanuj poprawki w pliku .plans/ z checkboxami
  4. Implementuj fazami, weryfikuj ddev code-check po każdej
  5. Powtórz z kolejnym fokusem jakościowym

Każda runda ma konkretny zakres. Próba naprawienia wszystkiego naraz prowadzi do szumnych diffów i przeoczonych regresji. Skupione rundy produkują dające się przejrzeć i zweryfikować zmiany.

Runda 1 — Value Objects zamiast tablic

Pierwsza runda celowała w własną zasadę projektu: "Nigdy nie zwracaj tablic asocjacyjnych dla złożonych danych."

Problem

MarkdownParser::parse() zwracał tablicę:

// Przed
public function parse(string $markdown): array
{
    // ...
    return [
        'frontMatter' => $frontMatter,
        'html' => $html,
    ];
}

// Konsument
$result = $this->parser->parse($content);
$frontMatter = $result['frontMatter'];  // brak bezpieczeństwa typów
$html = $result['html'];               // literówka = cichy bug

Ten sam wzorzec dla nawigacji sąsiednich wpisów — ContentTree::getAdjacentPosts() zwracał ['prev' => $post, 'next' => $post].

Problemy: brak bezpieczeństwa typów, brak autouzupełniania w IDE, PHPStan nie wyłapie literówki w $result['htlm'].

Poprawka

final readonly class ParsedMarkdown
{
    /** @param array<string, mixed> $frontMatter */
    public function __construct(
        public array $frontMatter,
        public string $html,
    ) {
    }
}
final readonly class AdjacentPosts
{
    public function __construct(
        public ?ContentItem $prev,
        public ?ContentItem $next,
    ) {
    }
}

Teraz parser zwraca ParsedMarkdown, konsument odwołuje się do $parsed->frontMatter i $parsed->html, a PHPStan wyłapuje każdą literówkę w nazwie właściwości na etapie analizy.

Siedem value objects zostało utworzonych lub przeniesionych w tej rundzie:

Value Object Zastępuje Właściwości
ParsedMarkdown tablicę ['frontMatter', 'html'] frontMatter, html
AdjacentPosts tablicę ['prev', 'next'] prev, next
ArchiveMonth tablicę inline year, month, count
CategoryCount tablicę inline slug, count
TagCount tablicę inline slug, count
SidebarData wiele wartości zwrotnych recentPosts, categories, tags, archiveMonths
RenderResult ad-hoc statystyki pages, skipped, errors

Wszystkie są readonly, używają constructor property promotion i żyją w src/Content/ValueObject/.

Runda 2 — interfejsy dla wszystkiego

Druga runda wymusiła kolejną zasadę projektu: "Każda wstrzykiwana klasa w src/Service/ musi mieć odpowiadający interfejs."

Problem

Dwa serwisy wstrzykiwane były jako klasy konkretne:

// Przed
public function __construct(
    private readonly ContentTreeBuilder $builder,
    private readonly MarkdownParser $parser,
) {
}

Działało, ale naruszało zasadę odwrócenia zależności. Reszta bazy kodu już wstrzykiwała przez interfejsy (ContentServiceInterface, ImageResizerInterface). Te dwa były wyjątkami.

Poprawka

interface MarkdownParserInterface
{
    public function parse(string $markdown): ParsedMarkdown;
}

interface ContentTreeBuilderInterface
{
    public function build(string $locale): ContentTree;
}
// Po
public function __construct(
    private readonly ContentTreeBuilderInterface $builder,
    private readonly MarkdownParserInterface $parser,
) {
}

Klasy konkretne implementują interfejsy. Autowiring Symfony obsługuje powiązanie. Zasada jest teraz wymuszona wszędzie: 12 interfejsów serwisów w 4 podkatalogach.

src/Service/
├── Content/
│   ├── ContentServiceInterface + ContentService
│   ├── ContentTreeBuilderInterface + ContentTreeBuilder
│   ├── MarkdownParserInterface + MarkdownParser
│   ├── SidebarDataProviderInterface + SidebarDataProvider
│   └── TranslationMapBuilderInterface + TranslationMapBuilder
├── Image/
│   ├── ImageResizerInterface + ImageResizer
│   └── ResponsiveImageServiceInterface + ResponsiveImageService
├── Preview/
│   ├── DraftPreviewServiceInterface + DraftPreviewService
│   └── ScheduledPreviewServiceInterface + ScheduledPreviewService
├── SiteConfigServiceInterface + SiteConfigService
└── TurnstileValidatorInterface + TurnstileValidator

Dodatkowa zasada z tej rundy: stałe należą do interfejsu, nie do klasy konkretnej. Klasa konkretna dziedziczy je przez self::CONSTANT_NAME.

Runda 3 — eliminacja zakodowanych wartości

Trzecia runda celowała w subtelniejszy problem: wartości, które działają dziś, ale rozjadą się jutro.

Zakodowane URL-e w szablonach

Przełącznik języka miał zakodowane ścieżki archiwum:

{# Przed — psuje się gdy routing się zmieni #}
{% set url = locale is same as('pl') ? '/archive/' : '/pl/archiwum/' %}
{# Po — używa nazwanych tras #}
{% set url = path('blog_archive_' ~ other, {year: archive_year, month: '%02d'|format(archive_month)}) %}

Ten sam wzorzec w BlogController dla przełączania tagów między locale — zakodowane /pl/tag/ i /blog/ zastąpione przez $this->generateUrl('blog_tag_'.$otherLocale, ...).

Zakodowane progi obrazów

Szablon responsywnych obrazów miał szerokości srcset zakodowane jako stringi. Gdyby zmienna środowiskowa IMAGE_VARIANT_WIDTHS się zmieniła, szablon odwoływałby się do plików, które nie istnieją:

{# Przed — szablon musi ręcznie pasować do konfiguracji env #}
srcset="...640w.webp 640w, ...960w.webp 960w, ..."

Poprawka: wstrzyknij szerokości wariantów jako Twig global z SiteConfigExtension, potem generuj srcset dynamicznie. Jedno źródło prawdy dla progów.

Magiczne liczby

Response::HTTP_BAD_REQUEST zastąpiło zakodowane 400. Mała zmiana, ale spójna z zasadą: każda literalna wartość to przyszły bug, gdzie ktoś zmieni logikę ale nie liczbę.

Ekstrakcja komponentów Twig

Między rundami jakości osobne wysiłki wyodrębniły reużywalne komponenty z monolitycznych szablonów.

Strona "O mnie"

Strona "o mnie" była najgorszym przypadkiem — 146 linii mieszających markup profilu, karty ekspertyzy, pigułki umiejętności, projekty open source i bloki cytatów rekomendacji w jednym szablonie.

Po ekstrakcji:

{# about.html.twig — czysty i czytelny #}
{% block body %}
<article class="page-content" data-pagefind-body>
    {{ include('components/about_profile.html.twig', {
        author: site_author,
        role: 'about.role'|trans,
        headline: 'about.headline'|trans
    }) }}

    <section class="about-section">
        <h2>{{ 'about.expertise_title'|trans }}</h2>
        {{ include('components/expertise_grid.html.twig', { expertise: expertise_items }) }}
    </section>

    {{ include('components/skills_pills.html.twig', { skills: ..., labels: ... }) }}
    {{ include('components/opensource_grid.html.twig', { projects: os_projects }) }}

    {% for rec in site_author.recommendations %}
        {{ include('components/recommendation_card.html.twig', { rec: rec, ... }) }}
    {% endfor %}

    {{ include('components/about_cta.html.twig', { text: ..., url: ..., label: ... }) }}
</article>
{% endblock %}

Osiem komponentów wyodrębnionych z jednej strony: about_profile, expertise_grid, skills_pills, opensource_grid, recommendation_card, about_cta, plus error_terminal i coming_soon_terminal z innych stron.

Korzyść ze styleguide'a

holas.pl ma dostępny tylko w dev styleguide pod /styleguide/, który pokazuje wszystkie komponenty UI. Przed ekstrakcją styleguide miał własny zakodowany markup dla każdego komponentu — który rozjeżdżał się z prawdziwymi szablonami przy zmianach.

Po ekstrakcji zarówno prawdziwa strona, jak i styleguide includują te same pliki komponentów:

{# styleguide.html.twig #}
{{ include('components/recommendation_card.html.twig', { rec: demo_recommendation, ... }) }}

{# about.html.twig #}
{{ include('components/recommendation_card.html.twig', { rec: rec, ... }) }}

Zmień komponent raz, oba się aktualizują automatycznie. Styleguide z ręcznego obciążenia synchronizacyjnego stał się bezobsługowy.

Liczba komponentów wzrosła z ~15 do 25 w ramach tych ekstrakcji.

Reorganizacja namespace'ów

Praca nad value objects i interfejsami stworzyła wystarczająco dużo plików, że płaskie namespace'y stały się zatłoczone:

# Przed
src/Content/AdjacentPosts.php
src/Content/ArchiveMonth.php
src/Content/CategoryCount.php
src/Content/CardLayout.php
src/Service/ContentService.php
src/Service/ImageResizer.php
src/Service/DraftPreviewService.php

# Po
src/Content/ValueObject/AdjacentPosts.php
src/Content/ValueObject/ArchiveMonth.php
src/Content/ValueObject/CategoryCount.php
src/Content/Enum/CardLayout.php
src/Service/Content/ContentService.php
src/Service/Image/ImageResizer.php
src/Service/Preview/DraftPreviewService.php

CardLayout przeniesiony do Enum/ — to PHP enum z backing type dla wariantów layoutu kart wpisów:

enum CardLayout: string
{
    case Top = 'layout-top';
    case Right = 'layout-right';
    case Text = 'layout-text';
    case Left = 'layout-left';

    /** @return list<string> */
    public static function cycle(): array
    {
        return array_map(fn (self $l) => $l->value, self::cases());
    }
}

Bezpieczny typowo, z autouzupełnianiem, niemożliwy do literówki. Metoda cycle() zwraca wartości layoutów, przez które szablon listingu wpisów rotuje dla wizualnej różnorodności.

Reorganizacja dotknęła 46 plików w jednym commicie — każda instrukcja use odwołująca się do przeniesionej klasy wymagała aktualizacji. To jest dokładnie ten rodzaj mechanicznej refaktoryzacji, w którym AI błyszczy: zmień namespace, zaktualizuj wszystkie importy, zweryfikuj że nic się nie popsuło. Człowiek decyduje o docelowej strukturze; AI obsługuje żmudną część.

Workflow AI dla refaktoryzacji

Praktyczny workflow stojący za tymi rundami:

Krok 1: Zawężony audyt. Poproś Claude Code o przegląd wszystkich plików PHP pod kątem konkretnej kategorii problemów. Nie "znajdź wszystkie problemy" — zbyt ogólne. Zamiast tego: "sprawdź każdy plik w src/ pod kątem metod zwracających tablice asocjacyjne zamiast value objects." AI czyta każdy plik i raportuje konkretne problemy ze ścieżkami plików i numerami linii.

Krok 2: Plan. Napisz plik .plans/ dokumentujący co trzeba zmienić, które pliki są dotknięte i kroki implementacji jako checkboxy. Plan jest źródłem prawdy — nie podsumowaniem intencji, ale szczegółową specyfikacją implementacji.

Krok 3: Implementacja fazami. Wykonaj jedną fazę, uruchom ddev code-check (PHP CS Fixer + PHPStan level 6), zweryfikuj że build przechodzi z ddev build. Przejdź do następnej fazy.

Krok 4: Weryfikacja. Po zakończeniu rundy uruchom pełny zestaw kontroli jakości. Liczby dla holas.pl:

$ ddev code-check
PHP CS Fixer: Found 0 of 53 files that can be fixed
PHPStan: [OK] No errors (53 files, level 6)

53 pliki PHP, zero problemów CS Fixer, zero błędów PHPStan. Automatyczne narzędzia potwierdzają to, co przegląd zamierzał.

W czym AI jest dobry przy refaktoryzacji

  • Systematyczny przegląd plik po pliku: czyta 53 pliki i raportuje każde naruszenie wzorca. Ludzie prześlizgują się; AI nie.
  • Mechaniczna refaktoryzacja: zmiana nazw namespace'ów w 46 plikach, aktualizacja instrukcji import, przenoszenie stałych z klas konkretnych do interfejsów.
  • Sprawdzanie spójności: weryfikacja że każdy serwis ma interfejs, każdy value object jest readonly, każde porównanie używa stylu Yoda — w całej bazie kodu.

Do czego AI potrzebuje ludzi

  • Decydowanie które abstrakcje wprowadzić: czy SidebarData powinno być jednym value object czy czterema osobnymi wartościami zwrotnymi? AI może zaimplementować jedno i drugie; człowiek decyduje co jest czystsze.
  • Ocena kiedy interfejs dodaje wartość vs. obciążenie: serwis używany w jednym miejscu nie potrzebuje interfejsu dla elastyczności testowania. Zasada projektu mówi "każdy serwis dostaje interfejs" — ale to człowiek ustalił tę zasadę i człowiek może ją zmienić.
  • Wiedzieć kiedy przestać: trzy rundy jakości wystarczą. Baza kodu jest czysta. Czwarta runda mikro-optymalizacji byłaby over-engineeringiem.

Pętla zwrotna AGENTS.md

Każda runda odkrywa wzorce warte udokumentowania. Runda 1 dodała konwencję value objects do AGENTS.md. Runda 2 dodała zasadę nazewnictwa interfejsów. Runda 3 dodała zasadę "zero zakodowanych URL-i".

Następnym razem gdy Claude Code generuje nowy serwis, od razu stosuje nauki ze wszystkich trzech rund. Pierwsza wersja serwisu teraz wysyłana jest z interfejsem, używa value objects dla złożonych zwrotów i odwołuje się do nazwanych tras zamiast zakodowanych ścieżek. Iteracja kumuluje się.

Rezultat

Po trzech rundach stan bazy kodu:

  • 53 pliki PHP, wszystkie z declare(strict_types=1), jawnymi typami zwrotnymi, warunkami Yoda
  • 12 interfejsów serwisów — każdy serwis wstrzykiwany przez interfejs
  • 7 value objects — zero tablic asocjacyjnych dla wielopolowych zwrotów
  • 1 enum — bezpieczne typowo warianty layoutu kart
  • 25 komponentów Twig — reużywalne, współdzielone między stroną a styleguide'em
  • 0 problemów PHP CS Fixer, 0 błędów PHPStan na poziomie 6
  • 0 zakodowanych URL-i w szablonach i kontrolerach

Nic z tego nie było w pierwszej wersji. Pierwsza wersja miała wstrzyknięcia konkretnych klas, zwroty tablic, monolityczne szablony i zakodowane sprawdzenia locale. Działała — strona się budowała, strony się renderowały, użytkownicy mogli czytać posty na blogu.

Różnica to utrzymywalność. Dodanie systemu wielojęzyczności (poprzedni wpis) było proste, bo baza kodu była już czysta: interfejsy dla wszystkiego, typowane zwroty, czyste rozdzielenie odpowiedzialności. Refaktoryzacja czystej bazy kodu jest szybka. Refaktoryzacja bałaganu jest wolna i podatna na błędy.

Wyślij najpierw. Iteruj potem. Używaj AI do systematycznej pracy, którą solowy deweloper pominąłby — lub odłożył aż stanie się problemem. Trzy skupione rundy, każda budująca na poprzedniej, każda zweryfikowana automatycznymi narzędziami. Kod jest mierzalnie lepszy, a inwestycja to kilka godzin cykli przeglądów, nie tygodniowy rewrite.