79 Bugs in a Symfony Codebase That Passed PHPStan
ai,php,static-sitePart 2 of 5
This is part 2 of a series on preparing notACMS for open-source release. Part 1 covers the test suite. The original WordPress to Symfony series covers the migration itself.
The code passed PHPStan level 6. PHP CS Fixer had nothing to complain about. Rector's dry-run was clean. I had reviewed every file myself. I was ready to open-source it.
Then I asked an AI to audit it against a 200-line instruction file. It found 79 issues.
The Setup: AGENTS.md as an Audit Manual
Not "review my code" — that produces generic feedback about error handling and edge cases. Instead, a detailed checklist in AGENTS.md:
- Architecture principles — final classes only, interface segregation, value objects over associative arrays
- Naming conventions —
XxxInterface→Xxx,public constin interfaces - Security patterns — path traversal guards, redirect validation, input sanitisation
- Code style rules — Yoda conditions, blank line before return, strict types
- Documentation sync — service tables matching
src/, variable tables matching_variables.scss
The AI didn't guess what to look for. It followed instructions. The difference is the same as between a code review from someone who knows your project and one from someone who doesn't.
What AI Caught That I Missed
Mutable cached state — ContentTree::setIncludeDrafts() was mutating a cached object. The same tree instance was shared across requests, so toggling drafts on one request affected the next. A race condition waiting to happen. The fix: make ContentTree immutable and move draft filtering to the service layer.
SOLID violations — SiteConfigServiceInterface had 16 methods. The project rule is max 5. ContentTree had 20+ methods doing data structure, query engine, and recommendation scoring — three jobs in one class. The fix: extract RelatedPostsService (73-line scoring algorithm → separate service with its own interface).
Duplicated logic — readingTime() and excerpt() both doing strip_tags + str_word_count on the same HTML content. Extracted to a shared getPlainText() method.
Accessibility gaps — Nested <label> elements in the contact form (invalid HTML), missing role="alert" on error spans, aria-current="true" instead of "page". Each a one-line fix, but invisible without a checklist.
Documentation rot — docs/STYLEGUIDE.md listing Bootstrap blue (#0d6efd) instead of the actual green (#2d8a4e). docs/ARCHITECTURE.md referencing phantom files: tagline.js, contact_widget.html.twig, _header.scss — none of which existed.
What AI Got Wrong (False Positives)
CSRF on the contact form — flagged as a vulnerability, but actually intentional. Static pre-rendered forms can't use session-bound CSRF tokens — the token is baked into the HTML at build time, tied to the build server's session. Every visitor gets the same token. Turnstile + X-Requested-With header provide the real defense.
User input in 404 error messages — flagged as enumeration risk, but these messages only appear during the static build, not to visitors. Symfony's production error pages don't expose exception messages.
"Non-yoda" comparisons — flagged variable-vs-method-call patterns like $page->directoryKey() === $directoryKey, but the real yoda rule is about literals (null, false, strings) on the left. Variable-vs-variable comparisons don't need flipping.
What I Changed vs What I Accepted
About 60 issues fixed. About 19 accepted as-is:
- Duplicate preview services —
DraftPreviewServiceandScheduledPreviewServiceare nearly identical, but each is 3 lines. Parameterizing them would require a ServiceLocator, enum injection, and custom serialization on the data collector. More complexity than the original. Accepted. - Wide
SiteConfigServiceInterface— 16 methods, but it's a config object, not a business service. Splitting it touches 20 files for annotation tidiness only. Accepted.
The fixes that mattered:
// Before: mutable cached tree
$tree->setIncludeDrafts(true); // mutates shared instance
// After: immutable tree, filtering at service layer
$posts = $this->contentService->getPosts($locale, includeDrafts: true);
// Before: 73-line scoring algorithm in ContentTree
public function getRelatedPosts(ContentItem $post, int $limit = 3): array
{
// ... 73 lines of tag/category/series scoring
}
// After: separate service
final class RelatedPostsService implements RelatedPostsServiceInterface
{
public function findRelated(ContentItem $post, ContentTree $tree, int $limit = 3): array
{
// ... same logic, but isolated
}
}
The Lesson
"Review my code" produces generic feedback. "Audit against these 200 lines of project standards" produces specific, actionable findings. AGENTS.md isn't just for building — it's for reviewing too. The AI caught things I'd looked at dozens of times because it was checking against rules, not relying on familiarity.
The next post covers the security vulnerabilities among those 79 issues: XSS in search, open redirects, and path traversal on a "static" site.