Iterating on Architecture with AI — Components, Quality Rounds, and Refactoring Cycles
ai,symfony,php,static-site,architecturePart 11 of 11
- 19 Years of WordPress — Why I Finally Quit
- Symfony as a Static Site Generator — How holas.pl Works
- Securing a Static Site's Only Dynamic Endpoint — The Contact Form
- Building a Tool Decision Tree for Claude Code with Global Memory
- Developer Experience — From Local Dev to Production in Two Containers
- Building holas.pl with AI — Claude Code, MCP, and Local Image Generation
- 4×100 on Lighthouse Mobile — What a Static Site Actually Gets You
- SEO Engineering on a Static Site — Structured Data, Social Cards, and Crawler Signals
- Responsive Images and Scheduled Posts on a Static Site — Build-Time Solutions
- Multilanguage on a Static Site — Configuration Over Code
- Iterating on Architecture with AI — Components, Quality Rounds, and Refactoring Cycles
This is part 10 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. Part 9 covers the multilanguage system.
Part 5 covered how Claude Code was used during the initial build — AGENTS.md as the AI's instruction manual, generating services and templates from conventions, translating content. This post covers what happens after: the site works, the build passes, but the architecture has rough edges. Three rounds of quality improvements, component extraction, and namespace reorganization — all driven by AI-assisted review cycles.
Why Iterate?
The first working version prioritizes shipping. Get the content online, make the build pass, deploy to the Raspberry Pi. Technical debt accumulates naturally along the way: methods return associative arrays instead of value objects, services are injected as concrete classes instead of interfaces, templates grow into monoliths.
On a team, code review catches these patterns. On a solo project, there's nobody to review your pull requests. AI fills that gap — not as a rubber stamp, but as a systematic reviewer that reads every file and reports issues with line numbers.
The iteration cycle:
- Ask the AI to audit for specific patterns (strict types, SOLID violations, DRY issues)
- AI reads every PHP file, reports issues with file paths and line numbers
- Plan the fixes in a
.plans/file with checkboxes - Implement phase by phase, verify with
ddev code-checkafter each - Repeat with the next quality focus
Each round has a specific scope. Trying to fix everything at once leads to noisy diffs and missed regressions. Focused rounds produce reviewable, verifiable changes.
Round 1 — Value Objects Over Arrays
The first round targeted the project's own rule: "Never return associative arrays for complex data."
The Problem
MarkdownParser::parse() returned an array:
// Before
public function parse(string $markdown): array
{
// ...
return [
'frontMatter' => $frontMatter,
'html' => $html,
];
}
// Consumer
$result = $this->parser->parse($content);
$frontMatter = $result['frontMatter']; // no type safety
$html = $result['html']; // typo = silent bug
Same pattern for adjacent post navigation — ContentTree::getAdjacentPosts() returned ['prev' => $post, 'next' => $post].
The issues: no type safety, no IDE autocomplete, PHPStan can't catch a typo in $result['htlm'].
The Fix
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,
) {
}
}
Now the parser returns ParsedMarkdown, the consumer accesses $parsed->frontMatter and $parsed->html, and PHPStan catches any property name typo at analysis time.
Seven value objects were created or moved in this round:
| Value Object | Replaces | Properties |
|---|---|---|
ParsedMarkdown |
['frontMatter', 'html'] array |
frontMatter, html |
AdjacentPosts |
['prev', 'next'] array |
prev, next |
ArchiveMonth |
inline array | year, month, count |
CategoryCount |
inline array | slug, count |
TagCount |
inline array | slug, count |
SidebarData |
multiple return values | recentPosts, categories, tags, archiveMonths |
RenderResult |
ad-hoc stats | pages, skipped, errors |
All are readonly, use constructor property promotion, and live in src/Content/ValueObject/.
Round 2 — Interfaces for Everything
The second round enforced another project rule: "Every injectable class in src/Service/ must have a corresponding interface."
The Problem
Two services were injected as concrete classes:
// Before
public function __construct(
private readonly ContentTreeBuilder $builder,
private readonly MarkdownParser $parser,
) {
}
This worked, but it violated the dependency inversion principle. The rest of the codebase already injected via interfaces (ContentServiceInterface, ImageResizerInterface). These two were the exceptions.
The Fix
interface MarkdownParserInterface
{
public function parse(string $markdown): ParsedMarkdown;
}
interface ContentTreeBuilderInterface
{
public function build(string $locale): ContentTree;
}
// After
public function __construct(
private readonly ContentTreeBuilderInterface $builder,
private readonly MarkdownParserInterface $parser,
) {
}
The concrete classes implement the interfaces. Symfony's autowiring handles the binding. The rule is now enforced everywhere: 12 service interfaces across 4 subdirectories.
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
A secondary rule from this round: constants belong in the interface, not the concrete class. The concrete class inherits them via self::CONSTANT_NAME.
Round 3 — Eliminating Hardcoded Values
The third round targeted a subtler problem: values that work today but drift tomorrow.
Hardcoded URLs in Templates
The language switcher had hardcoded archive paths:
{# Before — breaks if routing changes #}
{% set url = locale is same as('pl') ? '/archive/' : '/pl/archiwum/' %}
{# After — uses named routes #}
{% set url = path('blog_archive_' ~ other, {year: archive_year, month: '%02d'|format(archive_month)}) %}
Same pattern in BlogController for cross-locale tag switching — hardcoded /pl/tag/ and /blog/ replaced with $this->generateUrl('blog_tag_'.$otherLocale, ...).
Hardcoded Image Breakpoints
The responsive image template had srcset widths hardcoded as strings. If the IMAGE_VARIANT_WIDTHS environment variable changed, the template would reference files that don't exist:
{# Before — template must match env config manually #}
srcset="...640w.webp 640w, ...960w.webp 960w, ..."
The fix: inject variant widths as a Twig global from SiteConfigExtension, then generate srcset dynamically. One source of truth for breakpoints.
Magic Numbers
Response::HTTP_BAD_REQUEST replaced a hardcoded 400. Small change, but consistent with the principle: every literal value is a future bug where someone changes the logic but not the number.
Twig Component Extraction
Between quality rounds, a separate effort extracted reusable components from monolithic templates.
The About Page
The about page was the worst offender — 146 lines mixing profile markup, expertise cards, skill pills, open source projects, and recommendation blockquotes in one template.
After extraction:
{# about.html.twig — clean and scannable #}
{% 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 %}
Eight components extracted from one page: about_profile, expertise_grid, skills_pills, opensource_grid, recommendation_card, about_cta, plus error_terminal and coming_soon_terminal from other pages.
The Styleguide Benefit
holas.pl has a dev-only styleguide at /styleguide/ that demonstrates all UI components. Before extraction, the styleguide hardcoded its own markup to show each component — which drifted from the real templates when changes were made.
After extraction, both the real site and the styleguide include the same component files:
{# styleguide.html.twig #}
{{ include('components/recommendation_card.html.twig', { rec: demo_recommendation, ... }) }}
{# about.html.twig #}
{{ include('components/recommendation_card.html.twig', { rec: rec, ... }) }}
Change the component once, both update automatically. The styleguide went from a manual sync burden to zero maintenance.
The component count grew from ~15 to 25 across these extractions.
Namespace Reorganization
The value object and interface work created enough files that flat namespaces became crowded:
# Before
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
# After
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 moved to Enum/ — it's a backed PHP enum for post card layout variants:
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());
}
}
Type-safe, auto-completable, impossible to typo. The cycle() method returns layout values that the blog listing template rotates through for visual variety.
The reorganization touched 46 files in a single commit — every use statement referencing a moved class needed updating. This is exactly the kind of mechanical refactoring where AI shines: change the namespace, update all imports, verify nothing broke. The human decides the target structure; the AI handles the tedious part.
The AI Workflow for Refactoring
The practical workflow behind these rounds:
Step 1: Scoped audit. Ask Claude Code to review all PHP files for a specific category of issues. Not "find all problems" — too vague. Instead: "check every file in src/ for methods returning associative arrays instead of value objects." The AI reads every file and reports specific issues with file paths and line numbers.
Step 2: Plan. Write a .plans/ file documenting what needs to change, which files are affected, and the implementation steps as checkboxes. The plan is the source of truth — not a summary of intent, but a detailed implementation spec.
Step 3: Phase-by-phase implementation. Execute one phase, run ddev code-check (PHP CS Fixer + PHPStan level 6), verify the build passes with ddev build. Move to the next phase.
Step 4: Verify. After the round is complete, run the full quality suite. The numbers for 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 PHP files, zero CS Fixer issues, zero PHPStan errors. The automated tools confirm what the review intended.
What AI Does Well in Refactoring
- Systematic file-by-file review: reads 53 files and reports every violation of a pattern. Humans skim; AI doesn't.
- Mechanical refactoring: renaming namespaces across 46 files, updating import statements, moving constants from concrete classes to interfaces.
- Consistency checks: verifying that every service has an interface, every value object is
readonly, every comparison uses Yoda style — across the entire codebase.
What AI Needs Humans For
- Deciding which abstractions to introduce: should
SidebarDatabe one value object or four separate return values? The AI can implement either; the human decides which is cleaner. - Judging when an interface adds value vs. overhead: a service used in one place doesn't need an interface for testing flexibility. The project rule says "every service gets an interface" — but the human decided that rule, and the human could change it.
- Knowing when to stop: three quality rounds is enough. The codebase is clean. A fourth round of micro-optimizations would be over-engineering.
The AGENTS.md Feedback Loop
Each round discovers patterns worth documenting. Round 1 added the value object convention to AGENTS.md. Round 2 added the interface naming rule. Round 3 added the "no hardcoded URLs" principle.
The next time Claude Code generates a new service, it follows all three rounds' learnings from the start. The first version of a service now ships with an interface, uses value objects for complex returns, and references named routes instead of hardcoded paths. The iteration compounds.
The Result
After three rounds, the codebase state:
- 53 PHP files, all with
declare(strict_types=1), explicit return types, Yoda conditions - 12 service interfaces — every service injected via interface
- 7 value objects — no associative arrays for multi-field returns
- 1 enum — type-safe card layout variants
- 25 Twig components — reusable, shared between site and styleguide
- 0 PHP CS Fixer issues, 0 PHPStan errors at level 6
- 0 hardcoded URLs in templates or controllers
None of this was in the first version. The first version had concrete injections, array returns, monolithic templates, and hardcoded locale checks. It worked — the site built, the pages rendered, users could read blog posts.
The difference is maintainability. Adding the multilanguage system (previous post) was straightforward because the codebase was already clean: interfaces for everything, typed returns, clear separation of concerns. Refactoring a clean codebase is fast. Refactoring a messy one is slow and error-prone.
Ship first. Iterate second. Use AI for the systematic work that a solo developer would skip — or postpone until it becomes a problem. Three focused rounds, each building on the last, each verified by automated tools. The code is measurably better, and the investment is a few hours of review cycles, not a week-long rewrite.