<?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: Munir Ahmad</title>
    <description>The latest articles on DEV Community by Munir Ahmad (@munirahmad9959).</description>
    <link>https://dev.to/munirahmad9959</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2482133%2Fa50ab06b-f53b-4993-85ef-eee8b44539b3.png</url>
      <title>DEV Community: Munir Ahmad</title>
      <link>https://dev.to/munirahmad9959</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/munirahmad9959"/>
    <language>en</language>
    <item>
      <title>Fix Microsoft 365 SMTP 535 5.7.3: Send Laravel Email with Microsoft Graph and Restrict It to One Mailbox</title>
      <dc:creator>Munir Ahmad</dc:creator>
      <pubDate>Thu, 08 Oct 2026 18:13:46 +0000</pubDate>
      <link>https://dev.to/munirahmad9959/fix-microsoft-365-smtp-535-573-202o</link>
      <guid>https://dev.to/munirahmad9959/fix-microsoft-365-smtp-535-573-202o</guid>
      <description>&lt;p&gt;A step-by-step guide for developers who are not Exchange admins: stop using a mailbox password, send through the Graph API, and restrict the app to a single mailbox with Exchange RBAC for Applications.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Last verified: October 2026. Microsoft’s admin portals change often, so check menu names against the current docs if something looks different.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2w1bp8a51g4fzp4jwb10.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2w1bp8a51g4fzp4jwb10.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  TL;DR
&lt;/h3&gt;

&lt;p&gt;If your Laravel app sends mail through smtp.office365.com and fails with 535 5.7.3 Authentication unsuccessful, you have two options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Quick fix(not recommended for production):&lt;/strong&gt; turn off Security Defaults in Microsoft Entra. This fixed my login, but it removes MFA enforcement and legacy sign-in blocking for your whole tenant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Right fix (this article):&lt;/strong&gt; stop using a mailbox password. Send through the Microsoft Graph API with an app that can only send as one shared mailbox (&lt;a href="mailto:noreply@yourdomain.com"&gt;noreply@yourdomain.com&lt;/a&gt;), using a small custom Laravel mail transport. Your existing Mailables and Notifications work unchanged.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By the end you will have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a dedicated &lt;a href="mailto:noreply@yourdomain.com"&gt;noreply@yourdomain.com&lt;/a&gt; shared mailbox,&lt;/li&gt;
&lt;li&gt;an Entra app that can send &lt;strong&gt;only&lt;/strong&gt; as that mailbox,&lt;/li&gt;
&lt;li&gt;a Laravel graph mailer that works with all your existing Mailables, Notifications and queues, with no code changes in them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I did this on a real project, hit every wall, and wrote down the fixes.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The problem
&lt;/h3&gt;

&lt;p&gt;My Laravel API sent verification emails through Microsoft 365 over SMTP. Then this happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;AUTH LOGIN
&lt;span class="go"&gt;&amp;lt; 334 VXNlcm5hbWU6
&lt;/span&gt;&lt;span class="gp"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;…
&lt;span class="go"&gt;&amp;lt; 535 5.7.3 Authentication unsuccessful
curl: (67) Login denied
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I reproduced it outside Laravel with plain curl so I could rule out my app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"smtp://&lt;/span&gt;&lt;span class="nv"&gt;$SMTP_HOST&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$SMTP_PORT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ssl-reqd&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SMTP_USERNAME&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$SMTP_PASSWORD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mail-from&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MAIL_FROM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mail-rcpt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MAIL_TO&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--upload-file&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'From: %s\r\nTo: %s\r\nSubject: SMTP Test\r\n\r\nTest.\r\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MAIL_FROM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MAIL_TO&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The connection and TLS were fine. Microsoft simply refused the login, so the network and port 587 were not the problem.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsncvrldtj1phurjhzfhg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsncvrldtj1phurjhzfhg.png" width="800" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Why Microsoft rejects the login
&lt;/h3&gt;

&lt;p&gt;Microsoft’s &lt;a href="https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission" rel="noopener noreferrer"&gt;SMTP AUTH documentation&lt;/a&gt; lists three things that block SMTP AUTH even when your credentials are correct:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Security Defaults is on.&lt;/strong&gt; SMTP AUTH is already disabled, and mailbox settings don’t override it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SMTP AUTH is switched off&lt;/strong&gt; for the organization or the mailbox. The mailbox setting wins over the organization one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An authentication policy blocks Basic authentication for SMTP.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Security Defaults&lt;/strong&gt; is a free bundle of tenant-wide protections from Microsoft: MFA registration, MFA for admins, and &lt;a href="https://learn.microsoft.com/en-us/entra/fundamentals/security-defaults" rel="noopener noreferrer"&gt;blocking of legacy authentication&lt;/a&gt;, which includes SMTP, IMAP and POP3. That last rule stops an app from logging in with a username and password. The switch is under &lt;strong&gt;Entra ID → Overview → Properties → Manage security defaults&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What fixed it for me:&lt;/strong&gt; turning Security Defaults off. Nothing else changed, and I never switched SMTP AUTH on myself. Afterwards I ran this check on my mailbox:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-CASMailbox&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Identity&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;mailbox&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Format-List&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SmtpClientAuthenticationDisabled&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It returned False, which means SMTP AUTH is enabled for that mailbox. Run it on yours instead of assuming.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it’s only a bridge.&lt;/strong&gt; Turning Security Defaults off removes MFA enforcement and legacy sign-in blocking for your whole tenant. Microsoft’s advice is to replace them with Conditional Access. And under its &lt;a href="https://techcommunity.microsoft.com/blog/exchange/updated-exchange-online-smtp-auth-basic-authentication-deprecation-timeline/4489835" rel="noopener noreferrer"&gt;updated timeline&lt;/a&gt;, SMTP Basic authentication will be &lt;strong&gt;disabled by default for existing tenants at the end of December 2026&lt;/strong&gt;. Admins can still turn it back on, and the final removal date will be announced in the second half of 2027. These dates have moved before, so check the page again before you deploy.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The better design
&lt;/h3&gt;

&lt;p&gt;Instead of logging in as a person, your app gets its own identity: a client ID and a client secret. It trades them for a short-lived token and sends mail through the Microsoft Graph sendMail API.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Laravel Queue Worker
        │
        │ 1. client_id + client_secret
        ▼
Microsoft Entra ID
        │
        │ 2. access token
        ▼
Microsoft Graph
        │
        │ 3. POST /users/noreply@yourdomain.com/sendMail
        │ Authorization: Bearer &amp;lt;token&amp;gt;
        ▼
Exchange Online
        │
        │ Exchange RBAC
        │ ┌─────────────────────────────┐
        └──► noreply@yourdomain.com ONLY
           └─────────────────────────────┘
        │
        ▼
Recipient
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this is better:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No mailbox password in your .env. The secret expires, and you can rotate it without touching any mailbox.&lt;/li&gt;
&lt;li&gt;No SMTP, so Security Defaults can stay on. Graph doesn’t use SMTP AUTH, and I re-ran the test with Security Defaults enabled: it still returned 202 Accepted.&lt;/li&gt;
&lt;li&gt;It can be locked to one mailbox. This is the part most tutorials skip.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  The mistake most tutorials make
&lt;/h4&gt;

&lt;p&gt;The usual route is to add the Microsoft Graph Mail.Send application permission in Entra and grant admin consent. It works, but that permission lets the app send mail as &lt;strong&gt;any user in your organization&lt;/strong&gt;. If the client secret leaks, someone can send email as your CEO.&lt;/p&gt;

&lt;p&gt;Microsoft’s fix is &lt;a href="https://learn.microsoft.com/en-us/exchange/permissions-exo/application-rbac" rel="noopener noreferrer"&gt;RBAC for Applications in Exchange Online&lt;/a&gt;, which replaces the older Application Access Policies. You grant the send permission inside Exchange, limited to the mailboxes you choose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One rule:&lt;/strong&gt; don’t also grant Mail.Send in Entra. Microsoft's FAQ says the two grants add up, so an unscoped Entra grant cancels your restriction. This guide adds no API permissions in Entra.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Prerequisites
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A Microsoft 365 tenant with Exchange Online, and an admin account.&lt;/strong&gt; A Global Administrator covers everything in this guide. Microsoft’s &lt;a href="https://learn.microsoft.com/en-us/exchange/permissions-exo/application-rbac" rel="noopener noreferrer"&gt;RBAC for Applications&lt;/a&gt; article lists the minimum roles if you want a narrower account. If your tenant came through a reseller such as GoDaddy, see the note in Step 1.&lt;/li&gt;
&lt;li&gt;A Laravel app. I tested on Laravel 11. It should work on any version that uses Symfony Mailer (Laravel 9 and newer), but I haven’t tried them, so run the included test on yours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PowerShell 7&lt;/strong&gt; on a Mac, Linux or Windows machine. Microsoft documents the mailbox-restriction step only in PowerShell, so you need it once.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Step by step
&lt;/h3&gt;

&lt;h3&gt;
  
  
  Step 1: Create the sending mailbox
&lt;/h3&gt;

&lt;p&gt;Use a &lt;strong&gt;shared mailbox&lt;/strong&gt;. Nobody can sign in to it with a password, so there is nothing to leak, and basic shared mailboxes don’t need their own license.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the Exchange admin center: &lt;a href="https://admin.cloud.microsoft/exchange" rel="noopener noreferrer"&gt;https://admin.cloud.microsoft/exchange&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Go to &lt;strong&gt;Recipients → Mailboxes → Add a shared mailbox&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Display name:&lt;/strong&gt; your brand name, for example Your Brand. Recipients usually see this as the sender name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email address:&lt;/strong&gt; noreply, then pick your primary domain from the dropdown (for example yourdomain.com).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alias:&lt;/strong&gt; leave it blank.&lt;/li&gt;
&lt;li&gt;Create it. It may take a few minutes to appear.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8pjygji12fpwu6cw71jq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8pjygji12fpwu6cw71jq.png" width="800" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Reseller tenants (GoDaddy etc.):&lt;/strong&gt; In my tenant the “Microsoft 365 admin center” link inside the Exchange admin center redirected to GoDaddy. You don’t need it. Everything in this guide uses the Exchange admin center, the Entra admin center and PowerShell.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 2: Register the app in Microsoft Entra
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://entra.microsoft.com" rel="noopener noreferrer"&gt;https://entra.microsoft.com&lt;/a&gt; → &lt;strong&gt;Entra ID → App registrations → New registration&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name:&lt;/strong&gt; your-app-mailer. It's a label for admins, not the sender name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supported account types:&lt;/strong&gt; Single tenant only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirect URI:&lt;/strong&gt; leave empty.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Register&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;On the &lt;strong&gt;Overview&lt;/strong&gt; page, copy the &lt;strong&gt;Application (client) ID&lt;/strong&gt; and the &lt;strong&gt;Directory (tenant) ID&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa1vm70dbjp5yeficb3e7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa1vm70dbjp5yeficb3e7.png" width="800" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Go to &lt;strong&gt;Certificates &amp;amp; secrets → Client secrets → New client secret&lt;/strong&gt;. Add a description and choose an expiry. &lt;a href="https://learn.microsoft.com/en-us/entra/identity-platform/how-to-add-credentials" rel="noopener noreferrer"&gt;Microsoft recommends less than 12 months&lt;/a&gt;, so I use 6. Click  &lt;strong&gt;Add&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Copy the &lt;strong&gt;Value&lt;/strong&gt; immediately. Microsoft never shows it again once you leave the page.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Pitfall:&lt;/strong&gt; There are two columns, &lt;strong&gt;Value&lt;/strong&gt; and &lt;strong&gt;Secret ID&lt;/strong&gt;. You need &lt;strong&gt;Value&lt;/strong&gt;. The &lt;strong&gt;Secret ID&lt;/strong&gt; is just a label, and using it gives you an invalid-client error.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frpdot2y7qgcmnog98r8s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frpdot2y7qgcmnog98r8s.png" width="800" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A note on secrets in production:&lt;/strong&gt; Microsoft says client secrets are less secure than certificates and recommends certificates for production applications. A secret is the simplest way to get started, and in this guide the damage a leaked secret can do is limited to one mailbox (Step 4). If your security requirements are stricter, switch to a certificate once this works.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Do not&lt;/strong&gt; open “API permissions” and add anything. The permission comes from Exchange in Step 4.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Get the Enterprise App Object ID
&lt;/h3&gt;

&lt;p&gt;Exchange needs the object ID of the app’s &lt;strong&gt;service principal&lt;/strong&gt; , which is not the Object ID shown on the App registrations page.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In Entra, go to &lt;strong&gt;Entra ID → Enterprise apps&lt;/strong&gt; and click your app.&lt;/li&gt;
&lt;li&gt;Copy the &lt;strong&gt;Object ID&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Microsoft’s docs call this out explicitly: the Object ID on the App registrations page is a &lt;strong&gt;different value&lt;/strong&gt;. Using the wrong one is the easiest mistake in this whole process.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fok8lf6lvtbss2hcr4vet.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fok8lf6lvtbss2hcr4vet.png" width="800" height="407"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Restrict the app to one mailbox (PowerShell)
&lt;/h3&gt;

&lt;p&gt;This step limits what the app is allowed to do. Microsoft documents it in PowerShell, so install that first. The steps below are for macOS.&lt;/p&gt;

&lt;h4&gt;
  
  
  Install PowerShell 7
&lt;/h4&gt;

&lt;p&gt;Download the signed .pkg for your Mac from &lt;a href="https://aka.ms/powershell-release?tag=stable" rel="noopener noreferrer"&gt;Microsoft's PowerShell releases page&lt;/a&gt; (osx-arm64 for Apple silicon, osx-x64 for Intel), then double-click it. This is the method &lt;a href="https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-macos" rel="noopener noreferrer"&gt;Microsoft recommends&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;To check the download is genuine (your file name will differ):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pkgutil &lt;span class="nt"&gt;--check-signature&lt;/span&gt; powershell-7.x.x-osx-x64.pkg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for Developer ID Installer: Microsoft Corporation and Notarization: trusted by the Apple notary service.&lt;/p&gt;

&lt;p&gt;Homebrew works too (brew install — cask powershell), but Microsoft lists it as a third-party method.&lt;/p&gt;

&lt;p&gt;Start PowerShell and connect to Exchange Online:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;pwsh&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="n"&gt;Install-Module&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ExchangeOnlineManagement&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Scope&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;CurrentUser&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Connect-ExchangeOnline&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A browser window opens. Sign in with your admin account.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx2g1cqkn8or9t30krkkr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx2g1cqkn8or9t30krkkr.png" width="800" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, prove your filter matches exactly one mailbox:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-Mailbox&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-RecipientTypeDetails&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SharedMailbox&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-Filter&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PrimarySmtpAddress -eq 'noreply@yourdomain.com'"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;Format-Table&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;PrimarySmtpAddress&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You must see exactly one row: your noreply@ mailbox. If you see none or several, stop and fix the filter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then create the scope and the role assignment.&lt;/strong&gt; These commands follow Microsoft’s RBAC for Applications documentation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Tell Exchange about the Entra service principal&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;New-ServicePrincipal&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-AppId&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;APPLICATION_CLIENT_ID&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-ObjectId&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ENTERPRISE_APP_OBJECT_ID&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-DisplayName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-app-mailer"&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# 2. Define the set of mailboxes the app may touch&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;New-ManagementScope&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NoReplyOnly"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-RecipientRestrictionFilter&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PrimarySmtpAddress -eq 'noreply@yourdomain.com'"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# 3. Grant "send mail" only within that scope&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;New-ManagementRoleAssignment&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-App&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ENTERPRISE_APP_OBJECT_ID&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-Role&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Application Mail.Send"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-CustomResourceScope&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NoReplyOnly"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Verify with Microsoft’s test cmdlet.&lt;/strong&gt; Check the allowed mailbox and a mailbox that should be blocked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Test-ServicePrincipalAuthorization&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Identity&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ENTERPRISE_APP_OBJECT_ID&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Resource&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;noreply&lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nx"&gt;yourdomain.com&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Format-Table&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nx"&gt;Test-ServicePrincipalAuthorization&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Identity&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ENTERPRISE_APP_OBJECT_ID&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Resource&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;someone-else&lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nx"&gt;yourdomain.com&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Format-Table&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is what mine returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RoleName GrantedPermissions AllowedResourceScope ScopeType InScope
-------- ------------------ -------------------- --------- -------
Application Mail.Send Mail.Send NoReplyOnly CustomRecipientScope True ← noreply@
Application Mail.Send Mail.Send NoReplyOnly CustomRecipientScope False ← another mailbox
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;True for the shared mailbox, False for everyone else.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8cumvvmm9fs4wgfol9iy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8cumvvmm9fs4wgfol9iy.png" width="800" height="332"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Disconnect when you are done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Disconnect-ExchangeOnline&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Confirm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="bp"&gt;$false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Propagation delay:&lt;/strong&gt; Microsoft says permission changes can take roughly 30 minutes to 2 hours to take effect, depending on how recently the app was used. The test command above bypasses that delay, but real API calls don’t. If your first send fails with a 403, wait before you debug.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 5: Test the send with curl before touching Laravel
&lt;/h3&gt;

&lt;p&gt;Always test the Microsoft side in isolation first. Set the values in your shell. In zsh, read -s keeps the secret out of your shell history and screen:&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;export &lt;/span&gt;&lt;span class="nv"&gt;TENANT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;directory (tenant) id&amp;gt;'&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLIENT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;application (client) id&amp;gt;'&lt;/span&gt;

&lt;span class="c"&gt;# zsh (default on macOS):&lt;/span&gt;
&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"CLIENT_SECRET?Secret value: "&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;export &lt;/span&gt;CLIENT_SECRET

&lt;span class="c"&gt;# bash (most Linux):&lt;/span&gt;
&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Secret value: "&lt;/span&gt; CLIENT_SECRET &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;export &lt;/span&gt;CLIENT_SECRET
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Get an access token (the client-credentials flow):&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="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://login.microsoftonline.com/&lt;/span&gt;&lt;span class="nv"&gt;$TENANT_ID&lt;/span&gt;&lt;span class="s2"&gt;/oauth2/v2.0/token"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_id=&lt;/span&gt;&lt;span class="nv"&gt;$CLIENT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_secret=&lt;/span&gt;&lt;span class="nv"&gt;$CLIENT_SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"scope=https://graph.microsoft.com/.default"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=client_credentials"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import sys,json;print(json.load(sys.stdin).get('access_token','FAILED'))"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TOKEN&lt;/span&gt;:0:20&lt;span class="k"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see about 20 characters of a token. If the command crashes or prints FAILED, one of the three variables is empty or wrong. Check them with:&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;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="nv"&gt;$TENANT_ID&lt;/span&gt;&lt;span class="s2"&gt;]"&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="nv"&gt;$CLIENT_ID&lt;/span&gt;&lt;span class="s2"&gt;]"&lt;/span&gt; &lt;span class="k"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;CLIENT_SECRET&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Send an email:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://graph.microsoft.com/v1.0/users/noreply@yourdomain.com/sendMail"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"message":{"subject":"Graph test","body":{"contentType":"Text","content":"It works."},"toRecipients":[{"emailAddress":{"address":"you@example.com"}}]}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected: HTTP/1.1 202 Accepted. Per Microsoft, 202 means the request was &lt;strong&gt;accepted&lt;/strong&gt;. It doesn't guarantee delivery; delivery is still subject to Exchange Online limits and throttling.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo0f42ryd2xos4u3zml89.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo0f42ryd2xos4u3zml89.png" width="799" height="348"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Wiring it into Laravel
&lt;/h3&gt;

&lt;p&gt;Laravel has no built-in Microsoft Graph mail driver, but it sends mail through Symfony Mailer, which accepts custom transports. The transport below takes the finished MIME message and posts it to Graph, so your existing Mailables and Notifications work without changes. Ready-made packages exist too, such as &lt;a href="https://github.com/InnoGE/laravel-msgraph-mail" rel="noopener noreferrer"&gt;InnoGE/laravel-msgraph-mail&lt;/a&gt; and &lt;a href="https://github.com/maize-tech/laravel-msgraph-mailer" rel="noopener noreferrer"&gt;maize-tech/laravel-msgraph-mailer&lt;/a&gt;. I wrote a small one (under 70 lines) to avoid another dependency. The Microsoft setup is identical either way.&lt;/p&gt;

&lt;h3&gt;
  
  
  6.1 The transport
&lt;/h3&gt;

&lt;p&gt;app/Mail/Transport/GraphTransport.php&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Mail\Transport&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;Illuminate\Support\Facades\Cache&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;Illuminate\Support\Facades\Http&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\Mailer\Exception\TransportException&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\Mailer\SentMessage&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\Mailer\Transport\AbstractTransport&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;GraphTransport&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AbstractTransport&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;string&lt;/span&gt; &lt;span class="nv"&gt;$tenantId&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;string&lt;/span&gt; &lt;span class="nv"&gt;$clientId&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;string&lt;/span&gt; &lt;span class="nv"&gt;$clientSecret&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;string&lt;/span&gt; &lt;span class="nv"&gt;$sender&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;doSend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;SentMessage&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;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withToken&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;accessToken&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;withBody&lt;/span&gt;&lt;span class="p"&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;$message&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="s1"&gt;'text/plain'&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;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"https://graph.microsoft.com/v1.0/users/&lt;/span&gt;&lt;span class="si"&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;sender&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/sendMail"&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;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;202&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;TransportException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s2"&gt;"Microsoft Graph sendMail failed (&lt;/span&gt;&lt;span class="si"&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;status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;): "&lt;/span&gt;&lt;span class="mf"&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;body&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;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;accessToken&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="nc"&gt;Cache&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"graph-mail-token:&lt;/span&gt;&lt;span class="si"&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;clientId&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="nf"&gt;now&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;addMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&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="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;asForm&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;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s2"&gt;"https://login.microsoftonline.com/&lt;/span&gt;&lt;span class="si"&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;tenantId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/oauth2/v2.0/token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;
                    &lt;span class="s1"&gt;'client_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="s1"&gt;'client_secret'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;clientSecret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="s1"&gt;'scope'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'https://graph.microsoft.com/.default'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="s1"&gt;'grant_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'client_credentials'&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;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&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;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'access_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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TransportException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="s2"&gt;"Microsoft Graph token request failed (&lt;/span&gt;&lt;span class="si"&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;status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;): "&lt;/span&gt;&lt;span class="mf"&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;body&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;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'access_token'&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;__toString&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="s1"&gt;'graph'&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;How it works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Raw MIME in, 202 out. It posts the whole message base64-encoded, which is &lt;a href="https://learn.microsoft.com/en-us/graph/api/user-sendmail?view=graph-rest-1.0" rel="noopener noreferrer"&gt;Graph's MIME mode&lt;/a&gt;. That's why attachments, headers and HTML/text versions survive. Graph answers 202 Accepted when it takes the message, and anything else becomes a TransportException, so Laravel reports a failed send instead of losing the email.&lt;/li&gt;
&lt;li&gt;Token cache. Microsoft’s &lt;a href="https://learn.microsoft.com/en-us/entra/identity-platform/configurable-token-lifetimes" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; says access tokens last a random 60–90 minutes by default, so a 50-minute cache always expires first.&lt;/li&gt;
&lt;li&gt;Sender. The mailbox in the URL is the one Graph sends from. It must be the mailbox you scoped in Step 4.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  6.2 Register the driver
&lt;/h3&gt;

&lt;p&gt;app/Providers/AppServiceProvider.php&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;use&lt;/span&gt; &lt;span class="nc"&gt;App\Mail\Transport\GraphTransport&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;Illuminate\Support\Facades\Mail&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;boot&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="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'graph'&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="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$config&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;GraphTransport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'tenant_id'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'client_id'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'client_secret'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'sender'&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;
  
  
  6.3 Add the mailer
&lt;/h3&gt;

&lt;p&gt;In config/mail.php inside the mailers array:&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="err"&gt;'graph'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&amp;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="err"&gt;'transport'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'graph'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;'tenant_id'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;env('MS_GRAPH_TENANT_ID')&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;'client_id'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;env('MS_GRAPH_CLIENT_ID')&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;'client_secret'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;env('MS_GRAPH_CLIENT_SECRET')&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;'sender'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;env('MAIL_FROM_ADDRESS')&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="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  6.4 Environment variables
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MAIL_MAILER=graph
MAIL_FROM_ADDRESS=noreply@yourdomain.com
MAIL_FROM_NAME="${APP_NAME}"

MS_GRAPH_TENANT_ID=&amp;lt;directory (tenant) id&amp;gt;
MS_GRAPH_CLIENT_ID=&amp;lt;application (client) id&amp;gt;
MS_GRAPH_CLIENT_SECRET=&amp;lt;secret VALUE&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remove the old MAIL_HOST, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD and MAIL_ENCRYPTION lines. MAIL_FROM_ADDRESS must be the mailbox you scoped in Step 4. Add the three MS_GRAPH_* keys, empty, to .env.example, and keep real values in your host's secret store, never in git.&lt;/p&gt;

&lt;h3&gt;
  
  
  6.5 Apply the changes
&lt;/h3&gt;

&lt;p&gt;Laravel’s queue workers read your configuration once, when they start. After editing mail settings, clear cached config and restart them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan config:clear
php artisan queue:restart &lt;span class="c"&gt;# plain queue workers&lt;/span&gt;
php artisan horizon:terminate &lt;span class="c"&gt;# if you use Horizon (your process manager restarts it)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then confirm Laravel sees the new driver:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan config:show mail.default &lt;span class="c"&gt;# should print: graph&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If mail still goes out the old way, look for a MAIL_* variable set outside .env (Docker Compose, your host's environment settings, a CI secret). Real environment variables win over .env.&lt;/p&gt;

&lt;p&gt;Finally, send yourself a test:&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="n"&gt;php&lt;/span&gt; &lt;span class="n"&gt;artisan&lt;/span&gt; &lt;span class="n"&gt;tinker&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Mail::raw('Laravel Graph test', fn(&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;m) =&amp;gt; &lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;m-&amp;gt;to('you@example.com')-&amp;gt;subject('Laravel test'));"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  7. Troubleshooting
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Token request fails or returns nothing. Check TENANT_ID, CLIENT_ID and the secret for typos or empty values. Also check that you copied the secret's &lt;strong&gt;Value&lt;/strong&gt; , not its Secret ID, and that the secret hasn't expired. Run the raw token request from Step 5 to see Microsoft's error message.&lt;/li&gt;
&lt;li&gt;403 right after setup. Microsoft says permission changes can take roughly 30 minutes to 2 hours to apply. Wait before debugging.&lt;/li&gt;
&lt;li&gt;403 that doesn't go away. Make sure you used the &lt;strong&gt;Enterprise app Object ID&lt;/strong&gt; (Step 3) in the Step 4 commands, and that MAIL_FROM_ADDRESS is the mailbox you scoped.&lt;/li&gt;
&lt;li&gt;The app can send as other mailboxes. You likely also granted Mail.Send under &lt;strong&gt;API permissions&lt;/strong&gt; in Entra. Microsoft says the Entra grant and the Exchange grant add up, so remove it from Entra and keep only the Exchange one.&lt;/li&gt;
&lt;li&gt;Emails still arrive from the old sender. Your workers are running old configuration. Run the commands in “Apply the changes” in section 6.&lt;/li&gt;
&lt;li&gt;202 Accepted but nothing in the inbox. 202 only means Graph accepted the message. Check spam, run a message trace in the Exchange admin center, and make sure SPF, DKIM and DMARC are set up for your domain.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>microsoft365</category>
      <category>microsoftgraph</category>
      <category>email</category>
      <category>php</category>
    </item>
    <item>
      <title>Fastlane for Flutter: Complete Local Setup for iOS and Android Deployments</title>
      <dc:creator>Munir Ahmad</dc:creator>
      <pubDate>Mon, 14 Sep 2026 08:17:27 +0000</pubDate>
      <link>https://dev.to/munirahmad9959/fastlane-for-flutter-complete-local-setup-for-ios-and-android-deployments-i81</link>
      <guid>https://dev.to/munirahmad9959/fastlane-for-flutter-complete-local-setup-for-ios-and-android-deployments-i81</guid>
      <description>&lt;p&gt;Stop manual store clicks. Learn how to configure Fastlane locally to build, guard, and ship your Flutter app to TestFlight and Google Play with a single command.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Forf3142gel1vfaaal3t2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Forf3142gel1vfaaal3t2.png" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Why bother
&lt;/h4&gt;

&lt;p&gt;Every Flutter release usually looks like this: bump the version, build the IPA, open Xcode, wait for the organizer, switch to browsers, build the AAB, drag-and-drop into the Play Console, and fill out release notes. It’s ten minutes of tedious clicking that changes every time, meaning it never becomes muscle memory.&lt;/p&gt;

&lt;p&gt;Fastlane turns that entire ritual into:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;bundle exec fastlane ios deploy 
bundle exec fastlane android deploy
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This article is &lt;strong&gt;Part 1&lt;/strong&gt; : getting it working locally on your machine. Part 2 moves these exact lanes into GitHub Actions so a simple tag push handles the rest. Do this part first — the CI setup assumes your local lanes are already green.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you’ll need
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A Flutter app that already builds and runs&lt;/li&gt;
&lt;li&gt;An Apple Developer account ($99/yr) with the app registered in App Store Connect&lt;/li&gt;
&lt;li&gt;A Google Play Console account ($25 one-time) with the app created &lt;strong&gt;and at least one AAB uploaded manually&lt;/strong&gt;  — Play won’t accept the first-ever build over the API.&lt;/li&gt;
&lt;li&gt;Ruby (macOS ships with one; a version manager like rbenv is nicer but not required)&lt;/li&gt;
&lt;li&gt;Xcode with a valid signing setup for your app (Automatic signing with your team selected is fine for local)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I’m on FVM, so my Flutter SDK lives at &lt;code&gt;/Users/&amp;lt;you&amp;gt;/fvm/versions/3.32.0&lt;/code&gt;. Note your own path now — you'll need it in a minute.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — Install fastlane via a Gemfile (not globally)
&lt;/h3&gt;

&lt;p&gt;You &lt;em&gt;can&lt;/em&gt; brew install fastlane or gem install fastlane, but pinning it per-project with Bundler means your machine and your CI runner use the exact same version. Flutter projects have a ios/ and an android/ folder, and each gets its own Gemfile.&lt;/p&gt;

&lt;p&gt;ios/Gemfile&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="s2"&gt;"https://rubygems.org"&lt;/span&gt;
&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s2"&gt;"fastlane"&lt;/span&gt;
&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s2"&gt;"cocoapods"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;android/Gemfile&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="s2"&gt;"https://rubygems.org"&lt;/span&gt;
&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s2"&gt;"fastlane"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The iOS Gemfile also pins CocoaPods so the build doesn’t depend on whatever pod version happens to be on the system (this matters a lot on CI later). Android has no such dependency, so its Gemfile stays minimal.&lt;/p&gt;

&lt;p&gt;Install both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;cd ios &amp;amp;&amp;amp; bundle install &amp;amp;&amp;amp; cd ..
cd android &amp;amp;&amp;amp; bundle install &amp;amp;&amp;amp; cd ..
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From here on, always call fastlane as bundle exec fastlane … so you get the pinned version.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Set FLUTTER_ROOT
&lt;/h3&gt;

&lt;p&gt;fastlane’s iOS build scripts shell out to Flutter and need to know where the SDK is. Add this to your shell profile (&lt;code&gt;~/.zshrc&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;export FLUTTER_ROOT="/Users/&amp;lt;you&amp;gt;&lt;/span&gt;/fvm/versions/3.32.0&lt;span class="s2"&gt;"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you’re not on FVM, it’s wherever which flutter points, minus &lt;code&gt;/bin/flutter&lt;/code&gt;. Reload your shell (&lt;code&gt;source ~/.zshrc&lt;/code&gt;) and confirm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;echo $&lt;/span&gt;FLUTTER_ROOT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3 — Confirm the raw builds work
&lt;/h3&gt;

&lt;p&gt;Before automating anything, make sure the two build commands fastlane will run actually succeed on their own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;fvm flutter build appbundle --release #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Android -&amp;gt; build/app/outputs/bundle/release/app-release.aab
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;fvm flutter build ipa --release #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;iOS -&amp;gt; build/ios/ipa/&amp;lt;YourApp&amp;gt;.ipa
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;flutter build ipa&lt;/code&gt; complains about signing, open &lt;code&gt;ios/Runner.xcworkspace&lt;/code&gt; in Xcode once, select the Runner target → Signing &amp;amp; Capabilities, tick Automatically manage signing, pick your team, and let it create the profile. Then re-run. Don't move on until both commands produce an artifact.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4 — &lt;code&gt;fastlane init&lt;/code&gt; for iOS
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;from app&lt;span class="s1"&gt;'s root
&lt;/span&gt;&lt;span class="go"&gt;cd ios
bundle exec fastlane init
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick option 2 (Automate beta distribution to TestFlight). It parses your Xcode project, then asks you to log in with your Apple ID. This triggers a 2FA prompt on your trusted device — enter the 6-digit code. It’ll verify the app exists in both the Developer Portal and App Store Connect.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feslxuph7wzly1x20pd0k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feslxuph7wzly1x20pd0k.png" width="800" height="786"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This creates &lt;code&gt;ios/fastlane/Appfile&lt;/code&gt; and &lt;code&gt;ios/fastlane/Fastfile&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5 — &lt;code&gt;fastlane&lt;/code&gt; init for Android
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;from app&lt;span class="s1"&gt;'s root
&lt;/span&gt;&lt;span class="go"&gt;cd android
bundle exec fastlane init
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It asks for your package name (must match applicationId in android/app/build.gradle), then asks for the path to a Play service account JSON. Press Enter to skip that for now — we'll wire it up properly in Step 6.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fozldtxrh09gpmlydftrs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fozldtxrh09gpmlydftrs.png" width="800" height="660"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This creates &lt;code&gt;android/fastlane/Appfile&lt;/code&gt; and &lt;code&gt;android/fastlane/Fastfile&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6 — Fill in the Appfiles
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;android/fastlane/Appfile&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;json_key_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"pc-key.json"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# only used by validate/supply — the deploy lane uses the base64 env var&lt;/span&gt;
&lt;span class="n"&gt;package_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"com.example.app"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# must match applicationId in android/app/build.gradle&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now get the &lt;code&gt;pc-key.json&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the &lt;a href="https://console.cloud.google.com/apis/library/androidpublisher.googleapis.com" rel="noopener noreferrer"&gt;Google Cloud APIs &amp;amp; Services page&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Enable the Google Play Android Developer API for that project.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv7xz1obpggdk94eoc186.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv7xz1obpggdk94eoc186.png" width="800" height="221"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Create the Service Account directly in Google Cloud&lt;/strong&gt;
&lt;/h4&gt;

&lt;ol&gt;
&lt;li&gt;Open the &lt;a href="https://console.cloud.google.com/iam-admin/serviceaccounts" rel="noopener noreferrer"&gt;Google Cloud IAM Service Accounts page&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Make sure you are logged into the Google account that manages this Play Store developer profile.&lt;/li&gt;
&lt;li&gt;Select your Google Cloud project from the top dropdown (or create a quick project named “Play Store Fastlane” if none exists).&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;+ CREATE SERVICE ACCOUNT&lt;/strong&gt; at the top.&lt;/li&gt;
&lt;li&gt;Enter &lt;code&gt;fastlane-supply&lt;/code&gt; as the Service Account name and click  &lt;strong&gt;DONE&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy the generated Service Account Email address&lt;/strong&gt; (looks like &lt;code&gt;fastlane-supply@your-project-id.iam.gserviceaccount.com&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Generate and download the JSON key:&lt;/strong&gt;
&lt;/h4&gt;

&lt;ol&gt;
&lt;li&gt;Click on the newly created service account email in the list.&lt;/li&gt;
&lt;li&gt;Click the &lt;strong&gt;Keys&lt;/strong&gt; tab at the top.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;ADD KEY&lt;/strong&gt; &amp;gt; &lt;strong&gt;Create new key&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;JSON&lt;/strong&gt; as the key type and click  &lt;strong&gt;CREATE&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Save the downloaded &lt;code&gt;.json&lt;/code&gt; file into your project's &lt;code&gt;android/&lt;/code&gt; directory as &lt;code&gt;pc-key.json&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Grant access via Users and permissions:&lt;/strong&gt;
&lt;/h4&gt;

&lt;ol&gt;
&lt;li&gt;Return to your Google Play Console tab.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Users and permissions&lt;/strong&gt; from the left sidebar (the 3rd item from the top in your screenshot).&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Invite new users&lt;/strong&gt; at the top right.&lt;/li&gt;
&lt;li&gt;Paste the Service Account Email you copied above into the email address field.&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Account permissions&lt;/strong&gt; , grant permissions for &lt;strong&gt;Release to testing tracks&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Invite user&lt;/strong&gt; at the bottom right.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Verify the connection in Fastlane:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;bundle exec fastlane run validate_play_store_json_key json_key:pc-key.json
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbt27fe8k5699cr2e6g57.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbt27fe8k5699cr2e6g57.png" width="800" height="233"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now base64-encode it into &lt;code&gt;.env.deploy&lt;/code&gt; (this is what the lane actually uses, and the same value goes into GitHub Secrets later — one encoding, two homes):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;base64 -i android/pc-key.json | tr -d '\n' | pbcopy
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;paste &lt;/span&gt;into .env.deploy as: &lt;span class="nv"&gt;PLAY_STORE_JSON_KEY_BASE64&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;pasted&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ios/fastlane/Appfile&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;app_identifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"com.example.app"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# must match your bundle ID in Xcode&lt;/span&gt;
&lt;span class="n"&gt;apple_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"you@example.com"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# your Apple Developer account email&lt;/span&gt;
&lt;span class="n"&gt;itc_team_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"1234567"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# App Store Connect team ID&lt;/span&gt;
&lt;span class="n"&gt;team_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"ABCDE12345"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# Developer Portal team ID&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you don’t know the team IDs, run &lt;code&gt;bundle exec fastlane spaceship&lt;/code&gt; or check the URLs in the App Store Connect / Developer portal — they're in there. These aren't secret, but people often redact them out of habit; your call.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7 — iOS auth: use an App Store Connect API key, not your password
&lt;/h3&gt;

&lt;p&gt;You’ll see guides that tell you to set &lt;code&gt;FASTLANE_USER&lt;/code&gt; and &lt;code&gt;FASTLANE_PASSWORD&lt;/code&gt;. Don't. With an Apple ID that has 2FA (all of them now do), that approach means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;constant 2FA prompts you can’t answer from a CI runner&lt;/li&gt;
&lt;li&gt;sessions that expire every few days&lt;/li&gt;
&lt;li&gt;the occasional locked account when Apple decides the login looks robotic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An App Store Connect API key sidesteps all of it — no 2FA, no expiry, and it’s scoped to just what it needs.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;App Store Connect&lt;/strong&gt; → &lt;strong&gt;Users and Access&lt;/strong&gt; → &lt;strong&gt;Integrations&lt;/strong&gt; → &lt;strong&gt;App Store Connect API&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Create a key with the App Manager role.&lt;/li&gt;
&lt;li&gt;Download the &lt;code&gt;AuthKey_XXXXXXXXXX.p8&lt;/code&gt; — you can only download it once.&lt;/li&gt;
&lt;li&gt;Note the Key ID and the Issuer ID shown on that page.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Encode the key and the two IDs into &lt;code&gt;.env.deploy&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;base64 -i AuthKey_XXXXXXXXXX.p8 | tr -d '\n' | pbcopy

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;.env.deploy
&lt;span class="go"&gt;APP_STORE_CONNECT_API_KEY_ID=XXXXXXXXXX
APP_STORE_CONNECT_API_ISSUER_ID=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
&lt;/span&gt;&lt;span class="gp"&gt;APP_STORE_CONNECT_API_KEY_BASE64=&amp;lt;pasted&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Delete the downloaded &lt;code&gt;.p8&lt;/code&gt; once it's encoded — &lt;code&gt;.env.deploy&lt;/code&gt; is now the only copy you keep, and it's gitignored. Apple only lets you download the &lt;code&gt;.p8&lt;/code&gt; once. If you lose it, revoke that key in App Store Connect and issue a new one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7B — the &lt;code&gt;.env.deploy&lt;/code&gt; file
&lt;/h3&gt;

&lt;p&gt;All deploy secrets live in &lt;code&gt;foody-app/.env.deploy&lt;/code&gt;, which both Fastfiles load via &lt;code&gt;dotenv&lt;/code&gt; at the top. It is gitignored; a committed &lt;code&gt;.env.deploy.example&lt;/code&gt; documents every key. Your &lt;code&gt;.gitignore&lt;/code&gt; should already have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;.env&lt;/span&gt;
&lt;span class="err"&gt;.env.*&lt;/span&gt;
&lt;span class="c"&gt;!.env.example
!.env.deploy.example
&lt;/span&gt;&lt;span class="err"&gt;android/pc-key.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.env.deploy.example&lt;/code&gt; also lists &lt;code&gt;BUILD_CERTIFICATE_BASE64&lt;/code&gt;, &lt;code&gt;P12_PASSWORD&lt;/code&gt;, &lt;code&gt;BUILD_PROVISION_PROFILE_BASE64&lt;/code&gt;, &lt;code&gt;KEYCHAIN_PASSWORD&lt;/code&gt;, &lt;code&gt;APPLE_TEAM_ID&lt;/code&gt; — those are &lt;strong&gt;only for CI signing&lt;/strong&gt; (Part 2). Locally, Xcode's managed signing handles it and you can leave them blank.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 8 — Write the deploy lanes
&lt;/h3&gt;

&lt;p&gt;Replace the generated &lt;code&gt;Fastfile&lt;/code&gt; in each folder with these.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fastlane init&lt;/code&gt; generates a placeholder &lt;code&gt;Fastfile&lt;/code&gt;. Replace each with the version below. They share the same shape: load &lt;code&gt;.env.deploy&lt;/code&gt;, &lt;strong&gt;check the store first&lt;/strong&gt; , build with &lt;code&gt;fvm flutter&lt;/code&gt;, upload.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;android/fastlane/Fastfile&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;default_platform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:android&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"dotenv"&lt;/span&gt;
&lt;span class="n"&gt;deploy_env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expand_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"../../.env.deploy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;__dir__&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kp"&gt;__FILE__&lt;/span&gt; &lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="no"&gt;Dotenv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deploy_env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exist?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deploy_env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="ss"&gt;:android&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;desc&lt;/span&gt; &lt;span class="s2"&gt;"Automated build &amp;amp; deploy to Play Store Internal Track"&lt;/span&gt;
  &lt;span class="n"&gt;lane&lt;/span&gt; &lt;span class="ss"&gt;:deploy&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;json_key_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;play_json_key_data&lt;/span&gt;
    &lt;span class="n"&gt;guard_version_code_is_new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json_key_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="no"&gt;UI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"🚀 Building Android App Bundle"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="no"&gt;Bundler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_unbundled_env&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;sh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"cd ../.. &amp;amp;&amp;amp; fvm flutter build appbundle --release"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="n"&gt;upload_build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;json_key_data: &lt;/span&gt;&lt;span class="n"&gt;json_key_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;desc&lt;/span&gt; &lt;span class="s2"&gt;"Upload the already-built AAB to Play Console (skips Flutter build)"&lt;/span&gt;
  &lt;span class="n"&gt;lane&lt;/span&gt; &lt;span class="ss"&gt;:upload&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;json_key_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;play_json_key_data&lt;/span&gt;
    &lt;span class="n"&gt;guard_version_code_is_new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json_key_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;upload_build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;json_key_data: &lt;/span&gt;&lt;span class="n"&gt;json_key_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;private_lane&lt;/span&gt; &lt;span class="ss"&gt;:upload_build&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="n"&gt;app_root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expand_path&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="n"&gt;__dir__&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kp"&gt;__FILE__&lt;/span&gt; &lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;aab_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app_root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"build/app/outputs/bundle/release/app-release.aab"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="no"&gt;UI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user_error!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"No .aab found at &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;aab_path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; — run `fastlane android deploy` or `flutter build appbundle` first"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exist?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aab_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;upload_to_play_store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;json_key_data: &lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:json_key_data&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="ss"&gt;track: &lt;/span&gt;&lt;span class="s2"&gt;"internal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;aab: &lt;/span&gt;&lt;span class="n"&gt;aab_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;skip_upload_metadata: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;skip_upload_images: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;skip_upload_screenshots: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;play_json_key_data&lt;/span&gt;
  &lt;span class="n"&gt;b64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"PLAY_STORE_JSON_KEY_BASE64"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;
  &lt;span class="no"&gt;UI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user_error!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"PLAY_STORE_JSON_KEY_BASE64 missing from foody-app/.env.deploy"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;empty?&lt;/span&gt;
  &lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"base64"&lt;/span&gt;
  &lt;span class="no"&gt;Base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pubspec_version&lt;/span&gt;
  &lt;span class="n"&gt;pubspec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expand_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"../../pubspec.yaml"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;__dir__&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kp"&gt;__FILE__&lt;/span&gt; &lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pubspec&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^version:\s*(\d+\.\d+\.\d+)\+(\d+)/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="no"&gt;UI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user_error!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Could not read version from pubspec.yaml"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;m&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="n"&gt;m&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="nf"&gt;to_i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;guard_version_code_is_new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json_key_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pubspec_version&lt;/span&gt;
  &lt;span class="n"&gt;package&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;CredentialsManager&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;AppfileConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;try_fetch_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:package_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sx"&gt;%w[production internal]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flat_map&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;track&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="k"&gt;begin&lt;/span&gt;
      &lt;span class="n"&gt;google_play_track_version_codes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;package_name: &lt;/span&gt;&lt;span class="n"&gt;package&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;track: &lt;/span&gt;&lt;span class="n"&gt;track&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;json_key_data: &lt;/span&gt;&lt;span class="n"&gt;json_key_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
      &lt;span class="no"&gt;UI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;important&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Could not read Play '&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;track&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;' track (&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message&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="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:to_i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;max_live&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;+&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="nf"&gt;max&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;max_live&lt;/span&gt;
    &lt;span class="no"&gt;UI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user_error!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"pubspec versionCode &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is not ahead of Play's &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;max_live&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; — run `cider bump build` first."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="no"&gt;UI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"pubspec versionCode &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is ahead of Play (&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;max_live&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="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ios/fastlane/Fastfile&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;default_platform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:ios&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"dotenv"&lt;/span&gt;
&lt;span class="n"&gt;deploy_env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expand_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"../../.env.deploy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;__dir__&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kp"&gt;__FILE__&lt;/span&gt; &lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="no"&gt;Dotenv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deploy_env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exist?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deploy_env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="ss"&gt;:ios&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;desc&lt;/span&gt; &lt;span class="s2"&gt;"Build IPA and upload to TestFlight"&lt;/span&gt;
  &lt;span class="n"&gt;lane&lt;/span&gt; &lt;span class="ss"&gt;:deploy&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app_store_api_key&lt;/span&gt;
    &lt;span class="n"&gt;guard_build_number_is_new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="no"&gt;Bundler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_unbundled_env&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;sh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"cd .. &amp;amp;&amp;amp; fvm flutter build ipa --release"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="n"&gt;upload_build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;api_key: &lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;desc&lt;/span&gt; &lt;span class="s2"&gt;"Upload the already-built IPA to TestFlight (skips the Flutter build)"&lt;/span&gt;
  &lt;span class="n"&gt;lane&lt;/span&gt; &lt;span class="ss"&gt;:upload&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app_store_api_key&lt;/span&gt;
    &lt;span class="n"&gt;guard_build_number_is_new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;upload_build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;api_key: &lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;private_lane&lt;/span&gt; &lt;span class="ss"&gt;:upload_build&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="n"&gt;app_root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expand_path&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="n"&gt;__dir__&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kp"&gt;__FILE__&lt;/span&gt; &lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;ipa_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Dir&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app_root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"build/ios/ipa/*.ipa"&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;max_by&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="no"&gt;UI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user_error!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"No .ipa found under &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;app_root&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/build/ios/ipa — run `fastlane ios deploy` or `flutter build ipa` first"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;ipa_path&lt;/span&gt;

    &lt;span class="n"&gt;upload_to_testflight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;api_key: &lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:api_key&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="ss"&gt;ipa: &lt;/span&gt;&lt;span class="n"&gt;ipa_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;skip_waiting_for_build_processing: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;skip_submission: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;app_store_api_key&lt;/span&gt;
  &lt;span class="n"&gt;key_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"APP_STORE_CONNECT_API_KEY_ID"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;
  &lt;span class="n"&gt;issuer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"APP_STORE_CONNECT_API_ISSUER_ID"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;
  &lt;span class="n"&gt;key_b64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"APP_STORE_CONNECT_API_KEY_BASE64"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;

  &lt;span class="no"&gt;UI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user_error!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"APP_STORE_CONNECT_API_KEY_ID missing from foody-app/.env.deploy"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;empty?&lt;/span&gt;
  &lt;span class="no"&gt;UI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user_error!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"APP_STORE_CONNECT_API_ISSUER_ID missing from foody-app/.env.deploy"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;issuer_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;empty?&lt;/span&gt;
  &lt;span class="no"&gt;UI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user_error!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"APP_STORE_CONNECT_API_KEY_BASE64 missing from foody-app/.env.deploy"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key_b64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;empty?&lt;/span&gt;

  &lt;span class="n"&gt;app_store_connect_api_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="ss"&gt;key_id: &lt;/span&gt;&lt;span class="n"&gt;key_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;issuer_id: &lt;/span&gt;&lt;span class="n"&gt;issuer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;key_content: &lt;/span&gt;&lt;span class="n"&gt;key_b64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;is_key_content_base64: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;in_house: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pubspec_version&lt;/span&gt;
  &lt;span class="n"&gt;pubspec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expand_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"../../pubspec.yaml"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;__dir__&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kp"&gt;__FILE__&lt;/span&gt; &lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pubspec&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^version:\s*(\d+\.\d+\.\d+)\+(\d+)/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="no"&gt;UI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user_error!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Could not read version from pubspec.yaml"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;m&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="n"&gt;m&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="nf"&gt;to_i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;# Fail before the ~10-minute build if pubspec's +N was not bumped past what&lt;/span&gt;
&lt;span class="c1"&gt;# TestFlight already has for this marketing version.&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;guard_build_number_is_new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pubspec_version&lt;/span&gt;
  &lt;span class="k"&gt;begin&lt;/span&gt;
    &lt;span class="n"&gt;live&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;latest_testflight_build_number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;api_key: &lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;app_identifier: &lt;/span&gt;&lt;span class="no"&gt;CredentialsManager&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;AppfileConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;try_fetch_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:app_identifier&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="ss"&gt;version: &lt;/span&gt;&lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;initial_build_number: &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_i&lt;/span&gt;
  &lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
    &lt;span class="no"&gt;UI&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="s2"&gt;"Could not check TestFlight build numbers (&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;) — skipping the guard; the store will still reject a reused build."&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;end&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;live&lt;/span&gt;
    &lt;span class="no"&gt;UI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user_error!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"pubspec build &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;+&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is not ahead of TestFlight's &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;live&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; — run `cider bump build` first."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="no"&gt;UI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"pubspec &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;+&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is ahead of TestFlight (&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;live&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="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.env.deploy&lt;/code&gt; at the top &lt;strong&gt;.&lt;/strong&gt; Both files load it with &lt;code&gt;dotenv&lt;/code&gt; before anything runs. On CI the same variables come from the environment, so the Fastfile doesn't change.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;guard_*&lt;/code&gt; check runs first, and hits the store API, not a build. &lt;code&gt;flutter build ipa&lt;/code&gt; takes ~10 minutes. If you forgot to bump the version, the guard catches it in a couple of seconds — iOS asks TestFlight for the latest build number of the current marketing version; Android asks Play for the highest &lt;code&gt;versionCode&lt;/code&gt; across the &lt;code&gt;production&lt;/code&gt; and &lt;code&gt;internal&lt;/code&gt; tracks. If your &lt;code&gt;pubspec.yaml&lt;/code&gt; isn't ahead, it stops with &lt;em&gt;"run&lt;/em&gt; &lt;em&gt;&lt;code&gt;cider bump build&lt;/code&gt; first."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app_store_api_key&lt;/code&gt; / &lt;code&gt;play_json_key_data&lt;/code&gt; decode the base64 secret in memory. Nothing is ever written to disk.&lt;/li&gt;
&lt;li&gt;Two lanes. &lt;code&gt;deploy&lt;/code&gt; = build + upload. &lt;code&gt;upload&lt;/code&gt; = upload the artifact that's already in &lt;code&gt;build/&lt;/code&gt;, skipping the build — for when the upload failed and you don't want to wait 10 minutes again.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Bundler.with_unbundled_env { sh("… fvm flutter build …") }&lt;/code&gt; runs the Flutter build outside fastlane's Bundler context so Flutter/CocoaPods resolve against FVM cleanly.&lt;/li&gt;
&lt;li&gt;iOS picks the newest &lt;code&gt;build/ios/ipa/*.ipa&lt;/code&gt; by modified time rather than guessing the filename from the app's display name. It uploads with &lt;code&gt;skip_submission&lt;/code&gt; (goes to TestFlight, not the App Store review queue) and &lt;code&gt;skip_waiting_for_build_processing&lt;/code&gt; (don't block the terminal while Apple processes it).&lt;/li&gt;
&lt;li&gt;Android uploads to the &lt;code&gt;internal&lt;/code&gt; track and skips metadata/images/screenshots so the lane only touches the binary.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 9 — Bump the version and Changelog
&lt;/h3&gt;

&lt;p&gt;Play and TestFlight both reject a build number that already exists. pubspec.yaml’s version: line — X.Y.Z+N — is the single source of truth for both the iOS CFBundleVersion and the Android versionCode, and +N is the value both stores check for uniqueness:&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;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.0.0+1&lt;/span&gt; &lt;span class="c1"&gt;# &amp;lt;marketing version&amp;gt; + &amp;lt;build number&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This project manages the version with &lt;a href="https://pub.dev/packages/cider" rel="noopener noreferrer"&gt;cider&lt;/a&gt;, added as a dev-dependency:&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;dev_dependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;cider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;^0.2.10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It’s a dependency, not a global tool, so run it through the SDK: &lt;code&gt;fvm dart run cider&lt;/code&gt; …&lt;/p&gt;

&lt;h4&gt;
  
  
  While you work: log changes
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;cider&lt;/code&gt; collects entries under an &lt;code&gt;## Unreleased&lt;/code&gt; heading in &lt;code&gt;CHANGELOG.md&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;fvm dart run cider log added "Dark mode toggle in settings."
fvm dart run cider log fixed "Crash when opening an expired invite link."
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;log&lt;/code&gt; subcommands: &lt;code&gt;added&lt;/code&gt;, &lt;code&gt;changed&lt;/code&gt;, &lt;code&gt;deprecated&lt;/code&gt;, &lt;code&gt;fixed&lt;/code&gt;, &lt;code&gt;removed&lt;/code&gt;, &lt;code&gt;security&lt;/code&gt;.)&lt;/p&gt;

&lt;h4&gt;
  
  
  At release time: bump once, then release
&lt;/h4&gt;

&lt;p&gt;Step 1 — bump the version. Run one of these, never both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;fvm dart run cider bump build #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;same version, next build: 1.0.0+1 -&amp;gt; 1.0.0+2
&lt;span class="gp"&gt;fvm dart run cider bump patch --bump-build #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;new marketing version: 1.0.0+1 -&amp;gt; 1.0.1+2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;minor and major work the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;cider bump minor --bump-build
cider bump major --bump-build
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always pass --bump-build on a marketing bump. A plain cider bump patch drops the +N build number, and Play Console rejects any upload whose versionCode isn’t higher than the last one shipped. To set a version explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;fvm dart run cider version 1.4.0+37.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 2 — stamp the changelog:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;fvm dart run cider release
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;cider release turns &lt;code&gt;##Unreleased&lt;/code&gt; into a dated heading. After cider bump build &lt;code&gt;(-&amp;gt;1.0.0+2)&lt;/code&gt; and the two log entries above, CHANGELOG.md becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## 1.0.0+2 - 2026-09-10&lt;/span&gt;
&lt;span class="gu"&gt;### Added&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Dark mode toggle in settings.

&lt;span class="gu"&gt;### Fixed&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Crash when opening an expired invite link.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commit just those two files and tag — the tag is what Part 2’s CI triggers on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;git add pubspec.yaml CHANGELOG.md
git commit -m "chore(release): v1.0.0+2"
git tag -a v1.0.0+2 -m "v1.0.0+2"
git push --follow-tags
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do this once per release, from a clean tree, then deploy both platforms from that commit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 10 — Ship it
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;from app&lt;span class="s1"&gt;'s root
&lt;/span&gt;&lt;span class="go"&gt;cd android &amp;amp;&amp;amp; bundle exec fastlane deploy &amp;amp;&amp;amp; cd ..
cd ios &amp;amp;&amp;amp; bundle exec fastlane deploy &amp;amp;&amp;amp; cd ..
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both lanes read &lt;code&gt;foody-app/.env.deploy&lt;/code&gt;. If a build fails at upload, rerun just &lt;code&gt;bundle exec fastlane &amp;lt;platform&amp;gt; upload&lt;/code&gt; — it skips the 10-minute rebuild.&lt;/p&gt;

&lt;h3&gt;
  
  
  Troubleshooting the first run
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;flutter&lt;/code&gt; or &lt;code&gt;fvm: command not found&lt;/code&gt; inside a lane: Ensure FVM is added to your shell's PATH where you launch Fastlane.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;invalid curve name&lt;/code&gt; / &lt;code&gt;.p8&lt;/code&gt; key errors: Your Base64-encoded key string contains hidden line breaks. Re-encode it cleanly:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;base64 -i key.p8 | tr -d '\n'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;versionCode N is not ahead of Play's M&lt;/code&gt;: You skipped the version bump step. Run &lt;code&gt;fvm dart run cider bump patch --bump-build&lt;/code&gt;, commit, and retry.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;*_missing&lt;/code&gt; from &lt;code&gt;.env.deploy&lt;/code&gt; errors: Fastlane cannot locate your environment file. Ensure &lt;code&gt;.env.deploy&lt;/code&gt; sits directly at your app's root directory (&lt;code&gt;foody-app/.env.deploy&lt;/code&gt;), not inside &lt;code&gt;ios/&lt;/code&gt; or &lt;code&gt;android/&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What’s next — Part 2
&lt;/h3&gt;

&lt;p&gt;Part 2 moves this exact workflow to GitHub Actions. Because every secret is already a base64 string in &lt;code&gt;.env.deploy&lt;/code&gt;, CI is mostly “same variables, sourced from GitHub Secrets instead of the file.” The additions are: the iOS signing vars from &lt;code&gt;.env.deploy.example&lt;/code&gt; (&lt;code&gt;BUILD_CERTIFICATE_BASE64&lt;/code&gt;, &lt;code&gt;P12_PASSWORD&lt;/code&gt;, &lt;code&gt;BUILD_PROVISION_PROFILE_BASE64&lt;/code&gt;, &lt;code&gt;KEYCHAIN_PASSWORD&lt;/code&gt;), a step that builds a throwaway keychain on the runner, and a workflow triggered by the &lt;code&gt;v*&lt;/code&gt; tag you push in Step 9. The Fastfiles don't change at all.&lt;/p&gt;

&lt;p&gt;Ten minutes of tedious web console clicking are now permanently replaced by a single command. Your local release pipeline is fully automated, version-guarded, and bulletproof.&lt;/p&gt;

&lt;p&gt;Up next in &lt;strong&gt;Part 2&lt;/strong&gt; , we’ll take these exact lanes, move them to the cloud, and wire them up to GitHub Actions so a simple tag push handles the rest. Let’s ship code faster! 🚀&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>fastlane</category>
      <category>ios</category>
      <category>android</category>
    </item>
    <item>
      <title>Use Multiple GitHub Accounts on One Machine (SSH)</title>
      <dc:creator>Munir Ahmad</dc:creator>
      <pubDate>Sun, 14 Dec 2025 15:01:47 +0000</pubDate>
      <link>https://dev.to/munirahmad9959/how-to-manage-multiple-github-accounts-on-one-machine-using-ssh-keys-1kio</link>
      <guid>https://dev.to/munirahmad9959/how-to-manage-multiple-github-accounts-on-one-machine-using-ssh-keys-1kio</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F460upyqtorhqq7y9ansb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F460upyqtorhqq7y9ansb.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A step-by-step guide to seamlessly manage multiple GitHub accounts with SSH keys.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Managing Multiple GitHub Accounts on One Machine Using SSH
&lt;/h3&gt;

&lt;p&gt;If you use both personal and work GitHub accounts on the same machine, authentication issues are almost inevitable. Commits go to the wrong account, pushes fail, or Git keeps asking for credentials.&lt;/p&gt;

&lt;p&gt;The clean solution is simple: &lt;strong&gt;one SSH key per account, configured once, and reused forever&lt;/strong&gt;. This guide shows how to do exactly that.&lt;/p&gt;
&lt;h3&gt;
  
  
  What You’ll Learn
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Create separate SSH keys for each GitHub account&lt;/li&gt;
&lt;li&gt;Configure SSH to automatically select the correct identity&lt;/li&gt;
&lt;li&gt;Clone, push, and commit without account conflicts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Applies to:&lt;/strong&gt; macOS and Linux (Windows users via WSL or Git Bash can follow the same steps)&lt;/p&gt;
&lt;h3&gt;
  
  
  Prerequisites
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Git installed&lt;/li&gt;
&lt;li&gt;Basic terminal knowledge&lt;/li&gt;
&lt;li&gt;Access to multiple GitHub accounts&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Step 1: Generate a Dedicated SSH Key
&lt;/h3&gt;

&lt;p&gt;Create a new key for your work account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ssh-keygen -t ed25519 -C "work@email.com" -f ~/.ssh/id_ed25519_work
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This avoids overwriting existing keys and clearly separates identities.&lt;br&gt;&lt;br&gt;
When prompted, adding a passphrase is recommended for security.&lt;/p&gt;

&lt;p&gt;Generated files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;id_ed25519_work — private key (keep secret)&lt;/li&gt;
&lt;li&gt;id_ed25519_work.pub — public key (add to GitHub)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Step 2: Add the Key to the SSH Agent
&lt;/h3&gt;

&lt;p&gt;Start the agent and load your key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519_work
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This allows Git to authenticate without repeatedly asking for a passphrase.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Add the Public Key to GitHub
&lt;/h3&gt;

&lt;p&gt;Copy your public key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cat ~/.ssh/id_ed25519_work.pub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy the entire output (starts with ssh-ed25519).&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Navigate to: &lt;a href="https://github.com/settings/keys" rel="noopener noreferrer"&gt;&lt;strong&gt;https://github.com/settings/keys&lt;/strong&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;New SSH key&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Add a title (e.g., “Work Laptop — 2024”)&lt;/li&gt;
&lt;li&gt;Paste your public key&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Add SSH key&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9g6r8mn6npzs3p23jowm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9g6r8mn6npzs3p23jowm.png" alt=" " width="800" height="293"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Highlighting where to add your SSH key in GitHub settings.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 4: Configure SSH Host Aliases
&lt;/h3&gt;

&lt;p&gt;Edit your SSH config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nano ~/.ssh/config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Here you can add multiple identities personal, work etc.
# Personal account
Host github.com
  HostName github.com
  User git
  IdentityFile ~/.ssh/id_ed25519
  IdentitiesOnly yes

# Work account
Host github-work
  HostName github.com
  User git
  IdentityFile ~/.ssh/id_ed25519_work
  IdentitiesOnly yes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells SSH exactly which key to use for each account.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Test the Setup
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ssh -T github-work
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If successful, GitHub will confirm authentication. Expected output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hi username! You've successfully authenticated, but GitHub does not provide shell access.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Using the Correct Account
&lt;/h3&gt;

&lt;h3&gt;
  
  
  Clone Repositories
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Work account
git clone git@github-work:company/repo.git

# Personal account
git clone git@github.com:username/repo.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Fix an Existing Repository
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;git remote set-url origin git@github-work:company/repo.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Set Commit Identity (Per Repository)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;git config user.name "Your Name"
git config user.email "work@company.com"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This ensures commits are attributed correctly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Final Thoughts
&lt;/h3&gt;

&lt;p&gt;Managing multiple GitHub accounts doesn’t require hacks or workarounds.&lt;br&gt;&lt;br&gt;
With &lt;strong&gt;one SSH key per account and a clear SSH configuration&lt;/strong&gt; , Git becomes invisible — and that’s exactly how tooling should behave.&lt;/p&gt;

&lt;p&gt;Configure it once. Forget about it forever.&lt;/p&gt;

</description>
      <category>gitconfiguration</category>
      <category>github</category>
      <category>sshkeys</category>
      <category>multipleaccounts</category>
    </item>
    <item>
      <title>Secure Swagger &amp; ReDoc in Django REST Framework with DRF Spectacular: Best Practices for API…</title>
      <dc:creator>Munir Ahmad</dc:creator>
      <pubDate>Sun, 07 Dec 2025 17:56:18 +0000</pubDate>
      <link>https://dev.to/munirahmad9959/secure-swagger-redoc-in-django-rest-framework-with-drf-spectacular-best-practices-for-api-332a</link>
      <guid>https://dev.to/munirahmad9959/secure-swagger-redoc-in-django-rest-framework-with-drf-spectacular-best-practices-for-api-332a</guid>
      <description>&lt;h3&gt;
  
  
  Secure Swagger &amp;amp; ReDoc in Django REST Framework with DRF Spectacular: Best Practices for API Security
&lt;/h3&gt;

&lt;p&gt;API documentation tools like &lt;strong&gt;Swagger UI&lt;/strong&gt; and &lt;strong&gt;ReDoc&lt;/strong&gt; make development easier, but they also expose &lt;strong&gt;every endpoint, schema, and request structure&lt;/strong&gt; to anyone who can access the URL.&lt;/p&gt;

&lt;p&gt;If you deploy your Django REST Framework project without protecting /api/docs/ or /api/schema/, you're unintentionally giving outsiders:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A complete map of all your APIs&lt;/li&gt;
&lt;li&gt;All request/response structures&lt;/li&gt;
&lt;li&gt;Authentication patterns&lt;/li&gt;
&lt;li&gt;Internal admin/debug endpoints&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes exploitation easier and increases your attack surface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That’s why Swagger must never be public in production.&lt;/strong&gt; In this guide, we’ll show a clean, secure way to protect your API documentation using &lt;strong&gt;DRF Spectacular&lt;/strong&gt; in a Django REST Framework project.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu7mty7b8cfjz281n48e2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu7mty7b8cfjz281n48e2.png" alt=" " width="720" height="480"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A secure architecture for safeguarding Swagger and ReDoc in DRF using DRF Spectacular.&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;# Directory structure
core/
    docs/
        swagger_view.py
    urls.py
    settings.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;core/docs/swagger_view.py&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;from django.shortcuts import redirect
from drf_spectacular.views import (
    SpectacularAPIView,
    SpectacularSwaggerView,
    SpectacularRedocView,
)

class AdminOnlyMixin:
    """
    Restrict access to authenticated superusers.
    """

    def dispatch(self, request, *args, **kwargs):
        if not request.user.is_authenticated:
            return redirect(f"/admin/login/?next={request.path}")
        if not request.user.is_superuser:
            return redirect("/403/") # Make sure a 403 page exists
        return super().dispatch(request, *args, **kwargs)

class SuperuserSchemaView(AdminOnlyMixin, SpectacularAPIView):
    pass

class SuperuserSwaggerView(AdminOnlyMixin, SpectacularSwaggerView):
    pass

class SuperuserRedocView(AdminOnlyMixin, SpectacularRedocView):
    pass
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;core/urls.y&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;from django.urls import path
from core.docs import (
    SuperuserRedocView,
    SuperuserSchemaView,
    SuperuserSwaggerView,
)

urlpatterns = [
    # API documentation (protected)
    path("api/schema/", SuperuserSchemaView.as_view(), name="schema"),
    path("api/docs/", SuperuserSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
    path("api/redoc/", SuperuserRedocView.as_view(url_name="schema"), name="redoc"),
]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;core/settings.py&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": (
        # JWT for external API clients
        "rest_framework_simplejwt.authentication.JWTAuthentication",

        # Session auth so Swagger works automatically after admin login
        "rest_framework.authentication.SessionAuthentication",
    ),
    "DEFAULT_PERMISSION_CLASSES": (
        "rest_framework.permissions.IsAuthenticated",
    ),
    "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}

SPECTACULAR_SETTINGS = {
    "TITLE": "Your API",
    "DESCRIPTION": "API documentation",
    "VERSION": "1.0.0",

    # Allow our custom view to control permissions manually
    "SERVE_PERMISSIONS": ["rest_framework.permissions.IsAdminUser"],

    # Use DRF’s normal authentication classes (Session + JWT)
    "SERVE_AUTHENTICATION": None,

    # Important: Do NOT expose schema automatically in unrestricted mode
    "SERVE_INCLUDE_SCHEMA": False,
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SessionAuthentication allows admin users to access protected docs easily.&lt;/li&gt;
&lt;li&gt;SERVE_INCLUDE_SCHEMA = False ensures your schema isn’t automatically exposed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And that’s the real value of securing your API documentation: you empower your team without empowering your attackers. When Swagger and ReDoc live behind proper authentication, your developers gain clarity, your system gains integrity, and your attack surface quietly shrinks. Secure the docs, protect the blueprint, and let your API evolve with confidence — exactly the way production-grade engineering should be. Appreciate your time. Until next time 👋.&lt;/p&gt;

</description>
      <category>django</category>
      <category>djangorestframework</category>
      <category>drfspectacular</category>
      <category>restapi</category>
    </item>
    <item>
      <title>Install the Latest PostgreSQL (psql 18) on Ubuntu 24.04 (Noble)</title>
      <dc:creator>Munir Ahmad</dc:creator>
      <pubDate>Mon, 03 Nov 2025 17:40:16 +0000</pubDate>
      <link>https://dev.to/munirahmad9959/install-the-latest-postgresql-psql-18-on-ubuntu-2404-noble-3fdp</link>
      <guid>https://dev.to/munirahmad9959/install-the-latest-postgresql-psql-18-on-ubuntu-2404-noble-3fdp</guid>
      <description>&lt;p&gt;&lt;strong&gt;Why I Wrote This&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;While setting up my development environment on &lt;strong&gt;Ubuntu 24.04 (Noble Numbat)&lt;/strong&gt;, I noticed the default PostgreSQL version in Ubuntu’s repository was outdated.&lt;br&gt;&lt;br&gt;
So I decided to document the clean, official way to install the latest &lt;strong&gt;PostgreSQL (v18) &lt;/strong&gt;— straight from the PostgreSQL Apt repository.&lt;/p&gt;

&lt;p&gt;If you’ve ever run into issues like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;“No cluster suitable as a default target”&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;or old versions like PostgreSQL 14 or 15 being installed instead of the latest then this guide will help you start fresh.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  &lt;strong&gt;Step-by-Step Guide to Installing PostgreSQL 18&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4u6tijhx6istsf2xo48k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4u6tijhx6istsf2xo48k.png" alt=" " width="720" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Step 1 — Update and Upgrade Your System&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt upgrade -y
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 2 — Check Your Ubuntu &lt;strong&gt;Codename:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;PostgreSQL’s official repo depends on your Ubuntu version codename.&lt;br&gt;&lt;br&gt;
 Run the following to check yours:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;. /etc/os-release
echo $VERSION_CODENAME
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Ubuntu 24.04, the output should be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;noble
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 3 — Install Required Packages&lt;/p&gt;

&lt;p&gt;We’ll install curl, ca-certificates, and postgresql-common:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo apt install -y curl ca-certificates postgresql-common
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 4 — Add the PostgreSQL Apt Repository&lt;/p&gt;

&lt;p&gt;Run the following command to automatically add the official PostgreSQL repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 5 — Update Package Lists &amp;amp; Install PostgreSQL 18&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo apt update
sudo apt install -y postgresql-18
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When prompted during installation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Type &lt;strong&gt;Y&lt;/strong&gt; to confirm.&lt;/li&gt;
&lt;li&gt;If you see a purple configuration screen like this 👇 choose
&lt;strong&gt;“install the package maintainer’s version”&lt;/strong&gt;
(This ensures a clean, default setup for PostgreSQL 18.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhkga71btjf6gqnxq0dpt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhkga71btjf6gqnxq0dpt.png" alt=" " width="720" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Step 7 — Verify Installation&lt;/p&gt;

&lt;p&gt;Check the installed version you should see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;psql --version
psql (PostgreSQL) 18.x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;That’s it! You’ve successfully installed and verified PostgreSQL 18 on Ubuntu 24.04 (Noble).&lt;br&gt;&lt;br&gt;
Your system is now running the latest, officially supported PostgreSQL version, ready for use in production or local development.If you encounter any issues, check the PostgreSQL service logs with:&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo journalctl -u postgresql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Otherwise — you’re all set to start building with PostgreSQL 18.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
    </item>
  </channel>
</rss>
