# Phase 7 — Quality Report

**Date:** 2026-10-03 (test counts refreshed 2026-10-05) · **Build:** commit `b4e71d6` onwards · **Environment:** local macOS, PHP 8.4.21, MySQL 9.7.1, Node 22, Chrome stable

Every result below was produced by a command in this repository and can be re-run. Nothing here is estimated.

## 1. Automated test suites

| Suite | Command | Result |
|---|---|---|
| PHP (Pest: Unit, Feature, Arch) | `./vendor/bin/pest` | **393 passed, 1,428 assertions** |
| Browser + accessibility (Playwright + axe) | `npm run test:browser` | **86 passed** (10 skipped: desktop-only or mobile-only tests run once each) |

The browser suite runs against a disposable `gf_portfolio_e2e` database, rebuilt and seeded on every run (ADR-044). It covers:
- axe WCAG 2.1 A/AA on 12 pages × 2 viewports, in light **and** dark mode (including section colour and image backgrounds on About);
- the light/dark switch (remembered across reloads) and the admin Appearance preview (visible to the editor only, then exited);
- horizontal overflow at 375/768/1024/1440;
- 3→2→1 grid reflow;
- the 7-item capabilities grid;
- 44×44px touch targets on mobile;
- the header CV button and mobile CTA bar;
- the mobile menu with and without JS (Escape, focus return);
- the skip link;
- client-side filters (no reload, combined filters, empty state, Back) and the no-JS filter fallback;
- project section navigation, related projects and previous/next;
- every contact state (empty, validation, loading/disabled, success, server error with the email fallback, server-side validation);
- the booking fallback with the provider blocked and with JS off;
- keyboard-operable carousel with a live position.

## 2. Code quality

| Check | Result |
|---|---|
| Pint (`./vendor/bin/pint --test`) | Clean |
| Larastan | **Level 7, 0 errors, no baseline.** Raised from 6 in this phase; the 20 level-7 findings were fixed in code, not suppressed. Level 8 reports 25 nullability findings, recorded as a follow-up |
| `composer audit` | No advisories |
| `npm audit` | 0 vulnerabilities |
| Debug statements | None (arch test bans `dd`, `dump`, `ray`, `var_dump`, `print_r`) |
| Unescaped output | `{!!` only in the four allowlisted files (rich text, JSON-LD, two plain-text emails); arch test |
| Strict types | Every file in `app/` (arch test) |

## 3. Performance (Lighthouse 12, mobile profile, simulated throttling)

Served by `php artisan serve --env=e2e` with production assets (`npm run build`). The PHP dev server does **not** gzip, so transfer sizes are worse than production. These are conservative numbers.

| Page | Performance | LCP | CLS | TBT | FCP | Accessibility | Best practices | SEO |
|---|---|---|---|---|---|---|---|---|
| Home | **99** | **2.1 s** | **0** | 20 ms | 1.4 s | 100 | 100 | 66* |
| Project detail | **99** | **2.0 s** | **0** | 0 ms | 1.3 s | 100 | 100 | 66* |
| Article detail | **99** | **2.0 s** | **0** | 0 ms | 1.2 s | 100 | 100 | 66* |

\* The only failing SEO audit is `is-crawlable`: every non-production page is deliberately `noindex` (MP §14.1). In production the pages are indexable; all other SEO audits pass, including meta description, title, link text and crawlable anchors.

All three pages are in the Core Web Vitals "Good" range (LCP ≤ 2.5 s, CLS ≤ 0.1; TBT ≈ 0 as the INP proxy).

**Changes this phase that got there** (first run: Performance 92–96, LCP 2.6–3.0 s):
1. Removed Alpine.js from the public site. Its only remaining job was the mobile menu's Escape handling, now 25 lines of vanilla JS. Core JS went from 72.4 KB to **4.6 KB** (2.0 KB gzipped) (ADR-043).
2. Switched the heading font to the weight-axis-only Source Serif 4 file: 122 KB → **51 KB**. The LCP element was the hero subtitle waiting on this font.
3. Added a meta description fallback from identity settings, so no page ships without one.

## 4. Database

**Indexes used by key queries** (`EXPLAIN FORMAT=TRADITIONAL` on the e2e database):

| Query | Access | Key |
|---|---|---|
| Live projects listing | range | `projects_published_at_index` |
| Featured projects | ref | `projects_is_featured_featured_order_index` |
| Project by slug | const | `projects_slug_unique` |
| System page | const | `pages_system_key_unique` |
| Homepage | const | `pages_homepage_flag_unique` |
| Visible sections of an owner | ref | `content_sections_owner_order_index` |
| Navigation items | ref | `navigation_items_navigation_menu_id_parent_id_sort_order_index` |
| Live articles | range | `articles_published_at_index` |
| Redirect lookup | ref | `redirects_is_active_index` |
| CV events (30 days) | ref | `analytics_events_event_index` |
| Experience timeline | range | `experiences_published_at_index` |
| Tags of a record | ref | `taggables_taggable_type_taggable_id_index` |

No full table scans. Section rendering loads each referenced entity type with **one query per page** (asserted in `SectionRendererTest`). Lazy loading, silently discarded attributes and missing attributes all throw in tests (arch test).

**Migration rollback sanity** (disposable e2e database):

| Check | Result |
|---|---|
| `migrate` → `migrate:reset` | **0 tables left** (excluding `migrations`) |
| `migrate:rollback --step=5` → `migrate` | Clean |
| `migrate:rollback` (last batch) → `migrate` | Clean |

Two defects were found and fixed:
- The vendor-published `settings`, `media` and `activity_log` migrations had no `down()`, so tables survived a reset.
- The settings-defaults migration had no `down()`, so a partial rollback and re-migrate failed with "setting already exists".

## 5. Routes

`php artisan route:list`: 90 routes, 20 public. **Every public route is named**, with no method+URI duplicates. The CMS catch-all is registered last. An arch test now asserts that every first URL segment used by a system route is a reserved slug. That test found Livewire 4's hashed `livewire-xxxx` prefix, so package prefixes (`livewire*`, `filament*`) are now reserved.

## 6. Security and permissions

- **Role matrix (MP §9):** enforced by policies and verified by:
  - `RolesAndPermissionsTest` (content abilities per role, system capabilities, system pages undeletable, inactive users denied, last Super Admin protected);
  - `AdminScreensTest` (31 screens × 3 roles over HTTP);
  - `SettingsPagesTest` (9 settings screens × 3 roles);
  - `AdminFlowsTest` (publish hidden from editors, last-Super-Admin demotion refused).
- **Headers/CSP:** nonce-based public CSP with integration sources (booking frames, analytics script, CAPTCHA) added only when enabled (`SecurityHeadersTest`, `BookingTest`, `AnalyticsSeoTest`).
- **Inputs:** sanitiser XSS payloads, video allowlist, upload MIME rules, honeypot/timing/CAPTCHA, rate limits (`ContentSanitizerTest`, `BlockSystemTest`, `ContactFormTest`).
- **Production mode:** with `APP_DEBUG=false` and all caches built, pages serve normally and error pages contain no stack traces.

## 7. Production build

| Command | Result |
|---|---|
| `npm run build` | OK. CSS 39 KB (8.3 KB gz), core JS 4.6 KB (2.0 KB gz), feature modules 1–3 KB each, loaded on demand |
| `composer install --no-dev` (dry run) | Removes 50 dev-only packages; nothing at runtime depends on them |
| `php artisan optimize` (config, events, routes, views) + `filament:optimize` + `icons:cache` | All succeed; the site serves every route with caches built |

## 8. Known limitations and follow-ups

| Item | Why | Next step |
|---|---|---|
| Booking embed positive path not observed | Needs the owner's real Calendly/Cal.com URL (the fallback path is verified) | Owner configures booking, then re-run `booking-carousel.spec.mjs` without the route block |
| Analytics provider dashboards not checked | No provider configured | Owner sets Plausible/Umami/GA4, then verify events in its dashboard (PRD §34) |
| Lighthouse measured without compression | PHP dev server limitation | Re-run on the production host |
| Larastan level 8 | 25 nullability findings | Optional hardening pass |
| `analytics_events` uses separate `event` and `occurred_at` indexes | Fine at expected volumes | Add a composite `(event, occurred_at)` index if the table grows large |
