Features

Authentication

  • Email/password registration with email verification (symfonycasts/verify-email-bundle)
  • Login with rate limiting — 5 attempts per 15 minutes (Symfony rate limiter)
  • Logout is CSRF-protected (enable_csrf: true; test environment disables this override to allow programmatic POST)
  • Remember me (7-day session)
  • Password reset via email (symfonycasts/reset-password-bundle)
  • OAuth login: Google, Apple, Facebook (knpuniversity/oauth2-client-bundle)
  • Two-Factor Authentication — TOTP-based 2FA via any authenticator app (Google Authenticator, Authy). Users enable it from their account page by scanning a QR code. If they lose their phone, a time-limited recovery link is sent to their email. Powered by scheb/2fa-bundle + spomky-labs/otphp.
  • Passkeys — WebAuthn-based passwordless login using a device biometric or PIN (Face ID, Touch ID, Windows Hello, or a hardware security key). Passkeys are inherently multi-factor and bypass TOTP 2FA. Users register and delete passkeys from their account page. On the login page, a "Sign in with a Passkey" button triggers the ceremony. Powered by web-auth/webauthn-symfony-bundle + @simplewebauthn/browser.
  • Re-send verification email with its own rate limit

Session Management

Users can view all active sessions (device/browser label, IP address, creation time, last active time) at /my-account/sessions. Each session can be revoked individually; a "Sign out all other sessions" button revokes every session except the current one in one click. Revoking the current session logs the user out immediately. An event subscriber (SessionActivityListener) lazily creates session records on first authenticated request and detects revocation on subsequent requests — redirecting to /login within seconds of revocation. Session activity (lastActiveAt) is updated via DBAL every 5 minutes to avoid excessive DB writes. When the same browser re-logs in after revocation, a new session record is created rather than reactivating the revoked one (findByUserAndUserAgent only matches active sessions).

GDPR

  • Analytics notice — a fixed bottom bar shown on first visit informing users of cookieless analytics. Stores accepted in localStorage under the key analytics_notice_dismissed when dismissed, and fires a notice:dismissed DOM event. PostHog runs in cookieless mode and does not require consent; the notice is future-proofing for child sites that add cookie-dependent third parties, which should listen for notice:dismissed before initialising.
  • Contact & Legal page — a static page at /contact displaying the company's registered name, address, company registration number, and contact email address. Required by UK/EU law. Content is driven by src/Landing/Resources/content/contact.yaml — update the placeholder values when deploying a child site. Linked from the footer.
  • Data export — users can download a JSON file of all personal data held about their account (/my-account/data-export). Includes profile, login history (up to 500 events), sessions (up to 100), connected OAuth providers, passkeys, and preferences. Rate-limited to 3 downloads per day per user. Requires full authentication (no remember-me).
  • Email unsubscribe — every transactional email includes an unsubscribe link (/unsubscribe/{token}) that works without logging in. The link leads to a confirmation page; clicking "Confirm unsubscribe" sets marketingEmails = false on the user's account. The confirmation step prevents email security scanners (which pre-fetch links) from auto-unsubscribing users. The link uses a 64-character hex token stored on the User entity and always persisted on creation. Visiting the link after unsubscribing shows an "already unsubscribed" confirmation. The unsubscribe preference is the same field as the My Account marketing toggle — they are in sync.

Account Management (/my-account)

The settings page uses a sticky sidebar navigation with three named groups (Security, Activity, Preferences). A "Delete Account" link appears below a divider at the bottom of the sidebar, anchoring to the Danger Zone section at the bottom of the page — not visible on first scroll.

  • Profile — first name, last name
  • Change email — sends verification link to new address, notification to old
  • Change password — requires current password confirmation
  • Marketing email opt-in/out
  • Account deletion — requires reason selection, blocked for active subscribers
  • OAuth disconnect — remove a linked social provider from the "Connected Accounts" section. Blocked if it is the only login method and neither a password nor a passkey is registered (user must set a password or register a passkey first).
  • Login History — per-user audit log at /my-account/login-history showing the 50 most recent sign-in attempts. Each row shows success/failure, method (password/google/facebook/apple/passkey), timestamp, IP address, and device label. Failed password attempts are recorded even when the email is unknown (user = null). OAuth and passkey failures are not recorded. The "Login History" card in the Activity section on /my-account shows the date of last sign-in.

Billing

Stripe Checkout (hosted) handles all payment UI — no custom card forms. Stripe Customer Portal handles cancellation and payment method updates. Plan changes (upgrade, downgrade, interval switch) are handled in-app via a two-step preview → confirm flow — see Billing.md.

Subscription tiers: normal and pro. Intervals: monthly and annual. The Payment entity also supports one-off payments for service-style flows (e.g. pay-per-report) wired up per child site.

Roles granted by billing:

Role Granted when
ROLE_SUBSCRIBER Active/trialing/past_due subscription
ROLE_PRO Active/trialing/past_due subscription on the pro plan

past_due keeps access — Stripe is retrying and the user gets the benefit of the doubt during the ~7–14 day retry window. BillingService::hasAccess() and BillingService::hasProAccess() are the single source of truth — never read subscription status directly in controllers or templates.

Grace period: An admin can grant a manual grace period via billing:grant-grace-period user@example.com 30. A user with an active grace period retains access regardless of subscription status. Grace period is stored as graceUntil on the Subscription entity.

Two access models (configured per child site in security.yaml):

Model How
A — subscription gate ^/app requires ROLE_SUBSCRIBER. Stripe Checkout is the only way to unlock /app.
B — open + pay-per-use ^/app requires ROLE_USER. Content is free by default; individual features are gated behind one-off Stripe Checkout sessions.

Services:

Service Responsibility
StripeService Thin SDK wrapper — creates customers, checkout/portal sessions, constructs webhook events, retrieves invoices/subscriptions
BillingService Business logic — access checks, role management, checkout activation, subscription sync, grace periods

Entities: Subscription (OneToOne with User), Payment (ManyToOne with User). Both use onDelete: 'CASCADE' so account deletion removes all billing data automatically. Billing data is also included in the GDPR data export.

Stripe Tax: automatic_tax: true is set on all checkout sessions — Stripe handles UK/EU VAT automatically.

Apple Pay / Google Pay / PayPal: Stripe Checkout renders these automatically when available on the customer's device. No extra integration needed. Apple Pay additionally requires a domain verification file served at /.well-known/apple-developer-merchantid-domain-association — download it from Stripe Dashboard → Settings → Payment methods → Apple Pay and place it at public/.well-known/apple-developer-merchantid-domain-association. The skeleton ships a placeholder at that path as a reminder.

Pages:

Route Access Description
/pricing Public Pricing page with monthly/annual toggle and plan cards. CTAs adapt based on auth state and subscription status.
/my-account/billing ROLE_USER Subscription status, plan details, grace period and cancellation alerts, Stripe invoice history, and payment history.
/my-account/billing/subscription/change/preview ROLE_USER Plan change preview — shows exact Stripe invoice (line items, total due now) before the change is committed. Accepts plan and interval query params.
/my-account/billing/subscription/change ROLE_USER Executes a plan change (POST). Requires CSRF. Immediately charges via always_invoice proration.
/my-account/billing/success Public Post-checkout confirmation page. Public so the page renders even if the browser session cookie was lost during the Stripe redirect. A valid cs_ session_id query parameter is required; otherwise redirects to the billing page. Shows "Go to App" for authenticated users, "Log in" prompt for anonymous users.

Webhook: POST /webhook/stripe — public endpoint (no session auth). Stripe signs every request; StripeService::constructWebhookEvent() verifies the signature. Handles checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, invoice.payment_succeeded, and invoice.payment_failed.

Access Control

Path Required role
/app/* ROLE_SUBSCRIBER
/my-account/* ROLE_USER

Unverified users accessing /app/* are redirected to My Account by VerifiedEmailSubscriber.

Role routing after login: ROLE_SUBSCRIBER/app, ROLE_USER/my-account.

ROLE_SUBSCRIBER and ROLE_PRO are resolved live from the database by SubscriptionVoter, so access is granted immediately after a Stripe webhook activates the subscription — no re-login required. See Billing — SubscriptionVoter for details.

Developer Tools

  • Fixtures/seeder — two seed users (user@example.com / subscriber@example.com, password: password)
  • Dev-only controller at /dev/* (guarded by a NotFoundHttpException in the constructor when APP_ENV !== dev — there is no firewall rule; the constructor guard is the only protection)
  • Custom Symfony profiler data collector (DevCollector)
  • Makefile — shortcuts for common tasks

SEO

Every page inherits a full SEO head from base.html.twig via the {% block seo %} / _seo.html.twig partial pattern.

  • Title<title> renders as Page Name — App Name. The homepage renders as just App Name (no suffix) because its seo.title equals app_name.
  • Meta description — set per-page on all public pages via seo.description. Empty by default.
  • Robots — defaults to index, follow. Set seo.robots: 'noindex, nofollow' on private and transient pages.
  • Canonical URL — auto-generated from the request path; query strings are stripped.
  • Open Graphog:type, og:url, og:title, og:description derived from the seo dict. og:image is a placeholder — fill it in base.html.twig for a child site's site-wide OG image.
  • Twitter Cardtwitter:card: summary (or summary_large_image when og_image is set), title and description from the same dict.
  • JSON-LD<script type="application/ld+json"> structured data via _jsonld.html.twig. Homepage uses WebSite schema; contact page uses Organization; other public pages use WebPage. Auth and private pages have no JSON-LD. Powered by Twig's json_encode filter — no hand-rolled JSON strings.
  • Sitemappublic/sitemap.xml is generated by php bin/console app:sitemap:generate (src/Seo/Command/GenerateSitemapCommand.php). Run automatically on every deploy. The file is gitignored — it is generated, not committed. URLs are built using DEFAULT_URI from .env.local. When adding a new public page to a child site, add its route to the command's urls() method.

When building a new page, see the SEO section of the Agent Guide for the required {% block seo %} pattern.

AI Integration

AiService (src/Core/Ai/Service/AiService.php) is a thin wrapper around the Anthropic Messages API. It ships in Core so every child site can build AI-powered features on top of it without re-implementing the HTTP call.

Model: claude-haiku-4-5 — cheap and fast, appropriate for summaries, greetings, and light generation tasks. Swap to claude-sonnet-5 or claude-opus-5 in the service for more demanding reasoning tasks.

Configuration: Set ANTHROPIC_API_KEY in .env.local. When empty, isConfigured() returns false and any AI feature degrades gracefully (returns null) — no errors thrown. Set it to mock to activate mock mode (see below).

Caching: Use AiService::generateCached() rather than generate() directly — it wraps the call in a per-user-per-day response cache (cache.app) with key convention ai_{callType}_resp_{userId}_{Y-m-d}. Successful responses are cached for 24 hours; null results (rate-limited or API error) are never cached so the next request retries immediately.

Rate limiting: All limits live in config/packages/ai.yaml under ai.calls. Each call type declares user (all subscribers), user_pro (Pro tier, falls back to user if omitted), and optionally global (site-wide daily cap, unlimited if omitted). The service enforces these via cache-based counters that reset at midnight.

Known limitation — counter atomicity: The PSR-6 cache interface has no atomic increment, so two concurrent requests for the same user can both read the same counter value and both write the same post-increment result — allowing a one-generation bypass per concurrent pair. In practice this only matters if a user double-submits within milliseconds. When ready to harden, switch the cache backend to Redis and replace the increment() method with a direct Redis::incr() call, or add symfony/lock and wrap the read-write in a named lock keyed to the user + call type.

Demo: The skeleton ships two demos annotated with // DEMO: comments throughout — intended to be removed when building a child site:

  • AI greeting (/app) — a personalised fun sentence about the user's first name, cached once per day. See DashboardController.
  • Report summary (/app/report-summary) — a fake weekly summary demonstrating tier-based limits (standard: 1/day, Pro: 5/day) with informative flash messages and a force-regenerate button. See ReportSummaryController.

Mock mode: Set ANTHROPIC_API_KEY=mock in .env.local. AiService returns canned responses (defined in ai.yaml under ai.mock_responses) without making any HTTP request. Rate limiting and caching run in full — the entire feature flow is exercisable without spending tokens. The profiler shows a "Mock mode" banner and a MOCK badge on each mock call entry.

Profiler panel: In dev, an "AI" entry appears in the Symfony toolbar showing every generate() and generateCached() call this request — outcome (generated / cache hit / rate limited / API error), tokens, duration, prompt, and response. The rate limit config table includes a Reset button per call type to clear counters mid-session without running make cc. A "Mock mode" warning banner appears when ANTHROPIC_API_KEY=mock.

make cc-ai: Clears cache.app only — resets AI response caches and rate limit counters without the full cache:clear container rebuild. Use this during AI feature development instead of make cc.

Email & Analytics

Two distinct external services handle outbound communication and user tracking:

Resend (resend+api://...) — transactional email only. Every email the app sends (verification, password reset, email-change notification, unsubscribe confirmation) goes through Resend in production. In local dev, Mailpit intercepts all outgoing mail so nothing reaches the real world. Configured via MAILER_DSN, MAILER_FROM_ADDRESS, and MAILER_FROM_NAME.

PostHog — product/behavioural analytics only, no email. Runs in cookieless mode (persistence: 'memory') so no consent is required for data capture. Events tracked out of the box: user_registered, user_login, user_login_failed, ai_report_summary_generated (and any events added by child sites via PostHogService::capture()). PostHog does not send any emails or contact users — it is purely server-side event capture. Configured via POSTHOG_API_KEY and POSTHOG_HOST. Leave both empty to disable analytics silently.

Infrastructure

  • Docker Compose — local dev with PHP, Nginx, PostgreSQL, Mailpit
  • Production Docker Compose — same services, hardened, opcache enabled
  • Webpack Encore — Bootstrap 5, Bootstrap Icons, SCSS
  • GitHub Actions — test → deploy pipeline (push to main)
  • Resend — transactional email in production (see Email & Analytics above)
  • PostHog — cookieless product analytics (see Email & Analytics above)
  • Stripe — payment processing, subscription management, webhooks
  • Cloudflare Turnstile — bot protection on the registration and password reset forms. Widget rendered client-side; server-side token verification via TurnstileVerifier. Gracefully degrades — if keys are not set, the widget is hidden and verification is skipped.