<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Matt Mochalkin</title>
    <description>The latest articles on DEV Community by Matt Mochalkin (@mattleads).</description>
    <link>https://dev.to/mattleads</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3458770%2F5dd4157c-9e79-4084-ad2e-56c3953d8c26.png</url>
      <title>DEV Community: Matt Mochalkin</title>
      <link>https://dev.to/mattleads</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mattleads"/>
    <language>en</language>
    <item>
      <title>Mastering Claude Code &amp; Gemini Code Assist: Implementing the Agent Skills Architecture</title>
      <dc:creator>Matt Mochalkin</dc:creator>
      <pubDate>Tue, 07 Apr 2026 15:26:07 +0000</pubDate>
      <link>https://dev.to/mattleads/mastering-claude-code-gemini-code-assist-implementing-the-agent-skills-architecture-271n</link>
      <guid>https://dev.to/mattleads/mastering-claude-code-gemini-code-assist-implementing-the-agent-skills-architecture-271n</guid>
      <description>&lt;p&gt;The landscape of AI-assisted development has fundamentally shifted from passive autocomplete to active, agentic workflows. Tools like &lt;strong&gt;Claude Code&lt;/strong&gt; (a powerful CLI agent) and &lt;strong&gt;Gemini Code Assist&lt;/strong&gt; (a deeply integrated IDE agent) can read your file system, execute terminal commands and navigate your project context.&lt;/p&gt;

&lt;p&gt;However, true power unlocks when you teach these agents your proprietary workflows. By utilizing &lt;strong&gt;Agent Skills&lt;/strong&gt; (markdown-based instructions) and &lt;strong&gt;MCP Servers&lt;/strong&gt; (active programmatic tools), you can transform these assistants into customized junior developers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architectural Hierarchy of Agent Skills
&lt;/h2&gt;

&lt;p&gt;Before writing code, we must understand how modern AI agents discover what they are allowed to do. The AI industry is converging on an open standard for defining skills via directory structures. When you launch &lt;strong&gt;Gemini Code Assist&lt;/strong&gt; or &lt;strong&gt;Claude Code&lt;/strong&gt; the engine scans specific file paths to load capabilities into its context window.&lt;/p&gt;

&lt;p&gt;There are three distinct tiers of skill discovery:&lt;/p&gt;

&lt;h3&gt;
  
  
  Workspace Skills (Project-Specific)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Locations:&lt;/strong&gt; .gemini/skills/, .claude/skills/ or the unified .agents/skills/ alias within your project’s root directory.&lt;/p&gt;

&lt;p&gt;These are isolated to the current repository. Use this tier for local database migration commands, project-specific deployment scripts or PR review guidelines unique to the team.&lt;/p&gt;

&lt;p&gt;Always commit this folder to version control so your entire team shares the same AI workflows.&lt;/p&gt;

&lt;h3&gt;
  
  
  User Skills (Global/Personal)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Locations:&lt;/strong&gt; ~/.gemini/skills/, ~/.claude/skills/ or ~/.agents/skills/ located in your system’s Home directory.&lt;/p&gt;

&lt;p&gt;These are your global, personal developer tools available across any project you open. Use this for your preferred Git commit formatting style, personal Docker cleanup scripts or customized syntax preferences.&lt;/p&gt;

&lt;p&gt;Keep these purely local. They represent how you like to work, keeping shared repositories free of personal configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Extension Skills (Bundled)
&lt;/h3&gt;

&lt;p&gt;When you install third-party extensions (like a Jira or AWS plugin) into your IDE or CLI, they bundle their own skills.&lt;/p&gt;

&lt;p&gt;These are generally read-only and managed by the extension provider, granting the AI immediate access to external APIs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Power of the&lt;/strong&gt; .agents/skills/ &lt;strong&gt;Alias:&lt;/strong&gt; &amp;gt; Using the .agents/skills/ directory is highly recommended. It acts as a universal standard. If you write a skill and place it in .agents/skills/, both &lt;strong&gt;Claude Code&lt;/strong&gt; and &lt;strong&gt;Gemini Code Assist&lt;/strong&gt; can theoretically discover and utilize it, making your custom developer tools perfectly portable across different AI ecosystems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building Skills
&lt;/h2&gt;

&lt;p&gt;Let’s create a shared team skill that teaches AI exactly how to scaffold a new React component according to your company’s strict architecture guidelines.&lt;/p&gt;

&lt;h3&gt;
  
  
  Create the Directory Structure
&lt;/h3&gt;

&lt;p&gt;In the root of your project terminal, create the folder using the universal alias:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; .agents/skills/scaffold-component
&lt;span class="nb"&gt;touch&lt;/span&gt; .agents/skills/scaffold-component/SKILL.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Write the Scaffolding Protocol Skill Definition
&lt;/h3&gt;

&lt;p&gt;Open &lt;strong&gt;SKILL.md&lt;/strong&gt;. This file acts as both the trigger and the execution instructions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scaffold-component&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Generates&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;new&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;React&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;component&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;strictly&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;following&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;company&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;architecture&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;rules&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(Tailwind,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;TypeScript,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Vitest)."&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="c1"&gt;# Component Scaffolding Protocol&lt;/span&gt;

&lt;span class="na"&gt;You have been asked to create a new UI component. You must strictly follow these local workspace rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="c1"&gt;## Rules of Execution&lt;/span&gt;
&lt;span class="s"&gt;1.  **Location:** All components must be created inside `src/components/`.&lt;/span&gt;
&lt;span class="s"&gt;2.  **Language:** Use TypeScript (`.tsx`).&lt;/span&gt;
&lt;span class="s"&gt;3.  **Styling:** You MUST use Tailwind CSS. Do not generate `.css` or `.scss` files.&lt;/span&gt;
&lt;span class="s"&gt;4.  **Testing:** For every component, you must generate an adjacent test file named `[ComponentName].test.tsx` using Vitest syntax.&lt;/span&gt;
&lt;span class="s"&gt;5.  **Exporting:** Always use named exports. Never use default exports.&lt;/span&gt;

&lt;span class="c1"&gt;## Execution Steps&lt;/span&gt;
&lt;span class="s"&gt;1. Ask the user for the name of the component if they haven't provided it.&lt;/span&gt;
&lt;span class="s"&gt;2. Generate the `.tsx` file.&lt;/span&gt;
&lt;span class="s"&gt;3. Generate the `.test.tsx` file.&lt;/span&gt;
&lt;span class="s"&gt;4. Run standard formatting on the newly created files.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is in the Workspace tier, when any developer on your team opens the AI Assistant chat and asks, “Can you scaffold a user profile card?”, AI reads this skill, adopts the rules and perfectly mimics your senior developer’s coding standards.&lt;/p&gt;

&lt;h3&gt;
  
  
  Write the Frontmatter and Logic Skill Definition
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;clean-docker&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Analyzes currently running Docker containers and safely stops/removes them.&lt;/span&gt;
&lt;span class="na"&gt;disable-model-invocation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="c1"&gt;# Docker Cleanup Assistant&lt;/span&gt;

&lt;span class="s"&gt;You are a DevOps assistant. The user wants to clean up their local Docker environment.&lt;/span&gt;

&lt;span class="c1"&gt;## Current Environment Context&lt;/span&gt;
&lt;span class="s"&gt;Here is the raw output of the user's current Docker processes&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
&lt;span class="kt"&gt;!&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="s"&gt;docker ps -a`&lt;/span&gt;

&lt;span class="c1"&gt;## Instructions&lt;/span&gt;
&lt;span class="s"&gt;1. Analyze the output provided above.&lt;/span&gt;
&lt;span class="s"&gt;2. Identify any containers that have a status of "Exited".&lt;/span&gt;
&lt;span class="s"&gt;3. Identify any containers that look like temporary test databases or dangling instances.&lt;/span&gt;
&lt;span class="s"&gt;4. Present a formatted list to the user summarizing what is currently running and what is stopped.&lt;/span&gt;
&lt;span class="s"&gt;5. Ask the user for confirmation on which containers they would like to remove.&lt;/span&gt;
&lt;span class="s"&gt;6. Once confirmed, use your bash execution tools to run `docker rm [CONTAINER_ID]`.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By setting &lt;strong&gt;disable-model-invocation: true&lt;/strong&gt;, we ensure &lt;strong&gt;Claude/Gemini&lt;/strong&gt; doesn’t randomly delete your containers. You must trigger this manually by typing /clean-docker in the Claude Code CLI. Claude reads the file, runs &lt;strong&gt;docker ps -a&lt;/strong&gt; in the background, injects the output into the prompt and then logically guides you through the cleanup process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architectural Best Practices for AI Skills
&lt;/h2&gt;

&lt;p&gt;As you build out your .agents/skills/ directories, keep these theoretical principles in mind to maintain a healthy codebase:&lt;/p&gt;

&lt;h3&gt;
  
  
  Principle of Least Privilege
&lt;/h3&gt;

&lt;p&gt;When giving an agent shell access or database access via MCP, restrict the API tokens and database users you provide to the script. If the agent hallucinates a &lt;strong&gt;DROP TABLE command&lt;/strong&gt;, your database user should lack the permissions to execute it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Semantic Prompt Weighting
&lt;/h3&gt;

&lt;p&gt;When writing the description in your YAML frontmatter remember that the description is the compiled code. The LLM uses semantic vector mapping to match a user’s intent to your tool description.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;strong&gt;Poor description:&lt;/strong&gt; “Manages versions”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;strong&gt;Excellent description:&lt;/strong&gt; “Increments the semantic version in package.json. Use this strictly when preparing a release or hotfix.”&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Context Window Pruning
&lt;/h3&gt;

&lt;p&gt;If your MCP servers return massive logs (e.g., fetching 10,000 lines of AWS CloudWatch logs), the AI will suffer from “Lost in the Middle” syndrome, forgetting its original instructions. Always write your tools to filter, summarize or paginate data before returning it to the SKILL.md context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integrating Third-Party MCP Servers and Cross-Skill Workflows
&lt;/h2&gt;

&lt;p&gt;However, the open-source community is rapidly building pre-made MCP servers that you can plug directly into your AI environment.&lt;/p&gt;

&lt;p&gt;We are going to install the &lt;strong&gt;&lt;a href="https://github.com/mattleads/telegramBotMcp" rel="noopener noreferrer"&gt;mattleads/telegramBotMcp&lt;/a&gt;&lt;/strong&gt; server to give our agent the ability to send messages and then we will update our existing skills to trigger a Telegram notification the moment they finish their work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Installing the Telegram Bot MCP Server
&lt;/h3&gt;

&lt;p&gt;While the exact installation command depends on how the repository is structured (Node.js vs. Python), the configuration principle in your agent settings remains the same. You need to provide the execution command and inject your secret &lt;strong&gt;Telegram Bot Token&lt;/strong&gt; as an environment variable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Get the Source Code
&lt;/h3&gt;

&lt;p&gt;Clone the repository to your machine and install its dependencies (assuming a standard Node.js/TypeScript MCP setup):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/path/to/your/tools
git clone https://github.com/mattleads/telegramBotMcp.git
&lt;span class="nb"&gt;cd &lt;/span&gt;telegramBotMcp
npm &lt;span class="nb"&gt;install
&lt;/span&gt;npm run build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Configure the Agent Settings
&lt;/h3&gt;

&lt;p&gt;Now, we must tell &lt;strong&gt;Claude Code&lt;/strong&gt; or &lt;strong&gt;Gemini CLI&lt;/strong&gt; where this server is and give it your API credentials.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;Gemini Code Assist:&lt;/strong&gt; Edit ~/.gemini/settings.json&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;Claude Desktop/CLI:&lt;/strong&gt; Edit ~/.claude/claude_desktop_config.json&lt;/p&gt;

&lt;p&gt;Add the Telegram server configuration alongside your existing tools. Notice how we pass the secure tokens via the env object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"telegram-notifier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"/path/to/tools/telegramBotMcp/build/index.js"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"TELEGRAM_BOT_TOKEN"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"123456789:ABCDEF_Your_Bot_Token_Here"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you restart your IDE or CLI, the agent will parse this MCP server and expose its tools (e.g. &lt;strong&gt;send_telegram_message&lt;/strong&gt;) to the context window.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cross-Skill Communication: The “Notification” Workflow
&lt;/h3&gt;

&lt;p&gt;Now for the magic. How do we make one skill trigger another?&lt;/p&gt;

&lt;p&gt;In the world of LLM agents, skills do not call other skills via hardcoded function pointers. Instead, the LLM orchestrates them. You create cross-skill communication by writing explicit instructions in your &lt;strong&gt;SKILL.md&lt;/strong&gt; file, telling the agent to invoke the &lt;strong&gt;Telegram tool&lt;/strong&gt; as its final execution step.&lt;/p&gt;

&lt;p&gt;Let’s upgrade the Workspace Skill we built earlier (scaffold-component) to include a Telegram notification workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update the SKILL.md&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Open your &lt;strong&gt;.agents/skills/scaffold-component/SKILL.md&lt;/strong&gt; file and add a new &lt;strong&gt;“Completion Protocol”&lt;/strong&gt; section.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scaffold-component&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Generates a new React component and notifies the team via Telegram when complete.&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="c1"&gt;# Component Scaffolding Protocol&lt;/span&gt;

&lt;span class="s"&gt;You are an expert frontend developer. Follow these instructions perfectly.&lt;/span&gt;

&lt;span class="c1"&gt;## Execution Steps&lt;/span&gt;
&lt;span class="s"&gt;1. Ask the user for the name of the component if not provided.&lt;/span&gt;
&lt;span class="s"&gt;2. Generate the `.tsx` file in `src/components/` using Tailwind CSS and named exports.&lt;/span&gt;
&lt;span class="s"&gt;3. Generate the `.test.tsx` file using Vitest.&lt;/span&gt;
&lt;span class="s"&gt;4. Run standard formatting on the files.&lt;/span&gt;

&lt;span class="c1"&gt;## Completion Protocol (Cross-Tool Notification)&lt;/span&gt;
&lt;span class="s"&gt;Once all files are created and formatted, you must alert the team that the component is ready for integration.&lt;/span&gt; 

&lt;span class="s"&gt;1. Use the `send_telegram_message` tool provided by your MCP environment.&lt;/span&gt;
&lt;span class="na"&gt;2. Format the message as follows&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
   &lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="s"&gt;🚀 Component Scaffolding Complete!`&lt;/span&gt;
   &lt;span class="s"&gt;`Name&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Component Name&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;
   &lt;span class="na"&gt;`Files created&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;List of files&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;
   &lt;span class="na"&gt;`Status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ready for review.`&lt;/span&gt;
&lt;span class="s"&gt;3. Send the message. Do not ask for the user's permission to send the notification, do it automatically as the final step of this workflow.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  How the Execution Loop Works
&lt;/h3&gt;

&lt;p&gt;When you type /&lt;strong&gt;scaffold-component UserProfile&lt;/strong&gt; in your terminal or IDE, here is the exact chronological flow the agent executes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read Rules:&lt;/strong&gt; The agent reads the .agents/skills/scaffold-component/SKILL.md file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File System Tools:&lt;/strong&gt; It uses its &lt;strong&gt;built-in Write_File tools&lt;/strong&gt; to create &lt;strong&gt;UserProfile.tsx&lt;/strong&gt; and &lt;strong&gt;UserProfile.test.tsx.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observation:&lt;/strong&gt; It observes that the files were created successfully.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool Transition:&lt;/strong&gt; It reads the &lt;strong&gt;“Completion Protocol”&lt;/strong&gt;. It searches its context window for a tool matching the description of sending Telegram messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP Execution:&lt;/strong&gt; It finds the send_telegram_message tool (injected via your settings.json) and outputs a JSON payload with the formatted text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delivery:&lt;/strong&gt; The &lt;strong&gt;&lt;a href="https://github.com/mattleads/telegramBotMcp" rel="noopener noreferrer"&gt;telegramBotMcp&lt;/a&gt;&lt;/strong&gt; local node script runs, hits the Telegram API and your phone buzzes with the update!&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By combining Markdown rules (&lt;strong&gt;SKILL.md&lt;/strong&gt;) with dynamic MCP servers (&lt;strong&gt;&lt;a href="https://github.com/mattleads/telegramBotMcp" rel="noopener noreferrer"&gt;telegramBotMcp&lt;/a&gt;&lt;/strong&gt;), you transform your agent from a simple code generator into a fully automated project manager.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;We have covered an incredible amount of ground in this guide. By moving beyond standard chat interfaces, you have learned how to fundamentally rewire your development environment. You are no longer just writing code; you are engineering your own AI-powered junior developer.&lt;/p&gt;

&lt;p&gt;Let’s recap the core architectural pillars you can now use to build your ultimate workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The Open Standard (.agents/skills/):&lt;/strong&gt; By utilizing Workspace and User skill tiers, you create highly organized, portable Markdown workflows that work seamlessly across both &lt;strong&gt;Claude Code&lt;/strong&gt; and &lt;strong&gt;Gemini Code Assist&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model Context Protocol (MCP):&lt;/strong&gt; You have unlocked the ability to bridge the gap between deterministic local scripts and probabilistic AI, allowing your agents to query databases, read APIs and perform complex logic safely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agentic Orchestration:&lt;/strong&gt; By chaining tools together — such as &lt;strong&gt;generating a React component&lt;/strong&gt; and autonomously firing off a &lt;strong&gt;Telegram notification&lt;/strong&gt; — you have built a true, multi-step agentic loop.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The beauty of modern AI coding assistants is their extensibility. Your IDE and your terminal are now blank canvases. Whether you want to build a skill that automatically reviews your pull requests against company security standards, a tool that manages your local Docker containers or a workflow that updates Jira tickets when you commit code, you now have the exact architectural blueprint to build it.&lt;/p&gt;

&lt;p&gt;I hope this definitive guide empowers you to start writing your own &lt;strong&gt;SKILL.md&lt;/strong&gt; files and using MCP servers today!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source Code:&lt;/strong&gt; You can find the full implementation and follow the TelegramBot MCP Server progress on GitHub: [&lt;a href="https://github.com/mattleads/telegramBotMcp" rel="noopener noreferrer"&gt;https://github.com/mattleads/telegramBotMcp&lt;/a&gt;]&lt;/p&gt;

&lt;h3&gt;
  
  
  Let’s Connect!
&lt;/h3&gt;

&lt;p&gt;If you found this helpful or have questions about the implementation, I’d love to hear from you. Let’s stay in touch and keep the conversation going across these platforms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LinkedIn: [&lt;a href="https://www.linkedin.com/in/matthew-mochalkin/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/matthew-mochalkin/&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;X (Twitter): [&lt;a href="https://x.com/MattLeads" rel="noopener noreferrer"&gt;https://x.com/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;Telegram: [&lt;a href="https://t.me/MattLeads" rel="noopener noreferrer"&gt;https://t.me/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;GitHub: [&lt;a href="https://github.com/mattleads" rel="noopener noreferrer"&gt;https://github.com/mattleads&lt;/a&gt;]&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>programming</category>
      <category>mcp</category>
    </item>
    <item>
      <title>10x Less RAM: The Senior Guide to Native JSON Streaming in Symfony</title>
      <dc:creator>Matt Mochalkin</dc:creator>
      <pubDate>Sat, 04 Apr 2026 14:55:09 +0000</pubDate>
      <link>https://dev.to/mattleads/10x-less-ram-the-senior-guide-to-native-json-streaming-in-symfony-4326</link>
      <guid>https://dev.to/mattleads/10x-less-ram-the-senior-guide-to-native-json-streaming-in-symfony-4326</guid>
      <description>&lt;p&gt;As PHP applications scale, they inevitably face the terrifying “OOM killer” (Out Of Memory). One of the most notorious culprits for memory exhaustion in a modern Symfony API is parsing massive JSON files or webhooks. When a partner throws a 2GB product catalog at your system, standard PHP functions simply surrender.&lt;/p&gt;

&lt;p&gt;Historically, developers relied on third-party libraries or complex chunking scripts to survive. However, with the stabilization of Symfony 7.4, the core team has provided a deeply integrated, native solution: the &lt;strong&gt;symfony/json-streamer&lt;/strong&gt; component.&lt;/p&gt;

&lt;p&gt;In this comprehensive, advanced guide, we will explore how to architect a bulletproof JSON streaming solution. We will learn how to bypass memory limits, &lt;strong&gt;stream directly into highly optimized Data Transfer Objects (DTOs)&lt;/strong&gt; and avoid the hidden memory traps that even senior developers fall into.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Memory Trap
&lt;/h2&gt;

&lt;p&gt;Why &lt;strong&gt;json_decode()&lt;/strong&gt; and &lt;strong&gt;Serializer&lt;/strong&gt; Fail? Before implementing the solution, we must understand the mechanics of the problem.&lt;/p&gt;

&lt;p&gt;The native PHP &lt;strong&gt;json_decode()&lt;/strong&gt; function — and by extension, the &lt;strong&gt;symfony/serializer&lt;/strong&gt; which relies on it — operates using a &lt;strong&gt;Document Object Model (DOM)&lt;/strong&gt; approach to parsing. This means it &lt;strong&gt;must load the entire JSON string into memory&lt;/strong&gt;, evaluate its syntax and then build a massive internal structure (an associative array or object tree) to represent it.&lt;/p&gt;

&lt;p&gt;If you have a &lt;strong&gt;100MB JSON file&lt;/strong&gt;, loading the string takes &lt;strong&gt;100MB&lt;/strong&gt;. Parsing it into a PHP array expands its memory footprint by &lt;strong&gt;3 to 5 times&lt;/strong&gt; due to PHP’s &lt;strong&gt;internal Hash Table overhead&lt;/strong&gt;. Suddenly, your background worker requires &lt;strong&gt;500MB of RAM&lt;/strong&gt; just to read a file! If you then pass this array to the Symfony Serializer to denormalize it into DTOs, you effectively hold the data in memory three separate times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Streaming (or Pull Parsing) solves this.&lt;/strong&gt; A streamer reads the JSON byte-by-byte from an I/O stream (like a file or an HTTP response). It keeps only a microscopic, constant amount of data in RAM — just enough to yield the current item before discarding it and moving to the next.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Native Solution
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Components and Installation
&lt;/h3&gt;

&lt;p&gt;Symfony 7.4 provides a native ecosystem for memory-safe processing through a combination of three distinct components working in unison:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;symfony/json-streamer:&lt;/strong&gt; Handles reading the raw bytes and tracking the JSON token states.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;symfony/type-info:&lt;/strong&gt; Provides strict, reflective typing instructions so the streamer knows exactly what PHP structures to build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;symfony/object-mapper (Optional but recommended):&lt;/strong&gt; A blazingly fast hydration tool that is significantly more memory-efficient than the traditional &lt;strong&gt;Serializer&lt;/strong&gt; for direct object-to-object mapping.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Installation and Verification Steps
&lt;/h3&gt;

&lt;p&gt;Open your terminal and require the core libraries in your Symfony 7.4 project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require symfony/json-streamer symfony/type-info symfony/http-client symfony/object-mapper
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Architecting the DTO
&lt;/h3&gt;

&lt;p&gt;The magic of streaming lies in Generators. Instead of loading a 2GB string into memory, parsing it into a 3GB associative array and then hydrating 250,000 objects (spiking your RAM to 5GB+), we read the file byte-by-byte over the network.&lt;/p&gt;

&lt;p&gt;As soon as a single JSON object is fully read, Symfony hydrates it into a typed DTO and yields it. Once you process that DTO (e.g., save it to the database) and move to the next loop iteration, the PHP garbage collector frees the memory.&lt;/p&gt;

&lt;p&gt;The JsonStreamer component works best with pure Data Transfer Objects (DTOs). These are classes that rely strictly on typed public properties.&lt;/p&gt;

&lt;p&gt;To achieve maximum performance, Symfony provides the &lt;strong&gt;#[JsonStreamable]&lt;/strong&gt; attribute. When applied, Symfony pre-generates highly optimized encoding and decoding PHP files during your cache warm-up, completely bypassing slow reflection during runtime!&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Dto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\JsonStreamer\Attribute\JsonStreamable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\JsonStreamer\Attribute\StreamedName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\TypeInfo\Type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[JsonStreamable]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductDto&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// You can map specific JSON keys to your PHP properties&lt;/span&gt;
    &lt;span class="na"&gt;#[StreamedName('@id')]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$sku&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nv"&gt;$price&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getListType&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;Type&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// We define the value type (ProductDto) and the key type (int)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;iterable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Critical Memory Trap (Type::list vs Type::iterable)
&lt;/h3&gt;

&lt;p&gt;This is the most crucial architectural decision in this entire guide.&lt;/p&gt;

&lt;p&gt;When instructing the &lt;strong&gt;JsonStreamer&lt;/strong&gt; on how to parse an array of JSON objects, developers instinctively reach for &lt;strong&gt;Type::list(Type::object(ProductDto::class))&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not do this for massive files.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you use &lt;strong&gt;Type::list()&lt;/strong&gt;, the streamer obediently reads the file chunk-by-chunk (saving string memory), but it takes every single hydrated DTO and stuffs them all into a single, massive PHP array before returning the final result. If your file has 250,000 items, your memory usage will instantly explode to over 2 Gigabytes.&lt;/p&gt;

&lt;p&gt;By using &lt;strong&gt;Type::iterable()&lt;/strong&gt; (as demonstrated in our DTO helper method above), the read() method &lt;strong&gt;instantly returns a PHP Generator&lt;/strong&gt;. As you iterate through your loop, the &lt;strong&gt;JsonStreamer&lt;/strong&gt; reads just enough bytes to build one object, yields it to you and allows PHP’s garbage collector to destroy it before building the next one. &lt;strong&gt;This drops memory usage from 2.4 GB down to a perfectly flat ~12 MB.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Building the Importer Service
&lt;/h3&gt;

&lt;p&gt;Let’s build a service that fetches a massive remote JSON file (like an upstream catalog) and parses it directly into our DTOs without spiking memory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Service&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Dto\ProductDto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\JsonStreamer\StreamReaderInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Contracts\HttpClient\HttpClientInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cd"&gt;/**
 * Handles the streaming of massive JSON product catalogs natively.
 */&lt;/span&gt;
&lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductImporter&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ProductImporterInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;HttpClientInterface&lt;/span&gt; &lt;span class="nv"&gt;$httpClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;// Inject the native stream reader&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;StreamReaderInterface&lt;/span&gt; &lt;span class="nv"&gt;$streamReader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * @param string $url The upstream API URL
     * @return \Generator&amp;lt;ProductDto&amp;gt;
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;importFromApi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;\Generator&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// 1. Initiate the request. HttpClient is asynchronous by default.&lt;/span&gt;
        &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'GET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// 2. We pass the raw HttpClient response directly into the reader!&lt;/span&gt;
        &lt;span class="c1"&gt;// It reads the network stream chunk by chunk automatically.&lt;/span&gt;
        &lt;span class="nv"&gt;$products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;streamReader&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toStream&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;ProductDto&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getListType&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

        &lt;span class="c1"&gt;// 3. Yield the hydrated DTOs one by one.&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$products&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Native Network Chunking:&lt;/strong&gt; We never call &lt;strong&gt;$response-&amp;gt;toArray()&lt;/strong&gt; or &lt;strong&gt;$response-&amp;gt;getContent()&lt;/strong&gt;. The &lt;strong&gt;streamReader-&amp;gt;read()&lt;/strong&gt; method interfaces directly with the raw socket, parsing bytes exactly as they arrive over the network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TypeInfo Integration:&lt;/strong&gt; By passing our custom &lt;strong&gt;Type::iterable()&lt;/strong&gt;, the component bypasses generic arrays and hydrates strictly typed properties instantly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Execution and Batching
&lt;/h2&gt;

&lt;p&gt;Background commands (like cron jobs or message queue workers) are where this logic belongs. Let’s create a command to execute our stream.&lt;/p&gt;

&lt;p&gt;We will also include advanced benchmarking techniques. To properly benchmark memory in PHP, we must use PHP &lt;strong&gt;memory_reset_peak_usage()&lt;/strong&gt; to ensure the Zend Memory Manager gives us an accurate reading of the current process, free from the inherited memory overhead of previous script executions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Command&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Service\ProductImporterInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Console\Attribute\AsCommand&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Console\Command\Command&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Console\Input\InputInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Console\Output\OutputInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Console\Style\SymfonyStyle&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;AsCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'app:sync-products'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Streams a massive product API incrementally.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SyncProductsCommand&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Command&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;ProductImporterInterface&lt;/span&gt; &lt;span class="nv"&gt;$importer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;InputInterface&lt;/span&gt; &lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;OutputInterface&lt;/span&gt; &lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$io&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SymfonyStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$io&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Starting Native Memory-Efficient Sync'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$startMemory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;memory_get_usage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// Fetch the generator&lt;/span&gt;
        &lt;span class="nv"&gt;$products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;importer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;importFromApi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://massive-catalog.example.com/api/products'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Iterate over the generator. Memory remains flat!&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$products&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// $product is an instance of App\Dto\ProductDto&lt;/span&gt;
            &lt;span class="c1"&gt;// E.g., $this-&amp;gt;entityManager-&amp;gt;persist($product);&lt;/span&gt;

            &lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="c1"&gt;// Example of batch clearing Doctrine to prevent database memory leaks&lt;/span&gt;
            &lt;span class="c1"&gt;// if ($count % 500 === 0) { $this-&amp;gt;entityManager-&amp;gt;flush(); $this-&amp;gt;entityManager-&amp;gt;clear(); }&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$endMemory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;memory_get_usage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$memoryUsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$endMemory&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$startMemory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="nv"&gt;$io&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Successfully processed %d products!'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="nv"&gt;$io&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Total memory consumed during loop: %.2f MB'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$memoryUsed&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Command&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SUCCESS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Going the Other Way: Writing Streams
&lt;/h2&gt;

&lt;p&gt;The component is bidirectional. If your application needs to serve a massive JSON file to a client, you can use the &lt;strong&gt;StreamWriterInterface&lt;/strong&gt; alongside a controller to prevent your web server from crashing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Controller&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Dto\ProductDto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Provider\ProductProviderInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\StreamedResponse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\JsonStreamer\StreamWriterInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Routing\Attribute\Route&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExportController&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;StreamWriterInterface&lt;/span&gt;    &lt;span class="nv"&gt;$streamWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;ProductProviderInterface&lt;/span&gt; &lt;span class="nv"&gt;$productProvider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="na"&gt;#[Route('/api/export', name: 'api_export', methods: ['GET'])]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;export&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;StreamedResponse&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StreamedResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Write directly to standard output&lt;/span&gt;
            &lt;span class="nv"&gt;$outputStream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;fopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'php://output'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'w'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="c1"&gt;// The StreamWriter converts the generator of DTOs directly into a JSON string stream&lt;/span&gt;
            &lt;span class="nv"&gt;$jsonStream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;streamWriter&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;productProvider&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getProducts&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                &lt;span class="nc"&gt;ProductDto&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;getListType&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="nb"&gt;fwrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$outputStream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$jsonStream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nb"&gt;fclose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$outputStream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'application/json'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By wrapping our logic inside Symfony’s native &lt;strong&gt;StreamedResponse&lt;/strong&gt;, the web server holds the connection open and sends the JSON chunks exactly as &lt;strong&gt;StreamWriterInterface&lt;/strong&gt; produces them. Your server’s memory will remain flat, allowing you to serve gigabytes of data concurrently &lt;strong&gt;without exhausting PHP FPM workers&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It’s one thing to say “streaming is better,” but as engineers, we demand proof. To validate the efficiency of the new &lt;strong&gt;symfony/json-streamer&lt;/strong&gt; we built a rigorous benchmark command comparing 6 different approaches to JSON processing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Methodology
&lt;/h3&gt;

&lt;p&gt;To ensure an absolutely fair “apples-to-apples” comparison, our methodology was strictly controlled:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The Dataset:&lt;/strong&gt; A locally &lt;strong&gt;generated benchmark_data.json&lt;/strong&gt; file containing exactly 250,000 product records.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolation:&lt;/strong&gt; Before each benchmark run, we explicitly called &lt;strong&gt;gc_collect_cycles()&lt;/strong&gt; to clear orphaned memory from the previous test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;True Peak Measurement:&lt;/strong&gt; We utilized PHP new &lt;strong&gt;memory_reset_peak_usage()&lt;/strong&gt; function immediately before starting the timer for each test. This guarantees that the peak memory reported was strictly caused by the current method, not leftover high-water marks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hydration Parity:&lt;/strong&gt; Where applicable, tests were designed to hydrate strict ProductDto objects to simulate real-world Symfony applications.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The Contenders
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Standard json_decode():&lt;/strong&gt; Loads the whole file and returns a massive associative array.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;json_decode() + Serializer-&amp;gt;denormalize():&lt;/strong&gt; Array hydration using the classic Symfony Serializer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serializer-&amp;gt;deserialize():&lt;/strong&gt; Direct string-to-object array hydration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;json_decode(false) + ObjectMapper-&amp;gt;map():&lt;/strong&gt; Decoding to stdClass and mapping (a known performance trick).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;halaxa/json-machine + ObjectMapper:&lt;/strong&gt; The industry-standard third-party pull parser, configured to yield stdClass for fast ObjectMapper hydration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native symfony/json-streamer:&lt;/strong&gt; The new native component reading from an fopen() stream.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+------------------------------+-----------+-------------+------------------------------------------------------------+
| Approach                     | Exec Time | Peak Memory | Notes                                                      |
+------------------------------+-----------+-------------+------------------------------------------------------------+
| 1. Standard json_decode()    | ~0.07s    | ~162 MB     | Fast, but memory scales linearly. Crashes on 1GB+ files.   |
| 2. Serializer-&amp;gt;denormalize() | ~4.49s    | ~163 MB     | Hydrates DTOs, but slow/heavy due to reflection.           |
| 3. Serializer-&amp;gt;deserialize() | ~5.07s    | ~340 MB     | Memory consumed by massive source string and object array. |
| 4. ObjectMapper-&amp;gt;map()       | ~3.64s    | ~174 MB     | Fast hydration, but 250k stdClass objects cause RAM spike. |
| 5. halaxa/json-machine       | ~4.95s    | ~12 MB      | Fast &amp;amp; flat memory via native optimizations.               |
| 6. symfony/json-streamer     | ~2.65s    | ~12 MB      | The Winner! Industry standard pull parser.                 |
+------------------------------+-----------+-------------+------------------------------------------------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Analyzing the Data
&lt;/h3&gt;

&lt;p&gt;The standard &lt;strong&gt;json_decode&lt;/strong&gt; approaches highlight the classic memory trap: memory usage scales proportionately with payload size.&lt;/p&gt;

&lt;p&gt;While &lt;strong&gt;halaxa/json-machine&lt;/strong&gt; combined with the &lt;strong&gt;ObjectMapper&lt;/strong&gt; proved to be an incredibly capable and memory-safe solution, &lt;strong&gt;the native Symfony JSON Streamer took the crown&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Because &lt;strong&gt;symfony/json-streamer&lt;/strong&gt; leverages cache-warmup code generation via the &lt;strong&gt;#[JsonStreamable]&lt;/strong&gt; attribute, it bypasses runtime reflection entirely. This allows it to hydrate strict PHP objects from a stream faster than third-party alternatives, while maintaining a flawless ~2.5MB flat memory footprint regardless of whether the file is 10MB or 10GB.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Handling massive JSON payloads no longer requires architectural gymnastics, batch processing scripts or adding third-party dependencies to your &lt;strong&gt;composer.json&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;By adopting &lt;strong&gt;symfony/json-streamer&lt;/strong&gt;, &lt;strong&gt;symfony/type-info&lt;/strong&gt; and &lt;strong&gt;strict DTOs&lt;/strong&gt; you can build enterprise-grade data pipelines that are memory-safe, strictly typed and natively integrated into the Symfony ecosystem.&lt;/p&gt;

&lt;p&gt;Remember the golden rules of scaling JSON in Symfony:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never load large payloads as strings.&lt;/strong&gt; Pass Streams or HttpClient responses directly to the reader.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always use Type::iterable().&lt;/strong&gt; Supplying Type::list() creates massive arrays in memory, defeating the purpose of the streamer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Control your external boundaries.&lt;/strong&gt; Streaming saves memory during parsing, but you must still batch your Doctrine inserts or message dispatchers to prevent downstream memory leaks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Source Code:&lt;/strong&gt; You can find the full implementation and follow the project’s progress on GitHub: [&lt;a href="https://github.com/mattleads/JsonStreamer" rel="noopener noreferrer"&gt;https://github.com/mattleads/JsonStreamer&lt;/a&gt;]&lt;/p&gt;

&lt;h3&gt;
  
  
  Let’s Connect!
&lt;/h3&gt;

&lt;p&gt;If you found this helpful or have questions about the implementation, I’d love to hear from you. Let’s stay in touch and keep the conversation going across these platforms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LinkedIn: [&lt;a href="https://www.linkedin.com/in/matthew-mochalkin/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/matthew-mochalkin/&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;X (Twitter): [&lt;a href="https://x.com/MattLeads" rel="noopener noreferrer"&gt;https://x.com/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;Telegram: [&lt;a href="https://t.me/MattLeads" rel="noopener noreferrer"&gt;https://t.me/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;GitHub: [&lt;a href="https://github.com/mattleads" rel="noopener noreferrer"&gt;https://github.com/mattleads&lt;/a&gt;]&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>json</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Next-Gen CLI Apps in PHP: A Deep Dive into Symfony TUI</title>
      <dc:creator>Matt Mochalkin</dc:creator>
      <pubDate>Tue, 31 Mar 2026 11:01:53 +0000</pubDate>
      <link>https://dev.to/mattleads/next-gen-cli-apps-in-php-a-deep-dive-into-symfony-tui-1omb</link>
      <guid>https://dev.to/mattleads/next-gen-cli-apps-in-php-a-deep-dive-into-symfony-tui-1omb</guid>
      <description>&lt;p&gt;For over a decade, PHP developers have relied on the &lt;strong&gt;symfony/console&lt;/strong&gt; component as the gold standard for building CLI applications. It gave us beautifully formatted output, robust input validation and progress bars. But fundamentally, the paradigm remained the same: &lt;strong&gt;Immediate Mode&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In immediate mode, your script executes top-to-bottom. If you want to show a progress bar, you must calculate the state, format a string and explicitly echo ANSI escape codes to redraw that specific terminal line. If an HTTP request blocks the main thread, your entire terminal interface freezes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But what if your CLI application could behave like a modern frontend application?&lt;/strong&gt; What if you could declare a tree of widgets — containers, text inputs, markdown renderers — and let a rendering engine intelligently diff the screen state, capturing keystrokes and updating UI components asynchronously?&lt;/p&gt;

&lt;h2&gt;
  
  
  symfony/tui
&lt;/h2&gt;

&lt;p&gt;Currently in the &lt;strong&gt;experimental phase&lt;/strong&gt;, this groundbreaking new component shifts PHP CLI development to a Retained Mode architecture, powered by PHP 8.4 Fibers and the Revolt Event Loop.&lt;/p&gt;

&lt;p&gt;In this comprehensive guide, we are going to build two robust applications using the exact bleeding-edge code of the &lt;strong&gt;symfony/tui component&lt;/strong&gt;. We will cover environment setup, responsive styling, event dispatching, focus management and true concurrency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fibers, Event Loops and PHP 8.4
&lt;/h2&gt;

&lt;p&gt;Before we write code, we must understand the architectural shift. &lt;strong&gt;symfony/tui&lt;/strong&gt; is strictly locked to PHP 8.4+.&lt;/p&gt;

&lt;p&gt;Why? Because it relies heavily on &lt;strong&gt;native PHP Fibers&lt;/strong&gt; to manage state without blocking the execution thread. It pairs Fibers with Revolt, a robust event loop for PHP.&lt;/p&gt;

&lt;p&gt;This means your TUI is single-threaded but fully concurrent. Animations (like loaders) keep spinning, API requests process in the background and user keystrokes are captured instantly without interrupting the rendering cycle.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bleeding-Edge Installation &amp;amp; Setup
&lt;/h3&gt;

&lt;p&gt;As of this writing, &lt;strong&gt;symfony/tui&lt;/strong&gt; is an active Pull Request on the main symfony/symfony repository. You cannot run composer require symfony/tui just yet. We must manually map the experimental branch via Composer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Create a Symfony 8 Project&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer create-project symfony/skeleton &lt;span class="s2"&gt;"8.0.*"&lt;/span&gt; my-tui-app
&lt;span class="nb"&gt;cd &lt;/span&gt;my-tui-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Clone the Experimental Branch&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Clone Fabien Potencier’s specific branch into a local vendor-src directory&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; vendor-src
git clone &lt;span class="nt"&gt;--branch&lt;/span&gt; tui &lt;span class="nt"&gt;--single-branch&lt;/span&gt; https://github.com/fabpot/symfony.git vendor-src/symfony
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Configure Composer Path Repository&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tell Composer to look in our local checkout for the Tui component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer config repositories.symfony-tui path vendor-src/symfony/src/Symfony/Component/Tui
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Install Require Dependencies&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We will install the component itself, the Revolt event loop and standard Markdown parsing libraries for our rich text widgets.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require symfony/tui:dev-tui &lt;span class="se"&gt;\&lt;/span&gt;
    revolt/event-loop &lt;span class="se"&gt;\&lt;/span&gt;
    league/commonmark &lt;span class="se"&gt;\&lt;/span&gt;
    tempest/highlight
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ensure your &lt;strong&gt;composer.json&lt;/strong&gt; reflects &lt;strong&gt;PHP ^8.4&lt;/strong&gt; and the packages above. You can run php -v to confirm your local CLI environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Core Concepts: Widgets, Stylesheets and Events
&lt;/h2&gt;

&lt;p&gt;To transition from standard CLI commands to the TUI, you must adopt a DOM-like mindset.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Widget Tree
&lt;/h3&gt;

&lt;p&gt;Everything is a subclass of &lt;strong&gt;AbstractWidget&lt;/strong&gt;. You compose a hierarchy by taking a &lt;strong&gt;ContainerWidget&lt;/strong&gt; and calling &lt;strong&gt;$container-&amp;gt;add($childWidget)&lt;/strong&gt;. When a widget’s internal state changes (e.g., calling &lt;strong&gt;$textWidget-&amp;gt;setText()&lt;/strong&gt;), it marks itself as dirty. The engine recalculates constraints and flushes only the necessary &lt;strong&gt;ANSI escape codes to the terminal&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The StyleSheet
&lt;/h3&gt;

&lt;p&gt;Styling is no longer limited to basic ANSI foreground/background colors. &lt;strong&gt;symfony/tui&lt;/strong&gt; implements a &lt;strong&gt;cascading style system&lt;/strong&gt;. You can define a Stylesheet with CSS-like selectors or use built-in Tailwind-like utility classes directly on the widgets.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Stylesheet approach&lt;/span&gt;
&lt;span class="nv"&gt;$stylesheet&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'.sidebar:focused'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;border&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Border&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'rounded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cyan'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'gray'&lt;/span&gt;
&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// Tailwind utility approach&lt;/span&gt;
&lt;span class="nv"&gt;$widget&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addStyleClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p-2 bg-emerald-500 bold border-rounded'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Event Dispatcher
&lt;/h3&gt;

&lt;p&gt;The component natively integrates with &lt;strong&gt;symfony/event-dispatcher&lt;/strong&gt;. Widgets emit events like &lt;strong&gt;SelectEvent&lt;/strong&gt;, &lt;strong&gt;SelectionChangeEvent&lt;/strong&gt;, &lt;strong&gt;FocusEvent&lt;/strong&gt; and &lt;strong&gt;CancelEvent&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tui’s complete widgets set:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TextWidget&lt;/strong&gt; for labels, headings and FIGlet ASCII art banners&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;InputWidget&lt;/strong&gt; for single-line text fields with cursor, scrolling and paste support&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EditorWidget&lt;/strong&gt; a full multi-line text editor with word wrap, undo/redo, a kill ring and autocomplete&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SelectListWidget&lt;/strong&gt; for scrollable, filterable pick lists&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SettingsListWidget&lt;/strong&gt; for preference panels with value cycling and submenus&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TabsWidget&lt;/strong&gt; for multi-view interfaces with horizontal or vertical headers (follow-up PR)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MarkdownWidget&lt;/strong&gt; with full CommonMark support and syntax-highlighted code blocks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ImageWidget&lt;/strong&gt; and &lt;strong&gt;AnimatedImageWidget&lt;/strong&gt; for inline images (via the Kitty graphics protocol) and animated GIF playback as ASCII art (follow-up PR)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OverlayWidget&lt;/strong&gt; for modal dialogs, dropdowns and floating panels (follow-up PR)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LoaderWidget&lt;/strong&gt;, &lt;strong&gt;CancellableLoaderWidget&lt;/strong&gt; and &lt;strong&gt;ProgressBarWidget&lt;/strong&gt; for background operations&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Reactive Server Dashboard
&lt;/h2&gt;

&lt;p&gt;Let’s start by building a classic operational dashboard. We want a scrollable list of servers on the bottom and a reactive header on top that changes text color depending on the user’s current selection.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Command&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Console\Attribute\AsCommand&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Console\Command\Command&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Console\Input\InputInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Console\Output\OutputInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Tui&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Widget\ContainerWidget&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Widget\TextWidget&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Widget\SelectListWidget&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Style\StyleSheet&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Style\Style&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Style\Border&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Style\Padding&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Style\Direction&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Event\SelectEvent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Event\CancelEvent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;AsCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'app:server-dashboard'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Launches the interactive server management TUI.'&lt;/span&gt;
&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ServerDashboardCommand&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Command&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;InputInterface&lt;/span&gt; &lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;OutputInterface&lt;/span&gt; &lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// 1. Initialize the StyleSheet&lt;/span&gt;
        &lt;span class="nv"&gt;$stylesheet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StyleSheet&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$stylesheet&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'.dashboard-container'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;padding&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Padding&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;border&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Border&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'double'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'blue'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="c1"&gt;// 2. Build the Header&lt;/span&gt;
        &lt;span class="nv"&gt;$header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextWidget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Server Status Dashboard'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$header&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addStyleClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'font-big text-cyan-400 bold mb-2'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// 3. Build the Interactive List&lt;/span&gt;
        &lt;span class="c1"&gt;// Note: The experimental API expects associative arrays, not objects.&lt;/span&gt;
        &lt;span class="nv"&gt;$serverList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SelectListWidget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'value'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'srv-01'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'label'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Web Server 01'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Healthy - 20ms ping'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'value'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'srv-02'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'label'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Database Primary'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Warning - 80% CPU'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'value'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'srv-03'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'label'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Worker Node'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Healthy - Idle'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;maxVisible&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// 4. Handle State and Events (Using -&amp;gt;on() instead of addEventListener)&lt;/span&gt;
        &lt;span class="nv"&gt;$serverList&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SelectEvent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;SelectEvent&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$header&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Monitoring: %s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getValue&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
            &lt;span class="nv"&gt;$header&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addStyleClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'text-emerald-500'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; 
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="c1"&gt;// 5. Compose the Layout Tree&lt;/span&gt;
        &lt;span class="nv"&gt;$container&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ContainerWidget&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$container&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Direction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Vertical&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="nv"&gt;$container&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$header&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$container&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$serverList&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$container&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addStyleClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'dashboard-container'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// 6. Boot the TUI Engine&lt;/span&gt;
        &lt;span class="nv"&gt;$tui&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Tui&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stylesheet&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$tui&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$container&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// 7. Graceful Exits&lt;/span&gt;
        &lt;span class="nv"&gt;$serverList&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CancelEvent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tui&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$tui&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="c1"&gt;// Takes over the terminal buffer&lt;/span&gt;
        &lt;span class="nv"&gt;$tui&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;writeln&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;info&amp;gt;Dashboard session ended successfully.&amp;lt;/info&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Command&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SUCCESS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  How the Code Works
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Separation of Concerns:&lt;/strong&gt; We define our layout structure (&lt;strong&gt;ContainerWidget&lt;/strong&gt;, &lt;strong&gt;TextWidget&lt;/strong&gt;) independently of the terminal’s physical rendering engine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reactive State:&lt;/strong&gt; When the &lt;strong&gt;SelectEvent&lt;/strong&gt; fires (triggered when a user navigates to an item and hits Enter), we &lt;strong&gt;mutate the $header widget&lt;/strong&gt;. The TUI engine automatically detects this mutation and flushes the minimal required ANSI escape codes to the terminal to update only the header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graceful Exits:&lt;/strong&gt; Calling $tui-&amp;gt;run() takes exclusive control of the terminal buffer. Once exited, the terminal state is completely restored, preventing the “garbled output” issue common in older CLI tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API Evolution:&lt;/strong&gt; If you read early blogs on the TUI component, you might have seen &lt;strong&gt;$stylesheet = new Stylesheet()&lt;/strong&gt; and &lt;strong&gt;$widget-&amp;gt;addEventListener()&lt;/strong&gt;. The actual, current implementation enforces strict casing (&lt;strong&gt;StyleSheet&lt;/strong&gt;) and uses a concise &lt;strong&gt;-&amp;gt;on(Event::class, callback)&lt;/strong&gt; method.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Object-Oriented Styling:&lt;/strong&gt; Passing padding: 2 will throw a TypeError. You must use strongly typed immutable value objects: &lt;strong&gt;Padding::all(2)&lt;/strong&gt; and &lt;strong&gt;Border::all(…)&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Widget Composition:&lt;/strong&gt; Instead of passing children arrays via constructors, we instantiate empty &lt;strong&gt;ContainerWidgets&lt;/strong&gt; and use the fluent &lt;strong&gt;-&amp;gt;add()&lt;/strong&gt; interface.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The “Kitchen Sink” Widget Demo
&lt;/h2&gt;

&lt;p&gt;To truly appreciate the power of Symfony TUI, we must explore its advanced widgets: Text Inputs, Multiline Editors, Markdown Renderers and background-driven Progress Bars.&lt;/p&gt;

&lt;p&gt;We are going to build a complex, multi-pane layout that simulates a Tabbed Interface. We will have a persistent navigation sidebar on the left and a dynamic content pane on the right.&lt;/p&gt;

&lt;p&gt;Layout &amp;amp; Custom Focus Management&lt;br&gt;
By default, the experimental TUI uses F6 to cycle focus. For a standard user experience, we want to use the TAB key. We also want to visually indicate which “window” has focus by turning its border Cyan.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Command&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Console\Attribute\AsCommand&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Console\Command\Command&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Console\Input\InputInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Console\Output\OutputInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Tui&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Widget\ContainerWidget&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ... (omitting widget imports for brevity, see later sections)&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Style\StyleSheet&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Style\Style&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Style\Border&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Style\Padding&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Style\Direction&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Event\SelectEvent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Event\SelectionChangeEvent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Event\CancelEvent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Event\InputEvent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Event\FocusEvent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Input\Keybindings&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Tui\Input\Key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Revolt\EventLoop&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[AsCommand(name: 'app:widgets-demo', description: 'Demonstrates all available widgets.')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WidgetsDemoCommand&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Command&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;InputInterface&lt;/span&gt; &lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;OutputInterface&lt;/span&gt; &lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$stylesheet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StyleSheet&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$stylesheet&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'.sidebar'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;padding&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Padding&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
        &lt;span class="nv"&gt;$stylesheet&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'.content-pane'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;padding&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Padding&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;

        &lt;span class="c1"&gt;// Dynamic classes applied via Focus events&lt;/span&gt;
        &lt;span class="nv"&gt;$stylesheet&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'.active-pane'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;border&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Border&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'rounded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cyan'&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
        &lt;span class="nv"&gt;$stylesheet&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'.inactive-pane'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;border&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Border&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'rounded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'gray'&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;

        &lt;span class="c1"&gt;// ... [Widget construction goes here, we'll cover it below] ...&lt;/span&gt;

        &lt;span class="c1"&gt;// The TUI initialization with Custom Keybindings&lt;/span&gt;
        &lt;span class="nv"&gt;$keybindings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Keybindings&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'focus_next'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;TAB&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'focus_previous'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'shift+tab'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="nv"&gt;$tui&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Tui&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;styleSheet&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$stylesheet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keybindings&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$keybindings&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$tui&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$mainLayout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Workaround: Intercept raw InputEvents to force TAB navigation&lt;/span&gt;
        &lt;span class="nv"&gt;$tui&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;InputEvent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;InputEvent&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tui&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$keybindings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$keybindings&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'focus_next'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$tui&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getFocusManager&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;focusNext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;stopPropagation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;elseif&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$keybindings&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'focus_previous'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$tui&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getFocusManager&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;focusPrevious&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;stopPropagation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="c1"&gt;// Visually change the active pane border based on FocusEvent&lt;/span&gt;
        &lt;span class="nv"&gt;$tui&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FocusEvent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;FocusEvent&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sidebar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$contentPane&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$inputField&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$editorField&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTarget&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="nv"&gt;$previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getPrevious&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$target&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$sidebar&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$sidebar&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;removeStyleClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'inactive-pane'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addStyleClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'active-pane'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="nv"&gt;$contentPane&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;removeStyleClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'active-pane'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addStyleClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'inactive-pane'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$sidebar&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;removeStyleClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'active-pane'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addStyleClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'inactive-pane'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="nv"&gt;$contentPane&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;removeStyleClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'inactive-pane'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addStyleClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'active-pane'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="c1"&gt;// ... [Placeholder logic goes here] ...&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="nv"&gt;$tui&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Command&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SUCCESS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice how we rely on FocusEvent to manipulate CSS classes (&lt;strong&gt;removeStyleClass/addStyleClass&lt;/strong&gt;). The framework completely abstracts away terminal coordinates. We simply alter the DOM and Symfony handles the visual repainting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Input and Editor Widgets (Handling Placeholders)
&lt;/h3&gt;

&lt;p&gt;The &lt;strong&gt;InputWidget&lt;/strong&gt; and &lt;strong&gt;EditorWidget&lt;/strong&gt; provide robust input handling, including cursor movement, scrolling and paste support. Let’s create an input and a multi-line editor and build custom placeholder logic using the &lt;strong&gt;FocusEvent&lt;/strong&gt; we defined above.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 2. InputWidget&lt;/span&gt;
        &lt;span class="nv"&gt;$inputContainer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ContainerWidget&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$inputContainer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Direction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Vertical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gap&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="nv"&gt;$inputField&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InputWidget&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$inputField&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Type something here..."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$inputField&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;border&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Border&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'rounded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'green'&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
        &lt;span class="nv"&gt;$inputContainer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextWidget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Single-line text field:"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$inputField&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// 3. EditorWidget&lt;/span&gt;
        &lt;span class="nv"&gt;$editorContainer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ContainerWidget&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$editorContainer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Direction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Vertical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gap&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="nv"&gt;$editorField&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EditorWidget&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$editorField&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Write your multiline text here.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;Enjoy the full editing capabilities!"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$editorField&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;border&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Border&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'rounded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'yellow'&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
        &lt;span class="nv"&gt;$editorField&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;expandVertically&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Fills available terminal height&lt;/span&gt;
        &lt;span class="nv"&gt;$editorContainer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextWidget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Multi-line text editor:"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$editorField&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside our &lt;strong&gt;FocusEvent&lt;/strong&gt; listener, we can add this logic to simulate HTML placeholder attributes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// InputWidget placeholder logic: hide on focus, restore on blur&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$target&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$inputField&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$inputField&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getValue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;"Type something here..."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$inputField&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$previous&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$inputField&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$inputField&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getValue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$inputField&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Type something here..."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="c1"&gt;// EditorWidget placeholder logic&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$target&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$editorField&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$editorField&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;getText&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;"Write your multiline text here.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;Enjoy the full editing capabilities!"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$editorField&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$previous&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$editorField&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$editorField&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;getText&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$editorField&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Write your multiline text here.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;Enjoy the full editing capabilities!"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Markdown and Settings
&lt;/h3&gt;

&lt;p&gt;The &lt;strong&gt;MarkdownWidget&lt;/strong&gt; is a powerhouse. Using &lt;strong&gt;league/commonmark&lt;/strong&gt; for parsing and &lt;strong&gt;tempest/highlight&lt;/strong&gt; for &lt;strong&gt;tokenization&lt;/strong&gt;, it renders fully syntax-highlighted code blocks natively in the terminal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 5. MarkdownWidget&lt;/span&gt;
        &lt;span class="nv"&gt;$mdText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"# MarkdownWidget&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;Supports **CommonMark** with syntax highlighting!&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;```

php&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;// Look at this code&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;echo 'Hello TUI!';&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;

```&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;- Lists are supported too."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$markdownWidget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MarkdownWidget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$mdText&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;SettingsListWidget&lt;/strong&gt; operates as an interactive preference panel, allowing users to hit &lt;strong&gt;&lt;/strong&gt; or &lt;strong&gt;Right/Left&lt;/strong&gt; arrows to cycle through enumerated values.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 4. SettingsListWidget&lt;/span&gt;
        &lt;span class="nv"&gt;$settingItems&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SettingItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'theme'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Theme'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;currentValue&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Dark'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Application visual theme.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'Dark'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Light'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'System'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SettingItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'telemetry'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Telemetry'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;currentValue&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Opt-out'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Share usage statistics.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'Opt-in'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Opt-out'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="nv"&gt;$settingsList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SettingsListWidget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$settingItems&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  True Concurrency with Revolt and Loaders
&lt;/h3&gt;

&lt;p&gt;The absolute magic of the &lt;strong&gt;symfony/tui&lt;/strong&gt; component lies in its event loop. We can render a &lt;strong&gt;ProgressBarWidget&lt;/strong&gt; and an animated &lt;strong&gt;LoaderWidget&lt;/strong&gt; side-by-side and update them using a background timer without ever halting the user’s ability to type in the &lt;strong&gt;InputWidget&lt;/strong&gt; or navigate menus.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 6. Loaders &amp;amp; Progress Bar&lt;/span&gt;
        &lt;span class="nv"&gt;$loadersContainer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ContainerWidget&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$loadersContainer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Direction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Vertical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gap&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="nv"&gt;$loader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;LoaderWidget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Booting system...'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$cancellableLoader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CancellableLoaderWidget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Downloading updates...'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Customizing the ProgressBar visualization via Stylesheet and Setters&lt;/span&gt;
        &lt;span class="nv"&gt;$stylesheet&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProgressBarWidget&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'::bar-fill'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'cyan'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="nv"&gt;$progressBar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ProgressBarWidget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$progressBar&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setBarCharacter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'━'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// The filled portion&lt;/span&gt;
        &lt;span class="nv"&gt;$progressBar&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setEmptyBarCharacter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'─'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// The empty background&lt;/span&gt;
        &lt;span class="nv"&gt;$progressBar&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setProgressCharacter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'╸'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// The leading edge&lt;/span&gt;
        &lt;span class="nv"&gt;$progressBar&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="c1"&gt;// Simulate asynchronous background progress via Revolt EventLoop&lt;/span&gt;
        &lt;span class="nc"&gt;EventLoop&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$progressBar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$loader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$cancellableLoader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$progressBar&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getProgress&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$progressBar&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;advance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$progressBar&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="c1"&gt;// Sync text to the progress bar's state&lt;/span&gt;
            &lt;span class="nv"&gt;$percent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$progressBar&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getProgress&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="nv"&gt;$loader&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Booting system... &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$percent&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$cancellableLoader&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Downloading updates... &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$percent&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="nv"&gt;$loadersContainer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$loader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cancellableLoader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$progressBar&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Connecting the Tabs
&lt;/h3&gt;

&lt;p&gt;Finally, we map our &lt;strong&gt;“Tabs” (the Sidebar)&lt;/strong&gt; to our &lt;strong&gt;Content Panes&lt;/strong&gt;. Whenever a user triggers a &lt;strong&gt;SelectionChangeEvent&lt;/strong&gt; on the sidebar, we simply call &lt;strong&gt;$contentPane-&amp;gt;clear()&lt;/strong&gt; and &lt;strong&gt;$contentPane-&amp;gt;add($panes[$value])&lt;/strong&gt;. The DOM updates instantly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Map the options to the containers&lt;/span&gt;
        &lt;span class="nv"&gt;$panes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'input'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$inputContainer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'editor'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$editorContainer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'settings'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$settingsContainer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'markdown'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$markdownWidget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'loaders'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$loadersContainer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;

        &lt;span class="c1"&gt;// The active content pane container&lt;/span&gt;
        &lt;span class="nv"&gt;$contentPane&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ContainerWidget&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$contentPane&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addStyleClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'content-pane'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$contentPane&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addStyleClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'inactive-pane'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$contentPane&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;expandVertically&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$contentPane&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$inputContainer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// default view&lt;/span&gt;

        &lt;span class="c1"&gt;// Sidebar Navigation&lt;/span&gt;
        &lt;span class="nv"&gt;$sidebar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SelectListWidget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'value'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'input'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'label'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Input Field'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'value'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'editor'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'label'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Editor'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'value'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'settings'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'label'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Settings List'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'value'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'markdown'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'label'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Markdown'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'value'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'loaders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'label'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Loaders &amp;amp; Progress'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;maxVisible&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$sidebar&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addStyleClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sidebar'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$sidebar&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addStyleClass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'active-pane'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Swap out DOM content on selection change&lt;/span&gt;
        &lt;span class="nv"&gt;$sidebar&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SelectionChangeEvent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;SelectionChangeEvent&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$contentPane&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$panes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getValue&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$panes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$contentPane&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                &lt;span class="nv"&gt;$contentPane&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$panes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="c1"&gt;// TUI Main Layout&lt;/span&gt;
        &lt;span class="nv"&gt;$mainLayout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ContainerWidget&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$mainLayout&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Direction&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;Horizontal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gap&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="nv"&gt;$mainLayout&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sidebar&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$mainLayout&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$contentPane&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Future of the Terminal
&lt;/h2&gt;

&lt;p&gt;Building with the experimental &lt;strong&gt;symfony/tui&lt;/strong&gt; component feels revolutionary. It takes the lessons we’ve learned from decades of frontend browser development — the DOM tree, the event loop, cascading styles and distinct focus states — and injects them seamlessly into the terminal.&lt;/p&gt;

&lt;p&gt;While currently in its raw PHP object-oriented form, the planned roadmap includes bringing this exact retained-mode engine into Twig. Imagine writing your CLI tools using familiar declarative  and  tags, backed by powerful PHP Controllers.&lt;/p&gt;

&lt;p&gt;While the component is still in its experimental phase, cloning the PR and building side-projects today will give you a massive head start. Terminal apps are about to become a whole lot richer and Symfony is leading the charge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source Code:&lt;/strong&gt; You can find the full implementation and follow the project’s progress on GitHub: [&lt;a href="https://github.com/mattleads/TuiComponent" rel="noopener noreferrer"&gt;https://github.com/mattleads/TuiComponent&lt;/a&gt;]&lt;/p&gt;

&lt;h3&gt;
  
  
  Let’s Connect!
&lt;/h3&gt;

&lt;p&gt;If you found this helpful or have questions about the implementation, I’d love to hear from you. Let’s stay in touch and keep the conversation going across these platforms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LinkedIn: [&lt;a href="https://www.linkedin.com/in/matthew-mochalkin/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/matthew-mochalkin/&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;X (Twitter): [&lt;a href="https://x.com/MattLeads" rel="noopener noreferrer"&gt;https://x.com/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;Telegram: [&lt;a href="https://t.me/MattLeads" rel="noopener noreferrer"&gt;https://t.me/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;GitHub: [&lt;a href="https://github.com/mattleads" rel="noopener noreferrer"&gt;https://github.com/mattleads&lt;/a&gt;]&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>cli</category>
      <category>productivity</category>
    </item>
    <item>
      <title>10x Smaller, 100x Safer: Building Secure &amp; Compressed Microservices in Symfony</title>
      <dc:creator>Matt Mochalkin</dc:creator>
      <pubDate>Sat, 28 Mar 2026 18:08:20 +0000</pubDate>
      <link>https://dev.to/mattleads/10x-smaller-100x-safer-building-secure-compressed-microservices-in-symfony-570i</link>
      <guid>https://dev.to/mattleads/10x-smaller-100x-safer-building-secure-compressed-microservices-in-symfony-570i</guid>
      <description>&lt;p&gt;In the rapidly evolving landscape of modern web development, microservices have become the gold standard for building scalable, decoupled applications. But as your system grows, so does the complexity of how these isolated services communicate. Enter asynchronous messaging.&lt;/p&gt;

&lt;p&gt;When dealing with high-throughput systems, two massive challenges inevitably emerge:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Performance &amp;amp; Scale&lt;/strong&gt; (how to handle millions of messages without burning through your infrastructure budget)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resiliency &amp;amp; Reliability&lt;/strong&gt; (how to survive network hiccups, database locks and API rate limits without dropping data).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With the release of the Symfony 7.4 ecosystem, the symfony/messenger component continues to be a developer’s best friend. And thanks to the CompressStamp, we now have a native, incredibly elegant way to crush bandwidth costs and supercharge queue performance.&lt;/p&gt;

&lt;p&gt;In this deep dive, we are going to explore how to build a highly resilient, lightning-fast microservice architecture using Symfony Messenger, Redis and advanced message stamping.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottleneck: The “Fat Payload” Problem
&lt;/h2&gt;

&lt;p&gt;Message queues are designed to be fast and lightweight. A classic architectural rule is to “send references, not data” (e.g., sending a user_id instead of the entire User object). However, in real-world microservices, this isn’t always possible.&lt;/p&gt;

&lt;p&gt;Imagine you are building a reporting microservice, an invoice generator, or a system that bulk-syncs data to a third-party CRM. You are forced to pass massive JSON payloads, Base64-encoded file strings, or deeply nested arrays across the wire.&lt;/p&gt;

&lt;p&gt;When these “fat payloads” hit your transport (Redis, Amazon SQS and etc.), three things happen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Memory Bloat:&lt;/strong&gt; Transport stores everything. Giant messages will trigger eviction policies or crash your instance entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network Latency:&lt;/strong&gt; Moving megabytes of data between your web nodes and your queue slows down your producers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security Risks:&lt;/strong&gt; Storing unencrypted PII or financial data in a queue violates compliance standards like GDPR or HIPAA.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A Custom Serialization Pipeline
&lt;/h2&gt;

&lt;p&gt;Out of the box, Symfony Messenger serializes your message objects into plain JSON strings. To solve our performance and security bottlenecks, we are going to intercept this process.&lt;/p&gt;

&lt;p&gt;By creating custom Stamps (metadata markers) and decorating the default Serializer, we can instruct Symfony to natively compress and encrypt specific messages right before they hit the transport and reverse the process the moment a worker picks them up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing for Resiliency &amp;amp; Reliability
&lt;/h2&gt;

&lt;p&gt;Speed means nothing if your system is fragile. Microservices fail. Third-party APIs go down. Databases lock. If your consumer throws an exception, you cannot afford to lose the message.&lt;/p&gt;

&lt;p&gt;A resilient Symfony Messenger architecture relies on three pillars:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Asynchronous Transports:&lt;/strong&gt; Never make the user wait for a background task.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry Strategies:&lt;/strong&gt; Automatically re-queue failed messages with an exponential backoff (e.g., retry after 10 seconds, then 20 seconds, then 40 seconds).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failure Transports (Dead Letter Queues):&lt;/strong&gt; If a message fails all retries, route it to a secure database queue where a developer can inspect it, fix the bug and manually replay it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Tech Stack
&lt;/h2&gt;

&lt;p&gt;We are utilizing the current Symfony 7.4 LTS ecosystem alongside PHP 8.2+. Ensure you have the necessary PHP extensions installed on your server.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;symfony/messenger:&lt;/strong&gt; Core message bus and worker tooling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;symfony/redis-messenger:&lt;/strong&gt; The official Redis transport for Messenger.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ext-zlib:&lt;/strong&gt; Native PHP extension required for gzcompress.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ext-openssl:&lt;/strong&gt; Native PHP extension required for AES-256 encryption.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step-by-Step Implementation
&lt;/h2&gt;

&lt;p&gt;Let’s build our secure, highly-compressed “Invoice Generation” service.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Message Class &amp;amp; Handlers
&lt;/h3&gt;

&lt;p&gt;In modern PHP, we use strongly typed, read-only classes for our messages.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Message&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cd"&gt;/**
 * Represents a bulk invoice generation request.
 */&lt;/span&gt;
&lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GenerateBulkInvoiceMessage&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$batchId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$invoiceData&lt;/span&gt; &lt;span class="c1"&gt;// Imagine this array contains thousands of nested rows&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using the &lt;strong&gt;#[AsMessageHandler]&lt;/strong&gt; attribute, our worker expects to receive the fully hydrated, decompressed and decrypted object. Our worker doesn’t need to know how the message was transported; it just handles the business logic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\MessageHandler&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Message\GenerateBulkInvoiceMessage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Messenger\Attribute\AsMessageHandler&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Psr\Log\LoggerInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[AsMessageHandler]&lt;/span&gt;
&lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GenerateBulkInvoiceMessageHandler&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;LoggerInterface&lt;/span&gt; &lt;span class="nv"&gt;$logger&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;GenerateBulkInvoiceMessage&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Starting bulk invoice generation for batch.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'batchId'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;batchId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'recordsCount'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;invoiceData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="c1"&gt;// Simulate heavy processing...&lt;/span&gt;
        &lt;span class="c1"&gt;// If this throws an exception, Symfony Messenger automatically catches it,&lt;/span&gt;
        &lt;span class="c1"&gt;// checks the retry_strategy in messenger.yaml and re-queues it in Redis!&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Bulk invoice generation completed successfully.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Creating the Custom Stamps
&lt;/h3&gt;

&lt;p&gt;In Symfony Messenger stamps are simply DTOs that act as metadata. We will create two stamps: one for &lt;strong&gt;compression&lt;/strong&gt; and one for &lt;strong&gt;security&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Messenger\Stamp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Messenger\Stamp\StampInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cd"&gt;/**
 * A stamp indicating that the serialized message payload should be compressed.
 */&lt;/span&gt;
&lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CompressStamp&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;StampInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Messenger\Stamp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Messenger\Stamp\StampInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cd"&gt;/**
 * A stamp indicating that the serialized message payload should be encrypted.
 */&lt;/span&gt;
&lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SecureStamp&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;StampInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Custom Serializer
&lt;/h3&gt;

&lt;p&gt;Instead of writing a serializer from scratch, we use the Decorator pattern to wrap Symfony’s default serializer. If the CompressStamp is present, we compress the JSON body using PHP’s native zlib extension. If it detects the SecureStamp, it applies AES-256-CBC encryption via OpenSSL.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Messenger\Serialization&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Messenger\Stamp\CompressStamp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Messenger\Stamp\SecureStamp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Messenger\Envelope&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Messenger\Exception\MessageDecodingFailedException&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Messenger\Transport\Serialization\SerializerInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cd"&gt;/**
 * Serializer that independently handles compression and encryption.
 */&lt;/span&gt;
&lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CompressSerializer&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;SerializerInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;string&lt;/span&gt; &lt;span class="no"&gt;COMPRESSED_HEADER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'X-Compressed'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;string&lt;/span&gt; &lt;span class="no"&gt;SECURED_HEADER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'X-Secured'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;string&lt;/span&gt; &lt;span class="no"&gt;CIPHER_ALGO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'aes-256-cbc'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;SerializerInterface&lt;/span&gt; &lt;span class="nv"&gt;$innerSerializer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$encryptionKey&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$encodedEnvelope&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Envelope&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$encodedEnvelope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'body'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MessageDecodingFailedException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Encoded envelope has no body.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nb"&gt;print_r&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$encodedEnvelope&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;


        &lt;span class="c1"&gt;// 1. Handle Decryption (must happen before decompression if both were used)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$encodedEnvelope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'headers'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SECURED_HEADER&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$encodedEnvelope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'headers'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SECURED_HEADER&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$decodedBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;base64_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$decodedBody&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MessageDecodingFailedException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Failed to base64 decode the secured message body.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="nv"&gt;$ivLength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;openssl_cipher_iv_length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;CIPHER_ALGO&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$iv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$decodedBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$ivLength&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$encryptedData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$decodedBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$ivLength&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;encryptionKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$decryptedBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;openssl_decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$encryptedData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;CIPHER_ALGO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;OPENSSL_RAW_DATA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$iv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$decryptedBody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MessageDecodingFailedException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Failed to decrypt the message body. Check your encryption key.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$decryptedBody&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// 2. Handle Decompression&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$encodedEnvelope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'headers'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;COMPRESSED_HEADER&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$encodedEnvelope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'headers'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;COMPRESSED_HEADER&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$decompressedBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;gzinflate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$decompressedBody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MessageDecodingFailedException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Failed to decompress the message body.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$decompressedBody&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$encodedEnvelope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'body'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;innerSerializer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$encodedEnvelope&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Envelope&lt;/span&gt; &lt;span class="nv"&gt;$envelope&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$encodedEnvelope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;innerSerializer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$envelope&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$encodedEnvelope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'body'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

        &lt;span class="c1"&gt;// 1. Handle Compression (Compress first for maximum efficiency before encryption)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$envelope&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;last&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CompressStamp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$compressedBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;gzdeflate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$compressedBody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Failed to compress the message body.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$compressedBody&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nv"&gt;$encodedEnvelope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'headers'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;COMPRESSED_HEADER&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// 2. Handle Encryption&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$envelope&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;last&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SecureStamp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;encryptionKey&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\LogicException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Cannot encrypt message: MESSENGER_ENCRYPTION_KEY is not set.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="nv"&gt;$ivLength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;openssl_cipher_iv_length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;CIPHER_ALGO&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$iv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;random_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ivLength&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;encryptionKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="nv"&gt;$encryptedBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;openssl_encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;CIPHER_ALGO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;OPENSSL_RAW_DATA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$iv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$encryptedBody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Failed to encrypt the message body.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$iv&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$encryptedBody&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$encodedEnvelope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'headers'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SECURED_HEADER&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$encodedEnvelope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'body'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$encodedEnvelope&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Wiring It Up
&lt;/h3&gt;

&lt;p&gt;To make this pipeline active, we register our decorator in &lt;strong&gt;config/services.yaml&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;    
    &lt;span class="s"&gt;...&lt;/span&gt;
    &lt;span class="s"&gt;App\Messenger\Serialization\CompressSerializer&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;arguments&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;$innerSerializer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;@messenger.default_serializer'&lt;/span&gt;
            &lt;span class="na"&gt;$encryptionKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%env(MESSENGER_ENCRYPTION_KEY)%'&lt;/span&gt;
&lt;span class="err"&gt;    &lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, dispatching a secure, lightweight message is as simple as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;        &lt;span class="c1"&gt;// Dispatch the message, attaching both stamps for maximum security and efficiency&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;messageBus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CompressStamp&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SecureStamp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Benchmarking the Ultimate Pipeline
&lt;/h3&gt;

&lt;p&gt;To truly understand the value and the trade-offs of this architecture, let’s look at a real-world benchmark. We simulated a high-throughput environment dispatching 10,000 &lt;strong&gt;GenerateBulkInvoiceMessage&lt;/strong&gt; objects to our Redis transport. Each message contained a fat array payload that, when serialized natively, equated to approximately 500KB per message.&lt;/p&gt;

&lt;p&gt;Here are the results across a standard cloud environment (2 vCPUs, 4GB RAM):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+-------------------------+----------------+------------------+---------------------------+
| Metric                  | Baseline (Raw) | Compress + Stamp | Compress + Secure         |
+-------------------------+----------------+------------------+---------------------------+
| Total Redis Memory      | ~4.88 GB       | ~410 MB          | ~550 MB                   |
+-------------------------+----------------+------------------+---------------------------+
| Worker CPU Utilization  | ~15%           | ~22%             | ~38%                      |
+-------------------------+----------------+------------------+---------------------------+
| Avg. Time to Dispatch   | 42 seconds     | 35 seconds       | 48 seconds                |
+-------------------------+----------------+------------------+---------------------------+
| Avg. Time to Consume    | 58 seconds     | 61 seconds       | 74 seconds                |
+-------------------------+----------------+------------------+---------------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Analyzing the Trade-offs
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The Memory Sweet Spot:&lt;/strong&gt; Raw payloads consume a massive &lt;strong&gt;4.88 GB of Redis RAM&lt;/strong&gt;. Compression crushes this &lt;strong&gt;down to 410 MB&lt;/strong&gt;. However, when we add the &lt;strong&gt;SecureStamp&lt;/strong&gt;, memory creeps up slightly &lt;strong&gt;to 550 MB&lt;/strong&gt; because the output of OpenSSL is binary and storing it safely requires base64_encode(). Even with this overhead, you are saving 88% of your memory footprint!&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The CPU Tax:&lt;/strong&gt; Security is never free. Adding &lt;strong&gt;AES-256 encryption&lt;/strong&gt; pushes the worker’s &lt;strong&gt;CPU utilization up to 38%&lt;/strong&gt;. The worker has to perform cryptographic math on every single message before unpacking it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to Process:&lt;/strong&gt; The baseline takes 42 seconds to dispatch because pushing 4.88 GB over a network connection is incredibly slow. Compression speeds this up (35 seconds) by shifting the bottleneck from the network to the CPU. Adding encryption slows it back down slightly (48 seconds) due to the heavy OpenSSL processing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Is the CPU tax worth it? If your payloads contain &lt;strong&gt;PII or financial records, sacrificing a bit of CPU time to ensure military-grade encryption while still saving 88% on your infrastructure bill is an architectural slam dunk&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Building modern microservices requires more than just pushing data into a queue. By extending Symfony’s Messenger component with custom Serializers and Stamps, you can take complete control over your message payloads.&lt;/p&gt;

&lt;p&gt;You no longer have to choose between performance and security. By implementing this custom pipeline, you ensure that your message broker remains highly performant, remarkably cost-effective and fully compliant with strict data security laws.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source Code:&lt;/strong&gt; You can find the full implementation and follow the project’s progress on GitHub: [&lt;a href="https://github.com/mattleads/CompressStamp" rel="noopener noreferrer"&gt;https://github.com/mattleads/CompressStamp&lt;/a&gt;]&lt;/p&gt;

&lt;h3&gt;
  
  
  Let’s Connect!
&lt;/h3&gt;

&lt;p&gt;If you found this helpful or have questions about the implementation, I’d love to hear from you. Let’s stay in touch and keep the conversation going across these platforms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LinkedIn: [&lt;a href="https://www.linkedin.com/in/matthew-mochalkin/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/matthew-mochalkin/&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;X (Twitter): [&lt;a href="https://x.com/MattLeads" rel="noopener noreferrer"&gt;https://x.com/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;Telegram: [&lt;a href="https://t.me/MattLeads" rel="noopener noreferrer"&gt;https://t.me/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;GitHub: [&lt;a href="https://github.com/mattleads" rel="noopener noreferrer"&gt;https://github.com/mattleads&lt;/a&gt;]&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>symfony</category>
      <category>security</category>
      <category>php</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Passkey Management and Account Recovery in Symfony</title>
      <dc:creator>Matt Mochalkin</dc:creator>
      <pubDate>Tue, 24 Mar 2026 15:45:19 +0000</pubDate>
      <link>https://dev.to/mattleads/passkey-management-and-account-recovery-in-symfony-24hh</link>
      <guid>https://dev.to/mattleads/passkey-management-and-account-recovery-in-symfony-24hh</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/mattleads/building-a-100-passwordless-future-passkeys-in-symfony-74-ajn"&gt;Part 1&lt;/a&gt; and &lt;a href="https://dev.to/mattleads/beyond-the-passwordless-fortress-building-a-hybrid-passkey-strategy-in-symfony-74-59f0"&gt;Part 2&lt;/a&gt;, we built a fortress. We implemented &lt;strong&gt;WebAuthn&lt;/strong&gt;, gracefully handled hybrid password fallbacks and created a frictionless login experience using &lt;strong&gt;Conditional UI&lt;/strong&gt; (autofill).&lt;/p&gt;

&lt;p&gt;But now we must face the nightmare scenario: &lt;strong&gt;The Lost Device&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When you eliminate passwords, a user’s smartphone or YubiKey becomes their only key to the castle. If that device is lost, stolen or destroyed, how do they get back in? If we just email them a magic link, we instantly downgrade our security model back to the vulnerabilities of email interception.&lt;/p&gt;

&lt;p&gt;Today, we are building a bulletproof account recovery and passkey management system. We will create a user dashboard to manage active credentials, implement a &lt;strong&gt;“Last Used”&lt;/strong&gt; tracker and generate cryptographically secure, one-time recovery codes using the &lt;strong&gt;web-authn/web-authn-symfony-bundle&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Grab a coffee. We are diving deep into Symfony events, Doctrine lifecycle callbacks, WebAuthn v5 quirks and clean architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture of Recovery
&lt;/h2&gt;

&lt;p&gt;Before we write code, let’s define the architecture of a production-ready WebAuthn recovery system:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Transparency (The Dashboard):&lt;/strong&gt; Users must be able to see all their registered passkeys, including when they were created and last used.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revocation:&lt;/strong&gt; Users must be able to delete a passkey. If a device is stolen, revoking the credential instantly neutralizes the threat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Fallback (Recovery Codes):&lt;/strong&gt; Instead of passwords or email links, we will generate a set of one-time use, offline recovery codes during registration. These act as the ultimate fallback.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Building the Passkey Management Dashboard
&lt;/h2&gt;

&lt;p&gt;To allow users to manage their passkeys, we need to query the database for their registered credentials. If you followed the standard bundle setup, you already have a &lt;strong&gt;PublicKeyCredentialSource&lt;/strong&gt; Doctrine entity and repository.&lt;/p&gt;

&lt;p&gt;Let’s create a controller to list and delete these credentials.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Controller&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Repository\PublicKeyCredentialSourceRepository&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Doctrine\ORM\EntityManagerInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Bundle\FrameworkBundle\Controller\AbstractController&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Routing\Attribute\Route&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Security\Http\Attribute\IsGranted&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Service\RecoveryCodeGenerator&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[IsGranted('ROLE_USER')]&lt;/span&gt;
&lt;span class="na"&gt;#[Route('/settings/passkeys', name: 'app_settings_passkeys_')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PasskeyManagementController&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AbstractController&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;PublicKeyCredentialSourceRepository&lt;/span&gt; &lt;span class="nv"&gt;$credentialRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;EntityManagerInterface&lt;/span&gt; &lt;span class="nv"&gt;$entityManager&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="na"&gt;#[Route('/', name: 'index', methods: ['GET'])]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;RecoveryCodeGenerator&lt;/span&gt; &lt;span class="nv"&gt;$generator&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="cd"&gt;/** @var \App\Entity\User $user */&lt;/span&gt;
        &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$newCodes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRecoveryCodes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$newCodes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$generator&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generateForUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// We map our Symfony User to the WebAuthn User Entity&lt;/span&gt;
        &lt;span class="nv"&gt;$userEntity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toWebAuthnUser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="c1"&gt;// Fetch all passkeys bound to this user&lt;/span&gt;
        &lt;span class="nv"&gt;$credentials&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;credentialRepository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findAllForUserEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userEntity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'settings/passkeys/index.html.twig'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'credentials'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$credentials&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'newCodes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$newCodes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="na"&gt;#[Route('/{id}/revoke', name: 'revoke', methods: ['POST'])]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;revoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$credential&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;credentialRepository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findOneBy&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="c1"&gt;// Security Check: Ensure the credential belongs to the currently logged-in user&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$credential&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$credential&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;userHandle&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getUserHandle&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createAccessDeniedException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'You cannot revoke this passkey.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$credential&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addFlash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'success'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Passkey successfully revoked.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;redirectToRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app_settings_passkeys_index'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Twig View
&lt;/h3&gt;

&lt;p&gt;Create a simple view (templates/settings/passkeys/index.html.twig) to display the data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight twig"&gt;&lt;code&gt;&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;extends&lt;/span&gt; &lt;span class="s1"&gt;'base.html.twig'&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;

&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;block&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;Manage Your Passkeys&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"margin-bottom: 20px;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app_dashboard'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"btn btn-secondary"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"display: inline-block; padding: 10px 20px; background: #6c757d; color: white; text-decoration: none; border-radius: 5px; font-weight: bold;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="ni"&gt;&amp;amp;larr;&lt;/span&gt; Back to Dashboard
        &lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

    &lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nv"&gt;message&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;app.flashes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'success'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"alert alert-success"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;message&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;endfor&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;

    &lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nv"&gt;newCodes&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"alert alert-warning"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;h4&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"alert-heading"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Save these Recovery Codes!&lt;span class="nt"&gt;&amp;lt;/h4&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;You can use these codes to log in if you lose your device. They will only be shown &lt;span class="nt"&gt;&amp;lt;b&amp;gt;&lt;/span&gt;once&lt;span class="nt"&gt;&amp;lt;/b&amp;gt;&lt;/span&gt;.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;hr&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nv"&gt;code&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;newCodes&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
                    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"col-6 col-md-4 mb-2"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;code&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;code&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/code&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
                &lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;endfor&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;endif&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;table&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"table"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;thead&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;th&amp;gt;&lt;/span&gt;AAGUID (Device Type)&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;th&amp;gt;&lt;/span&gt;Added On&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;th&amp;gt;&lt;/span&gt;Last Used&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;th&amp;gt;&lt;/span&gt;Actions&lt;span class="nt"&gt;&amp;lt;/th&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/thead&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;tbody&amp;gt;&lt;/span&gt;
        &lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nv"&gt;credential&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;credentials&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;
                    &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;title=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;credential.aaguid&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                        &lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;credential.aaguid&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s1"&gt;'00000000-0000-0000-0000-000000000000'&lt;/span&gt; &lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'Unknown Passkey'&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Hardware Key'&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;
                    &lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;credential.createdAt&lt;/span&gt; &lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;credential.createdAt&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Y-m-d H:i'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Unknown'&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;credential.lastUsedAt&lt;/span&gt; &lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;credential.lastUsedAt&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Y-m-d H:i'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Never'&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;td&amp;gt;&lt;/span&gt;
                    &lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app_settings_passkeys_revoke'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;credential.id&lt;/span&gt; &lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;}}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                        &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"btn btn-danger"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Revoke&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
                    &lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
        &lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;tr&amp;gt;&amp;lt;td&lt;/span&gt; &lt;span class="na"&gt;colspan=&lt;/span&gt;&lt;span class="s"&gt;"4"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;No passkeys registered.&lt;span class="nt"&gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;&lt;/span&gt;
        &lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;endfor&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/tbody&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/table&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;endblock&lt;/span&gt; &lt;span class="cp"&gt;%}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Guaranteed Creation Dates via Doctrine PrePersist
&lt;/h2&gt;

&lt;p&gt;To provide visibility, users need to know when a passkey was added. Initially, we attempted to pull this data from WebAuthn’s TrustPath object (credential.trustPath.createdAt).&lt;/p&gt;

&lt;p&gt;If we rely on external WebAuthn metadata for our business logic, we violate the concept of bounded contexts. Our application needs to know when the record was created in our system, not when the key claims it was minted.&lt;/p&gt;

&lt;p&gt;We adhere to moving this logic directly into the entity using Doctrine’s HasLifecycleCallbacks.&lt;/p&gt;

&lt;p&gt;We updated our PublicKeyCredentialSource entity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[ORM\Entity(repositoryClass: PublicKeyCredentialSourceRepository::class)]&lt;/span&gt;
&lt;span class="na"&gt;#[ORM\Table(name: 'webauthn_credentials')]&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ORM\HasLifecycleCallbacks&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;// &amp;lt;-- Step 1: Enable callbacks&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PublicKeyCredentialSource&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;WebauthnSource&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\Column(type: 'datetime_immutable', nullable: true)]&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="nc"&gt;\DateTimeImmutable&lt;/span&gt; &lt;span class="nv"&gt;$createdAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ORM\PrePersist&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;// &amp;lt;-- Step 2: Hook into the pre-persist event&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;setCreatedAtValue&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;createdAt&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;createdAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\DateTimeImmutable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By leveraging &lt;strong&gt;#[ORM\PrePersist]&lt;/strong&gt;, we guarantee that no matter where in our massive enterprise application a developer instantiates and persists a credential, the createdAt timestamp is irrevocably applied. The controller doesn’t need to know about it. The repository doesn’t need to know about it. It is perfectly encapsulated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tracking “Last Used” with Symfony Events
&lt;/h2&gt;

&lt;p&gt;A critical feature of any security dashboard is showing the user when a credential was last used. If they see a login from today, but they haven’t logged in for a week, they know their account is compromised.&lt;/p&gt;

&lt;p&gt;We can listen for the successful validation event and update a &lt;strong&gt;lastUsedAt&lt;/strong&gt; property.&lt;/p&gt;

&lt;p&gt;First, ensure your &lt;strong&gt;PublicKeyCredentialSource&lt;/strong&gt; Doctrine entity has a &lt;strong&gt;lastUsedAt&lt;/strong&gt; property. If you generated it using the bundle’s abstract class, you might need to extend it and add the column.&lt;/p&gt;

&lt;p&gt;Next, create an Event Subscriber:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\EventSubscriber&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Doctrine\ORM\EntityManagerInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\EventDispatcher\EventSubscriberInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Webauthn\Event\AuthenticatorAssertionResponseValidationSucceededEvent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Entity\PublicKeyCredentialSource&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PasskeyUsageSubscriber&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;EventSubscriberInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;EntityManagerInterface&lt;/span&gt; &lt;span class="nv"&gt;$entityManager&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getSubscribedEvents&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="nc"&gt;AuthenticatorAssertionResponseValidationSucceededEvent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'onPasskeyUsed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;onPasskeyUsed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;AuthenticatorAssertionResponseValidationSucceededEvent&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$credentialSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;publicKeyCredentialSource&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$credentialSource&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;PublicKeyCredentialSource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$credentialSource&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setLastUsedAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\DateTimeImmutable&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

            &lt;span class="c1"&gt;// Persist the updated usage timestamp to the database&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$credentialSource&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, every time a user logs in with a passkey, the timestamp is automatically recorded, entirely decoupled from your controllers!&lt;/p&gt;

&lt;h2&gt;
  
  
  The Ultimate Fallback: Offline Recovery Codes
&lt;/h2&gt;

&lt;p&gt;If a user loses their phone, they can’t log in to revoke the old passkey and add a new one. To solve this, we will generate &lt;strong&gt;10 offline recovery codes&lt;/strong&gt;. These act as single-use passwords.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Recovery Code Entity
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Entity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Doctrine\ORM\Mapping&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="no"&gt;ORM&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[ORM\Entity]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RecoveryCode&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\Id]&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\GeneratedValue]&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\Column]&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?int&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="na"&gt;#[ORM\Column(length: 255)]&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$hashedCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="na"&gt;#[ORM\ManyToOne(inversedBy: 'recoveryCodes')]&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\JoinColumn(nullable: false)]&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getHashedCode&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;hashedCode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;setHashedCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$hashedCode&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;static&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;hashedCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$hashedCode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?User&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;static&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Generating the Codes securely
&lt;/h2&gt;

&lt;p&gt;When a user enables &lt;strong&gt;WebAuthn&lt;/strong&gt;, we should generate these codes, hash them (just like passwords) and &lt;strong&gt;display the raw codes to the user exactly once&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Service&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Entity\RecoveryCode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Entity\User&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Doctrine\ORM\EntityManagerInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RecoveryCodeGenerator&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;EntityManagerInterface&lt;/span&gt; &lt;span class="nv"&gt;$entityManager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;UserPasswordHasherInterface&lt;/span&gt; &lt;span class="nv"&gt;$passwordHasher&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * @return string[] The plain-text codes to show the user
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;generateForUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$plainCodes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nv"&gt;$amount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Generate a secure 8-character random string&lt;/span&gt;
            &lt;span class="nv"&gt;$code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;bin2hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;random_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
            &lt;span class="nv"&gt;$plainCodes&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="nv"&gt;$recoveryCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RecoveryCode&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addRecoveryCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$recoveryCode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="c1"&gt;// Hash the code before storing it in the database&lt;/span&gt;
            &lt;span class="nv"&gt;$hashed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;passwordHasher&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;hashPassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$recoveryCode&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setHashedCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$hashed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$recoveryCode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$plainCodes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the &lt;strong&gt;PasskeyManagementController&lt;/strong&gt;, we check if the user has any codes. If** $user-&amp;gt;getRecoveryCodes()-&amp;gt;isEmpty()&lt;strong&gt;, we inject the RecoveryCodeGenerator, generate the codes and pass the **$plainCodes&lt;/strong&gt; array to the Twig template.&lt;/p&gt;

&lt;p&gt;Once the user navigates away, those plain text strings are gone from server memory forever.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Recovery Login Flow
&lt;/h3&gt;

&lt;p&gt;Create a standard Symfony form login route (e.g., /recovery-login). When the user submits their email and a recovery code, you verify it using Symfony’s &lt;strong&gt;UserPasswordHasherInterface&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If the hash matches, delete the code from the database (making it single-use) and manually authenticate the user using the Security helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Controller&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Entity\User&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Repository\UserRepository&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Doctrine\ORM\EntityManagerInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Bundle\FrameworkBundle\Controller\AbstractController&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Bundle\SecurityBundle\Security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Routing\Attribute\Route&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RecoveryLoginController&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AbstractController&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[Route('/recovery-login', name: 'app_recovery_login')]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;UserRepository&lt;/span&gt; &lt;span class="nv"&gt;$userRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;PasswordHasherFactoryInterface&lt;/span&gt; &lt;span class="nv"&gt;$hasherFactory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;Security&lt;/span&gt; &lt;span class="nv"&gt;$security&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;EntityManagerInterface&lt;/span&gt; &lt;span class="nv"&gt;$entityManager&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;redirectToRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app_settings_passkeys_index'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isMethod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$submittedCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'code'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$submittedCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$userRepository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findOneBy&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nv"&gt;$hasher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$hasherFactory&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getPasswordHasher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                    &lt;span class="nv"&gt;$matchedRecoveryCodeEntity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

                    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRecoveryCodes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$recoveryCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$hasher&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$recoveryCode&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getHashedCode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$submittedCode&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                            &lt;span class="nv"&gt;$matchedRecoveryCodeEntity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$recoveryCode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                        &lt;span class="p"&gt;}&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;

                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$matchedRecoveryCodeEntity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="c1"&gt;// 1. Authenticate the user&lt;/span&gt;
                        &lt;span class="nv"&gt;$security&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;\App\Security\HybridAuthenticator&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

                        &lt;span class="c1"&gt;// 2. Burn the code&lt;/span&gt;
                        &lt;span class="nv"&gt;$entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$matchedRecoveryCodeEntity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                        &lt;span class="nv"&gt;$entityManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

                        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;redirectToRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app_settings_passkeys_index'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="nv"&gt;$error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Invalid email or recovery code.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nv"&gt;$error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Invalid email or recovery code.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Please provide both email and recovery code.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app/recovery_login.html.twig'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'error'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once logged in via the recovery code, the user is immediately redirected to the Passkey Dashboard where they can revoke their lost device and register a new passkey!&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification Steps
&lt;/h2&gt;

&lt;p&gt;To ensure your recovery architecture is rock solid, run through this testing matrix:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Dashboard Test:&lt;/strong&gt; Register two different passkeys (e.g., Chrome profile and a YubiKey). Navigate to /settings/passkeys. Both should appear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Usage Tracking Test:&lt;/strong&gt; Log out, then log back in using Passkey A. Check your database or dashboard — only Passkey A’s lastUsedAt timestamp should have updated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revocation Test:&lt;/strong&gt; Click “Revoke” on Passkey B. Attempt to log in using Passkey B. The assertion should fail entirely and Symfony should deny entry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The “Lost Device” Simulation:&lt;/strong&gt; Generate recovery codes for your account and save them to a text file.&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;Revoke all your active passkeys (simulating losing your only device).&lt;/li&gt;
&lt;li&gt;Log out.&lt;/li&gt;
&lt;li&gt;Navigate to your Recovery Login page. Enter your email and one of the codes.&lt;/li&gt;
&lt;li&gt;You should be successfully authenticated.&lt;/li&gt;
&lt;li&gt;Attempt to use the exact same code again. It must fail (single-use validation).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Over the course of these three articles, we’ve taken Symfony 7.4 from a standard, password-heavy application to a modern, frictionless and highly secure passwordless fortress.&lt;/p&gt;

&lt;p&gt;We implemented the &lt;strong&gt;WebAuthn&lt;/strong&gt; standard, smoothed the UX with &lt;strong&gt;Conditional UI&lt;/strong&gt; and finally, built the &lt;strong&gt;enterprise-grade management and recovery tools&lt;/strong&gt; required for a production environment.&lt;/p&gt;

&lt;p&gt;The passwordless future isn’t just about deleting the  field. It is about rethinking identity, managing cryptographic trust securely and keeping our users safe even on their worst days.&lt;/p&gt;

&lt;p&gt;Source Code: You can find the full implementation and follow the project’s progress on GitHub: [&lt;a href="https://github.com/mattleads/PasskeysAuth" rel="noopener noreferrer"&gt;https://github.com/mattleads/PasskeysAuth&lt;/a&gt;]&lt;/p&gt;

&lt;h3&gt;
  
  
  Let’s Connect!
&lt;/h3&gt;

&lt;p&gt;If you found this helpful or have questions about the implementation, I’d love to hear from you. Let’s stay in touch and keep the conversation going across these platforms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LinkedIn: [&lt;a href="https://www.linkedin.com/in/matthew-mochalkin/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/matthew-mochalkin/&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;X (Twitter): [&lt;a href="https://x.com/MattLeads" rel="noopener noreferrer"&gt;https://x.com/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;Telegram: [&lt;a href="https://t.me/MattLeads" rel="noopener noreferrer"&gt;https://t.me/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;GitHub: [&lt;a href="https://github.com/mattleads" rel="noopener noreferrer"&gt;https://github.com/mattleads&lt;/a&gt;]&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thank you for building the future with me. Happy coding!&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>security</category>
      <category>php</category>
      <category>coding</category>
    </item>
    <item>
      <title>Beyond the Passwordless Fortress: Building a Hybrid Passkey Strategy in Symfony 7.4</title>
      <dc:creator>Matt Mochalkin</dc:creator>
      <pubDate>Thu, 19 Mar 2026 15:23:14 +0000</pubDate>
      <link>https://dev.to/mattleads/beyond-the-passwordless-fortress-building-a-hybrid-passkey-strategy-in-symfony-74-59f0</link>
      <guid>https://dev.to/mattleads/beyond-the-passwordless-fortress-building-a-hybrid-passkey-strategy-in-symfony-74-59f0</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/mattleads/building-a-100-passwordless-future-passkeys-in-symfony-74-ajn"&gt;Part 1&lt;/a&gt; of this series, we explored the “holy grail” of modern authentication: a 100% passwordless application. We stripped away passwords, hashes and reset emails, replacing them with the cryptographic elegance of the WebAuthn API.&lt;/p&gt;

&lt;p&gt;But the real world is rarely that clean. You have legacy users who trust their password managers more than their biometrics. You have corporate environments where security keys aren’t yet standard. Most importantly, you have the “Transition Period” — that awkward phase where you need to support the old while aggressively pushing the new.&lt;/p&gt;

&lt;p&gt;Today, we are building the Hybrid Model. We’re going to create a single, intelligent login form that automatically detects if a user has a Passkey, triggers biometrics if available, but gracefully falls back to a traditional password when necessary.&lt;/p&gt;

&lt;p&gt;We’ll also look at Conditional Mediation (Passkey Autofill) — the “magic” UX that allows a user to log in simply by focusing an input field.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Tech Stack
&lt;/h3&gt;

&lt;p&gt;To follow this guide, you will need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PHP 8.2+:&lt;/strong&gt; Leveraging readonly classes and constructor promotion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Symfony 7.4:&lt;/strong&gt; Utilizing the latest Security Component improvements.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;web-auth/webauthn-symfony-bundle:&lt;/strong&gt; The industry standard for WebAuthn in Symfony.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stimulus &amp;amp; AssetMapper:&lt;/strong&gt; For a zero-Node.js frontend experience.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The UX Masterpiece: How It Works
&lt;/h2&gt;

&lt;p&gt;Instead of confusing users with two separate login buttons (“Log in with Password” vs “Log in with Passkey”), we present them with a single, elegant input: Their Email Address.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The user enters their email and clicks “Continue”.&lt;/li&gt;
&lt;li&gt;Behind the scenes, our Symfony 7.4 backend does a lightning-fast check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If the user has a registered Passkey:&lt;/strong&gt; We instantly trigger the native WebAuthn biometric prompt (FaceID, TouchID, Windows Hello).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If the user relies on a password:&lt;/strong&gt; The form gracefully expands to reveal the traditional password input field.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the exact flow used by tech giants like Google and GitHub and today, we are building it entirely with standard Symfony tools!&lt;/p&gt;

&lt;h2&gt;
  
  
  The Domain Model: Bridging Two Worlds
&lt;/h2&gt;

&lt;p&gt;In our &lt;a href="https://dev.to/mattleads/building-a-100-passwordless-future-passkeys-in-symfony-74-ajn"&gt;previous pure-passwordless setup&lt;/a&gt;, our User entity didn’t even have a password field. To support a hybrid flow, we must re-introduce it, but as an optional credential. This allows for a tiered security model: a user can start with a simple password and later “upgrade” their account by registering a Passkey, which eventually becomes their primary (and most secure) way to log in.&lt;/p&gt;

&lt;p&gt;By implementing the &lt;strong&gt;PasswordAuthenticatedUserInterface&lt;/strong&gt; while keeping the password field nullable, we satisfy Symfony’s security requirements for traditional login without forcing every user to have a legacy credential. This architectural choice is crucial for maintaining backwards compatibility while clearly signaling that Passkeys are the future-proof standard for the application.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Entity/User.php&lt;/span&gt;
&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Entity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Repository\UserRepository&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Doctrine\ORM\Mapping&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="no"&gt;ORM&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Security\Core\User\UserInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Uid\Uuid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[ORM\Entity(repositoryClass: UserRepository::class)]&lt;/span&gt;
&lt;span class="na"&gt;#[ORM\Table(name: '`user`')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;UserInterface&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PasswordAuthenticatedUserInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\Id]&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\GeneratedValue]&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\Column]&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?int&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="na"&gt;#[ORM\Column(length: 180, unique: true)]&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="na"&gt;#[ORM\Column(length: 255, unique: true)]&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$userHandle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="na"&gt;#[ORM\Column(type: 'string', length: 255, nullable: true)]&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$password&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// WebAuthn requires a persistent, non-identifying handle&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;userHandle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Uuid&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;v4&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toRfc4122&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// ... standard getters/setters&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getPassword&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;setPassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$password&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;static&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$password&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;eraseCredentials&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Clear temporary sensitive data&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getUserIdentifier&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Architecture of Choice: Flow Detection
&lt;/h2&gt;

&lt;p&gt;The core of a great hybrid UX is Flow Detection. We don’t want to show two forms. We want one input: “Enter your email.” When the user clicks “Continue,” our Stimulus controller hits a lightweight API endpoint to decide the next move. This prevents the “password field fatigue” where users are confronted with a complex form before they’ve even identified themselves.&lt;/p&gt;

&lt;p&gt;Importantly, this endpoint is designed with “security through ambiguity” in mind. If an email is not found, we default the response to the password flow. This prevents malicious actors from using the API to verify which emails are registered in our system (user enumeration), while still allowing us to provide a tailored, progressive UI for our legitimate users.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Flow API
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Controller/AuthController.php&lt;/span&gt;
&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Controller&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Repository\UserRepository&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Repository\PublicKeyCredentialSourceRepository&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\JsonResponse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Routing\Attribute\Route&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuthController&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AbstractController&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[Route('/api/auth/flow', name: 'app_api_auth_flow', methods: ['GET'])]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;apiAuthFlow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
        &lt;span class="kt"&gt;UserRepository&lt;/span&gt; &lt;span class="nv"&gt;$userRepo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
        &lt;span class="kt"&gt;PublicKeyCredentialSourceRepository&lt;/span&gt; &lt;span class="nv"&gt;$credsRepo&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;JsonResponse&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$userRepo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findOneBy&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Treat non-existent users as password users to prevent enumeration&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'flow'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'password'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nv"&gt;$hasPasskeys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$credsRepo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findAllForUserEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toWebauthnUser&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'flow'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$hasPasskeys&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'passkey'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'password'&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Security Guard: HybridAuthenticator
&lt;/h2&gt;

&lt;p&gt;While the &lt;strong&gt;webauthn-symfony-bundle&lt;/strong&gt; handles &lt;strong&gt;Passkey verification automatically&lt;/strong&gt;, we need a way to handle the traditional password fallback. Instead of using the built-in form_login, we implement a custom &lt;strong&gt;HybridAuthenticator&lt;/strong&gt;. This allows us to treat different credential types (&lt;strong&gt;Passkeys vs. Passwords&lt;/strong&gt;) as separate “badges” within a single unified authentication event, providing a much cleaner integration with the modern Symfony 7.4 Security component.&lt;/p&gt;

&lt;p&gt;By using a custom authenticator, we can also ensure that both authentication methods share the exact same success and failure handlers. This means redirected dashboard URLs, flash messages and security logging are consistent across the entire app, regardless of whether the user used their fingerprint or a 20-character password to gain entry.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Security/HybridAuthenticator.php&lt;/span&gt;
&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Security\Core\Authentication\Token\TokenInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Security\Core\Exception\AuthenticationException&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Security\Http\Authenticator\Passport\Passport&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HybridAuthenticator&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AbstractAuthenticator&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;AuthenticationSuccessHandler&lt;/span&gt; &lt;span class="nv"&gt;$successHandler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;AuthenticationFailureHandler&lt;/span&gt; &lt;span class="nv"&gt;$failureHandler&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;supports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Only intercept standard POST login attempts with a password&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isMethod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; 
            &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getPathInfo&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'/login'&lt;/span&gt; 
            &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'password'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Passport&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'username'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$password&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'password'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Passport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UserBadge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PasswordCredentials&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;onAuthenticationSuccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;TokenInterface&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$firewall&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;successHandler&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;onAuthenticationSuccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;onAuthenticationFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;AuthenticationException&lt;/span&gt; &lt;span class="nv"&gt;$exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;failureHandler&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;onAuthenticationFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$exception&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Symfony 7.4’s authenticator system is incredibly flexible. We can configure our &lt;strong&gt;security.yaml&lt;/strong&gt; to accept both form logins (passwords) and WebAuthn assertions on the same firewall!&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;security&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;password_hashers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;App\Entity\User&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;auto'&lt;/span&gt;
    &lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app_user_provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;entity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;App\Entity\User&lt;/span&gt;
                &lt;span class="na"&gt;property&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;email&lt;/span&gt;
    &lt;span class="na"&gt;firewalls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;dev&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^/(_(profiler|wdt)|css|images|js)/&lt;/span&gt;
            &lt;span class="na"&gt;security&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="na"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;lazy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
            &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_user_provider&lt;/span&gt;

            &lt;span class="na"&gt;custom_authenticator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;App\Security\HybridAuthenticator&lt;/span&gt;

            &lt;span class="na"&gt;webauthn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;authentication&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                    &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                        &lt;span class="na"&gt;options_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/login/passkey/options&lt;/span&gt;
                        &lt;span class="na"&gt;result_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/login/passkey/result&lt;/span&gt;
                &lt;span class="na"&gt;registration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
                    &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                        &lt;span class="na"&gt;options_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/register/passkey/options&lt;/span&gt;
                        &lt;span class="na"&gt;result_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/register/passkey/result&lt;/span&gt;
                &lt;span class="na"&gt;success_handler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;App\Security\AuthenticationSuccessHandler&lt;/span&gt;
                &lt;span class="na"&gt;failure_handler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;App\Security\AuthenticationFailureHandler&lt;/span&gt;

            &lt;span class="na"&gt;logout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_logout&lt;/span&gt;
    &lt;span class="na"&gt;access_control&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;^/dashboard&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;ROLE_USER&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Frontend Magic: Conditional Mediation (Autofill)
&lt;/h2&gt;

&lt;p&gt;This is where the application starts to feel truly modern. Conditional Mediation allows the browser to show a Passkey suggestion as soon as the user focuses the email field. This “zero-effort” login means that for many users, the login process consists of a single tap on their name in a browser popup, followed by a biometric scan.&lt;/p&gt;

&lt;p&gt;To achieve this, we use the &lt;strong&gt;autocomplete=”username webauthn”&lt;/strong&gt; attribute in our HTML. This serves as a direct signal to the browser’s credential manager. On the JavaScript side, the Stimulus connect() method is the perfect place to initiate a background “listen” for these autofill suggestions, allowing the page to remain interactive while the browser waits for the user’s selection.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Template
&lt;/h3&gt;

&lt;p&gt;We need to add &lt;strong&gt;autocomplete=”username webauthn”&lt;/strong&gt; to our input. This is the signal to the browser.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight twig"&gt;&lt;code&gt;&lt;span class="c"&gt;{# templates/app/login.html.twig #}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;data-controller=&lt;/span&gt;&lt;span class="s"&gt;"hybrid-login"&lt;/span&gt; &lt;span class="na"&gt;data-action=&lt;/span&gt;&lt;span class="s"&gt;"submit-&amp;gt;hybrid-login#submit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; 
           &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"username"&lt;/span&gt; 
           &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"username webauthn"&lt;/span&gt;
           &lt;span class="na"&gt;data-hybrid-login-target=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; 
           &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Enter your email"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;data-hybrid-login-target=&lt;/span&gt;&lt;span class="s"&gt;"passwordContainer"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"d-none"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"password"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"password"&lt;/span&gt; &lt;span class="na"&gt;data-hybrid-login-target=&lt;/span&gt;&lt;span class="s"&gt;"password"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt; &lt;span class="na"&gt;data-hybrid-login-target=&lt;/span&gt;&lt;span class="s"&gt;"continueButton"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Continue&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Stimulus Controller
&lt;/h3&gt;

&lt;p&gt;In our connect() method, we check if the browser supports autofill. If it does, we start the &lt;strong&gt;WebAuthn ceremony immediately in the background&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// assets/controllers/hybrid_login_controller.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Controller&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@hotwired/stimulus&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;startAuthentication&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;browserSupportsWebAuthnAutofill&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@simplewebauthn/browser&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;Controller&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;browserSupportsWebAuthnAutofill&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="c1"&gt;// Background listen for autofill&lt;/span&gt;
                &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;credential&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;startAuthentication&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                    &lt;span class="na"&gt;optionsJSON&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchOptions&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                    &lt;span class="na"&gt;useBrowserAutofill&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
                &lt;span class="p"&gt;});&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verifyResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="c1"&gt;// Silent catch: user might just type manually&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emailTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// 1. Check Flow&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;flow&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/auth/flow?email=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flow&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;passkey&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;triggerPasskeyLogin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;showPasswordInput&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The “Upgrade” Path: Adding Passkeys from the Dashboard
&lt;/h2&gt;

&lt;p&gt;The biggest challenge with Passkeys isn’t the code; it’s the adoption. You need to give your users a reason and a way to add biometrics to their existing password accounts. We’ve implemented a specialized &lt;strong&gt;PasskeySettingsController&lt;/strong&gt; that allows already-logged-in users to safely bridge the gap between their password-based past and their biometric future without the friction of a full re-registration.&lt;/p&gt;

&lt;p&gt;A key part of this flow is the use of &lt;strong&gt;excludeCredentials&lt;/strong&gt;. By passing the user’s existing credential IDs to the browser during this process, we ensure that the user isn’t prompted to create a duplicate Passkey on the same device. This prevents “credential clutter” and ensures that the user’s dashboard only shows unique, functional security keys, keeping the experience clean and manageable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Controller/PasskeySettingsController.php&lt;/span&gt;
&lt;span class="na"&gt;#[Route('/dashboard/passkey/options', methods: ['POST'])]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;JsonResponse&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$userEntity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PublicKeyCredentialUserEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getEmail&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getUserHandle&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getEmail&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Exclude existing keys so the browser doesn't offer duplicates&lt;/span&gt;
    &lt;span class="nv"&gt;$excludeDescriptors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PublicKeyCredentialDescriptor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'public-key'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$source&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;publicKeyCredentialId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;credsRepo&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findAllForUserEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userEntity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;optionsFactory&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'default'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$userEntity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$excludeDescriptors&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;optionsStorage&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Item&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$userEntity&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;serializer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'json'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Technical Pitfalls &amp;amp; Verification
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Binary Data and JSON
&lt;/h3&gt;

&lt;p&gt;One of the most common issues in WebAuthn development is encoding. Credential IDs and challenges are raw binary data, which standard json_encode cannot handle.&lt;/p&gt;

&lt;p&gt;We must use the base64url standard, which is specifically designed for safe transport in URLs and JSON without the problematic + or / characters found in standard Base64. Using the bundle’s specialized serializer ensures this conversion happens correctly and consistently.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verification Steps
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Password Registration:&lt;/strong&gt; Create an account via the legacy /register/password route to establish your baseline “old world” user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid Login:&lt;/strong&gt; Go to /login and enter the email. The system should correctly identify the user as a password-only user and reveal the password field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade:&lt;/strong&gt; Log in with your password, navigate to the Dashboard and click “Add Passkey” to bridge the account into the biometric world.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Autofill:&lt;/strong&gt; Log out. Click the email field. Your browser should now offer the Passkey suggestion immediately, completing the circle of the hybrid experience.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The implementation of a hybrid authentication model in Symfony 7.4 represents a sophisticated balance between cutting-edge security and practical accessibility. By providing a unified interface that respects both legacy password habits and the move toward biometrics, you eliminate the friction that often kills new feature adoption. This approach doesn’t just improve security; it builds trust with your users by meeting them where they are while clearly showing them a better, faster way forward.&lt;/p&gt;

&lt;p&gt;Technically, we’ve seen that Symfony’s modern Security component and the WebAuthn bundle provide a remarkably robust foundation for these complex flows. The ability to treat passwords and passkeys as complementary badges within the same ecosystem means that as a developer, you aren’t fighting the framework to implement custom logic. Instead, you’re utilizing standard, interoperable primitives to build a system that is as maintainable as it is secure.&lt;/p&gt;

&lt;p&gt;Looking ahead, the goal is a web where authentication is so frictionless it becomes invisible. With Conditional Mediation and hybrid fallbacks, we are moving closer to that reality, where the burden of security shifts from the user’s memory to the hardware in their pocket. By adopting these patterns today, you are future-proofing your application and ensuring that your users benefit from the highest standards of digital safety without sacrificing the convenience they’ve come to expect.&lt;/p&gt;

&lt;p&gt;Source Code: You can find the full implementation and follow the project’s progress on GitHub: [&lt;a href="https://github.com/mattleads/PasskeysAuth" rel="noopener noreferrer"&gt;https://github.com/mattleads/PasskeysAuth&lt;/a&gt;]&lt;/p&gt;

&lt;h3&gt;
  
  
  Let’s Connect!
&lt;/h3&gt;

&lt;p&gt;If you found this helpful or have questions about the implementation, I’d love to hear from you. Let’s stay in touch and keep the conversation going across these platforms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LinkedIn: [&lt;a href="https://www.linkedin.com/in/matthew-mochalkin/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/matthew-mochalkin/&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;X (Twitter): [&lt;a href="https://x.com/MattLeads" rel="noopener noreferrer"&gt;https://x.com/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;Telegram: [&lt;a href="https://t.me/MattLeads" rel="noopener noreferrer"&gt;https://t.me/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;GitHub: [&lt;a href="https://github.com/mattleads" rel="noopener noreferrer"&gt;https://github.com/mattleads&lt;/a&gt;]&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>symfony</category>
      <category>security</category>
      <category>php</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Building a 100% Passwordless Future: Passkeys in Symfony 7.4</title>
      <dc:creator>Matt Mochalkin</dc:creator>
      <pubDate>Thu, 12 Mar 2026 10:16:07 +0000</pubDate>
      <link>https://dev.to/mattleads/building-a-100-passwordless-future-passkeys-in-symfony-74-ajn</link>
      <guid>https://dev.to/mattleads/building-a-100-passwordless-future-passkeys-in-symfony-74-ajn</guid>
      <description>&lt;p&gt;In the modern web era, passwords are no longer sufficient. They are the root cause of over 80% of data breaches, subject to phishing, reuse and terrible complexity rules. The industry has spoken: &lt;strong&gt;Passkeys are the future&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Passkeys, built on the Web Authentication (&lt;strong&gt;WebAuthn&lt;/strong&gt;) and &lt;strong&gt;FIDO2&lt;/strong&gt; standards, replace traditional passwords with cryptographic key pairs. Your device (iPhone, Android, Windows Hello, YubiKey) stores a private key, while the server only ever sees the public key. No hashes to steal, no passwords to reset and inherently phishing-resistant.&lt;/p&gt;

&lt;p&gt;In this comprehensive guide, &lt;strong&gt;we will build a 100% passwordless authentication system&lt;/strong&gt; using Symfony and the official &lt;strong&gt;web-auth/webauthn-symfony-bundle&lt;/strong&gt;. We will eliminate the concept of a password entirely from our application. No fallback, no “reset password” links. Just pure, secure, biometric-backed passkeys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Core Architecture &amp;amp; Requirements
&lt;/h2&gt;

&lt;p&gt;Passkeys work by replacing a shared secret (password) with a public/private key pair. The private key never leaves the user’s Apple device (iPhone, Mac, iPad) and the public key is stored on your Symfony server.&lt;/p&gt;

&lt;h3&gt;
  
  
  Technical Stack
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;PHP: 8.2 or higher (Required for the latest WebAuthn libs)&lt;/li&gt;
&lt;li&gt;Symfony: 7.4 LTS&lt;/li&gt;
&lt;li&gt;Database: PostgreSQL, MySQL or SQLite for dev (to store Credential Sources)&lt;/li&gt;
&lt;li&gt;Primary Library: web-auth/webauthn-symfony-bundle&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Essential Packages
&lt;/h3&gt;

&lt;p&gt;Run the following command to install the necessary dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require web-auth/webauthn-symfony-bundle:^5.2 &lt;span class="se"&gt;\&lt;/span&gt;
                 web-auth/webauthn-stimulus:^5.2 &lt;span class="se"&gt;\&lt;/span&gt;
                 symfony/uid:^7.4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We use &lt;strong&gt;@simplewebauthn/browser&lt;/strong&gt; via AssetMapper (which provides excellent wrapper functions for the native browser WebAuthn APIs) because Apple Passkeys require a frontend interaction that is best handled via a &lt;strong&gt;Stimulus controller&lt;/strong&gt; in a modern Symfony environment or you can use React/Vue modules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Database Schema: The Credential Source
&lt;/h2&gt;

&lt;p&gt;This is where our application dramatically diverges from a traditional Symfony app. We are going to strip passwords entirely from the system.&lt;/p&gt;

&lt;p&gt;Standard Symfony User entities aren’t equipped to store Passkey metadata (like AAGUIDs or public key Cose algorithms). We need a dedicated entity to store the credentials.&lt;/p&gt;

&lt;h3&gt;
  
  
  The User Entity
&lt;/h3&gt;

&lt;p&gt;Our User entity implements Symfony\Component\Security\Core\User\UserInterface. Noticeably absent is the PasswordAuthenticatedUserInterface.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Entity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Repository\UserRepository&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Doctrine\ORM\Mapping&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="no"&gt;ORM&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Security\Core\User\UserInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Uid\Uuid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Validator\Constraints&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[ORM\Entity(repositoryClass: UserRepository::class)]&lt;/span&gt;
&lt;span class="na"&gt;#[ORM\Table(name: '`user`')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;UserInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\Id]&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\GeneratedValue]&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\Column]&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?int&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="na"&gt;#[ORM\Column(length: 255, unique: true)]&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$userHandle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="na"&gt;#[ORM\Column(length: 180, unique: true)]&lt;/span&gt;
    &lt;span class="na"&gt;#[Assert\NotBlank]&lt;/span&gt;
    &lt;span class="na"&gt;#[Assert\Email]&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;userHandle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Uuid&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;v4&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toRfc4122&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="mf"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The PublicKeyCredentialSource Entity
&lt;/h3&gt;

&lt;p&gt;A single user can have multiple passkeys (e.g., Face ID on their phone, Touch ID on their Mac, a YubiKey on their keychain). We need an entity to store these public keys and their associated metadata.&lt;/p&gt;

&lt;p&gt;Create &lt;strong&gt;src/Entity/PublicKeyCredentialSource.php&lt;/strong&gt;. This entity must be capable of translating to and from the bundle’s native &lt;strong&gt;Webauthn\PublicKeyCredentialSource&lt;/strong&gt; object.&lt;/p&gt;

&lt;p&gt;Crucially, we must preserve the &lt;strong&gt;TrustPath&lt;/strong&gt;. Failing to do so destroys the attestation data needed if you ever require high-security enterprise hardware keys.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Entity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Repository\PublicKeyCredentialSourceRepository&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Doctrine\ORM\Mapping&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="no"&gt;ORM&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Webauthn\PublicKeyCredentialSource&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;WebauthnSource&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[ORM\Entity(repositoryClass: PublicKeyCredentialSourceRepository::class)]&lt;/span&gt;
&lt;span class="na"&gt;#[ORM\Table(name: 'webauthn_credentials')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PublicKeyCredentialSource&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;WebauthnSource&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\Id]&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\GeneratedValue]&lt;/span&gt;
    &lt;span class="na"&gt;#[ORM\Column]&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;?int&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The CredentialSourceRepository
&lt;/h3&gt;

&lt;p&gt;You must also implement a &lt;strong&gt;CredentialSourceRepository&lt;/strong&gt; that implements &lt;strong&gt;Webauthn\Bundle\Repository\PublicKeyCredentialSourceRepository&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Repository&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Entity\PublicKeyCredentialSource&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Doctrine\Persistence\ManagerRegistry&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\ObjectMapper\ObjectMapperInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Webauthn\Bundle\Repository\PublicKeyCredentialSourceRepositoryInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Webauthn\Bundle\Repository\CanSaveCredentialSource&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Webauthn\PublicKeyCredentialSource&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;WebauthnSource&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Webauthn\PublicKeyCredentialUserEntity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PublicKeyCredentialSourceRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;ServiceEntityRepository&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;PublicKeyCredentialSourceRepositoryInterface&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CanSaveCredentialSource&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ManagerRegistry&lt;/span&gt; &lt;span class="nv"&gt;$registry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;ObjectMapperInterface&lt;/span&gt; &lt;span class="nv"&gt;$objectMapper&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$registry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PublicKeyCredentialSource&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;findOneByCredentialId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$publicKeyCredentialId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?WebauthnSource&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findOneBy&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'publicKeyCredentialId'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$publicKeyCredentialId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;findAllForUserEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;PublicKeyCredentialUserEntity&lt;/span&gt; &lt;span class="nv"&gt;$publicKeyCredentialUserEntity&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findBy&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'userHandle'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$publicKeyCredentialUserEntity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;saveCredentialSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;WebauthnSource&lt;/span&gt; &lt;span class="nv"&gt;$publicKeyCredentialSource&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$entity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;findOneBy&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'publicKeyCredentialId'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$publicKeyCredentialSource&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;publicKeyCredentialId&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
            &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;objectMapper&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$publicKeyCredentialSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PublicKeyCredentialSource&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getEntityManager&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getEntityManager&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The WebAuthn bundle relies on abstract interfaces to find and persist users and credentials. Our repositories must implement these interfaces.&lt;/p&gt;

&lt;h3&gt;
  
  
  The UserRepository
&lt;/h3&gt;

&lt;p&gt;The UserRepository implements &lt;strong&gt;PublicKeyCredentialUserEntityRepositoryInterface&lt;/strong&gt;. Because we want the bundle to handle user creation automatically during a passkey registration, we also implement &lt;strong&gt;CanRegisterUserEntity&lt;/strong&gt; and &lt;strong&gt;CanGenerateUserEntity&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Repository&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Entity\User&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Doctrine\Persistence\ManagerRegistry&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Uid\Uuid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Webauthn\Bundle\Repository\CanGenerateUserEntity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Webauthn\Bundle\Repository\CanRegisterUserEntity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Webauthn\Bundle\Repository\PublicKeyCredentialUserEntityRepositoryInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Webauthn\Exception\InvalidDataException&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Webauthn\PublicKeyCredentialUserEntity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;ServiceEntityRepository&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;PublicKeyCredentialUserEntityRepositoryInterface&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CanRegisterUserEntity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CanGenerateUserEntity&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ManagerRegistry&lt;/span&gt; &lt;span class="nv"&gt;$registry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$registry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;saveUserEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;PublicKeyCredentialUserEntity&lt;/span&gt; &lt;span class="nv"&gt;$userEntity&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userEntity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setUserHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userEntity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getEntityManager&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getEntityManager&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;generateUserEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$displayName&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;PublicKeyCredentialUserEntity&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PublicKeyCredentialUserEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nv"&gt;$username&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;Uuid&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;v4&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toRfc4122&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="nv"&gt;$displayName&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$username&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="mf"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Configuration: Bridging Symfony and Apple
&lt;/h2&gt;

&lt;p&gt;Apple requires specific &lt;strong&gt;“Relying Party” (RP)&lt;/strong&gt; information. This identifies your application to the user’s iCloud Keychain.&lt;/p&gt;

&lt;h3&gt;
  
  
  WebAuthn Configuration
&lt;/h3&gt;

&lt;p&gt;Create or update config/packages/webauthn.yaml:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;webauthn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;allowed_origins&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%env(WEBAUTHN_ALLOWED_ORIGINS)%'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;credential_repository&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;App\Repository\PublicKeyCredentialSourceRepository'&lt;/span&gt;
    &lt;span class="na"&gt;user_repository&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;App\Repository\UserRepository'&lt;/span&gt;
    &lt;span class="na"&gt;creation_profiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;rp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%env(RELYING_PARTY_NAME)%'&lt;/span&gt;
                &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%env(RELYING_PARTY_ID)%'&lt;/span&gt;
    &lt;span class="na"&gt;request_profiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;rp_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%env(RELYING_PARTY_ID)%'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WebAuthn is incredibly strict about domains. A passkey created for &lt;strong&gt;example.com&lt;/strong&gt; cannot be used on &lt;strong&gt;phishing-example.com&lt;/strong&gt;. To ensure our application is portable across environments, we define our Relying Party (RP) settings in the &lt;strong&gt;.env&lt;/strong&gt; file.&lt;/p&gt;

&lt;p&gt;Open &lt;strong&gt;.env&lt;/strong&gt; or &lt;strong&gt;.env.local&lt;/strong&gt; and add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;###&amp;gt; web-auth/webauthn-symfony-bundle ###
RELYING_PARTY_ID=localhost
RELYING_PARTY_NAME="My Application"
WEBAUTHN_ALLOWED_ORIGINS=localhost
###&amp;lt; web-auth/webauthn-symfony-bundle ###
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production &lt;strong&gt;RELYING_PARTY_ID&lt;/strong&gt; must be your exact root domain (e.g., example.com) and &lt;strong&gt;WebAuthn require a secure HTTPS context&lt;/strong&gt;. &lt;strong&gt;Browsers only exempt localhost for development.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Registration Flow (Creation)
&lt;/h2&gt;

&lt;p&gt;Passkey registration is a two-step handshake:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Challenge:&lt;/strong&gt; The server generates a unique challenge and &lt;strong&gt;“Creation Options.”&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attestation:&lt;/strong&gt; The browser (Safari/Chrome) asks the user for FaceID/TouchID, signs the challenge and sends the &lt;strong&gt;“Attestation Object”&lt;/strong&gt; back to the server.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The Frontend: Stimulus and CSRF
&lt;/h3&gt;

&lt;p&gt;Security is paramount. Even though &lt;strong&gt;WebAuthn&lt;/strong&gt; is inherently phishing-resistant, your endpoints are still vulnerable to traditional &lt;strong&gt;Cross-Site Request Forgery (CSRF)&lt;/strong&gt; if left unprotected. We will pass Symfony’s built-in CSRF tokens via headers in our &lt;strong&gt;fetch() calls&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Assuming you have a standard &lt;strong&gt;CSRF&lt;/strong&gt; helper (like &lt;strong&gt;csrf_protection_controller.js&lt;/strong&gt; that extracts the token from a meta tag or hidden input) we inject it into our Passkey controller.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Controller&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@hotwired/stimulus&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;startRegistration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startAuthentication&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@simplewebauthn/browser&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generateCsrfHeaders&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./csrf_protection_controller.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;Controller&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;optionsUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;resultUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;isLogin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Boolean&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Passkey controller connected! 🔑&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;username&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[name="username"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isLoginValue&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Please provide a username/email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;csrfHeaders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generateCsrfHeaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// 1. Fetch options&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;optionsUrlValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;csrfHeaders&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;username&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;username&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
            &lt;span class="p"&gt;});&lt;/span&gt;

            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;errorData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({}));&lt;/span&gt;
                &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errorData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errorMessage&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Failed to fetch WebAuthn options from server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

            &lt;span class="c1"&gt;// 2. Trigger Apple's Passkey UI (Create or Get)&lt;/span&gt;
            &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isLoginValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nx"&gt;credential&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;startAuthentication&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;optionsJSON&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nx"&gt;credential&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;startRegistration&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;optionsJSON&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="c1"&gt;// 3. Send result back to verify&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resultUrlValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;csrfHeaders&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;});&lt;/span&gt;

            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;errorText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                &lt;span class="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Authentication failed: &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;errorText&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WebAuthn process failed: &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Routing
&lt;/h2&gt;

&lt;p&gt;You need to ensure the routing type for webauthn exists. Create &lt;strong&gt;config/routes/webauthn_routes.yaml&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;webauthn_routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;webauthn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Security Bundle Integration
&lt;/h2&gt;

&lt;p&gt;To allow users to log in with their &lt;strong&gt;Passkey&lt;/strong&gt;, we need to configure the &lt;strong&gt;Symfony Guard (now the Authenticator system)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;config/packages/security.yaml&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;security&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app_user_provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;entity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;App\Entity\User&lt;/span&gt;
                &lt;span class="na"&gt;property&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;email&lt;/span&gt;
    &lt;span class="na"&gt;firewalls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;dev&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^/(_(profiler|wdt)|css|images|js)/&lt;/span&gt;
            &lt;span class="na"&gt;security&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="na"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;lazy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
            &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_user_provider&lt;/span&gt;

            &lt;span class="na"&gt;webauthn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;authentication&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                    &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                        &lt;span class="na"&gt;options_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/login/passkey/options&lt;/span&gt;
                        &lt;span class="na"&gt;result_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/login/passkey/result&lt;/span&gt;
                &lt;span class="na"&gt;registration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
                    &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                        &lt;span class="na"&gt;options_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/register/passkey/options&lt;/span&gt;
                        &lt;span class="na"&gt;result_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/register/passkey/result&lt;/span&gt;
                &lt;span class="na"&gt;success_handler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;App\Security\AuthenticationSuccessHandler&lt;/span&gt;
                &lt;span class="na"&gt;failure_handler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;App\Security\AuthenticationFailureHandler&lt;/span&gt;

            &lt;span class="na"&gt;logout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_logout&lt;/span&gt;
    &lt;span class="na"&gt;access_control&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;^/dashboard&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;ROLE_USER&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Authentication Failure Handler
&lt;/h3&gt;

&lt;p&gt;Because WebAuthn ceremonies involve &lt;strong&gt;AJAX fetch()&lt;/strong&gt; requests from the frontend, a standard Symfony redirect on failure (e.g., trying to register an email that already exists) will be silently swallowed by the browser, resulting in a frustrating user experience.&lt;/p&gt;

&lt;p&gt;We implement a custom &lt;strong&gt;AuthenticationFailureHandler&lt;/strong&gt; that returns a clean &lt;strong&gt;401 Unauthorized JSON&lt;/strong&gt; response when the request is AJAX.&lt;/p&gt;

&lt;p&gt;Create &lt;strong&gt;src/Security/AuthenticationFailureHandler.php&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\JsonResponse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\RedirectResponse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Routing\Generator\UrlGeneratorInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Security\Core\Exception\AuthenticationException&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Security\Http\SecurityRequestAttributes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuthenticationFailureHandler&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;AuthenticationFailureHandlerInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;UrlGeneratorInterface&lt;/span&gt; &lt;span class="nv"&gt;$urlGenerator&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;onAuthenticationFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;AuthenticationException&lt;/span&gt; &lt;span class="nv"&gt;$exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;RedirectResponse&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nc"&gt;JsonResponse&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getContentTypeFormat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'json'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isXmlHttpRequest&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                &lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'error'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'errorMessage'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$exception&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMessageKey&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;HTTP_UNAUTHORIZED&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Store the error in the session&lt;/span&gt;
        &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SecurityRequestAttributes&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;AUTHENTICATION_ERROR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$exception&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RedirectResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;urlGenerator&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app_login'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Authentication Success Handler
&lt;/h3&gt;

&lt;p&gt;Since Passkeys often bypass the traditional login form, you need to define where the user goes after a &lt;strong&gt;successful “Handshake.”&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\RedirectResponse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\HttpFoundation\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Routing\Generator\UrlGeneratorInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Security\Core\Authentication\Token\TokenInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuthenticationSuccessHandler&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;AuthenticationSuccessHandlerInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;UrlGeneratorInterface&lt;/span&gt; &lt;span class="nv"&gt;$urlGenerator&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;onAuthenticationSuccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;TokenInterface&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;RedirectResponse&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RedirectResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;urlGenerator&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app_dashboard'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Verification &amp;amp; Apple-Specific Gotchas
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;HTTPS is mandatory:&lt;/strong&gt; Browsers will not expose &lt;strong&gt;navigator.credentials&lt;/strong&gt; on insecure origins (&lt;strong&gt;except localhost&lt;/strong&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RP ID Match:&lt;/strong&gt; Ensure the &lt;strong&gt;id in webauthn.yaml&lt;/strong&gt; exactly matches your domain. If you are on &lt;strong&gt;dev.example.com&lt;/strong&gt;, your &lt;strong&gt;RP ID should be example.com&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apple AAGUID:&lt;/strong&gt; Apple devices often return a &lt;strong&gt;“Zero AAGUID”&lt;/strong&gt; (all zeros). If your library is configured to strictly validate authenticators via metadata, you may need to allow &lt;strong&gt;“Unknown Authenticators”&lt;/strong&gt; in your configuration.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Transitioning to Apple Passkeys with Symfony 7.4 isn’t just a security upgrade; it’s a significant improvement to your user experience. By removing the friction of &lt;strong&gt;password managers&lt;/strong&gt;, &lt;strong&gt;“forgot password” emails&lt;/strong&gt; and complex character requirements, you increase conversion and user retention.&lt;/p&gt;

&lt;p&gt;As a senior developer or lead, your priority is ensuring that this implementation remains maintainable. By sticking to the &lt;strong&gt;WebAuthn-Symfony-Bundle&lt;/strong&gt; and PHP 8.x attributes, you ensure that your codebase remains idiomatic and ready for future Symfony LTS releases.&lt;/p&gt;

&lt;h3&gt;
  
  
  Summary Checklist for Deployment
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SSL/TLS:&lt;/strong&gt; Ensure your production environment uses a valid certificate (Passkeys will fail on plain HTTP).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RP ID Strategy:&lt;/strong&gt; Decide if you want to support subdomains by setting your Relaying Party ID to the top-level domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backup Methods:&lt;/strong&gt; Always provide a secondary login method (like Magic Links or a traditional password) for users on older devices that do not support FIDO2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata Validation:&lt;/strong&gt; For high-security apps, consider enabling the web-auth/webauthn-metadata-service to verify that the Passkey is indeed coming from an Apple device and not an unauthorized emulator.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Source Code:&lt;/strong&gt; You can find the full implementation and follow the project’s progress on GitHub: [&lt;a href="https://github.com/mattleads/PasskeysAuth" rel="noopener noreferrer"&gt;https://github.com/mattleads/PasskeysAuth&lt;/a&gt;]&lt;/p&gt;

&lt;h3&gt;
  
  
  Let’s Connect!
&lt;/h3&gt;

&lt;p&gt;If you found this helpful or have questions about the implementation, I’d love to hear from you. Let’s stay in touch and keep the conversation going across these platforms:&lt;/p&gt;

&lt;p&gt;LinkedIn: [&lt;a href="https://www.linkedin.com/in/matthew-mochalkin/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/matthew-mochalkin/&lt;/a&gt;]&lt;br&gt;
X (Twitter): [&lt;a href="https://x.com/MattLeads" rel="noopener noreferrer"&gt;https://x.com/MattLeads&lt;/a&gt;]&lt;br&gt;
Telegram: [&lt;a href="https://t.me/MattLeads" rel="noopener noreferrer"&gt;https://t.me/MattLeads&lt;/a&gt;]&lt;br&gt;
GitHub: [&lt;a href="https://github.com/mattleads" rel="noopener noreferrer"&gt;https://github.com/mattleads&lt;/a&gt;]&lt;/p&gt;

</description>
      <category>symfony</category>
      <category>security</category>
      <category>php</category>
      <category>coding</category>
    </item>
    <item>
      <title>Beyond debug.log: 10 Advanced Logging Patterns for Symfony 7.4</title>
      <dc:creator>Matt Mochalkin</dc:creator>
      <pubDate>Wed, 25 Feb 2026 13:46:13 +0000</pubDate>
      <link>https://dev.to/mattleads/beyond-debuglog-10-advanced-logging-patterns-for-symfony-74-3i27</link>
      <guid>https://dev.to/mattleads/beyond-debuglog-10-advanced-logging-patterns-for-symfony-74-3i27</guid>
      <description>&lt;p&gt;Logging is the heartbeat of a production application. In the early days of a project, a simple &lt;strong&gt;dev.log&lt;/strong&gt; tail is sufficient. But as your Symfony application scales to handle &lt;strong&gt;payments&lt;/strong&gt;, &lt;strong&gt;asynchronous workers and high-concurrency traffic&lt;/strong&gt;, “writing to a file” becomes a liability rather than an asset.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;symfony/monolog-bundle&lt;/strong&gt; offers sophisticated tools to transform logs from simple text streams into structured, actionable observability data.&lt;/p&gt;

&lt;p&gt;This guide explores 10 advanced logging patterns that go beyond the defaults. We will use strict typing, PHP Attributes and modern YAML configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prerequisites
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Symfony:&lt;/strong&gt; 7.4+&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PHP:&lt;/strong&gt; 8.3+&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;symfony/monolog-bundle&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;monolog/monolog&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;symfony/notifier&lt;/strong&gt; (for alerting examples)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Scenation 1. The “Black Box” Recorder: FingersCrossed Handler
&lt;/h2&gt;

&lt;p&gt;You want detailed debug logs when an error occurs to understand the sequence of events leading up to it, but you can’t afford the disk I/O to log debug messages for every successful request in production.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;FingersCrossedHandler&lt;/strong&gt; buffers all logs in memory during the request. If the request finishes successfully, the buffer is discarded. If an error (or a specific threshold) is reached, the entire buffer (including previous debug logs) is flushed to the persistence handler.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuration
&lt;/h3&gt;

&lt;p&gt;config/packages/prod/monolog.yaml:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;monolog:
    handlers:
        main:
            type: fingers_crossed
            # The strategy: "error" means if an ERROR occurs, dump everything.
            action_level: error
            # Where to dump the logs if the threshold is met
            handler: nested
            # Optional: Keep a small buffer size to prevent memory leaks in long processes
            buffer_size: 50
        nested:
            type: stream
            path: "%kernel.logs_dir%/%kernel.environment%.log"
            level: debug
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You’ll get the forensic detail of debug level logging exactly when you need it — during a crash — without filling your disk with noise during normal operations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scenario 2. Segregated Channels: The “Payment” Log
&lt;/h2&gt;

&lt;p&gt;Your &lt;strong&gt;app.log&lt;/strong&gt; is a mix of Doctrine queries, router matching and critical business logic. You need a dedicated file for financial transactions that can be audited separately.&lt;/p&gt;

&lt;p&gt;Create a custom Monolog Channel.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuration
&lt;/h3&gt;

&lt;p&gt;config/packages/monolog.yaml:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;monolog:
    channels: ['payment'] # Register the channel

    handlers:
        payment:
            type: stream
            path: "%kernel.logs_dir%/payment.log"
            level: info
            channels: ["payment"] # Only listen to this channel

        main:
            type: stream
            path: "%kernel.logs_dir%/%kernel.environment%.log"
            level: debug
            channels: ["!payment"] # Exclude payment logs from the main file
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Implementation
&lt;/h3&gt;

&lt;p&gt;Inject the logger specifically for this channel using the Target attribute (available since Symfony 5.3+).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Command;

use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\DependencyInjection\Attribute\Target;

#[AsCommand(name: 'app:process-payments', description: 'Processes pending payments')]
class ProcessPaymentsCommand extends Command
{
    public function __construct(
        #[Target('payment.logger')]
        private readonly LoggerInterface $paymentLogger,
        private readonly LoggerInterface $mainLogger
    ) { parent::__construct(); }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $this-&amp;gt;mainLogger-&amp;gt;info('Cron job app:process-payments started.');

        $amounts = [10.50, 99.99, 45.00];
        foreach ($amounts as $amount) {
            $this-&amp;gt;paymentLogger-&amp;gt;info('Processing payment', ['amount' =&amp;gt; $amount, 'status' =&amp;gt; 'success']);
        }

        $this-&amp;gt;mainLogger-&amp;gt;info('Cron job finished.');
        return Command::SUCCESS;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the Command. You will see &lt;strong&gt;payment.log&lt;/strong&gt; created in &lt;strong&gt;var/log/&lt;/strong&gt; containing only these specific entries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scenario 3. Context Enrichment: The #[AsMonologProcessor] Attribute
&lt;/h2&gt;

&lt;p&gt;Logs are useless if you can’t correlate them to a specific user or request ID. You find yourself manually adding [‘user_id’ =&amp;gt; $user-&amp;gt;getId()] to every single log statement.&lt;/p&gt;

&lt;p&gt;A global Processor can automatically injects context into every log record.&lt;/p&gt;

&lt;h3&gt;
  
  
  Implementation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Log;

use Monolog\Attribute\AsMonologProcessor;
use Monolog\LogRecord;

#[AsMonologProcessor]
class RequestContextProcessor
{
    public function __invoke(LogRecord $record): LogRecord
    {
        // Simulated context since CLI commands don't have HTTP Requests
        $extra = [
            'pid' =&amp;gt; getmypid(),
            'user' =&amp;gt; get_current_user(),
        ];

        return $record-&amp;gt;with(extra: array_merge($record-&amp;gt;extra, $extra));
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;strong&gt;Monolog 3&lt;/strong&gt;, &lt;strong&gt;LogRecord is immutable&lt;/strong&gt;. We use &lt;strong&gt;with()&lt;/strong&gt; to return a modified copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scenario 4. GDPR Compliance: Sensitive Data Redaction
&lt;/h2&gt;

&lt;p&gt;A developer accidentally logs a user object, dumping PII (Personally Identifiable Information) or credit card numbers into the logs, violating GDPR/PCI-DSS.&lt;/p&gt;

&lt;p&gt;A specialized processor can scans the context array and masks sensitive keys.&lt;/p&gt;

&lt;h3&gt;
  
  
  Implementation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Log;

use Monolog\Attribute\AsMonologProcessor;
use Monolog\LogRecord;

#[AsMonologProcessor]
class SensitiveDataProcessor
{
    private const array SENSITIVE_KEYS = ['password', 'credit_card', 'cvv', 'token'];

    public function __invoke(LogRecord $record): LogRecord
    {
        $context = $record-&amp;gt;context;

        foreach ($context as $key =&amp;gt; $value) {
            if (in_array($key, self::SENSITIVE_KEYS, true)) {
                $context[$key] = '***REDACTED***';
            }
        }

        return $record-&amp;gt;with(context: $context);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Verification:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$logger-&amp;gt;info('User login', ['password' =&amp;gt; 'secret123']);
// Output in log: "User login" {"password": "***REDACTED***"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Scenario 5. Structured Logging: JSON for ELK/Datadog
&lt;/h2&gt;

&lt;p&gt;Parsing multi-line text logs (like stack traces) in Kibana or Datadog is painful. Regex parsers break easily.&lt;/p&gt;

&lt;p&gt;You can output logs as JSON lines. This allows log aggregators to natively index fields like &lt;strong&gt;context.order_id&lt;/strong&gt; or &lt;strong&gt;extra.req_id&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuration
&lt;/h3&gt;

&lt;p&gt;config/packages/monolog.yaml:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;monolog:
    handlers:
        json_report:
            type: stream
            path: "%kernel.logs_dir%/app.json"
            level: info
            formatter: monolog.formatter.json
            channels: ["!payment", "!event"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;strong&gt;var/log/app.json&lt;/strong&gt;. The output should look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"message":"Order created","context":{"id":123},"level":200,"channel":"app","datetime":"..."}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Scenario 6. Spam Prevention: The Deduplication Handler
&lt;/h2&gt;

&lt;p&gt;Your database goes down. Your application receives &lt;strong&gt;5,000 requests&lt;/strong&gt; in a minute. Your “Email on Error” handler sends you &lt;strong&gt;5,000 emails&lt;/strong&gt;, getting your &lt;strong&gt;SMTP server blacklisted and flooding your inbox&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;DeduplicationHandler&lt;/strong&gt; can aggregates identical log records and sends a single summary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuration
&lt;/h3&gt;

&lt;p&gt;config/packages/monolog.yaml:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;monolog:
    handlers:
        deduplication:
            type: deduplication
            handler: nested_dedup
            buffer_size: 60
            time: 60
            level: error
            channels: ["!console"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the DB crashes, you receive &lt;strong&gt;one email every 60 seconds&lt;/strong&gt; listing all occurrences, rather than one email per request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scenario 7. Dynamic Log Levels (Runtime Debugging)
&lt;/h2&gt;

&lt;p&gt;A specific customer is reporting an issue in production. You can’t reproduce it and you can’t switch the entire production server to DEBUG level because of the performance hit.&lt;/p&gt;

&lt;p&gt;Use an &lt;strong&gt;ActivationStrategy&lt;/strong&gt; to switch the log level dynamically based on a request header.&lt;/p&gt;

&lt;h3&gt;
  
  
  Implementation
&lt;/h3&gt;

&lt;p&gt;Create a custom strategy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Command;

use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(name: 'app:dynamic-debug', description: 'Tests dynamic log level activation')]
class DynamicDebugCommand extends Command
{
    public function __construct(private readonly LoggerInterface $logger) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this-&amp;gt;addOption('force-debug', null, InputOption::VALUE_NONE, 'Force debug logging for this run');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        if ($input-&amp;gt;getOption('force-debug')) {
            $output-&amp;gt;writeln('Debug mode forced via option. (Simulated, as Monolog ActivationStrategy relies on Http/Request state typically. But you can add processors/handlers dynamically in real apps based on this flag).');
        }

        $this-&amp;gt;logger-&amp;gt;debug('This detailed trace only appears if --force-debug is passed or an error occurs.');
        $this-&amp;gt;logger-&amp;gt;info('Standard processing information.');

        return Command::SUCCESS;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Scenario 8. Messenger Logging: Worker Context
&lt;/h2&gt;

&lt;p&gt;Logs from &lt;strong&gt;messenger:consume&lt;/strong&gt; are hard to trace. You see “Handling message,” but you don’t know which message ID caused the error because workers run as long-running processes.&lt;/p&gt;

&lt;p&gt;Use &lt;strong&gt;Symfony’s EventListener&lt;/strong&gt; to inject the Message ID into the Monolog context specifically for the worker process.&lt;/p&gt;

&lt;h3&gt;
  
  
  Implementation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\EventListener;

use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Messenger\Event\WorkerMessageReceivedEvent;

readonly class WorkerLogContextListener
{
    public function __construct(private LoggerInterface $logger) {}

    #[AsEventListener]
    public function onMessageHandling(WorkerMessageReceivedEvent $event): void
    {
        $this-&amp;gt;logger-&amp;gt;info('Worker started message', [
            'message_class' =&amp;gt; $event-&amp;gt;getEnvelope()-&amp;gt;getMessage()::class,
        ]);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Scenario 9. Excluding 404s from Error Logs
&lt;/h2&gt;

&lt;p&gt;Bots scanning your site for &lt;strong&gt;.env&lt;/strong&gt; or &lt;strong&gt;wp-login.php&lt;/strong&gt; generate thousands of &lt;strong&gt;404 NotFoundHttpException&lt;/strong&gt; logs. These clog your error monitoring tool (Sentry/Slack) with false positives.&lt;/p&gt;

&lt;p&gt;Use the channels exclusion or a specific configuration to ignore bounced logs or better - configure the &lt;strong&gt;NotFoundHttpException&lt;/strong&gt; to be ignored by the main error handler.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuration
&lt;/h3&gt;

&lt;p&gt;config/packages/monolog.yaml:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;monolog:
    handlers:
        fingers_crossed:
            type: fingers_crossed
            action_level: error
            handler: nested
            excluded_http_codes: [404, 405]
            buffer_size: 50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Scenario 10. Notifier Bridge: ChatOps
&lt;/h2&gt;

&lt;p&gt;Email alerts are slow and often ignored. You want critical infrastructure failures to ping a &lt;strong&gt;Slack&lt;/strong&gt; channel immediately.&lt;/p&gt;

&lt;p&gt;Use &lt;strong&gt;symfony/notifier&lt;/strong&gt; bridged with Monolog.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prerequisites
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;composer require symfony/notifier symfony/slack-notifier
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Configuration
&lt;/h3&gt;

&lt;p&gt;config/packages/monolog.yaml:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;monolog:
    handlers:
        slack_alerts:
            type: service
            id: Symfony\Bridge\Monolog\Handler\NotifierHandler
            level: critical
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then configure the notifier chatter in &lt;strong&gt;config/packages/notifier.yaml&lt;/strong&gt; and your DSN in &lt;strong&gt;.env&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;framework:
    notifier:
        chatter_transports:
            slack: '%env(SLACK_DSN)%'
        texter_transports:
        channel_policy:
            urgent: ['chat/slack']
            high: ['chat/slack']
            medium: ['chat/slack']
            low: ['chat/slack']
        admin_recipients:
            - { email: admin@example.com }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;NotifierHandler&lt;/strong&gt; maps log levels to Notifier importance. A critical log becomes a &lt;strong&gt;High Priority Slack notification automatically&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Logging is not a byproduct of code - it is a feature of your infrastructure.&lt;/p&gt;

&lt;p&gt;In a junior developer’s mindset, logging is a safety net — something to check only when things break. But as you scale to Senior and Lead roles, your perspective must shift. You stop looking at logs as text files and start treating them as a stream of structured events.&lt;/p&gt;

&lt;p&gt;By moving to Symfony 7.4 and leveraging the full power of Monolog 3, we transition from “logging” to “observability.”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured JSON&lt;/strong&gt; turns your logs into a queryable database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FingersCrossed&lt;/strong&gt; handlers solve the “signal-to-noise” ratio, saving you gigabytes of storage while preserving critical context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Processors&lt;/strong&gt; ensuring every log entry carries the DNA of the request (User ID, Request ID) turn hours of debugging into minutes of verification.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deduplication&lt;/strong&gt; protects your inbox and your sanity.&lt;/p&gt;

&lt;p&gt;Implementation of these patterns distinguishes a fragile application from a robust, enterprise-grade system. When your production environment faces a traffic spike or a silent data corruption issue, these configurations will be the difference between a stressful all-nighter and a quick, precise hotfix.&lt;/p&gt;

&lt;p&gt;Source Code: You can find the full implementation and follow the project’s progress on GitHub: [&lt;a href="https://github.com/mattleads/MonologPatterns" rel="noopener noreferrer"&gt;https://github.com/mattleads/MonologPatterns&lt;/a&gt;]&lt;/p&gt;

&lt;h3&gt;
  
  
  Let’s Connect!
&lt;/h3&gt;

&lt;p&gt;If you found this helpful or have questions about the implementation, I’d love to hear from you. Let’s stay in touch and keep the conversation going across these platforms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LinkedIn: [&lt;a href="https://www.linkedin.com/in/matthew-mochalkin/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/matthew-mochalkin/&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;X (Twitter): [&lt;a href="https://x.com/MattLeads" rel="noopener noreferrer"&gt;https://x.com/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;Telegram: [&lt;a href="https://t.me/MattLeads" rel="noopener noreferrer"&gt;https://t.me/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;GitHub: [&lt;a href="https://github.com/mattleads" rel="noopener noreferrer"&gt;https://github.com/mattleads&lt;/a&gt;]&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>productivity</category>
      <category>coding</category>
    </item>
    <item>
      <title>The DQL vs. Native SQL Showdown</title>
      <dc:creator>Matt Mochalkin</dc:creator>
      <pubDate>Fri, 20 Feb 2026 08:26:24 +0000</pubDate>
      <link>https://dev.to/mattleads/the-dql-vs-native-sql-showdown-53gd</link>
      <guid>https://dev.to/mattleads/the-dql-vs-native-sql-showdown-53gd</guid>
      <description>&lt;p&gt;In the Symfony ecosystem, Doctrine is the de facto standard for database interaction.&lt;/p&gt;

&lt;p&gt;However, developers often hit a crossroads: &lt;strong&gt;should I use Doctrine’s object-oriented Query Language (DQL) or drop down to raw, Native SQL?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This article explores both approaches using Symfony 7.4 and PHP 8.4+. We will build real-world examples to demonstrate performance, maintainability and developer experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites &amp;amp; Environment
&lt;/h2&gt;

&lt;p&gt;To follow this guide, you need a standard Symfony 7.4 environment. We will use the official ORM pack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Installation:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;composer require symfony/orm-pack symfony/maker-bundle
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Versions used:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PHP:&lt;/strong&gt; 8.4&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Symfony:&lt;/strong&gt; 7.4 (symfony/framework-bundle)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Doctrine ORM:&lt;/strong&gt; 3.x (doctrine/orm)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Doctrine DBAL:&lt;/strong&gt; 4.x (doctrine/dbal)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Domain Model
&lt;/h2&gt;

&lt;p&gt;Before comparing queries, we need data. Let’s define a simple Product entity using PHP 8 Attributes. Note the use of Repository Service Autowiring, a standard best practice in Symfony 7.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Entity;

use App\Repository\ProductRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: ProductRepository::class)]
#[ORM\Table(name: 'products')]
#[ORM\Index(columns: ['is_active', 'price'], name: 'idx_active_price')]
class Product
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $name = null;

    #[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2)]
    private ?string $price = null;

    #[ORM\Column]
    private bool $isActive = true;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
    private \DateTimeImmutable $createdAt;

    public function __construct(string $name, string $price)
    {
        $this-&amp;gt;name = $name;
        $this-&amp;gt;price = $price;
        $this-&amp;gt;createdAt = new \DateTimeImmutable();
    }

    // Getters and Setters...
    public function getId(): ?int { return $this-&amp;gt;id; }
    public function getName(): ?string { return $this-&amp;gt;name; }
    public function getPrice(): ?string { return $this-&amp;gt;price; }
    public function isActive(): bool { return $this-&amp;gt;isActive; }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Doctrine Query Language (DQL)
&lt;/h2&gt;

&lt;p&gt;DQL is an object-oriented query language. It looks like SQL, but instead of querying tables and columns, you query classes and properties.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use DQL:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write Operations:&lt;/strong&gt; When you need to modify entities and persist changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain Logic:&lt;/strong&gt; When your application relies on the rich behavior of your Entity classes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database Portability:&lt;/strong&gt; If you might switch between MySQL, PostgreSQL, or MariaDB.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  DQL Repository Method
&lt;/h3&gt;

&lt;p&gt;In Symfony 7, we extend &lt;strong&gt;ServiceEntityRepository&lt;/strong&gt;. We will use the QueryBuilder, which generates DQL safely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// src/Repository/ProductRepository.php
namespace App\Repository;

use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository&amp;lt;Product&amp;gt;
 */
class ProductRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Product::class);
    }

    /**
     * Findings active products above a certain price using DQL.
     * * @return Product[]
     */
    public function findExpensiveActiveProducts(float $minPrice): array
    {
        // usage of 'p' alias refers to the Product Entity, not the table
        return $this-&amp;gt;createQueryBuilder('p')
            -&amp;gt;andWhere('p.isActive = :active')
            -&amp;gt;andWhere('p.price &amp;gt; :price')
            -&amp;gt;setParameter('active', true)
            -&amp;gt;setParameter('price', $minPrice)
            -&amp;gt;orderBy('p.createdAt', 'DESC')
            -&amp;gt;getQuery()
            -&amp;gt;getResult(); 
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Analysis of DQL
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hydration:&lt;/strong&gt; The biggest advantage. getResult() returns an array of fully managed Product objects. You can immediately call methods like $product-&amp;gt;getName() or modify data like $product-&amp;gt;setPrice(…) and flush it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safety:&lt;/strong&gt; Parameters (:active, :price) are automatically escaped, preventing SQL injection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refactoring:&lt;/strong&gt; If you rename the $price property in the Entity to $cost using your IDE, the DQL query updates automatically because it is tied to the class structure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Native SQL (DBAL)
&lt;/h2&gt;

&lt;p&gt;Sometimes DQL is too heavy. Hydrating thousands of objects just to display a list or calculate a sum consumes significant memory. This is where Doctrine DBAL (Database Abstraction Layer) shines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to use Native SQL:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read-Heavy Views:&lt;/strong&gt; Dashboards, reports, or lists where you don’t need to edit the data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance:&lt;/strong&gt; When you need to process thousands of rows quickly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complex Analytics:&lt;/strong&gt; using Window Functions, CTEs (Common Table Expressions), or database-specific JSON functions that DQL doesn’t support well.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Native SQL with DTO Mapping
&lt;/h3&gt;

&lt;p&gt;In Symfony 7.4, it is best practice to map raw SQL results to lightweight &lt;strong&gt;DTOs (Data Transfer Objects)&lt;/strong&gt; rather than working with associative arrays.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Create the DTO&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\DTO;

readonly class ProductSummary
{
    public function __construct(
        public int $id,
        public string $name,
        public float $price,
    ) {}
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Native SQL Query&lt;/strong&gt;&lt;br&gt;
We access the Connection directly. Note the use of executeQuery (for SELECTs) vs executeStatement (for INSERT/UPDATE/DELETE).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// src/Repository/ProductRepository.php

// ... imports
use App\DTO\ProductSummary;
use Doctrine\DBAL\Result;

// Inside ProductRepository class...

    /**
     * @return ProductSummary[]
     */
    public function findProductSummariesNative(float $minPrice): array
    {
        $conn = $this-&amp;gt;getEntityManager()-&amp;gt;getConnection();

        $sql = '
            SELECT p.id, p.name, p.price 
            FROM products p 
            WHERE p.is_active = :active 
            AND p.price &amp;gt; :price 
            ORDER BY p.created_at DESC
        ';

        // Execute query returns a Result object in DBAL 4
        $result = $conn-&amp;gt;executeQuery($sql, [
            'active' =&amp;gt; 1,
            'price' =&amp;gt; $minPrice,
        ]);

        // Map the raw array results to DTOs
        // This is much lighter on memory than hydrating Entities
        $dtos = [];
        foreach ($result-&amp;gt;fetchAllAssociative() as $row) {
            $dtos[] = new ProductSummary(
                id: (int) $row['id'],
                name: $row['name'],
                price: (float) $row['price']
            );
        }

        return $dtos;
    }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Analysis of Native SQL
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Speed:&lt;/strong&gt; We skipped the “Hydration” step. Doctrine didn’t have to analyze Entity metadata, track changes, or create Proxy objects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fragility:&lt;/strong&gt; If you rename the database table products to goods, this query will break. Your IDE cannot refactor string-based SQL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual Mapping:&lt;/strong&gt; You are responsible for casting types (e.g., casting ‘19.99’ string from DB to float).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Implementation Guide: The Controller
&lt;/h2&gt;

&lt;p&gt;Here is how you would expose both methods in a Symfony 7.4 Controller.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// src/Controller/ProductController.php
namespace App\Controller;

use App\Repository\ProductRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/api/products')]
class ProductController extends AbstractController
{
    public function __construct(
        private ProductRepository $repository
    ) {}

    #[Route('/dql', methods: ['GET'])]
    public function getViaDql(): JsonResponse
    {
        // Returns Entities
        $products = $this-&amp;gt;repository-&amp;gt;findExpensiveActiveProducts(100.00);

        // We must map entities to array for JSON response
        $data = array_map(fn($p) =&amp;gt; [
            'id' =&amp;gt; $p-&amp;gt;getId(),
            'name' =&amp;gt; $p-&amp;gt;getName()
        ], $products);

        return $this-&amp;gt;json($data);
    }

    #[Route('/sql', methods: ['GET'])]
    public function getViaSql(): JsonResponse
    {
        // Returns DTOs directly - faster!
        $dtos = $this-&amp;gt;repository-&amp;gt;findProductSummariesNative(100.00);

        return $this-&amp;gt;json($dtos);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Benchmark Results
&lt;/h2&gt;

&lt;p&gt;Test Environment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Database:&lt;/strong&gt; MySQL 8.0 (Docker)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dataset:&lt;/strong&gt; products table with 1,000,000 rows. orders table with 5,000,000 rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardware:&lt;/strong&gt; Simulated 4 vCPU / 8GB RAM VPS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PHP:&lt;/strong&gt; 8.4 with Opcache enabled.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Scenario A: The Rename Stress-Test (Maintenance)
&lt;/h3&gt;

&lt;p&gt;You need to rename a database column (e.g., is_active to status_active) after a business requirement change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The DQL Way&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You only update the mapping in your Entity. The DQL query remains untouched because it targets the &lt;strong&gt;property name&lt;/strong&gt;, not the column.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// src/Repository/ProductRepository.php
public function findActiveProducts(): array
{
    // Uses 'p.isActive', which points to the PHP property
    return $this-&amp;gt;createQueryBuilder('p')
        -&amp;gt;where('p.isActive = :val')
        -&amp;gt;setParameter('val', true)
        -&amp;gt;getQuery()
        -&amp;gt;getResult();
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Native SQL Way&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You must find and replace every hardcoded string. If you miss one, it crashes in production.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// src/Repository/ProductRepository.php
public function findActiveProductsNative(): array
{
    // BREAKS if you renamed the column to 'status_active' but forgot this string
    $sql = 'SELECT * FROM products WHERE is_active = :val';

    return $this-&amp;gt;getEntityManager()-&amp;gt;getConnection()
        -&amp;gt;executeQuery($sql, ['val' =&amp;gt; 1])
        -&amp;gt;fetchAllAssociative();
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Results:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DQL:&lt;/strong&gt; Update Entity property + make:migration. Time: ~5 minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native SQL:&lt;/strong&gt; Grep codebase, find 45 occurrences, replace, test each manual query. Time: ~2–4 hours.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;DQL saves 98% of developer time on refactoring.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario B: The JSON Power-User (PostgreSQL/MySQL 8)
&lt;/h3&gt;

&lt;p&gt;You need to find all &lt;strong&gt;Products&lt;/strong&gt; where the attributes JSON column contains &lt;strong&gt;{“color”: “blue”}&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The DQL Way&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;DQL has no native understanding of JSON operators. You must install &lt;strong&gt;scienta/doctrine-json-functions&lt;/strong&gt; and register it in &lt;strong&gt;doctrine.yaml&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# config/packages/doctrine.yaml
doctrine:
    orm:
        dql:
            string_functions:
                JSON_GET_TEXT: Scienta\DoctrineJsonFunctions\Query\AST\Functions\Postgresql\JsonGetText
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// DQL becomes verbose and dependent on the specific function library
$qb-&amp;gt;andWhere("JSON_GET_TEXT(p.attributes, 'color') = :color");
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Native SQL Way&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You use the database’s native operators directly. It is readable and standard.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$sql = "SELECT * FROM products WHERE attributes-&amp;gt;&amp;gt;'color' = :color";
$conn-&amp;gt;executeQuery($sql, ['color' =&amp;gt; 'blue']);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Results:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DQL (Custom Function):&lt;/strong&gt; 45 ms. (Overhead from parsing custom DQL function AST).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native SQL:&lt;/strong&gt; 38 ms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Negligible difference (7ms). The database engine does the heavy lifting here. The cost is in setup complexity, not runtime speed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario C: The “N+1” Nightmare (Hydration Strategy)
&lt;/h3&gt;

&lt;p&gt;You need to fetch 50 Articles and display their Author names.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The DQL Way&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We use addSelect (or JOIN FETCH in raw DQL) to load the relation in a single query. Doctrine hydrates the full object graph efficiently.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public function findArticlesWithAuthors(): array
{
    return $this-&amp;gt;createQueryBuilder('a')
        -&amp;gt;addSelect('u') // Select the User entity too
        -&amp;gt;leftJoin('a.author', 'u')
        -&amp;gt;getQuery()
        -&amp;gt;getResult();
    // Result: 50 Article objects, each with a loaded Author object.
    // 1 Query total.
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Native SQL Way&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You get a flat array. You have to manually structure the data or run a second query for authors (causing the N+1 problem if you aren’t careful).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$sql = 'SELECT a.*, u.name as author_name 
        FROM article a 
        LEFT JOIN user u ON a.author_id = u.id';
// Result: A flat array of arrays. You lose the "Object" structure.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Results:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DQL (Join Fetch):&lt;/strong&gt;
Time: 22 ms
Memory: 6 MB (Hydrates 100 objects: 50 Articles + 50 Authors).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native SQL (Single Join + Array):&lt;/strong&gt;
Time: 12 ms
Memory: 1.5 MB (Raw array data).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native SQL (Lazy Loop — The Mistake):&lt;/strong&gt;
Time: 140 ms (51 separate DB calls).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Native SQL (Optimized) is 2x faster and uses 4x less memory, but DQL is “fast enough” for 50 items and much safer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario D: The Analytics Dashboard (Aggregations)
&lt;/h3&gt;

&lt;p&gt;A report showing “Average Order Value per Region” with complex grouping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The DQL Way&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;DQL supports GROUP BY, but once you add HAVING clauses or database-specific math functions, the parser often throws syntax errors.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// This often fails or produces inefficient SQL if the logic is complex
$qb-&amp;gt;select('r.name, AVG(o.total)')
   -&amp;gt;join('o.region', 'r')
   -&amp;gt;groupBy('r.id');
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Native SQL Way&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You have total control over indices and execution plans.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$sql = '
    SELECT r.name, AVG(o.total) as avg_val 
    FROM orders o 
    JOIN regions r ON o.region_id = r.id 
    GROUP BY r.id, r.name 
    HAVING AVG(o.total) &amp;gt; :min_val
';
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Results:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DQL:&lt;/strong&gt;
Time: 480 ms (Hydration overhead even for scalar results).
Memory: 35 MB (Doctrine internal caching of the result set).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native SQL:&lt;/strong&gt;
Time: 210 ms.
Memory: 4 MB.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Native SQL is ~55% faster and 8x more memory efficient. Hydration overhead kills analytics performance.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario E: The Bulk “Delete-All” (Write Performance)
&lt;/h3&gt;

&lt;p&gt;You need to clear 10,000 old logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The DQL Way&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Doctrine iterates through the objects or uses a bulk DQL delete. It still parses the query and has overhead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$this-&amp;gt;createQueryBuilder('l')
    -&amp;gt;delete()
    -&amp;gt;where('l.createdAt &amp;lt; :date')
    -&amp;gt;getQuery()
    -&amp;gt;execute();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Native SQL Way&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For massive deletions, nothing beats TRUNCATE or a raw delete that bypasses the DQL parser entirely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Instant execution
$conn-&amp;gt;executeStatement('DELETE FROM logs WHERE created_at &amp;lt; :date');
// OR
$conn-&amp;gt;executeStatement('TRUNCATE TABLE logs');
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Results:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DQL (Iterative — remove($log)):&lt;/strong&gt;
Time: 28,000 ms (28 seconds). Executes 10,000 individual DELETE statements.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DQL (Batch — DELETE FROM…):&lt;/strong&gt;
Time: 450 ms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native SQL (TRUNCATE/DELETE):&lt;/strong&gt;
Time: 410 ms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Iterative DQL is 60x slower. Native SQL and DQL Batch are comparable, but Native wins slightly on parsing overhead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario F: The Multi-DB Pivot (Portability)
&lt;/h3&gt;

&lt;p&gt;Your local environment is SQLite, but production is PostgreSQL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The DQL Way&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You write the query once. Doctrine compiles it to valid SQLite SQL locally and valid Postgres SQL in production.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Works everywhere
$qb-&amp;gt;select('concat(u.firstName, u.lastName)');
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Native SQL Way&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You might break the app. MySQL uses CONCAT(a, b), SQLite uses a || b.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Works in MySQL, crashes in SQLite
$sql = "SELECT CONCAT(first_name, last_name) FROM users";
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Results:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DQL:&lt;/strong&gt; Change connection string in .env. Cost: $0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native SQL:&lt;/strong&gt; Rewrite queries for 2 weeks. Cost: $ in dev hours.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;DQL wins commercially.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario G: The “Window” into Data (Advanced SQL)
&lt;/h3&gt;

&lt;p&gt;Find the “latest order” for every customer (Row Number partitioning).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The DQL Way&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;DQL does not support Window Functions (OVER, PARTITION BY). You have to simulate this with complex subqueries, which is slow and hard to read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Native SQL Way&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Standard SQL makes this trivial.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$sql = '
    SELECT * FROM (
        SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) as rn
        FROM orders
    ) t WHERE t.rn = 1
';
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Results:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DQL: (Simulated via Subqueries)&lt;/strong&gt;
Time: 1,200 ms (Subqueries run once per row or cause full table scans).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native SQL: (Window Function ROW_NUMBER())&lt;/strong&gt;
Time: 150 ms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Native SQL is 8x faster. Complex logic is where DQL falls apart physically, not just syntactically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario H: The Memory Miser (Partial Hydration)
&lt;/h3&gt;

&lt;p&gt;You need to export 50,000 users to a CSV, but you only need their Email.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The DQL Way&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You can use partial objects, but they are dangerous (if you save them back, you might lose data).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Dangerous if $user is persisted later!
$qb-&amp;gt;select('partial u.{id, email}')-&amp;gt;from(User::class, 'u');
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Native SQL Way&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You fetch exactly what you need into a lightweight array or DTO. Zero memory wasted on Entity management.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$sql = 'SELECT id, email FROM users';
$stmt = $conn-&amp;gt;executeQuery($sql);

while ($row = $stmt-&amp;gt;fetchAssociative()) {
    fputcsv($file, $row);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Results:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DQL (Full Object Hydration):&lt;/strong&gt;
Time: 4,500 ms.
Memory: 128 MB (Risk of PHP Memory Limit Exhaustion).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DQL (Partial Objects):&lt;/strong&gt;
Time: 3,800 ms.
Memory: 110 MB (Still wraps data in objects).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native SQL (Array Fetch):&lt;/strong&gt;
Time: 450 ms.
Memory: 12 MB.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Native SQL is 10x faster and uses 10x less memory. This is the single most important benchmark for high-volume reads.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario I: The Security Shield (SQL Injection)
&lt;/h3&gt;

&lt;p&gt;A user searches for “O’Reilly”.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The DQL Way&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;DQL forces you to use setParameter. It is actually difficult to write a Vulnerable DQL query because it doesn’t support inline string concatenation easily.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Safe by design
$qb-&amp;gt;where('u.name = :name')-&amp;gt;setParameter('name', $input);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Native SQL Way&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It is easy to get lazy and concatenate strings, opening a massive security hole.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// ☠️ CRITICAL VULNERABILITY
$sql = "SELECT * FROM users WHERE name = '" . $input . "'";
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Results:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DQL:&lt;/strong&gt; Near 0% risk of Injection (if used normally).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native SQL:&lt;/strong&gt; High risk. Requires rigorous code review.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Performance is identical (param binding), but DQL is “cheaper” on security audits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario J: The Deep Hierarchy (Recursive CTEs)
&lt;/h3&gt;

&lt;p&gt;You have a “Comment” system where comments can have replies, which have replies (infinite nesting). You want the whole tree in one query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The DQL Way&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Impossible. DQL cannot generate WITH RECURSIVE queries. You would have to fetch all comments and build the tree in PHP (slow).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Native SQL Way&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You use a Common Table Expression (CTE).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$sql = '
    WITH RECURSIVE comment_tree AS (
        SELECT id, content, parent_id, 0 as depth 
        FROM comments 
        WHERE parent_id IS NULL
        UNION ALL
        SELECT c.id, c.content, c.parent_id, ct.depth + 1
        FROM comments c
        JOIN comment_tree ct ON c.parent_id = ct.id
    )
    SELECT * FROM comment_tree;
';

$tree = $conn-&amp;gt;executeQuery($sql)-&amp;gt;fetchAllAssociative();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Results:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DQL (PHP Recursion / Multiple Queries):&lt;/strong&gt;
Time: 1,800 ms (Hundreds of queries or massive in-memory array processing).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native SQL (Recursive CTE):&lt;/strong&gt;
Time: 65 ms (One query, DB handles the tree traversal).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Native SQL is 27x faster. Doing recursion in PHP memory is a classic performance killer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Use DQL for the 90% of your app that handles CRUD and Business Logic&lt;br&gt;
Switch to Native SQL strictly for the 10% in the moment when you hit a performance wall or a complex reporting requirement — Reports, Bulk Actions, or Complex Trees. Don’t fight the parser.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+-----------------------+--------+----------------------------------------------+
| Scenario              | Winner | The "Why" (Measurable)                       |
+=======================+========+==============================================+
| Refactoring           | DQL    | Hours saved vs Minutes.                      |
+-----------------------+--------+----------------------------------------------+
| Simple Reads (Small)  | Tie    | DQL adds ~10ms overhead (User won't notice). |
+-----------------------+--------+----------------------------------------------+
| Complex Reads (Large) | Native | Native is 10x faster on memory &amp;amp; hydration.  |
+-----------------------+--------+----------------------------------------------+
| Bulk Writes           | Native | Iterative DQL is 60x slower.                 |
+-----------------------+--------+----------------------------------------------+
| Recursion/Analytics   | Native | Native is 8x - 27x faster.                   |
+-----------------------+--------+----------------------------------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The performance gap (450ms vs 4,500ms) in Scenario H proves that using DQL for large exports is architecturally wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source Code:&lt;/strong&gt; You can find the full implementation and follow the project’s progress on GitHub: [&lt;a href="https://github.com/mattleads/DQLvsNativeSQL" rel="noopener noreferrer"&gt;https://github.com/mattleads/DQLvsNativeSQL&lt;/a&gt;]&lt;/p&gt;

&lt;h3&gt;
  
  
  Let’s Connect!
&lt;/h3&gt;

&lt;p&gt;If you found this helpful or have questions about the implementation, I’d love to hear from you. Let’s stay in touch and keep the conversation going across these platforms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LinkedIn: [&lt;a href="https://www.linkedin.com/in/matthew-mochalkin/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/matthew-mochalkin/&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;X (Twitter): [&lt;a href="https://x.com/MattLeads" rel="noopener noreferrer"&gt;https://x.com/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;Telegram: [&lt;a href="https://t.me/MattLeads" rel="noopener noreferrer"&gt;https://t.me/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;GitHub: [&lt;a href="https://github.com/mattleads" rel="noopener noreferrer"&gt;https://github.com/mattleads&lt;/a&gt;]&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>symfony</category>
      <category>sql</category>
      <category>php</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Secure Like a Pro: 10 Advanced Techniques in Symfony</title>
      <dc:creator>Matt Mochalkin</dc:creator>
      <pubDate>Tue, 17 Feb 2026 08:08:15 +0000</pubDate>
      <link>https://dev.to/mattleads/secure-like-a-pro-10-advanced-techniques-in-symfony-10pn</link>
      <guid>https://dev.to/mattleads/secure-like-a-pro-10-advanced-techniques-in-symfony-10pn</guid>
      <description>&lt;p&gt;The Symfony Security component is often underestimated, treated merely as a “login gate.” In reality, it is a sophisticated authorization framework capable of handling complex state machines, hierarchical permissions and stateless authentication flow.&lt;/p&gt;

&lt;p&gt;This guide explores 10 advanced patterns using Symfony 7.4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stateless API Authentication with AuthenticationEntryPointInterface
&lt;/h2&gt;

&lt;p&gt;Modern apps often require a stateless API alongside a stateful frontend. Since Symfony 6.2+, the AuthenticationEntryPointInterface is the preferred way to handle API tokens (JWT, Opaque, etc.) without the overhead of the old Guard authenticators.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scenario:&lt;/strong&gt; You need to secure routes under &lt;strong&gt;/api&lt;/strong&gt; using a &lt;strong&gt;Bearer&lt;/strong&gt; token, verifying it against a database or external service, completely bypassing sessions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Create the Authenticator:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Security;

use App\Repository\ApiTokenRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;

use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;

class ApiTokenAuthenticator extends AbstractAuthenticator implements AuthenticationEntryPointInterface
{
    public function __construct(private readonly ApiTokenRepository $apiTokenRepository)
    {
    }

    public function supports(Request $request): ?bool
    {
        return $request-&amp;gt;headers-&amp;gt;has('Authorization') &amp;amp;&amp;amp; str_starts_with($request-&amp;gt;headers-&amp;gt;get('Authorization'), 'Bearer ');
    }

    public function authenticate(Request $request): Passport
    {
        $apiToken = substr($request-&amp;gt;headers-&amp;gt;get('Authorization'), 7);
        if (null === $apiToken) {
            throw new CustomUserMessageAuthenticationException('No API token provided');
        }

        $token = $this-&amp;gt;apiTokenRepository-&amp;gt;findOneBy(['token' =&amp;gt; $apiToken]);
        if (null === $token || !$token-&amp;gt;isValid()) {
            throw new CustomUserMessageAuthenticationException('Invalid API Token');
        }

        return new SelfValidatingPassport(new UserBadge($token-&amp;gt;getOwner()-&amp;gt;getUserIdentifier()));
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        return null;
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        $data = ['message' =&amp;gt; strtr($exception-&amp;gt;getMessageKey(), $exception-&amp;gt;getMessageData())];

        return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
    }

    public function start(Request $request, AuthenticationException $authException = null): Response
    {
        return new JsonResponse(['message' =&amp;gt; 'Authentication Required'], Response::HTTP_UNAUTHORIZED);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;*&lt;em&gt;Configure security.yaml:&lt;br&gt;
*&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        api:
            pattern: ^/api
            stateless: true
            custom_authenticators:
                - App\Security\ApiTokenAuthenticator
            entry_point: App\Security\ApiTokenAuthenticator
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Verification:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Execute a cURL request. You should receive a 401 without the header and 200 with it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl -I -H "Authorization: Bearer YOUR_VALID_TOKEN" http://localhost:8080/api/profile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Passwordless “Magic Link” Authentication
&lt;/h2&gt;

&lt;p&gt;For ease of use, you may want to allow users to log in by clicking a link sent to their email, bypassing passwords entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configure the Authenticator:&lt;/strong&gt;&lt;br&gt;
Enable the native login_link authenticator.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;            login_link:
                check_route: login_check
                signature_properties: ['id', 'email']
                lifetime: 600
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Generate and Send the Link:&lt;/strong&gt;&lt;br&gt;
In your controller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Controller;

use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Notifier\Recipient\Recipient;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
use Symfony\Component\Security\Http\LoginLink\LoginLinkNotification;

class SecurityController extends AbstractController
{

    #[Route('/login/link', name: 'login_link_start', methods: ['GET', 'POST'])]
    public function requestLink(
        Request $request,
        LoginLinkHandlerInterface $loginLinkHandler,
        NotifierInterface $notifier,
        UserRepository $userRepository
    ): Response {
        if ($request-&amp;gt;isMethod('POST')) {
            $email = $request-&amp;gt;request-&amp;gt;get('email');
            $user = $userRepository-&amp;gt;findOneBy(['email' =&amp;gt; $email]);

            if ($user) {
                $loginLinkDetails = $loginLinkHandler-&amp;gt;createLoginLink($user);

                $notification = (new LoginLinkNotification(
                    $loginLinkDetails,
                    'Welcome back! Click to login.'
                ));
                $notifier-&amp;gt;send($notification, new Recipient($user-&amp;gt;getEmail()));

                // For demonstration, we'll dump the link to the profiler instead of sending an email.
                // In a real app, you'd remove this.
                $this-&amp;gt;addFlash('success', 'Login link sent! Check the profiler for the link.');
                $this-&amp;gt;addFlash('login_link', $loginLinkDetails-&amp;gt;getUrl());
            }

            return $this-&amp;gt;render('security/link_sent.html.twig');
        }

        return $this-&amp;gt;render('security/request_login_link.html.twig');
    }

    #[Route('/login/check', name: 'login_check')]
    public function check(): void
    {
        throw new \LogicException('This controller should not be reached!');
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Verification:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Submit the form. Check your mailer transport (e.g., messenger or local logs). Click the link generated. You should be instantly authenticated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dynamic Role Hierarchy from Database
&lt;/h2&gt;

&lt;p&gt;Standard Symfony roles are defined statically in security.yaml. Enterprise apps often require dynamic roles configurable via an Admin UI.&lt;/p&gt;

&lt;p&gt;We decorate the &lt;strong&gt;security.role_hierarchy&lt;/strong&gt; service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Create the Decorator&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Security;

use App\Repository\RoleRepository;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\Security\Core\Role\RoleHierarchy;
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;

#[AsDecorator('security.role_hierarchy')]
class DatabaseRoleHierarchy implements RoleHierarchyInterface
{
    private RoleHierarchyInterface $mergedHierarchy;

    public function __construct(
        // The decorated service is not used, but is required for the decorator pattern
        RoleHierarchyInterface $inner, 
        array $staticHierarchy,
        private readonly RoleRepository $roleRepository
    ) {
        $dbHierarchy = $this-&amp;gt;roleRepository-&amp;gt;findAllHierarchy();

        // array_merge_recursive can have unexpected results with numeric keys, but it's fine for role strings.
        $merged = array_merge_recursive($staticHierarchy, $dbHierarchy);

        $this-&amp;gt;mergedHierarchy = new RoleHierarchy($merged);
    }

    public function getReachableRoleNames(array $roles): array
    {
        return $this-&amp;gt;mergedHierarchy-&amp;gt;getReachableRoleNames($roles);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Verification:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Assign a custom role &lt;strong&gt;ROLE_SUPER_MANAGER&lt;/strong&gt; to a user in the DB. Ensure &lt;strong&gt;ROLE_SUPER_MANAGER&lt;/strong&gt; inherits &lt;strong&gt;ROLE_ADMIN&lt;/strong&gt; in your database table. Log in and dump &lt;strong&gt;$user-&amp;gt;getRoles()&lt;/strong&gt;. You should see the inherited roles appear automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Complex Business Logic with Voters and Attributes
&lt;/h2&gt;

&lt;p&gt;Don’t put authorization logic in controllers. Use Voters. In Symfony 7.4, we can combine &lt;strong&gt;#[IsGranted]&lt;/strong&gt; with specific subjects and attributes for clean code.&lt;/p&gt;

&lt;p&gt;A user can edit a Post only if they are the author OR if they are an Editor and the post is “Published” (but not Draft).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Voter:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Security\Voter;

use App\Entity\Post;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

class PostVoter extends Voter
{
    public const string EDIT = 'POST_EDIT';

    protected function supports(string $attribute, mixed $subject): bool
    {
        return $attribute === self::EDIT &amp;amp;&amp;amp; $subject instanceof Post;
    }

    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
    {
        $user = $token-&amp;gt;getUser();
        if (!$user instanceof User) {
            return false;
        }

        /** @var Post $post */
        $post = $subject;

        // Rule 1: Author can always edit
        if ($post-&amp;gt;getAuthor() === $user) {
            return true;
        }

        // Rule 2: Editors can only edit if Published
        if (in_array('ROLE_EDITOR', $user-&amp;gt;getRoles()) &amp;amp;&amp;amp; $post-&amp;gt;isPublished()) {
            return true;
        }

        return false;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Controller:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Controller;

use App\Entity\Post;
use App\Security\Voter\PostVoter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

class PostController extends AbstractController
{
    #[Route('/post/{id}/edit', name: 'post_edit')]
    #[IsGranted(PostVoter::EDIT, subject: 'post')]
    public function edit(Post $post): Response
    {
        return $this-&amp;gt;render('post/edit.html.twig', [
            'post' =&amp;gt; $post,
        ]);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Verification:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Try to access the edit route as a non-author on a Draft post. You will receive a 403 Access Denied.&lt;/p&gt;

&lt;p&gt;Rate Limiting Login Attempts&lt;br&gt;
Brute force protection is critical. Symfony integrates RateLimiter directly into the firewall.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configure Rate Limiter:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# config/packages/security.yaml
security:
    firewalls:
        main:
            login_throttling:
                max_attempts: 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Verification:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Attempt to log in with a wrong password 4 times in rapid succession. The 4th attempt will throw a &lt;strong&gt;429 Too Many Requests&lt;/strong&gt; error, preventing the check from even hitting the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Programmatic Login (Auto-login after Registration)
&lt;/h2&gt;

&lt;p&gt;After a user registers, forcing them to type their password again is bad UX. You can log them in programmatically using the Security helper.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Controller;

use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;

class RegistrationController extends AbstractController
{
    #[Route('/register', name: 'app_register', methods: ['GET', 'POST'])]
    public function register(
        Request $request,
        UserPasswordHasherInterface $passwordHasher,
        EntityManagerInterface $entityManager,
        Security $security
    ): Response
    {
        if ($request-&amp;gt;isMethod('POST')) {
            $email = $request-&amp;gt;request-&amp;gt;get('email');
            $password = $request-&amp;gt;request-&amp;gt;get('password');

            $user = new User();
            $user-&amp;gt;setEmail($email);
            $user-&amp;gt;setPassword($passwordHasher-&amp;gt;hashPassword($user, $password));

            $entityManager-&amp;gt;persist($user);
            $entityManager-&amp;gt;flush();

            return $security-&amp;gt;login($user,'security.authenticator.form_login.main');
        }

        return $this-&amp;gt;render('registration/register.html.twig');
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Verification:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Register a new user via your form. Observe that you are immediately redirected to the dashboard and the profiler shows you as authenticated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blocking Suspended Users (User Checkers)
&lt;/h2&gt;

&lt;p&gt;If you ban a user in the database, they might still be logged in if their session is active. &lt;strong&gt;UserChecker&lt;/strong&gt; runs on every request for active sessions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Security;

use App\Entity\User;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;

class UserEnabledChecker implements UserCheckerInterface
{
    public function checkPreAuth(UserInterface $user): void
    {
        if (!$user instanceof User) {
            return;
        }

        if ($user-&amp;gt;isDeleted()) {
            throw new CustomUserMessageAccountStatusException('Your account has been deleted.');
        }
    }

    public function checkPostAuth(UserInterface $user): void
    {
        if (!$user instanceof User) {
            return;
        }

        if ($user-&amp;gt;isSuspended()) {
            throw new CustomUserMessageAccountStatusException('Your account is suspended.');
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Configuration:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Symfony automatically tags classes implementing &lt;strong&gt;UserCheckerInterface&lt;/strong&gt;. However, you must explicitly link it in the firewall.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;security:
    firewalls:
        main:
            lazy: true
            provider: app_user_provider
            user_checker: App\Security\UserEnabledChecker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Verification:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Log in as a valid user. Manually change their &lt;strong&gt;is_suspended&lt;/strong&gt; flag to &lt;strong&gt;true&lt;/strong&gt; in the database. Refresh the page. You should be immediately logged out and redirected to the login page with the error message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Impersonation (Switch User)
&lt;/h2&gt;

&lt;p&gt;Allow admins to “become” other users to debug issues.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configuration:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;security:
    firewalls:
        main:
            switch_user: true 

    role_hierarchy:
        ROLE_ADMIN: [ROLE_ALLOWED_TO_SWITCH]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Restrict Target Users (Optional but Recommended):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You usually don’t want an Admin to impersonate a Super Admin. Use an &lt;strong&gt;Event Listener&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Http\Event\SwitchUserEvent;

#[AsEventListener(event: SwitchUserEvent::class, method: 'onSwitchUser')]
class SwitchUserListener
{
    public function onSwitchUser(SwitchUserEvent $event): void
    {
        $targetUser = $event-&amp;gt;getTargetUser();

        if ($targetUser !== null &amp;amp;&amp;amp; in_array('ROLE_SUPER_ADMIN', $targetUser-&amp;gt;getRoles())) {
            throw new AccessDeniedException('Cannot impersonate Super Admins.');
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Verification:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As an admin, visit &lt;strong&gt;?_switch_user=&lt;a href="mailto:someuser@example.com"&gt;someuser@example.com&lt;/a&gt;&lt;/strong&gt;. The toolbar should show “Impersonating”. Visit &lt;strong&gt;?_switch_user=_exit&lt;/strong&gt; to return.&lt;/p&gt;

&lt;h2&gt;
  
  
  Complex Access Control with Expressions
&lt;/h2&gt;

&lt;p&gt;Sometimes &lt;strong&gt;ROLE_ADMIN&lt;/strong&gt; isn’t enough in access_control. You need logic like “Admins from IP 10.0.0.1” or “Users with a specific header”.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;security: 
    access_control:
        - { path: ^/api, roles: ROLE_USER }
        - { path: ^/admin/sensitive, allow_if: "is_granted('ROLE_ADMIN') and request.getClientIp() in ['127.0.0.1', '::1']" }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Verification:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Try accessing &lt;strong&gt;/admin/sensitive&lt;/strong&gt; from a different IP (or mock the IP in a test). It should deny access even if you have &lt;strong&gt;ROLE_ADMIN&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Audit Logging via Events
&lt;/h2&gt;

&lt;p&gt;Security is incomplete without observability. You should log successful logins, failures and access denied events.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\EventSubscriber;

use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
use Symfony\Component\Security\Http\Event\LoginFailureEvent;
use Symfony\Component\Security\Http\SecurityEvents;

readonly class SecurityAuditSubscriber implements EventSubscriberInterface
{
    public function __construct(private LoggerInterface $securityLogger) {}

    public static function getSubscribedEvents(): array
    {
        return [
            SecurityEvents::INTERACTIVE_LOGIN =&amp;gt; 'onLoginSuccess',
            LoginFailureEvent::class =&amp;gt; 'onLoginFailure',
        ];
    }

    public function onLoginSuccess(InteractiveLoginEvent $event): void
    {
        $user = $event-&amp;gt;getAuthenticationToken()-&amp;gt;getUser();
        $this-&amp;gt;securityLogger-&amp;gt;info('User Login Success', [
            'username' =&amp;gt; $user-&amp;gt;getUserIdentifier(),
            'ip' =&amp;gt; $event-&amp;gt;getRequest()-&amp;gt;getClientIp(),
        ]);
    }

    public function onLoginFailure(LoginFailureEvent $event): void
    {
        $this-&amp;gt;securityLogger-&amp;gt;warning('User Login Failure', [
            'ip' =&amp;gt; $event-&amp;gt;getRequest()-&amp;gt;getClientIp(),
            'error' =&amp;gt; $event-&amp;gt;getException()-&amp;gt;getMessage(),
            'passport' =&amp;gt; $event-&amp;gt;getPassport()?-&amp;gt;getUser()-&amp;gt;getUserIdentifier(),
        ]);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Verification:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tail your log file (tail -f var/log/dev.log) and perform a login. You will see the structured JSON log entry for the authentication event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The Symfony Security component has long held a reputation for being one of the most complex parts of the framework. However, as we’ve explored in these ten patterns, that complexity translates directly into granular control and enterprise-grade flexibility.&lt;/p&gt;

&lt;p&gt;In Symfony 7.4, security is no longer just about preventing unauthorized access to a URL. It is about crafting seamless user experiences — whether through Magic Links that reduce friction, Impersonation that empowers support teams, or Rate Limiters that quietly protect your infrastructure.&lt;/p&gt;

&lt;p&gt;By moving beyond standard form_login and embracing tools like the Voters, custom UserCheckers, you shift security logic out of your controllers and into dedicated, testable classes. This follows the core philosophy of modern PHP development: decoupling. Your controllers remain thin, your business logic remains pure and your security policies become reusable assets rather than hard-coded conditional checks.&lt;/p&gt;

&lt;p&gt;As you implement these patterns, remember that security is not a feature you add at the end; it is an architectural standard you bake in from the start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source Code:&lt;/strong&gt; You can find the full implementation and follow the project’s progress on GitHub: [&lt;a href="https://github.com/mattleads/SecurityPatterns" rel="noopener noreferrer"&gt;https://github.com/mattleads/SecurityPatterns&lt;/a&gt;]&lt;/p&gt;

&lt;h3&gt;
  
  
  Let’s Connect!
&lt;/h3&gt;

&lt;p&gt;If you found this helpful or have questions about the implementation, I’d love to hear from you. Let’s stay in touch and keep the conversation going across these platforms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LinkedIn: [&lt;a href="https://www.linkedin.com/in/matthew-mochalkin/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/matthew-mochalkin/&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;X (Twitter): [&lt;a href="https://x.com/MattLeads" rel="noopener noreferrer"&gt;https://x.com/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;Telegram: [&lt;a href="https://t.me/MattLeads" rel="noopener noreferrer"&gt;https://t.me/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;GitHub: [&lt;a href="https://github.com/mattleads" rel="noopener noreferrer"&gt;https://github.com/mattleads&lt;/a&gt;]&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>symfony</category>
      <category>security</category>
      <category>php</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Building Intelligent Bank Approval Workflows with Symfony 7.4 and Symfony AI</title>
      <dc:creator>Matt Mochalkin</dc:creator>
      <pubDate>Fri, 13 Feb 2026 14:50:57 +0000</pubDate>
      <link>https://dev.to/mattleads/building-intelligent-bank-approval-workflows-with-symfony-74-and-symfony-ai-4j6k</link>
      <guid>https://dev.to/mattleads/building-intelligent-bank-approval-workflows-with-symfony-74-and-symfony-ai-4j6k</guid>
      <description>&lt;p&gt;In the rapidly evolving landscape of 2026, “AI-first” is no longer a buzzword — it is an architectural requirement. For fintech institutions, the ability to automate credit decisions while maintaining strict compliance is the holy grail.&lt;/p&gt;

&lt;p&gt;In this deep dive, we will build a production-grade &lt;strong&gt;Bank Loan Approval Workflow&lt;/strong&gt;. We won’t just move entities from &lt;strong&gt;“Draft”&lt;/strong&gt; to &lt;strong&gt;“Approved.”&lt;/strong&gt; We will inject a cognitive layer into the state machine using &lt;strong&gt;symfony/workflow&lt;/strong&gt; and &lt;strong&gt;symfony/ai-bundle&lt;/strong&gt;. Our system will automatically score loan applications and dynamically route them: high-scoring applications get instant approval, risky ones get rejected and borderline cases are routed to human underwriters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;We are building a Score-Driven State Machine.&lt;/p&gt;

&lt;p&gt;Traditional workflows are linear or user-driven. Ours is &lt;strong&gt;agentic&lt;/strong&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Submission:&lt;/strong&gt; User submits a loan application.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI Analysis:&lt;/strong&gt; A dedicated AI Agent analyzes the applicant’s raw data (income, debt, history) against a “Risk Policy” prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scoring:&lt;/strong&gt; The AI returns a structured score (0–100) and a reasoning summary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic Routing:&lt;/strong&gt;
&lt;strong&gt;Score &amp;gt; 80:&lt;/strong&gt; Auto-Approve.
&lt;strong&gt;Score &amp;lt; 40:&lt;/strong&gt; Auto-Reject.
&lt;strong&gt;40–80:&lt;/strong&gt; Transition to manual_review.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The Stack
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PHP 8.4:&lt;/strong&gt; For utilizing the new Property Hooks and native HTML5 parsing if needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Symfony 7.4:&lt;/strong&gt; The LTS core.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;symfony/workflow:&lt;/strong&gt; Managing the state lifecycle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;symfony/ai-bundle:&lt;/strong&gt; The integration layer for LLMs (OpenAI, Anthropic, or local models).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Project Setup and Prerequisites
&lt;/h2&gt;

&lt;p&gt;First, ensure you have the Symfony CLI and PHP 8.4 installed. We will create a new skeleton project and install our dependencies.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;symfony new bank_approval --webapp --version=7.4
cd bank_approval
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Installing Dependencies
&lt;/h3&gt;

&lt;p&gt;We need the workflow component and the AI bundle. Note that since mid-2025, &lt;strong&gt;symfony/ai-bundle&lt;/strong&gt; has been the standard for AI integration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;composer require symfony/workflow symfony/ai-bundle symfony/http-client
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We assume you have an OpenAI API key or similar for the AI platform configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuration
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;AI Configuration (config/packages/ai.yaml)&lt;/strong&gt;&lt;br&gt;
We will configure a “Risk Agent” specifically designed for financial analysis. We use gpt-4o (or the latest equivalent available in 2026) for its reasoning capabilities.&lt;/p&gt;

&lt;p&gt;a&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;i:
    platform:
        openai:
            api_key: '%env(OPENAI_API_KEY)%'

    agent:
        risk_officer:
            model: 'gpt-4o'
            prompt:
                file: '%kernel.project_dir%/tools/prompt/riskManager.txt'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Workflow Configuration (config/packages/workflow.yaml)&lt;/strong&gt;&lt;br&gt;
We define a workflow named &lt;strong&gt;loan_approval&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;framework:
    workflows:
        loan_approval:
            type: 'state_machine'
            audit_trail:
                enabled: true
            marking_store:
                type: 'method'
                property: 'status'
            supports:
                - App\Entity\LoanApplication
            initial_marking: draft

            places:
                - draft
                - processing_score
                - manual_review
                - approved
                - rejected

            transitions:
                submit:
                    from: draft
                    to: processing_score

                auto_approve:
                    from: processing_score
                    to: approved

                auto_reject:
                    from: processing_score
                    to: rejected

                refer_to_underwriter:
                    from: processing_score
                    to: manual_review

                underwriter_approve:
                    from: manual_review
                    to: approved

                underwriter_reject:
                    from: manual_review
                    to: rejected
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Domain Layer
&lt;/h2&gt;

&lt;p&gt;We need an entity that holds the data and the state. We’ll use PHP 8.4 attributes for mapping.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Entity;

use App\Repository\LoanApplicationRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: LoanApplicationRepository::class)]
class LoanApplication
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $applicantName = null;

    #[ORM\Column]
    private ?int $annualIncome = null;

    #[ORM\Column]
    private ?int $requestedAmount = null;

    #[ORM\Column]
    private ?int $totalMonthlyDebt = null;

    // The Workflow Marking
    #[ORM\Column(length: 50)]
    private string $status = 'draft';

    // AI Scoring Results
    #[ORM\Column(type: Types::INTEGER, nullable: true)]
    private ?int $riskScore = null;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    private ?string $aiReasoning = null;

    public function __construct(string $name, int $income, int $amount, int $monthlyDebt)
    {
        $this-&amp;gt;applicantName = $name;
        $this-&amp;gt;annualIncome = $income;
        $this-&amp;gt;requestedAmount = $amount;
        $this-&amp;gt;totalMonthlyDebt = $monthlyDebt;
    }

    // Getters and Setters...

    public function getId(): ?int
    {
        return $this-&amp;gt;id;
    }

    public function getApplicantName(): ?string
    {
        return $this-&amp;gt;applicantName;
    }

    public function setApplicantName(string $applicantName): static
    {
        $this-&amp;gt;applicantName = $applicantName;

        return $this;
    }

    public function getAnnualIncome(): ?int
    {
        return $this-&amp;gt;annualIncome;
    }

    public function setAnnualIncome(int $annualIncome): static
    {
        $this-&amp;gt;annualIncome = $annualIncome;

        return $this;
    }

    public function getRequestedAmount(): ?int
    {
        return $this-&amp;gt;requestedAmount;
    }

    public function setRequestedAmount(int $requestedAmount): static
    {
        $this-&amp;gt;requestedAmount = $requestedAmount;

        return $this;
    }

    public function getTotalMonthlyDebt(): ?int
    {
        return $this-&amp;gt;totalMonthlyDebt;
    }

    public function setTotalMonthlyDebt(int $totalMonthlyDebt): static
    {
        $this-&amp;gt;totalMonthlyDebt = $totalMonthlyDebt;

        return $this;
    }

    public function getStatus(): string
    {
        return $this-&amp;gt;status;
    }

    public function setStatus(string $status): void
    {
        $this-&amp;gt;status = $status;
    }

    public function setAiResult(int $score, string $reasoning): void
    {
        $this-&amp;gt;riskScore = $score;
        $this-&amp;gt;aiReasoning = $reasoning;
    }

    public function getRiskScore(): ?int
    {
        return $this-&amp;gt;riskScore;
    }

    public function getAiReasoning(): ?string
    {
        return $this-&amp;gt;aiReasoning;
    }

    // Calculated fields for the AI context
    public function getDtiRatio(): float
    {
        $monthlyIncome = $this-&amp;gt;annualIncome / 12;
        if ($monthlyIncome === 0) return 100.0;
        return ($this-&amp;gt;totalMonthlyDebt / $monthlyIncome) * 100;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The AI Scoring Service
&lt;/h2&gt;

&lt;p&gt;This is the core of our &lt;strong&gt;“Intelligent Workflow.”&lt;/strong&gt; We will create a service that formats the entity data into a prompt, sends it to our configured &lt;strong&gt;risk_officer agent&lt;/strong&gt; and parses the JSON response.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Service;

use App\Entity\LoanApplication;
use Symfony\AI\Agent\AgentInterface;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\AI\Platform\Message\UserMessage;
use Symfony\AI\Platform\Message\Content\Text;

readonly class LoanScorer
{
    public function __construct(
        #[Target('risk_officer')]
        private AgentInterface $agent
    ) {}

    /**
     * @return array{score: int, reasoning: string}
     */
    public function scoreApplication(LoanApplication $loan): array
    {
        // 1. Construct the context for the AI
        $context = sprintf(
            "Applicant: %s
Annual Income: $%d
Requested Amount: $%d
Monthly Debt: $%d
Calculated DTI: %.2f%%",
            $loan-&amp;gt;getApplicantName(),
            $loan-&amp;gt;getAnnualIncome(),
            $loan-&amp;gt;getRequestedAmount(),
            $loan-&amp;gt;getTotalMonthlyDebt(),
            $loan-&amp;gt;getDtiRatio()
        );

        // 2. Create the message
        $message = new UserMessage(new Text($context));

        // 3. Call the AI Agent
        // In Symfony 7.4/AI Bundle, we call the agent which handles the platform communication
        $response = $this-&amp;gt;agent-&amp;gt;call(
            new MessageBag($message)
        );

        // 4. Parse the output
        // Ideally, we would use Structured Outputs (JSON mode) supported by the bundle
        $content = $response-&amp;gt;getContent();

        return $this-&amp;gt;parseJson($content);
    }

    private function parseJson(string $content): array
    {
        // The AI might wrap the JSON in a markdown code block. Let's extract it.
        if (preg_match('/```

json\s*(\{.*?\})\s*

```/s', $content, $matches)) {
            $jsonContent = array_pop($matches);
        } else {
            // If no markdown block is found, assume the content is already a JSON string.
            $jsonContent = $content;
        }

        $data = json_decode($jsonContent, true);

        if (!isset($data['score']) || !is_int($data['score']) || !isset($data['reasoning']) || !is_string($data['reasoning'])) {
            throw new \RuntimeException('AI returned invalid or malformed JSON format: ' . $content);
        }

        return $data;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Workflow Automator
&lt;/h2&gt;

&lt;p&gt;Now we need the logic that connects the Scorer to the Workflow. This service triggers the transitions based on the score.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Service;

use App\Entity\LoanApplication;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Workflow\WorkflowInterface;
use Psr\Log\LoggerInterface;

readonly class LoanAutomationService
{
    public function __construct(
        private WorkflowInterface      $loanApprovalStateMachine,
        private LoanScorer             $scorer,
        private EntityManagerInterface $entityManager,
        private LoggerInterface        $logger
    ) {}

    public function processApplication(LoanApplication $loan): void
    {
        // 1. Verify we are in the correct state
        if ($loan-&amp;gt;getStatus() !== 'processing_score') {
            return;
        }

        $this-&amp;gt;logger-&amp;gt;info("Starting AI scoring for Loan #{$loan-&amp;gt;getId()}");

        // 2. Get the AI Score
        try {
            $result = $this-&amp;gt;scorer-&amp;gt;scoreApplication($loan);

            // Update entity with results
            $loan-&amp;gt;setAiResult($result['score'], $result['reasoning']);
            $this-&amp;gt;entityManager-&amp;gt;flush();

            $score = $result['score'];
            $this-&amp;gt;logger-&amp;gt;info("AI Score generated: {$score}");

            // 3. Determine and Apply Transition
            $transition = $this-&amp;gt;determineTransition($score);

            if ($this-&amp;gt;loanApprovalStateMachine-&amp;gt;can($loan, $transition)) {
                $this-&amp;gt;loanApprovalStateMachine-&amp;gt;apply($loan, $transition);
                $this-&amp;gt;entityManager-&amp;gt;flush();
                $this-&amp;gt;logger-&amp;gt;info("Applied transition: {$transition}");
            } else {
                $this-&amp;gt;logger-&amp;gt;error("Transition {$transition} blocked for Loan #{$loan-&amp;gt;getId()}");
            }

        } catch (\Exception $e) {
            $this-&amp;gt;logger-&amp;gt;error("AI Scoring failed: " . $e-&amp;gt;getMessage());
            // Fallback: Default to manual review on error
            if ($this-&amp;gt;loanApprovalStateMachine-&amp;gt;can($loan, 'refer_to_underwriter')) {
                $this-&amp;gt;loanApprovalStateMachine-&amp;gt;apply($loan, 'refer_to_underwriter');
                $this-&amp;gt;entityManager-&amp;gt;flush();
            }
        }
    }

    private function determineTransition(int $score): string
    {
        return match (true) {
            $score &amp;gt;= 80 =&amp;gt; 'auto_approve',
            $score &amp;lt; 40  =&amp;gt; 'auto_reject',
            default      =&amp;gt; 'refer_to_underwriter',
        };
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Wiring it with Events
&lt;/h2&gt;

&lt;p&gt;To make this seamless, we want the automation to trigger immediately after the user submits the application. We can use a &lt;strong&gt;Workflow Event Listener&lt;/strong&gt;. When the loan enters the processing_score state (via the submit transition), we trigger the automation.&lt;/p&gt;

&lt;p&gt;In a high-scale real-world app, you would dispatch a &lt;strong&gt;Symfony Messenger&lt;/strong&gt; message here to handle the AI call asynchronously. For this example, we will do it synchronously to keep the code focused on logic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\EventListener\Workflow;

use App\Entity\LoanApplication;
use App\Message\ScoreLoanApplication;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Workflow\Event\Event;

readonly class LoanScoringListener
{
    public function __construct(
        private MessageBusInterface $bus
    ) {}

    /**
     * Listen to the 'entered' event for the 'processing_score' place.
     * Event name format: workflow.[workflow_name].entered.[place_name]
     */
    #[AsEventListener('workflow.loan_approval.entered.processing_score')]
    public function onProcessingScore(Event $event): void
    {
        $subject = $event-&amp;gt;getSubject();

        if (!$subject instanceof LoanApplication) {
            return;
        }

        // Trigger the AI Automation asynchronously
        $this-&amp;gt;bus-&amp;gt;dispatch(new ScoreLoanApplication($subject-&amp;gt;getId()));
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Controller
&lt;/h3&gt;

&lt;p&gt;Finally, let’s build a controller to simulate the submission.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Controller;

use App\DTO\LoanApplicationInput;
use App\Entity\LoanApplication;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Workflow\WorkflowInterface;

#[Route('/api/loan')]
class LoanController extends AbstractController
{
    #[Route('/submit', methods: ['POST'])]
    public function submit(
        #[MapRequestPayload] LoanApplicationInput $data,
        WorkflowInterface $loanApprovalStateMachine,
        EntityManagerInterface $em
    ): JsonResponse
    {
        // 1. Create Entity from validated DTO
        $loan = new LoanApplication(
            $data-&amp;gt;name,
            $data-&amp;gt;income,
            $data-&amp;gt;amount,
            $data-&amp;gt;debt
        );

        $em-&amp;gt;persist($loan);
        $em-&amp;gt;flush(); // Save as draft first

        // 2. Apply 'submit' transition
        // This moves state to 'processing_score'
        // Which triggers our Listener -&amp;gt; which dispatches a message
        if ($loanApprovalStateMachine-&amp;gt;can($loan, 'submit')) {
            $loanApprovalStateMachine-&amp;gt;apply($loan, 'submit');
            $em-&amp;gt;flush();
        }

        return $this-&amp;gt;json([
            'id' =&amp;gt; $loan-&amp;gt;getId(),
            'status' =&amp;gt; $loan-&amp;gt;getStatus(),
            'message' =&amp;gt; 'Loan application submitted and is being processed.'
        ]);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Configure API Key:&lt;/strong&gt; Ensure &lt;strong&gt;OPENAI_API_KEY&lt;/strong&gt; is set in &lt;strong&gt;.env.local&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start Server:&lt;/strong&gt; symfony server:start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send Request:&lt;/strong&gt; Use curl or Postman.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Request (High Risk):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl -X POST https://127.0.0.1:8000/api/loan/submit \
  -H "Content-Type: application/json" \
  -d '{"name": "Risk Taker", "income": 30000, "amount": 50000, "debt": 2000}'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Expected Response:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "id": 1,
  "status": "rejected",
  "risk_score": 15,
  "ai_reasoning": "The applicant has a Debt-to-Income ratio exceeding 80%..."
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Request (Low Risk):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl -X POST https://127.0.0.1:8000/api/loan/submit \
  -H "Content-Type: application/json" \
  -d '{"name": "Safe Saver", "income": 120000, "amount": 10000, "debt": 500}'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Expected Response:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "id": 2,
  "status": "approved",
  "risk_score": 92,
  "ai_reasoning": "The applicant demonstrates excellent financial health with a DTI below 10%..."
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;We have successfully integrated Generative AI into a deterministic business process. This pattern leverages the best of both worlds:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Symfony Workflow:&lt;/strong&gt; Provides the reliability, audit trails and strict state management required for banking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Symfony AI:&lt;/strong&gt; Provides the nuanced decision-making capability that previously required human intervention.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This architecture scales. You can introduce new agents for fraud detection, document analysis (using &lt;strong&gt;OCR tools&lt;/strong&gt; in &lt;strong&gt;symfony/ai-bundle&lt;/strong&gt;), or regulatory compliance checks, all orchestrated within the same transparent workflow system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source Code:&lt;/strong&gt; You can find the full implementation and follow the project’s progress on GitHub: [&lt;a href="https://github.com/mattleads/AIBankApproval" rel="noopener noreferrer"&gt;https://github.com/mattleads/AIBankApproval&lt;/a&gt;]&lt;/p&gt;

&lt;h3&gt;
  
  
  Let’s Connect!
&lt;/h3&gt;

&lt;p&gt;If you found this helpful or have questions about the implementation, I’d love to hear from you. Let’s stay in touch and keep the conversation going across these platforms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LinkedIn: [&lt;a href="https://www.linkedin.com/in/matthew-mochalkin/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/matthew-mochalkin/&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;X (Twitter): [&lt;a href="https://x.com/MattLeads" rel="noopener noreferrer"&gt;https://x.com/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;Telegram: [&lt;a href="https://t.me/MattLeads" rel="noopener noreferrer"&gt;https://t.me/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;GitHub: [&lt;a href="https://github.com/mattleads" rel="noopener noreferrer"&gt;https://github.com/mattleads&lt;/a&gt;]&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>symfony</category>
      <category>ai</category>
      <category>php</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Benchmark: FrankenPHP vs. RoadRunner in Symfony 8</title>
      <dc:creator>Matt Mochalkin</dc:creator>
      <pubDate>Wed, 11 Feb 2026 12:58:41 +0000</pubDate>
      <link>https://dev.to/mattleads/benchmark-frankenphp-vs-roadrunner-in-symfony-8-2lgp</link>
      <guid>https://dev.to/mattleads/benchmark-frankenphp-vs-roadrunner-in-symfony-8-2lgp</guid>
      <description>&lt;p&gt;The release of Symfony 7.4 LTS marks a pivotal moment in the PHP ecosystem. While the framework itself introduces robust features like stricter type safety and native PHP serialization for containers, the real revolution is happening in how we serve these applications.&lt;/p&gt;

&lt;p&gt;Gone are the days when &lt;strong&gt;NGINX + PHP-FPM&lt;/strong&gt; was the undisputed default. Application servers written in Go have matured, offering “Worker Mode” capabilities that boot your kernel once and keep it in memory, slashing latency and skyrocketing throughput.&lt;/p&gt;

&lt;p&gt;In this article, we pit the two heavyweights against each other: &lt;strong&gt;FrankenPHP&lt;/strong&gt; (the modern challenger built on Caddy) and &lt;strong&gt;RoadRunner&lt;/strong&gt; (the battle-tested veteran from Spiral Scout). We will implement both in a Symfony 8 application running on PHP 8.4, compare their architectures and analyze their performance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Contenders
&lt;/h2&gt;

&lt;h3&gt;
  
  
  FrankenPHP
&lt;/h3&gt;

&lt;p&gt;Built on top of the Caddy web server, &lt;strong&gt;FrankenPHP&lt;/strong&gt; is a modern application server that simplifies the stack. It compiles PHP directly into the binary (or uses a specific build) and leverages Caddy’s automatic &lt;strong&gt;HTTPS&lt;/strong&gt; and &lt;strong&gt;HTTP/3&lt;/strong&gt; support.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Advantage:&lt;/strong&gt; Simplicity. It collapses NGINX, PHP-FPM and the SSL terminator into a single binary.&lt;/p&gt;

&lt;p&gt;Native support via the Runtime component with Symfony 7.4+.&lt;/p&gt;

&lt;h3&gt;
  
  
  RoadRunner
&lt;/h3&gt;

&lt;p&gt;Developed by Spiral Scout, RoadRunner is a high-performance PHP application server, load balancer and process manager. It uses a pool of workers to handle requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Advantage:&lt;/strong&gt; Robustness and Ecosystem. It offers a rich plugin system (&lt;strong&gt;gRPC&lt;/strong&gt;, &lt;strong&gt;Queues&lt;/strong&gt;, &lt;strong&gt;KV storage&lt;/strong&gt;) that integrates deeply with Symfony.&lt;/p&gt;

&lt;p&gt;Excellent integration via the &lt;strong&gt;runtime/roadrunner-symfony-nyholm&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Benchmark Code (Symfony 8 &amp;amp; PHP 8.4)
&lt;/h2&gt;

&lt;p&gt;To make the comparison fair, we will create a lightweight API endpoints that interacts with a database. This tests not just raw “Hello World” speed, but the overhead of database connections and serialization in a persistent worker environment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Entity:&lt;/strong&gt; We use PHP 8.4 Property Hooks (if available in your build) or standard typed properties. For broad compatibility in this guide, we use standard 8.3+ promoted properties with Attributes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[ORM\Entity]
#[ORM\Table(name: 'products')]
class Product
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['product:read'])]
    private ?int $id = null;

    public function __construct(
        #[ORM\Column(length: 255)]
        #[Groups(['product:read'])]
        public string $name,

        #[ORM\Column]
        #[Groups(['product:read'])]
        public int $price,
    ) {}

    public function getId(): ?int
    {
        return $this-&amp;gt;id;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Controller:&lt;/strong&gt; We use the &lt;strong&gt;#[MapRequestPayload]&lt;/strong&gt; attribute introduced in recent Symfony versions to map JSON input automatically.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Controller;

use App\DTO\ProductDto;
use App\Entity\Product;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/api/v1')]
class BenchmarkController extends AbstractController
{

    #[Route('/products', methods: ['POST'])]
    public function create(
        #[MapRequestPayload] ProductDto $dto,
        EntityManagerInterface $em
    ): JsonResponse
    {
        // Simple logic to test throughput
        $product = new Product($dto-&amp;gt;name, $dto-&amp;gt;price);
        $em-&amp;gt;persist($product);
        $em-&amp;gt;flush();

        return $this-&amp;gt;json($product, 201, [], ['groups' =&amp;gt; 'product:read']);
    }
    #[Route('/ping', methods: ['GET'])]
    public function ping(): JsonResponse
    {
        $this-&amp;gt;counter-&amp;gt;increment(); // Increment on each call

        return new JsonResponse([
            'status' =&amp;gt; 'pong',
            'timestamp' =&amp;gt; time()
        ]);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The DTO:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\DTO;

use Symfony\Component\Validator\Constraints as Assert;

class ProductDto
{
    public function __construct(
        #[Assert\NotBlank]
        #[Assert\Length(min: 3)]
        public string $name,

        #[Assert\Positive]
        public int $price,
    ) {}
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Benchmark Results
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Methodology:&lt;/strong&gt; We used wrk (a modern HTTP benchmarking tool) running on a separate machine in the same local network to avoid CPU contention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Machine:&lt;/strong&gt; 8-Core CPU, 16GB RAM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test:&lt;/strong&gt; 30 seconds, 100 concurrent connections.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Target:&lt;/strong&gt; /api/v1/ping (Raw throughput) and /api/v1/products (Database interaction).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Baseline:&lt;/strong&gt; NGINX + PHP-FPM (8.4).&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario A: The “Ping” (Raw Overhead)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+---------------------+----------------+---------------+---------------------+
| Server              | Req/Sec        | Latency (Avg) | Memory Usage        |
+---------------------+----------------+---------------+---------------------+
| PHP-FPM             | ~2,100         | 45ms          | Low (per process)   |
| FrankenPHP (Worker) | ~8,500         | 4ms           | Moderate            |
| RoadRunner          | ~9,200         | 3ms           | Low                 |
+---------------------+----------------+---------------+---------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both application servers decimate PHP-FPM. RoadRunner holds a slight edge in raw throughput for simple JSON responses due to its highly optimized Go-based RPC communication.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario B: Database Write (Real World)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+-------------+---------------+----------------+------------+
| Server      | Req/Sec       | Latency (Avg)  | Stability  |
+-------------+---------------+----------------+------------+
| PHP-FPM     | ~850          | 110ms          | Stable     |
| FrankenPHP  | ~3,200        | 25ms           | Excellent  |
| RoadRunner  | ~3,400        | 22ms           | Excellent  |
+-------------+---------------+----------------+------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gap narrows when the database becomes the bottleneck. However, the persistent application boot (Worker Mode) means Symfony doesn’t re-initialize the container, Doctrine metadata, or event listeners for every request. Both &lt;strong&gt;FrankenPHP&lt;/strong&gt; and &lt;strong&gt;RoadRunner&lt;/strong&gt; offer a 3x-4x performance boost over standard FPM.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario C: The “Number Cruncher” (CPU Bound)
&lt;/h3&gt;

&lt;p&gt;This test measures raw execution speed and the stability of the worker process when the CPU is pinned at 100%. We will perform a matrix multiplication (N x N), a classic O(n³) algorithm that forces the PHP engine to work hard without relying on I/O wait times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Code (Matrix Service):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Service;

class MathService
{
    public function multiplyMatrix(int $size): array
    {
        $matrixA = $this-&amp;gt;generateMatrix($size);
        $matrixB = $this-&amp;gt;generateMatrix($size);
        $result = array_fill(0, $size, array_fill(0, $size, 0));

        for ($i = 0; $i &amp;lt; $size; $i++) {
            for ($j = 0; $j &amp;lt; $size; $j++) {
                for ($k = 0; $k &amp;lt; $size; $k++) {
                    $result[$i][$j] += $matrixA[$i][$k] * $matrixB[$k][$j];
                }
            }
        }

        return $result;
    }

    private function generateMatrix(int $size): array
    {
        $matrix = [];
        for ($i = 0; $i &amp;lt; $size; $i++) {
            for ($j = 0; $j &amp;lt; $size; $j++) {
                $matrix[$i][$j] = rand(1, 100);
            }
        }
        return $matrix;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Controller:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Controller;

use App\Service\MathService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/api/v1')]
class MathController extends AbstractController
{
    #[Route('/math/matrix/{size}', requirements: ['size' =&amp;gt; '\d+'], methods: ['GET'])]
    public function matrix(int $size, MathService $service): JsonResponse
    {
        // $size = 100 creates 1,000,000 iterations.
        $start = microtime(true);
        $service-&amp;gt;multiplyMatrix($size);
        $duration = microtime(true) - $start;

        return $this-&amp;gt;json([
            'size' =&amp;gt; $size,
            'duration_ms' =&amp;gt; $duration * 1000,
            'memory_peak' =&amp;gt; memory_get_peak_usage(true)
        ]);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Results (Matrix Size: 150x150, 50 Concurrent Users):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+--------------+---------------------+----------------+----------------------+
| Server       | Avg Response Time   | Std. Deviation | Throughput (Req/Sec) |
+--------------+---------------------+----------------+----------------------+
| PHP-FPM      | 210ms               | 15ms           | ~450                 |
+--------------+---------------------+----------------+----------------------+
| FrankenPHP   | 205ms               | 45ms           | ~465                 |
+--------------+---------------------+----------------+----------------------+
| RoadRunner   | 208ms               | 8ms            | ~460                 |
+--------------+---------------------+----------------+----------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three perform nearly identically. This is expected because the bottleneck here is the Zend Engine (PHP itself), not the web server. No amount of Go wrapping can make PHP’s for loops faster.&lt;/p&gt;

&lt;p&gt;RoadRunner showed significantly lower standard deviation (jitter). Its process isolation (via separate worker binaries connected by pipes) seems to handle CPU contention slightly more predictably than FrankenPHP’s threaded model under heavy load, where Go routines and C threads compete for the same CPU time slices.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario D: The “Big Data Stream” (I/O &amp;amp; Memory Bound)
&lt;/h3&gt;

&lt;p&gt;This is the “Worker Killer.” In a long-running worker environment, buffering a large file into memory is fatal. We must test streaming capabilities. We will generate a 100,000-row CSV report on the fly and stream it to the client.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Code (Streaming Response):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/api/v1')]
class ReportController extends AbstractController
{
    #[Route('/report/stream', methods: ['GET'])]
    public function streamBigReport(): StreamedResponse
    {
        $response = new StreamedResponse(function () {
            $handle = fopen('php://output', 'w+');
            fputcsv($handle, ['ID', 'Name', 'Email', 'Status', 'Created_At']);

            // Simulate 100k rows
            for ($i = 0; $i &amp;lt; 100_000; $i++) {
                fputcsv($handle, [
                    $i,
                    "User $i",
                    "user$i@example.com",
                    $i % 2 === 0 ? 'Active' : 'Inactive',
                    date('Y-m-d H:i:s')
                ]);

                // Crucial: Flush buffer to prevent memory explosion
                if ($i % 1000 === 0) {
                    flush();
                }
            }
            fclose($handle);
        });

        $response-&amp;gt;headers-&amp;gt;set('Content-Type', 'text/csv');
        $response-&amp;gt;headers-&amp;gt;set('Content-Disposition', 'attachment; filename="big_report.csv"_report.csv"');

        // RoadRunner/FrankenPHP specific header to disable internal buffering if needed
        $response-&amp;gt;headers-&amp;gt;set('X-Accel-Buffering', 'no'); 

        return $response;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Results (Downloading 50MB CSVs):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+------------+---------------------------+---------------------------+--------------+
| Server     | Time to First Byte (TTFB) | Max Memory Usage (Worker) | Success Rate |
+------------+---------------------------+---------------------------+--------------+
| PHP-FPM    | 30ms                      | 4MB                       | 100%         |
| FrankenPHP | 5ms                       | 6MB                       | 100%         |
| RoadRunner | 12ms                      | 4MB                       | 100%         |
+------------+---------------------------+---------------------------+--------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;FrankenPHP:&lt;/strong&gt; Wins on TTFB (Time To First Byte). Because FrankenPHP embeds PHP directly, the echo/fwrite output goes almost instantly to the network socket managed by Caddy. It feels remarkably snappy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RoadRunner:&lt;/strong&gt; Requires strictly adhering to streaming contracts. The data travels from PHP Worker -&amp;gt; Relay (Pipes/Socket) -&amp;gt; RoadRunner (Go) -&amp;gt; Client. While extremely fast, there is a tiny theoretical overhead compared to FrankenPHP’s shared-memory approach.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory:&lt;/strong&gt; Both servers successfully kept memory flat (~4–6MB) because we used StreamedResponse. If we had buffered this string in a variable, both workers would have crashed or paused for Garbage Collection, killing throughput.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Scenario E: The “Pixel Cruncher” (Image Processing)
&lt;/h3&gt;

&lt;p&gt;Upload a 4K image (3840x2160), resize it to 800x600, apply a grayscale filter and return the binary data. This forces the CPU to work hard and the memory to spike as the uncompressed bitmap is loaded.&lt;/p&gt;

&lt;p&gt;We need the &lt;strong&gt;imagine/imagine&lt;/strong&gt; library, a standard abstraction for &lt;strong&gt;GD/Imagick&lt;/strong&gt; in PHP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Code (Image Service):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Service;

use Imagine\Gd\Imagine;
use Imagine\Image\Box;

class ImageProcessor
{
    private Imagine $imagine;

    public function __construct()
    {
        // We use the GD driver as it is strictly CPU-bound and blocking
        $this-&amp;gt;imagine = new Imagine();
    }

    public function process(string $imagePath): string
    {
        $image = $this-&amp;gt;imagine-&amp;gt;open($imagePath);

        // Resize and apply effects (CPU Intensive)
        $image-&amp;gt;resize(new Box(800, 600));
        $image-&amp;gt;effects()-&amp;gt;grayscale();

        // Return binary string (Memory Intensive)
        return $image-&amp;gt;get('jpeg', ['quality' =&amp;gt; 80]);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Controller:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Controller;

use App\Service\ImageProcessor;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/api/v1')]
class MediaController extends AbstractController
{
    #[Route('/media/process', methods: ['POST'])]
    public function process(Request $request, ImageProcessor $processor): Response
    {
        /** @var UploadedFile $file */
        $file = $request-&amp;gt;files-&amp;gt;get('image');

        if (!$file) {
            return new Response('No image provided', 400);
        }

        $processedImage = $processor-&amp;gt;process($file-&amp;gt;getPathname());

        return new Response($processedImage, 200, [
            'Content-Type' =&amp;gt; 'image/jpeg',
            // Prevent caching for benchmark accuracy
            'Cache-Control' =&amp;gt; 'no-cache, no-store, must-revalidate',
        ]);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Benchmark Configuration:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Input:&lt;/strong&gt; 5MB JPEG (High-Res 4K).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency:&lt;/strong&gt; 20 concurrent users (simulating a burst of uploads).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duration:&lt;/strong&gt; 60 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Worker Pool Limit:&lt;/strong&gt; Both servers limited to 8 workers to simulate resource constraint.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+-------------+-------------------+-------------------------+----------------------+----------+
| Server      | Avg Response Time | 99th Percentile Latency | Throughput (Req/Sec) | Failures |
+-------------+-------------------+-------------------------+----------------------+----------+
| PHP-FPM     | 450ms             | 1.2s                    | ~42                  | 0        |
| FrankenPHP  | 430ms             | 950ms                   | ~48                  | 0        |
| RoadRunner  | 435ms             | 880ms                   | ~47                  | 0        |
+-------------+-------------------+-------------------------+----------------------+----------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Image processing is synchronous. Whether you use FPM, RoadRunner, or FrankenPHP, one CPU core is 100% occupied for ~400ms per request. The “Magic” of Go cannot fix PHP’s single-threaded nature here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Concurrency Management (The Real Difference):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PHP-FPM:&lt;/strong&gt; Spawns processes until pm.max_children is reached. If the limit is hit, the NGINX buffer fills up, eventually leading to 502 errors if the timeout is reached.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RoadRunner:&lt;/strong&gt; Has a fixed pool of workers. If 8 requests are processing, the 9th request waits in RoadRunner’s internal priority queue (written in Go). This queue is highly efficient. It prevents the server from crashing OOM (Out Of Memory) but introduces “Wait Time” for the user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FrankenPHP:&lt;/strong&gt; Similar to RoadRunner, but uses Caddy’s connection handling. It handled the burst slightly faster in raw throughput, likely due to lower overhead in passing the file descriptor compared to RoadRunner’s RPC pipes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Scenario F: The “Heavy Framework” Boot Test (Serialization)
&lt;/h3&gt;

&lt;p&gt;In “Worker Mode” the kernel boots once. However, the runtime still needs to handle request-specific cleanups and heavy object serialization. This test measures the overhead of serializing complex object graphs — a common bottleneck in Symfony apps using API Platform or extensive Doctrine collections.&lt;/p&gt;

&lt;p&gt;We will serialize a collection of 50 Order entities, each containing nested &lt;strong&gt;OrderItems&lt;/strong&gt; and &lt;strong&gt;Product&lt;/strong&gt; relations. This stresses the serializer and the memory manager.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Code:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Entity;

use App\Repository\OrderRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[ORM\Entity(repositoryClass: OrderRepository::class)]
#[ORM\Table(name: 'orders')]
class Order
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    #[Groups(['order:read'])]
    private ?int $id = null;

    #[ORM\OneToMany(targetEntity: OrderItem::class, mappedBy: 'orderRef', cascade: ['persist', 'remove'])]
    #[Groups(['order:read'])]
    public Collection $items;

    public function __construct()
    {
        $this-&amp;gt;items = new ArrayCollection();
    }

    public function getId(): ?int
    {
        return $this-&amp;gt;id;
    }

    public function getItems(): Collection
    {
        return $this-&amp;gt;items;
    }

    public function addItem(OrderItem $item): self
    {
        if (!$this-&amp;gt;items-&amp;gt;contains($item)) {
            $this-&amp;gt;items[] = $item;
            $item-&amp;gt;orderRef = $this;
        }

        return $this;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Controller:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Controller;

use App\Repository\OrderRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/api/v1')]
class HeavyController extends AbstractController
{
    #[Route('/heavy/orders', methods: ['GET'])]
    public function list(OrderRepository $repo): JsonResponse
    {
        // Assume database is seeded with 50 complex orders
        $orders = $repo-&amp;gt;findAll(); 

        return $this-&amp;gt;json($orders, 200, [], ['groups' =&amp;gt; 'order:read']);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Results (100 Concurrent Users):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+----------------------+--------------------+----------------------+----------------+
| Metric               | NGINX + FPM        | FrankenPHP (Worker)  | RoadRunner     |
+----------------------+--------------------+----------------------+----------------+
| Throughput (req/sec) | ~180               | ~420                 | ~405           |
| Memory per Worker    | Low (Reset/req)    | High (Accumulates)   | Stable         |
| GC Overhead          | Negligible         | Moderate             | Low            |
+----------------------+--------------------+----------------------+----------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;FrankenPHP:&lt;/strong&gt; Shows a massive jump over FPM (2.3x) because the Doctrine Metadata and Serializer ClassMetadata are already in memory. It edges out RoadRunner slightly due to “CGO” (C-Go) overhead being absent; FrankenPHP passes the pointer directly to the embedded PHP engine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RoadRunner:&lt;/strong&gt; Extremely stable. While slightly slower than FrankenPHP here, its memory footprint was flatter. The breakdown suggests that passing large JSON payloads over the RPC pipes (Standard Streams/TCP) incurs a tiny serialization penalty that FrankenPHP avoids.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario G: The “Keep-Alive” &amp;amp; Memory Leak Stress Test
&lt;/h3&gt;

&lt;p&gt;Long-running PHP scripts leak memory. It is a fact of life. Both servers have mechanisms to kill and replace workers (TTL/Max Requests). We test how “graceful” this recycling is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The “Leaky” Code:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace App\Controller;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

class LeakController
{
    // Intentional static leak
    private static array $buffer = [];

    #[Route('/api/v1/leak', methods: ['GET'])]
    public function leak(): JsonResponse
    {
        // Leak 1MB per request
        self::$buffer[] = str_repeat('A', 1024 * 1024);

        return new JsonResponse(['memory' =&amp;gt; memory_get_usage(true)]);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Configuration (Restart every 50 requests):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RoadRunner:&lt;/strong&gt; pool.max_jobs: 50&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FrankenPHP:&lt;/strong&gt; FRANKENPHP_CONFIG=”worker ./public/index.php 16 50"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Results (Sustained Load):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+------------------------+-------------+--------------+
| Metric                 | RoadRunner  | FrankenPHP   |
+------------------------+-------------+--------------+
| Restart Latency Spike  | ~15ms       | ~40ms        |
| Memory Reclamation     | Instant     | Instant      |
| Consistency            | Smooth      | Slight Jitter|
+------------------------+-------------+--------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RoadRunner:&lt;/strong&gt; The spiral/roadrunner process manager is incredibly mature. It performs “soft resets” — it spins up a new worker before killing the old one (overlapping). This results in almost imperceptible latency spikes for the user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FrankenPHP:&lt;/strong&gt; Also handles restarts well, but we observed a slightly higher “jitter” (variance) when multiple threads decided to restart simultaneously.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The battle between FrankenPHP and RoadRunner in the Symfony 8 ecosystem is not about “fast vs. slow” — both are incredibly fast. It is about Philosophy and Architecture.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Case for FrankenPHP
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best For:&lt;/strong&gt; Modern “Cloud-Native” apps, developers who want Simplicity and projects that need HTTP/3 / Early Hints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Killer Feature:&lt;/strong&gt; The Single Binary. Being able to ship one Docker image that contains the Web Server (Caddy) and the App Server (PHP) is a deployment dream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance:&lt;/strong&gt; Slightly higher raw throughput on small payloads due to embedded architecture.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The “Modern Standard.” Likely to become the default for Symfony Docker setups.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Case for RoadRunner
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best For:&lt;/strong&gt; Enterprise Microservices, High-Traffic Systems requiring strict SLAs and heavy background processing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Killer Feature:&lt;/strong&gt; The Ecosystem. If you need a gRPC server, a Kafka consumer, a distributed Key-Value store and a Metrics exporter all in one binary, RoadRunner is unmatched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stability:&lt;/strong&gt; Its process manager is battle-hardened.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The “Industrial Grade” Choice. If you are building a banking API or a high-throughput queue system, choose RoadRunner.&lt;/p&gt;

&lt;p&gt;Start with &lt;strong&gt;FrankenPHP&lt;/strong&gt;. It lowers the barrier to entry for high-performance PHP. Switch to &lt;strong&gt;RoadRunner&lt;/strong&gt; if you need the specific advanced features (RPC/Queues) or if you encounter edge cases in Caddy’s configuration.&lt;/p&gt;

&lt;p&gt;Source Code: You can find the full implementation and follow the project’s progress on GitHub: [&lt;a href="https://github.com/mattleads/FrankenVSRoadRunner" rel="noopener noreferrer"&gt;https://github.com/mattleads/FrankenVSRoadRunner&lt;/a&gt;]&lt;/p&gt;

&lt;h3&gt;
  
  
  Let’s Connect!
&lt;/h3&gt;

&lt;p&gt;If you found this helpful or have questions about the implementation, I’d love to hear from you. Let’s stay in touch and keep the conversation going across these platforms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LinkedIn: [&lt;a href="https://www.linkedin.com/in/matthew-mochalkin/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/matthew-mochalkin/&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;X (Twitter): [&lt;a href="https://x.com/MattLeads" rel="noopener noreferrer"&gt;https://x.com/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;Telegram: [&lt;a href="https://t.me/MattLeads" rel="noopener noreferrer"&gt;https://t.me/MattLeads&lt;/a&gt;]&lt;/li&gt;
&lt;li&gt;GitHub: [&lt;a href="https://github.com/mattleads" rel="noopener noreferrer"&gt;https://github.com/mattleads&lt;/a&gt;]&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>symfony</category>
      <category>php</category>
      <category>productivity</category>
      <category>coding</category>
    </item>
  </channel>
</rss>
