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; the list updates live via React — no page reload needed. Deletion uses a
DELETE /my-account/passkeys/{id}JSON endpoint with CSRF viaX-Csrf-Tokenheader. On the login page, a "Sign in with a Passkey" button triggers the ceremony. Powered byweb-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
acceptedinlocalStorageunder the keyanalytics_notice_dismissedwhen dismissed, and fires anotice:dismissedDOM 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 fornotice:dismissedbefore initialising. - Contact & Legal page — a static page at
/contactdisplaying the company's registered name, address, company registration number, and contact email address. Required by UK/EU law. Content is driven bysrc/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, subscription, payment history, login history (up to 500 events), sessions (up to 100), connected OAuth providers, passkeys, and notifications (up to 500). 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" setsmarketingEmails = falseon 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 theUserentity 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-historyshowing 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-accountshows the date of last sign-in.
Notifications
Users receive in-app notifications for account events (password changes, email changes), billing events (grace period granted), and system-wide announcements. Notifications are stored per-user in the notification table and never shared across accounts.
Inbox icon in nav: An inbox button in the authenticated navbar shows a red badge with the unread count when there are unread notifications. The count is fetched once per page load — no polling. Clicking the bell opens a dropdown showing the 8 most recent notifications; each item is a link to the notification detail page, which marks it read server-side on arrival. When on the /my-account/notifications page, React additionally syncs the badge count in real time as notifications are marked read.
Notification page: /my-account/notifications renders a React component (NotificationPanel.jsx, webpack entry notifications) that mounts on #notification-panel-root. The component fetches notifications via GET /my-account/notifications with Accept: application/json, displays a two-panel layout (list left, detail right) on desktop, and handles all interaction client-side without page reloads. Selecting a notification updates the URL via history.pushState and marks it read via POST /my-account/notifications/{slug}/read. Direct navigation to /my-account/notifications/{slug} is also supported — the server marks the notification read and renders the same mount div; React detects the slug from the URL and auto-selects it. The nav badge (#notification-badge) is updated in the DOM by React whenever the unread count changes. The CSRF token for marking a notification read is embedded as data-csrf-read on the mount div.
URL slugs: Notification URLs use opaque Sqids slugs (e.g. /my-account/notifications/XFgt3a) instead of sequential integer IDs. The integer primary key is never exposed in URLs. NotificationSlugger encodes/decodes slugs; decode() validates the round-trip to reject arbitrary strings before any DB query. Security relies on the ownership guard (findOneByIdAndUser) — the slug is opacity only, not access control.
Sending notifications:
| Method | Description |
|---|---|
NotificationService::send(User, NotificationType, title, body, ?actionUrl, ?actionLabel) |
Send a notification to a single user. |
NotificationService::broadcast(NotificationType, title, body, ?actionUrl, ?actionLabel) |
Fan-out on write — single INSERT INTO notification ... SELECT FROM "user" via DBAL. Returns the count of notifications created. |
Console commands:
| Command | Description |
|---|---|
notification:send --user=EMAIL --type=TYPE --title=TITLE --body=BODY [--action-url=URL] [--action-label=LABEL] |
Send to a single user by email. |
notification:broadcast --type=TYPE --title=TITLE --body=BODY [--action-url=URL] [--action-label=LABEL] |
Broadcast to all users (prompts for confirmation). |
Notification types (NotificationType enum): account, billing, support, legal, announcement. Each maps to a coloured type badge and display label.
Body field: Accepts HTML — rendered via |raw in templates. Callers must sanitise or use htmlspecialchars() for user-supplied content (e.g., email address in the email-change notification). Use <p> tags and <strong> for emphasis — the .notification-body CSS class adds paragraph spacing.
Notifications vs email: Notifications are in-app only — a persistent record surfaced to logged-in users. Use a notification for anything worth showing on the account page after the fact ("your password was changed", "your grace period was extended"). Use email for anything the user needs to act on before their next login (verification links, password reset, 2FA codes). Some events legitimately do both (email change fires a verification email and a security-alert notification).
Active notification triggers — update this table whenever a new notification is added or removed:
| Event | Type | Title | Also sends email? | Source |
|---|---|---|---|---|
| Password changed | account |
"Your password was changed" | No | MyAccountController::changePassword() |
| Email change requested | account |
"Email address change requested" | Yes — verification link to new address | MyAccountController::changeEmail() |
| Passkey added | account |
"A new passkey was added to your account" | No | PasskeyController::register() |
| Passkey removed | account |
"A passkey was removed from your account" | No | PasskeyController::delete() / deleteJson() |
| 2FA enabled | account |
"Two-factor authentication enabled" | No | TwoFactorController::enable() |
| 2FA disabled | account |
"Two-factor authentication disabled" | No | TwoFactorController::disable() |
| Payment failed (Stripe webhook) | billing |
"Your last payment failed" | No | BillingService::handlePaymentFailed() |
| Subscription cancelled (Stripe webhook) | billing |
"Your subscription has been cancelled" | No | BillingService::fireSubscriptionEvents() |
| Plan or interval changed (Stripe webhook) | billing |
"Your plan has been updated" | No | BillingService::fireSubscriptionEvents() |
| Grace period granted | billing |
"Your access has been extended until…" | No | GrantGracePeriodCommand |
Pages:
| Route | Access | Description |
|---|---|---|
GET /my-account/notifications |
IS_AUTHENTICATED_FULLY |
React mount page (HTML) or JSON list when Accept: application/json. Route: my_account_notifications. |
GET /my-account/notifications/{slug} |
IS_AUTHENTICATED_FULLY |
HTML path: marks notification as read, renders the React mount page. JSON path (Accept: application/json): returns the notification object without marking it read (React marks read separately via POST /read). 404 if slug is invalid or belongs to another user. Route: my_account_notification_show. |
POST /my-account/notifications/{slug}/read |
IS_AUTHENTICATED_FULLY |
AJAX mark-as-read. CSRF token: notification_read. Returns 204 (JSON). Route: my_account_notification_read. |
Twig functions (available on all authenticated pages):
| Function | Returns |
|---|---|
notification_unread_count() |
int — unread count for the current user (0 if not authenticated) |
notifications_recent() |
Notification[] — up to 8 most recent notifications (empty array if not authenticated) |
notification_slug(notification) |
string — Sqids slug for use in path('my_account_notification_show', {slug: ...}) |
App Settings (/app/settings)
- Dark mode — users toggle between light and dark theme. Preference is stored as
themePreferenceon theUserentity (light|dark, nullable — defaults tolight). Thedata-themeattribute is set server-side on every page load from this value, so there is no client-side flash. Marketing pages are always light and ignore the preference.
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 (last 12), and a "View full payment history" link. |
/my-account/payment-history |
ROLE_USER |
Full paginated payment history (20 per page). Merges Stripe invoices (all pages via autoPagingIterator) with one-off Payment entity rows, sorted newest-first. Zero-amount invoices excluded. Gracefully degrades when Stripe is unavailable — DB payments still shown with an amber alert. Route: my_account_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 |
Two event subscribers enforce access on /app/* requests:
VerifiedEmailSubscriber— redirects unverified users to My Account with a "please verify your email" prompt.SubscriptionRequiredSubscriber— redirects verified users without an active subscription to/pricingwith a warning flash message. Priority 4 (runs afterVerifiedEmailSubscriber).
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 aNotFoundHttpExceptionin the constructor whenAPP_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 asPage Name — App Name. The homepage renders as justApp Name(no suffix) because itsseo.titleequalsapp_name. - Meta description — set per-page on all public pages via
seo.description. Empty by default. - Robots — defaults to
index, follow. Setseo.robots: 'noindex, nofollow'on private and transient pages. - Canonical URL — auto-generated from the request path; query strings are stripped.
- Open Graph —
og:type,og:site_name,og:url,og:title,og:description, andog:imagealways rendered.og:imagedefaults to/images/og-default.png; passseo.og_imageon a page to override. - Twitter Card —
twitter:cardis alwayssummary_large_image. Title, description, and image match the OG tags. - JSON-LD —
<script type="application/ld+json">structured data via_jsonld.html.twig. Homepage usesWebSiteschema; contact page usesOrganization; other public pages useWebPage. Auth and private pages have no JSON-LD. Powered by Twig'sjson_encodefilter — no hand-rolled JSON strings. - Sitemap —
public/sitemap.xmlis generated byphp bin/console app:sitemap:generate(src/Core/Seo/Command/GenerateSitemapCommand.php). Run automatically on every deploy. The file is gitignored — it is generated, not committed. URLs are built usingDEFAULT_URIfrom.env.local. When adding a new public page to a child site, add its route to the command'surls()method.
When building a new page, see the SEO section of Codebase 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 directRedis::incr()call, or addsymfony/lockand 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. SeeDashboardController. - 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. SeeReportSummaryController.
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.
Twig is the single source of truth for email HTML. Resend and other tools receive the rendered HTML from Symfony Mailer — no separate templates are maintained. To import email layouts into Resend, Mailchimp, or similar tools, run make email-preview, which renders all templates to static HTML preview files in resources/email/preview/. See Codebase — Transactional Email for the full design spec and CTA button pattern.
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 — Tailwind CSS v4, custom CSS token design system
- 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.