<?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: Puneetpal Arneja</title>
    <description>The latest articles on DEV Community by Puneetpal Arneja (@puneetpalarneja).</description>
    <link>https://dev.to/puneetpalarneja</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%2F4203501%2Fb844e6ab-e15b-46a2-adce-4d4fa36656b6.png</url>
      <title>DEV Community: Puneetpal Arneja</title>
      <link>https://dev.to/puneetpalarneja</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/puneetpalarneja"/>
    <language>en</language>
    <item>
      <title>Mobile App Security Checklist 2026 (Banking &amp; Fintech)</title>
      <dc:creator>Puneetpal Arneja</dc:creator>
      <pubDate>Mon, 09 Nov 2026 15:10:18 +0000</pubDate>
      <link>https://dev.to/puneetpalarneja/mobile-app-security-checklist-2026-banking-fintech-23h6</link>
      <guid>https://dev.to/puneetpalarneja/mobile-app-security-checklist-2026-banking-fintech-23h6</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; a banking app security checklist should follow the OWASP MASVS v2 control groups: STORAGE, CRYPTO, AUTH, NETWORK, PLATFORM, CODE, RESILIENCE and PRIVACY. Keep secrets in the Keychain or Android Keystore, let the server make every authorization decision, enforce TLS, treat jailbreak and root detection as defense in depth, and verify it all with the OWASP MASTG.&lt;/p&gt;

&lt;p&gt;In a banking app a leak is both a regulatory event and a direct financial loss. This checklist covers the client-side controls I check when building or auditing fintech and banking apps, from authentication to incident response, and maps each one to the &lt;a href="https://mas.owasp.org/MASVS/" rel="noopener noreferrer"&gt;OWASP Mobile Application Security Verification Standard (MASVS)&lt;/a&gt; so it lines up with the standard security reviewers already use.&lt;/p&gt;

&lt;p&gt;The server half (token lifecycle, policy enforcement, rate limiting and abuse control) is in &lt;a href="https://puneetpal.dev/insights/laravel-api-security-checklist-2026" rel="noopener noreferrer"&gt;the Laravel API security checklist&lt;/a&gt;. The two are meant to be read together: a hardened app in front of an API that trusts the client is still an open attack path. For how these controls fit a regulated release process, see &lt;a href="https://puneetpal.dev/services/mobile-architecture-audits" rel="noopener noreferrer"&gt;mobile architecture audits&lt;/a&gt;, and for having them built into a new banking app from the start, see &lt;a href="https://puneetpal.dev/services/fintech-app-development" rel="noopener noreferrer"&gt;fintech and banking app development&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does OWASP MASVS map to a banking app?
&lt;/h2&gt;

&lt;p&gt;MASVS v2 dropped the old L1/L2/R verification levels from the standard itself and organizes requirements into eight control groups. The levels live on as MAS testing profiles, which decide how strictly each control is tested; a banking app is what the stricter profiles exist for. The table maps each group to what it means for a banking app, the concrete platform check, and the section that covers it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;MASVS group&lt;/th&gt;
&lt;th&gt;What it means for a banking app&lt;/th&gt;
&lt;th&gt;Concrete iOS / Android check&lt;/th&gt;
&lt;th&gt;Section&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MASVS-STORAGE&lt;/td&gt;
&lt;td&gt;Tokens, account data and statements never land in plaintext files, backups, caches or the clipboard.&lt;/td&gt;
&lt;td&gt;iOS: Keychain with a ThisDeviceOnly accessibility class; files under NSFileProtectionComplete. Android: Keystore-wrapped keys; allowBackup off or dataExtractionRules excluding sensitive files.&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MASVS-CRYPTO&lt;/td&gt;
&lt;td&gt;Platform crypto with modern algorithms, and keys that are never hardcoded in the binary.&lt;/td&gt;
&lt;td&gt;iOS: CryptoKit AES-GCM, keys in the Secure Enclave or Keychain. Android: Keystore keys, StrongBox where the device has it.&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MASVS-AUTH&lt;/td&gt;
&lt;td&gt;The server decides who the user is; biometrics unlock a credential instead of returning a yes/no.&lt;/td&gt;
&lt;td&gt;iOS: Keychain item with SecAccessControl (.biometryCurrentSet). Android: Keystore key with setUserAuthenticationRequired, used through BiometricPrompt with a CryptoObject.&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MASVS-NETWORK&lt;/td&gt;
&lt;td&gt;TLS everywhere, no cleartext exceptions, and a deliberate decision on certificate pinning.&lt;/td&gt;
&lt;td&gt;iOS: App Transport Security with no NSAllowsArbitraryLoads. Android: Network Security Config with cleartextTrafficPermitted="false", optional pin-set with an expiration.&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MASVS-PLATFORM&lt;/td&gt;
&lt;td&gt;Balances and account numbers do not leak through the app switcher, screenshots, WebViews or IPC.&lt;/td&gt;
&lt;td&gt;iOS: cover the window when the scene resigns active; restrict WKWebView JavaScript. Android: FLAG_SECURE on sensitive activities; exported="false" on components that do not need it.&lt;/td&gt;
&lt;td&gt;1, 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MASVS-CODE&lt;/td&gt;
&lt;td&gt;Supported OS versions are enforced, dependencies are current, and all input from outside the app is validated.&lt;/td&gt;
&lt;td&gt;Both: minimum OS policy, forced-update path, SDK inventory, deep link and push payload validation.&lt;/td&gt;
&lt;td&gt;5, 7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MASVS-RESILIENCE&lt;/td&gt;
&lt;td&gt;Tampering, hooking and debugging are made expensive, as defense in depth rather than as a primary control.&lt;/td&gt;
&lt;td&gt;iOS: App Attest, jailbreak and debugger checks. Android: Play Integrity API, root checks, R8 shrinking and obfuscation.&lt;/td&gt;
&lt;td&gt;4, 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MASVS-PRIVACY&lt;/td&gt;
&lt;td&gt;Collect the minimum personal data, keep it out of logs and analytics, and disclose what third-party SDKs do.&lt;/td&gt;
&lt;td&gt;iOS: privacy manifest and os_log privacy redaction. Android: Play Data safety form, R8 rules stripping debug logs.&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  1. Authentication &amp;amp; Session Management
&lt;/h2&gt;

&lt;p&gt;MASVS-AUTH, MASVS-STORAGE, MASVS-PLATFORM&lt;/p&gt;

&lt;p&gt;Most account takeovers start with a stolen credential or a lifted token, so these controls limit how long any single secret is useful and where it can be used.&lt;/p&gt;

&lt;h3&gt;
  
  
  Token Lifecycle
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Short-lived access tokens (around 15 minutes) with refresh tokens in the 7 to 30 day range (with Sanctum that means a short token expiry plus a rotate endpoint, or Passport; see the &lt;a href="https://puneetpal.dev/insights/laravel-api-security-checklist-2026" rel="noopener noreferrer"&gt;Laravel checklist&lt;/a&gt;): short enough that a token captured from a proxy log is dead before anyone reads it, long enough that refresh happens between sessions rather than mid-transfer&lt;/li&gt;
&lt;li&gt;Rotate the refresh token on every use, and revoke the whole family if a rotated-out token is presented again; that is the signal a token was copied&lt;/li&gt;
&lt;li&gt;Invalidate tokens server-side on logout. Expiry alone leaves a stolen token valid until the clock runs out&lt;/li&gt;
&lt;li&gt;Keep access tokens in memory only, never in UserDefaults or SharedPreferences&lt;/li&gt;
&lt;li&gt;Store refresh tokens in the iOS Keychain with &lt;code&gt;kSecAttrAccessibleWhenUnlockedThisDeviceOnly&lt;/code&gt;, or encrypted with an Android Keystore key&lt;/li&gt;
&lt;li&gt;For high-value apps, bind the session to the device: a non-exportable Secure Enclave or Keystore key, registered with the server at enrolment, signs each request, so a copied token cannot be replayed elsewhere&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Biometric &amp;amp; MFA
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Treat Face ID, Touch ID and Android biometrics as a local convenience that unlocks a stored credential. The server authenticates the credential; a biometric result alone never grants access&lt;/li&gt;
&lt;li&gt;Make biometrics unlock a secret instead of returning a boolean: a Keychain item protected by &lt;code&gt;SecAccessControl&lt;/code&gt; with &lt;code&gt;.biometryCurrentSet&lt;/code&gt; on iOS, or a Keystore key with &lt;code&gt;setUserAuthenticationRequired(true)&lt;/code&gt; used through &lt;code&gt;BiometricPrompt&lt;/code&gt; with a &lt;code&gt;CryptoObject&lt;/code&gt; on Android. A hooked &lt;code&gt;evaluatePolicy&lt;/code&gt; callback then unlocks nothing, and &lt;code&gt;.biometryCurrentSet&lt;/code&gt; invalidates the item if a new fingerprint or face is enrolled&lt;/li&gt;
&lt;li&gt;Require a second factor for transfers above a threshold, password changes, and new payees&lt;/li&gt;
&lt;li&gt;Step-up within a session: re-authenticate before sensitive operations even if the session is fresh&lt;/li&gt;
&lt;li&gt;Prefer TOTP or push approval over SMS codes. SIM-swap fraud is the standard way SMS OTP is defeated, and it is cheap&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Passkeys Instead of Passwords
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Passkeys (FIDO2/WebAuthn) remove the password from sign-in, so there is nothing to phish, reuse or stuff. The private key never leaves the device or the user’s credential manager, and each passkey is bound to your domain, so a look-alike site cannot use it&lt;/li&gt;
&lt;li&gt;On iOS 16 and later use &lt;code&gt;ASAuthorizationPlatformPublicKeyCredentialProvider&lt;/code&gt; with an associated domain (&lt;code&gt;webcredentials:&lt;/code&gt;). On Android 9 and later use the Credential Manager API. Both talk to the same WebAuthn relying-party endpoints on your server&lt;/li&gt;
&lt;li&gt;Synced passkeys (iCloud Keychain, Google Password Manager) survive a lost phone, which matters for account recovery. NIST SP 800-63B-4 accepts synced passkeys at AAL2 when the sync service meets its requirements; for the highest assurance, keep a device-bound key such as the Secure Enclave or Keystore key used for session binding above&lt;/li&gt;
&lt;li&gt;Roll out alongside the existing login, not as a replacement on day one. Offer passkey creation after a successful sign-in, keep step-up checks for transfers and new payees, and make recovery at least as strong as sign-in, since a weak reset flow undoes the passkey&lt;/li&gt;
&lt;li&gt;The passkey, biometric, device-binding and recovery code for all of this is in &lt;a href="https://puneetpal.dev/insights/secure-authentication-fintech-apps-2026" rel="noopener noreferrer"&gt;secure authentication best practices for fintech apps&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Session Controls
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Auto-lock after a short inactivity window; 2 to 5 minutes is typical for banking apps. Make it configurable only within that range&lt;/li&gt;
&lt;li&gt;Limit concurrent sessions per user, show the user their active devices, and let them sign one out. On a Laravel backend, &lt;a href="https://github.com/puneetpals271/laravel-device-tokens" rel="noopener noreferrer"&gt;laravel-device-tokens&lt;/a&gt; gives you one Sanctum token per device with sign-out per device or everywhere&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Screenshot and App-Switcher Masking
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;iOS takes a snapshot of the screen for the app switcher when the app goes to the background. Cover the window with a privacy view in &lt;code&gt;sceneWillResignActive&lt;/code&gt; and remove it in &lt;code&gt;sceneDidBecomeActive&lt;/code&gt;, so balances and account numbers never reach that snapshot&lt;/li&gt;
&lt;li&gt;iOS has no public API to block screenshots. Detect screen recording and mirroring with &lt;code&gt;UIScreen.isCaptured&lt;/code&gt;, react to &lt;code&gt;userDidTakeScreenshotNotification&lt;/code&gt;, and hide sensitive values while capture is active&lt;/li&gt;
&lt;li&gt;On Android, set &lt;code&gt;WindowManager.LayoutParams.FLAG_SECURE&lt;/code&gt; on sensitive activities. It blocks screenshots, screen recording and the recents-screen preview in one step&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. API &amp;amp; Network Security
&lt;/h2&gt;

&lt;p&gt;MASVS-NETWORK&lt;/p&gt;

&lt;h3&gt;
  
  
  Transport Security
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;TLS 1.2 or later only, enforced at the server. ATS already refuses anything older on iOS, so the client side is mostly about not adding exceptions&lt;/li&gt;
&lt;li&gt;App Transport Security with no exceptions. &lt;code&gt;NSAllowsArbitraryLoads&lt;/code&gt; in a production Info.plist is a finding on its own&lt;/li&gt;
&lt;li&gt;On Android, ship a Network Security Config with &lt;code&gt;cleartextTrafficPermitted="false"&lt;/code&gt; and no user-added CAs trusted in release builds (scope &lt;code&gt;debug-overrides&lt;/code&gt; to debug only)&lt;/li&gt;
&lt;li&gt;Validate the full certificate chain. A &lt;code&gt;URLSessionDelegate&lt;/code&gt; or &lt;code&gt;TrustManager&lt;/code&gt; that accepts any certificate to make a staging environment work has a way of surviving into release&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Certificate Pinning: Trade-offs
&lt;/h3&gt;

&lt;p&gt;Pinning stops interception by a mis-issued or user-installed certificate. It does not stop an attacker who controls the device, and a badly managed pin locks every user out on the day a certificate renews.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pin the public key (SPKI hash) rather than the whole certificate, so renewing a certificate with the same key does not break the app&lt;/li&gt;
&lt;li&gt;Include at least one backup pin for a key that is not yet in use, so rotation does not force an app update&lt;/li&gt;
&lt;li&gt;iOS: use &lt;code&gt;NSPinnedDomains&lt;/code&gt; in the ATS configuration or validate in a &lt;code&gt;URLSessionDelegate&lt;/code&gt; trust challenge. Android: use a &lt;code&gt;&amp;lt;pin-set&amp;gt;&lt;/code&gt; with an &lt;code&gt;expiration&lt;/code&gt; date in Network Security Config, so an outdated build falls back to normal TLS validation rather than failing closed&lt;/li&gt;
&lt;li&gt;Have a remote way to update or relax pins, and a runbook for certificate rotation, before you ship pinning&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Request Integrity
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;TLS already protects requests in transit. Request signing earns its place as device binding: sign with the non-exportable Secure Enclave or Keystore key from section 1, so a bearer token is useless off the device it was issued to. An HMAC with a per-session secret delivered over the same TLS channel adds little&lt;/li&gt;
&lt;li&gt;With signing in place, include a timestamp and nonce in the signed payload and reject anything outside a few-minute window, so a captured request cannot be replayed&lt;/li&gt;
&lt;li&gt;Send tokens only in the &lt;code&gt;Authorization: Bearer&lt;/code&gt; header. Query strings end up in access logs, proxies, and crash reports&lt;/li&gt;
&lt;li&gt;Expect HTTP 429 with &lt;code&gt;Retry-After&lt;/code&gt; and honour it with backoff. A client that retries instantly turns a brief server incident into a longer one&lt;/li&gt;
&lt;li&gt;Error responses must not leak stack traces, SQL, or whether an account exists. Field-level validation messages are fine; “no account with that email” is not&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Data Storage &amp;amp; Encryption
&lt;/h2&gt;

&lt;p&gt;MASVS-STORAGE, MASVS-CRYPTO&lt;/p&gt;

&lt;h3&gt;
  
  
  What Goes Where on iOS
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keychain:&lt;/strong&gt; tokens, passwords, private keys, PINs. Use &lt;code&gt;kSecAttrAccessibleWhenUnlockedThisDeviceOnly&lt;/code&gt; so items are never migrated to another device through a backup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Core Data / SQLite:&lt;/strong&gt; financial records, encrypted with SQLCipher or protected with the Data Protection class &lt;code&gt;NSFileProtectionComplete&lt;/code&gt;. Data Protection only makes the file unreadable while the device is locked; data that must stay unreadable on a jailbroken, unlocked device needs application-level encryption too&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UserDefaults:&lt;/strong&gt; non-sensitive UI preferences only. It is a plist on disk and lands in backups&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cached images and PDFs:&lt;/strong&gt; disable or clear the &lt;code&gt;URLCache&lt;/code&gt; for sensitive responses and mark files with &lt;code&gt;NSFileProtectionComplete&lt;/code&gt;. Downloaded statements are the classic leak here&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups:&lt;/strong&gt; set &lt;code&gt;isExcludedFromBackup&lt;/code&gt; on files that should not leave the device&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clipboard:&lt;/strong&gt; copy sensitive values with &lt;code&gt;UIPasteboard&lt;/code&gt; options &lt;code&gt;.localOnly&lt;/code&gt; and an &lt;code&gt;.expirationDate&lt;/code&gt; (60 seconds is enough for an account number), so they do not sync through Universal Clipboard or linger&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What Goes Where on Android
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Android Keystore:&lt;/strong&gt; generate keys inside the Keystore so the key material is non-exportable; request StrongBox with &lt;code&gt;setIsStrongBoxBacked(true)&lt;/code&gt; where the device supports it and fall back when it does not&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App-private storage:&lt;/strong&gt; encrypt tokens and records with a Keystore-held key before writing them. Never plain SharedPreferences or external storage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups:&lt;/strong&gt; set &lt;code&gt;android:allowBackup="false"&lt;/code&gt;, or use &lt;code&gt;dataExtractionRules&lt;/code&gt; (Android 12+) to exclude sensitive files from cloud backup and device-to-device transfer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clipboard:&lt;/strong&gt; mark copied sensitive values with &lt;code&gt;ClipDescription.EXTRA_IS_SENSITIVE&lt;/code&gt; (Android 13+) so they are not shown in the clipboard preview&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Encryption Standards
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;AES-256-GCM for symmetric encryption, which gives confidentiality and integrity in one primitive (CryptoKit &lt;code&gt;AES.GCM&lt;/code&gt; on iOS, a Keystore-backed &lt;code&gt;Cipher&lt;/code&gt; on Android). Never reuse a nonce with the same key&lt;/li&gt;
&lt;li&gt;Use the Secure Enclave for private keys when available. It holds P-256 keys only, so wrap symmetric keys with an enclave key rather than storing them inside it&lt;/li&gt;
&lt;li&gt;Never hardcode keys. Generate them in the Keystore or Secure Enclave; derive from a user secret only when the data must survive without the server&lt;/li&gt;
&lt;li&gt;Zero sensitive buffers after use. Swift and Kotlin strings cannot be reliably zeroed and may be copied around memory, so keep secrets in mutable byte arrays you can wipe&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Device &amp;amp; Runtime Integrity
&lt;/h2&gt;

&lt;p&gt;MASVS-RESILIENCE&lt;/p&gt;

&lt;h3&gt;
  
  
  Jailbreak and Root Detection: Defense in Depth, Not a Control
&lt;/h3&gt;

&lt;p&gt;On a jailbroken or rooted device the sandbox is gone: Keychain items can be dumped, pinning bypassed with one Frida script, and any method hooked. Every detection check runs on that same device, so each one can be patched out. I treat detection as friction plus a risk signal for the server, never as the thing that keeps an account safe. Layered checks still raise the cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Look for jailbreak artifacts: package manager apps, the &lt;code&gt;/var/jb&lt;/code&gt; prefix used by rootless jailbreaks, and injected libraries listed by &lt;code&gt;_dyld_get_image_name&lt;/code&gt;. Hardcoded lists of 2015-era paths are the first thing attackers patch&lt;/li&gt;
&lt;li&gt;Attempt to write outside the app sandbox; it should fail on a stock device&lt;/li&gt;
&lt;li&gt;Check whether &lt;code&gt;fork()&lt;/code&gt; succeeds; it is denied to sandboxed apps on stock iOS&lt;/li&gt;
&lt;li&gt;On Android, check for &lt;code&gt;su&lt;/code&gt; binaries, &lt;code&gt;test-keys&lt;/code&gt; builds, Magisk and other root-management packages, and whether SELinux is enforcing&lt;/li&gt;
&lt;li&gt;Verify the app’s own code signature and bundle identifier at runtime; repackaged apps are a common distribution path for banking malware&lt;/li&gt;
&lt;li&gt;Combine several independent signals and send them to the server. A single boolean in the app is one patch away from always-false&lt;/li&gt;
&lt;li&gt;On detection, degrade rather than refuse: warn the user, disable transfers and payee changes, and log the event server-side with the device signals&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Runtime Attack Detection
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Debugger detection on iOS via &lt;code&gt;sysctl&lt;/code&gt; (the &lt;code&gt;P_TRACED&lt;/code&gt; flag) or &lt;code&gt;ptrace(PT_DENY_ATTACH)&lt;/code&gt;. &lt;code&gt;ptrace&lt;/code&gt; is not public API, so it is called through &lt;code&gt;dlsym&lt;/code&gt;, and both checks are trivially patched. A speed bump, not a gate&lt;/li&gt;
&lt;li&gt;For Objective-C code paths that matter, confirm at runtime that &lt;code&gt;method_getImplementation&lt;/code&gt; still points at your own code. Swift &lt;code&gt;final&lt;/code&gt; classes and structs are harder to swizzle in the first place&lt;/li&gt;
&lt;li&gt;Detect the simulator and emulators, and never ship production credentials or endpoints in builds that can run there&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;App Attest&lt;/code&gt; (DeviceCheck framework, iOS 14+). It creates a Secure Enclave key attested by Apple; your server verifies the attestation once and a per-request assertion after that. It is the strongest available signal that a request came from your unmodified app&lt;/li&gt;
&lt;li&gt;On Android, use the Play Integrity API and verify its verdict on your server, not in the app. Decide in advance what each verdict level (device, app, account) is allowed to do&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Code Hardening &amp;amp; Obfuscation
&lt;/h2&gt;

&lt;p&gt;MASVS-CODE, MASVS-RESILIENCE, MASVS-PLATFORM&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Xcode release builds on arm64 already produce position-independent executables with stack protection. Verify nothing in the build settings turned them off (&lt;code&gt;otool -hv&lt;/code&gt; shows the &lt;code&gt;PIE&lt;/code&gt; flag), and never ship Swift built with &lt;code&gt;-Ounchecked&lt;/code&gt;, which removes overflow and precondition checks&lt;/li&gt;
&lt;li&gt;Strip symbols from release builds (&lt;code&gt;Deployment Postprocessing&lt;/code&gt; and &lt;code&gt;Strip Linked Product&lt;/code&gt; set to YES) and upload the dSYM to your crash reporter, so stack traces are readable to you and not to whoever downloads the IPA&lt;/li&gt;
&lt;li&gt;Obfuscate sensitive strings and class names where the threat model justifies it: iXGuard on iOS, DexGuard on Android. R8 alone shrinks and renames; it is not meaningful obfuscation against a determined reverser&lt;/li&gt;
&lt;li&gt;Remove &lt;code&gt;NSLog&lt;/code&gt;, &lt;code&gt;print&lt;/code&gt;, and debug output from production builds. A &lt;code&gt;#if DEBUG&lt;/code&gt; wrapper is enough on iOS; section 6 has the Android R8 rule&lt;/li&gt;
&lt;li&gt;Restrict JavaScript in &lt;code&gt;WKWebView&lt;/code&gt; (&lt;code&gt;WKWebpagePreferences.allowsContentJavaScript = false&lt;/code&gt;) unless the page needs it, and never expose a native bridge (&lt;code&gt;WKScriptMessageHandler&lt;/code&gt;, &lt;code&gt;addJavascriptInterface&lt;/code&gt;) to content you do not control&lt;/li&gt;
&lt;li&gt;Treat deep links, universal and app links, and push payloads as untrusted input: validate every parameter and never let a deep link complete an action by itself. Set &lt;code&gt;android:exported="false"&lt;/code&gt; on components that do not need to be reachable&lt;/li&gt;
&lt;li&gt;Enforce a minimum supported OS version and keep a forced-update path, so a build with a known vulnerability can be retired without waiting for users&lt;/li&gt;
&lt;li&gt;Inventory third-party SDKs. Each one ships with your signing identity and permissions; review what it collects, how often it updates, and whether it has a privacy manifest&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. Logging: What NOT to Log
&lt;/h2&gt;

&lt;p&gt;MASVS-STORAGE, MASVS-PRIVACY&lt;/p&gt;

&lt;p&gt;Logs are the storage location nobody threat-models. On iOS, &lt;code&gt;NSLog&lt;/code&gt; and unredacted &lt;code&gt;os_log&lt;/code&gt; output goes to the unified log, readable from any Mac the phone is plugged into and included in sysdiagnose bundles. Server logs are often less protected than the database they describe.&lt;/p&gt;

&lt;h3&gt;
  
  
  Never Log
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Full account numbers, card numbers, CVV&lt;/li&gt;
&lt;li&gt;Auth tokens, refresh tokens, session IDs&lt;/li&gt;
&lt;li&gt;Passwords (even hashed)&lt;/li&gt;
&lt;li&gt;Full API request/response bodies&lt;/li&gt;
&lt;li&gt;PII: SSN, DOB, full address&lt;/li&gt;
&lt;li&gt;Biometric data or persistent device identifiers&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Safe to Log
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Event types (login_attempt, transfer_initiated)&lt;/li&gt;
&lt;li&gt;Masked identifiers (last 4 of account)&lt;/li&gt;
&lt;li&gt;Error codes and HTTP status codes&lt;/li&gt;
&lt;li&gt;Request duration and latency&lt;/li&gt;
&lt;li&gt;Feature flags and A/B experiment IDs&lt;/li&gt;
&lt;li&gt;Random per-launch correlation IDs that are not the authenticated session identifier&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Secure Logging in the App
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;On iOS, use &lt;code&gt;Logger&lt;/code&gt; / &lt;code&gt;os_log&lt;/code&gt; and keep dynamic values at the default &lt;code&gt;.private&lt;/code&gt; level so they are redacted outside a debugger. Marking a value &lt;code&gt;.public&lt;/code&gt; is a code review item&lt;/li&gt;
&lt;li&gt;On Android, strip &lt;code&gt;Log.d&lt;/code&gt; and &lt;code&gt;Log.v&lt;/code&gt; from release builds with an R8 &lt;code&gt;-assumenosideeffects&lt;/code&gt; rule on &lt;code&gt;android.util.Log&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Check what crash reporters and analytics SDKs capture. Breadcrumbs, network logging, and session replay can collect request bodies and screen contents by default&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Server-Side Audit Logging
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Log every authentication event with timestamp, IP, device identifier, and outcome&lt;/li&gt;
&lt;li&gt;Log high-value transactions with user ID, amount, and a masked counterparty&lt;/li&gt;
&lt;li&gt;Log admin actions and configuration changes with the actor&lt;/li&gt;
&lt;li&gt;Ship logs to a SIEM (Splunk, Datadog, CloudWatch) with retention set by your compliance regime. PCI DSS requires twelve months with three months immediately available; treat that as the floor&lt;/li&gt;
&lt;li&gt;Alert on brute-force patterns, impossible travel, and unusual transaction volume per account, not just per system&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. Incident Response Readiness
&lt;/h2&gt;

&lt;p&gt;MASVS does not cover incident response, but a banking app cannot ship without it. The three kill switches below separate a two-hour incident from a two-week one, because each removes the dependency on users updating the app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kill Switches
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Remote feature flags that can disable any feature (transfers, new payees, a vulnerable SDK) without an app update&lt;/li&gt;
&lt;li&gt;A forced-update mechanism driven by a server-controlled minimum build, so a compromised version can be blocked the same day&lt;/li&gt;
&lt;li&gt;Server-side session revocation for one user or every user, tested in staging before you need it&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Detection &amp;amp; Response Playbook
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Define P0/P1/P2 severity levels with response SLAs before launch&lt;/li&gt;
&lt;li&gt;Name a single incident owner with authority to pull the kill switches&lt;/li&gt;
&lt;li&gt;Prepare user notification templates for credential-exposure scenarios in advance; writing them during the incident is how they go out late&lt;/li&gt;
&lt;li&gt;Know the regulatory clocks before launch. GDPR gives 72 hours to notify the supervisory authority; the US banking agencies’ Computer-Security Incident Notification rule requires a banking organization to notify its primary federal regulator within 36 hours of determining that a notification incident has occurred (a bank service provider must notify the bank as soon as possible); PCI DSS card-brand rules and US state breach laws each set their own deadlines&lt;/li&gt;
&lt;li&gt;Run a tabletop exercise each quarter against a realistic scenario: a leaked signing key, a malicious SDK update, a credential-stuffing run&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do you test a banking app against MASVS?
&lt;/h2&gt;

&lt;p&gt;MASVS says what to achieve; the &lt;a href="https://mas.owasp.org/MASTG/" rel="noopener noreferrer"&gt;OWASP Mobile Application Security Testing Guide (MASTG)&lt;/a&gt; says how to verify it, with iOS and Android test cases organized by the same control groups. I use it to structure internal reviews and to scope external penetration tests, so both produce evidence in the same shape.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scope the review by MASVS group, and agree which MAS testing profile applies before testing starts&lt;/li&gt;
&lt;li&gt;Run static checks on every release build: entitlements, ATS and Network Security Config, exported components, backup flags, hardcoded secrets&lt;/li&gt;
&lt;li&gt;Run dynamic checks on a test device: inspect what ends up in the Keychain, Keystore, files, logs and the app-switcher snapshot&lt;/li&gt;
&lt;li&gt;Record results per control so the evidence is ready for compliance and audit reviews&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Mobile App Penetration Testing Checklist for Fintech Apps
&lt;/h2&gt;

&lt;p&gt;When I scope a pen test on a banking or fintech app, iOS and Android, these are the checks I make sure are in it. Each one maps back to a section above.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Traffic&lt;/strong&gt;: route the app through an intercepting proxy, confirm TLS is enforced, and try to bypass certificate pinning&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage&lt;/strong&gt;: pull the app container and check the Keychain or Keystore, databases, caches, logs and screenshots for tokens or personal data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API authorization&lt;/strong&gt;: replay and edit requests to read another user’s accounts, change amounts or skip steps; the server must refuse every one&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session&lt;/strong&gt;: confirm tokens expire, logout revokes them on the server, and a stolen token cannot be used from a second device&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime&lt;/strong&gt;: run on a jailbroken or rooted device with Frida, and check that tampering is detected and reported to the server&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Binary&lt;/strong&gt;: decompile the release build for hardcoded keys, test endpoints, debug menus and verbose logging&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Platform&lt;/strong&gt;: check deep links, exported Android components, pasteboard use and backups for data that leaks out of the app&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Security Audit Checklist Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Short-lived tokens + refresh rotation&lt;/li&gt;
&lt;li&gt;Biometrics unlock a Keychain/Keystore secret&lt;/li&gt;
&lt;li&gt;TLS 1.2+, ATS and Network Security Config&lt;/li&gt;
&lt;li&gt;Certificate pinning with backup pins&lt;/li&gt;
&lt;li&gt;Device-bound request signing&lt;/li&gt;
&lt;li&gt;Keychain with device-only access&lt;/li&gt;
&lt;li&gt;AES-256-GCM encryption at rest&lt;/li&gt;
&lt;li&gt;App-switcher masking and FLAG_SECURE&lt;/li&gt;
&lt;li&gt;Jailbreak/root detection (layered)&lt;/li&gt;
&lt;li&gt;App Attest / Play Integrity&lt;/li&gt;
&lt;li&gt;Release build hardening verified&lt;/li&gt;
&lt;li&gt;Symbols stripped from release&lt;/li&gt;
&lt;li&gt;No PII in any log output&lt;/li&gt;
&lt;li&gt;SIEM with anomaly alerts&lt;/li&gt;
&lt;li&gt;Remote kill switches in place&lt;/li&gt;
&lt;li&gt;Incident response playbook ready&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Need a Security Audit for Your Mobile Banking App?
&lt;/h3&gt;

&lt;p&gt;If you want this checklist run against your app, see &lt;a href="https://puneetpal.dev/services/mobile-architecture-audits" rel="noopener noreferrer"&gt;mobile architecture audits&lt;/a&gt;. If you are building the app, or a new module in it, see &lt;a href="https://puneetpal.dev/services/fintech-app-development" rel="noopener noreferrer"&gt;fintech and banking app development&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://puneetpal.dev/contact" rel="noopener noreferrer"&gt;Request a Security Audit&lt;/a&gt;&lt;br&gt;
&lt;a href="https://puneetpal.dev/services/mobile-architecture-audits" rel="noopener noreferrer"&gt;View Audit Services&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://puneetpal.dev/insights/mobile-banking-security-checklist-2026" rel="noopener noreferrer"&gt;puneetpal.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>security</category>
    </item>
    <item>
      <title>Laravel API Security Checklist 2026 for Mobile Apps</title>
      <dc:creator>Puneetpal Arneja</dc:creator>
      <pubDate>Mon, 02 Nov 2026 17:17:10 +0000</pubDate>
      <link>https://dev.to/puneetpalarneja/laravel-api-security-checklist-2026-for-mobile-apps-1pga</link>
      <guid>https://dev.to/puneetpalarneja/laravel-api-security-checklist-2026-for-mobile-apps-1pga</guid>
      <description>&lt;p&gt;A Laravel API used by a mobile app is secure when Sanctum tokens expire and carry narrow abilities, every record lookup is checked by a policy, input passes through &lt;code&gt;validated()&lt;/code&gt; and &lt;code&gt;$fillable&lt;/code&gt;, named rate limiters guard login and writes, &lt;code&gt;APP_DEBUG&lt;/code&gt; is off with secrets out of git, and &lt;code&gt;composer audit&lt;/code&gt; gates every deploy. Logging and alerting close the loop.&lt;/p&gt;

&lt;p&gt;Mobile backends fail security reviews for the same few reasons: tokens that never expire, lookups that check identity but not ownership, no limits on login or OTP endpoints, and logs that cannot answer “who read this record?” This is the order I work through when hardening a Laravel API behind a mobile app. For the design side (versioning, response shape, pagination), see &lt;a href="https://puneetpal.dev/insights/laravel-backend-for-mobile-apps-2026" rel="noopener noreferrer"&gt;Laravel as a backend for mobile apps&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist mapped to the OWASP API Security Top 10 (2023)
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://owasp.org/API-Security/editions/2023/en/0x11-t10" rel="noopener noreferrer"&gt;OWASP API Security Top 10&lt;/a&gt; is the list most security reviewers work from. Each risk maps to a Laravel control and a section below.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;OWASP risk&lt;/th&gt;
&lt;th&gt;Laravel control&lt;/th&gt;
&lt;th&gt;Section&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API1 Broken Object Level Authorization&lt;/td&gt;
&lt;td&gt;Policies plus queries scoped to the owner&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API2 Broken Authentication&lt;/td&gt;
&lt;td&gt;Sanctum token expiry, abilities, revocation, login throttling&lt;/td&gt;
&lt;td&gt;2, 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API3 Broken Object Property Level Authorization&lt;/td&gt;
&lt;td&gt;$fillable, validated() input, API Resources for output&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API4 Unrestricted Resource Consumption&lt;/td&gt;
&lt;td&gt;Named rate limiters, page-size caps, upload limits&lt;/td&gt;
&lt;td&gt;5, 7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API5 Broken Function Level Authorization&lt;/td&gt;
&lt;td&gt;Gates and policies on admin routes, token abilities&lt;/td&gt;
&lt;td&gt;2, 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API6 Unrestricted Access to Sensitive Business Flows&lt;/td&gt;
&lt;td&gt;Per-account limits on signup, OTP, checkout&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API7 Server Side Request Forgery&lt;/td&gt;
&lt;td&gt;Allowlist outbound URLs fetched on a user’s behalf&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API8 Security Misconfiguration&lt;/td&gt;
&lt;td&gt;APP_DEBUG off, secrets out of git, HTTPS, trusted proxies&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API9 Improper Inventory Management&lt;/td&gt;
&lt;td&gt;route:list review, retire old API versions&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API10 Unsafe Consumption of APIs&lt;/td&gt;
&lt;td&gt;Validate third-party and webhook payloads, timeouts&lt;/td&gt;
&lt;td&gt;7, 9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  1. Threat Model Before Feature Build
&lt;/h2&gt;

&lt;p&gt;Spend an hour per feature area on four questions before the endpoints exist, and write the answers down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Actors: end user, support staff, admin, partner integrations, and the attacker holding a stolen phone with a valid token.&lt;/li&gt;
&lt;li&gt;Data: what is regulated or damaging if leaked (PII, payment metadata, health signals, location history).&lt;/li&gt;
&lt;li&gt;Likely abuse: credential stuffing, OTP guessing, scraping of list endpoints, replay of a captured token.&lt;/li&gt;
&lt;li&gt;Blast radius: what one leaked token can read or change, and how fast you can revoke it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The useful output is a table of endpoints with three columns: needs step-up auth, needs a per-account rate limit, touches regulated data. Apply the controls below against that table, not uniformly.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Authentication and Token Lifecycle
&lt;/h2&gt;

&lt;p&gt;With &lt;a href="https://laravel.com/docs/sanctum" rel="noopener noreferrer"&gt;Laravel Sanctum&lt;/a&gt;, the most common gap is that personal access tokens never expire: &lt;code&gt;expiration&lt;/code&gt; in &lt;code&gt;config/sanctum.php&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt; by default. The config value applies to every token; the third argument to &lt;code&gt;createToken()&lt;/code&gt; sets a per-token expiry. Expired rows stay in the table until &lt;code&gt;sanctum:prune-expired&lt;/code&gt; runs.&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="c1"&gt;// config/sanctum.php&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="s1"&gt;'expiration'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// minutes; null means tokens never expire&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AuthController: issue one token per device with narrow abilities and its own expiry&lt;/span&gt;
&lt;span class="nv"&gt;$token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;device_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'orders:read'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'orders:write'&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;addDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;plainTextToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Require an ability on a route (alias the abilities middleware in bootstrap/app.php)&lt;/span&gt;
&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;OrderController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'store'&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;middleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'auth:sanctum'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'abilities:orders:write'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// Logout revokes only this device's token&lt;/span&gt;
&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&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;currentAccessToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Password reset, email change, "sign out everywhere": revoke all of them&lt;/span&gt;
&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// routes/console.php: delete expired token rows&lt;/span&gt;
&lt;span class="nc"&gt;Schedule&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sanctum:prune-expired --hours=24'&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;daily&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Beyond expiry:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sanctum has no refresh tokens. Either accept re-login at expiry, or add a &lt;code&gt;/auth/token/rotate&lt;/code&gt; endpoint that issues a new token and deletes the old one in one transaction. Real OAuth2 refresh semantics are Passport’s job.&lt;/li&gt;
&lt;li&gt;One token per device, named after the device, so a lost phone can be revoked alone. Device binding (a Secure Enclave or Keystore key that signs requests) is worth the client work only where a replayed token is a real threat, such as payments.&lt;/li&gt;
&lt;li&gt;Step-up for high-risk actions. &lt;code&gt;password.confirm&lt;/code&gt; is session-based, so for a token API store a &lt;code&gt;reauthenticated_at&lt;/code&gt; timestamp on the token and require it to be recent before transfers, payout changes, or email changes.&lt;/li&gt;
&lt;li&gt;Login and reset responses must not reveal whether an account exists: same message, same timing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Authorization Must Be Explicit and Centralized
&lt;/h2&gt;

&lt;p&gt;The most common API flaw, Broken Object Level Authorization, is simply this: a signed-in user changes an ID in the URL and reads someone else’s record. Authentication passed; nothing checked ownership. Fix it with &lt;a href="https://laravel.com/docs/authorization" rel="noopener noreferrer"&gt;policies&lt;/a&gt; or by scoping the query to the caller.&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderController&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Controller&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Vulnerable: any authenticated user can fetch any order by ID&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;showUnsafe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;OrderResource&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OrderResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;findOrFail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Fixed with a policy (OrderPolicy::view)&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;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Order&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;OrderResource&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Gate&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'view'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OrderResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Or scope the lookup to the caller's own records&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;showOwned&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;OrderResource&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OrderResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&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;orders&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;findOrFail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;One policy class per model, and scoped relationships for list endpoints too. An &lt;code&gt;index&lt;/code&gt; that starts from &lt;code&gt;Order::query()&lt;/code&gt; is the same bug spread across a page.&lt;/li&gt;
&lt;li&gt;Function-level checks on admin routes with &lt;code&gt;can:&lt;/code&gt; middleware or a gate, separate from token abilities. Abilities say what a device may do; policies say what the user may do.&lt;/li&gt;
&lt;li&gt;ULIDs or UUIDs (&lt;code&gt;HasUlids&lt;/code&gt;) in public URLs. Not a substitute for the policy, but it stops someone walking &lt;code&gt;/orders/1&lt;/code&gt; to &lt;code&gt;/orders/99999&lt;/code&gt; in an afternoon.&lt;/li&gt;
&lt;li&gt;One feature test per resource that fetches another user’s record and asserts 403 or 404. It is the cheapest test in the suite and catches the refactor that drops a policy call.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Input Validation and Output Minimization
&lt;/h2&gt;

&lt;p&gt;Every write endpoint gets a Form Request, and only validated fields reach the model.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Declare &lt;code&gt;$fillable&lt;/code&gt; on every model and pass &lt;code&gt;$request-&amp;gt;validated()&lt;/code&gt;, never &lt;code&gt;$request-&amp;gt;all()&lt;/code&gt;. Otherwise a client sets &lt;code&gt;is_admin&lt;/code&gt; or &lt;code&gt;user_id&lt;/code&gt; by adding a key to the JSON body.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;max:&lt;/code&gt; on every string and array rule, and &lt;code&gt;File::types([...])-&amp;gt;max(...)&lt;/code&gt; on uploads. Unbounded input is how a free-text field becomes a 50 MB request.&lt;/li&gt;
&lt;li&gt;Eloquent binds parameters, so SQL injection enters through &lt;code&gt;DB::raw&lt;/code&gt;, &lt;code&gt;whereRaw&lt;/code&gt;, or a user-chosen column name. Bind values; allowlist column names, which cannot be bound.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$hidden&lt;/code&gt; for &lt;code&gt;password&lt;/code&gt;, &lt;code&gt;remember_token&lt;/code&gt;, and internal flags, and an API Resource for every response. Returning a model directly leaks every column the day someone adds one.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Mass assignment: only fields that passed validation&lt;/span&gt;
&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validated&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="c1"&gt;// Raw SQL: bind values, never interpolate them&lt;/span&gt;
&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereRaw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'total &amp;gt; ?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$minimum&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;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Column names cannot be bound, so allowlist them&lt;/span&gt;
&lt;span class="nv"&gt;$sort&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sort'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'created_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'total'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sort'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'created_at'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sort&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;paginate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Rate Limiting and Abuse Controls
&lt;/h2&gt;

&lt;p&gt;A single global limit protects the server and nothing else. Limits need to match the endpoint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Login, password reset, OTP request: per account plus IP, and per IP alone. Five a minute per account is a normal start.&lt;/li&gt;
&lt;li&gt;OTP verification: a handful of attempts per code, then invalidate it. A six-digit code with unlimited tries is a million-row lookup table.&lt;/li&gt;
&lt;li&gt;Search and feed endpoints: a per-user burst limit so one client cannot scrape at full speed.&lt;/li&gt;
&lt;li&gt;Page sizes capped server-side (&lt;code&gt;min($request-&amp;gt;integer('per_page', 25), 100)&lt;/code&gt;) so one request cannot pull a table.&lt;/li&gt;
&lt;li&gt;Business flows (signup, checkout, referral): per-account counts over a day, not just per minute.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Laravel’s &lt;a href="https://laravel.com/docs/routing#rate-limiting" rel="noopener noreferrer"&gt;named rate limiters&lt;/a&gt; let each group key its limit by user, IP, or both. Key by user ID whenever the caller is authenticated; carrier NAT puts thousands of phones behind one IP, so IP-only limits lock out real users while barely slowing a proxy pool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AppServiceProvider::boot()&lt;/span&gt;
&lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'api'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&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="nc"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;perMinute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&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;by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'login'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&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="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;perMinute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&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;by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'|'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
        &lt;span class="nc"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;perMinute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&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;by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// routes/api.php&lt;/span&gt;
&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/login'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;LoginController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'throttle:login'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two operational details. Counters live in the cache, so production needs Redis or another shared store; the &lt;code&gt;file&lt;/code&gt; driver keeps separate counts per server. And &lt;code&gt;throttle&lt;/code&gt; already sends &lt;code&gt;Retry-After&lt;/code&gt; on 429; make sure the body is your JSON error envelope so the app shows a message instead of a decode failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Data Protection, Secrets, and Production Config
&lt;/h2&gt;

&lt;p&gt;Security Misconfiguration findings come almost entirely from this list, and each is a five-minute fix.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;APP_DEBUG=false&lt;/code&gt; and &lt;code&gt;APP_ENV=production&lt;/code&gt;, with &lt;code&gt;config:cache&lt;/code&gt; on deploy. The &lt;a href="https://laravel.com/docs/configuration#debug-mode" rel="noopener noreferrer"&gt;debug error page&lt;/a&gt; prints stack traces, request data, and environment values to whoever triggered the exception.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.env&lt;/code&gt; never committed; secrets injected from the host’s secret store. &lt;code&gt;APP_PREVIOUS_KEYS&lt;/code&gt; lets you rotate &lt;code&gt;APP_KEY&lt;/code&gt; without breaking existing encrypted data.&lt;/li&gt;
&lt;li&gt;Trusted proxies configured (&lt;code&gt;$middleware-&amp;gt;trustProxies(...)&lt;/code&gt;) behind a load balancer. Otherwise &lt;code&gt;$request-&amp;gt;ip()&lt;/code&gt; is the balancer’s address, every user shares one IP, and the per-IP limiters in section 5 lock everyone out at once.&lt;/li&gt;
&lt;li&gt;TLS at the load balancer with HSTS, and &lt;code&gt;URL::forceScheme('https')&lt;/code&gt; so generated links never downgrade.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;encrypted&lt;/code&gt; cast for third-party tokens and similar secrets. Encrypted columns cannot be indexed, so add a hashed blind-index column if you need to look a value up.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;config/cors.php&lt;/code&gt; tight for any web frontend, knowing that native HTTP clients ignore CORS entirely. It is not an access control for the app.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. Secure File Upload and External Integration Boundaries
&lt;/h2&gt;

&lt;p&gt;Uploads and outbound requests are where an API starts touching things it does not control.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Validate uploads with &lt;code&gt;File::types()&lt;/code&gt; and &lt;code&gt;max()&lt;/code&gt;. Laravel’s &lt;code&gt;mimes&lt;/code&gt; rule inspects file contents, not the extension.&lt;/li&gt;
&lt;li&gt;Uploads on a private disk, served through temporary URLs; nothing user-supplied under &lt;code&gt;public/&lt;/code&gt;. For large files, let the app upload straight to S3 with a presigned URL (see the &lt;a href="https://puneetpal.dev/insights/laravel-backend-for-mobile-apps-2026" rel="noopener noreferrer"&gt;backend design guide&lt;/a&gt;) and validate size and type afterwards.&lt;/li&gt;
&lt;li&gt;If the API fetches a URL the user supplies (image import, link preview, webhook target), that is a server-side request forgery surface. Allow only &lt;code&gt;http&lt;/code&gt; and &lt;code&gt;https&lt;/code&gt;, resolve the host and reject loopback, private, and link-local ranges including IPv6, re-check after each redirect, and set a short timeout.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Http::timeout()&lt;/code&gt;, &lt;code&gt;connectTimeout()&lt;/code&gt;, and a bounded &lt;code&gt;retry()&lt;/code&gt; on every third-party call. A partner API that hangs for 60 seconds takes your PHP-FPM pool with it.&lt;/li&gt;
&lt;li&gt;Validate third-party responses as strictly as client input; a buggy upstream is API10 for a reason.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  8. Logging, Monitoring, and Alerting
&lt;/h2&gt;

&lt;p&gt;After an incident a log has to answer “which user, from which device, read or changed which record, and when?” Build that context in from day one.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A middleware that generates a request ID and calls &lt;code&gt;Log::withContext()&lt;/code&gt; with it plus the user ID, client platform, and build. In Laravel 11+, the &lt;code&gt;Context&lt;/code&gt; facade carries the same values into queued jobs, so a job log traces back to the request that queued it.&lt;/li&gt;
&lt;li&gt;Listen for &lt;code&gt;Illuminate\Auth\Events\Failed&lt;/code&gt; and &lt;code&gt;Lockout&lt;/code&gt;, and log 403s from the exception handler with user, route, and object ID. A user collecting 403s across sequential IDs is someone probing for the bug in section 3.&lt;/li&gt;
&lt;li&gt;Never log the &lt;code&gt;Authorization&lt;/code&gt; header, tokens, passwords, OTP codes, or full request bodies. Check what your error reporter captures; most send request data unless told not to.&lt;/li&gt;
&lt;li&gt;Alerts on failed logins per IP and per account, 403s per user, 5xx per route, and queue backlog. Dashboards nobody is paged from are decoration.&lt;/li&gt;
&lt;li&gt;Retention is set by your compliance regime, not disk cost. PCI DSS, for example, requires twelve months of audit logs with three months immediately available.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  9. Queue, Job, and Cron Hardening
&lt;/h2&gt;

&lt;p&gt;Workers move money, send notifications, and call partner APIs with the same credentials as the web tier, usually with less scrutiny.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pass IDs into jobs and reload the model instead of serializing sensitive attributes. If a job must carry a secret, implement &lt;code&gt;ShouldBeEncrypted&lt;/code&gt; so the payload is encrypted at rest.&lt;/li&gt;
&lt;li&gt;Verify webhook signatures with &lt;code&gt;hash_equals()&lt;/code&gt;, reject timestamps older than a few minutes, and make the handler idempotent on the provider’s event ID with a unique index. Providers retry; your handler must not double-credit.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;$tries&lt;/code&gt; and &lt;code&gt;$backoff&lt;/code&gt;, implement &lt;code&gt;failed()&lt;/code&gt;, and watch &lt;code&gt;failed_jobs&lt;/code&gt;. Retrying a payment job blindly after a fix is how refunds go out twice.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ShouldBeUnique&lt;/code&gt; or an idempotency key stored before the side effect for provisioning and money movement, so a duplicate dispatch is a no-op.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  10. CI/CD Security Gates
&lt;/h2&gt;

&lt;p&gt;If a check is optional in CI, it stops running the first week a deadline slips. Make these fail the build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;composer audit&lt;/code&gt; (and &lt;code&gt;npm audit&lt;/code&gt; for frontend assets), failing on known advisories; see the &lt;a href="https://getcomposer.org/doc/03-cli.md#audit" rel="noopener noreferrer"&gt;Composer audit docs&lt;/a&gt;. Install with &lt;code&gt;--no-dev&lt;/code&gt; in production so debugging packages never ship.&lt;/li&gt;
&lt;li&gt;Larastan (PHPStan) at a level that flags unchecked nullables and untyped request data flowing into models.&lt;/li&gt;
&lt;li&gt;A Laravel version that still receives &lt;a href="https://laravel.com/docs/releases#support-policy" rel="noopener noreferrer"&gt;security fixes&lt;/a&gt;; security support ends about two years after each major release.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;php artisan route:list&lt;/code&gt; diff each release, so forgotten debug routes and retired API versions are noticed before a scanner finds them.&lt;/li&gt;
&lt;li&gt;The cross-user access tests from section 3, and a test that the login limiter actually fires.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  11. Incident Response Readiness
&lt;/h2&gt;

&lt;p&gt;Each procedure below is a few lines of code or a short runbook. Write them before launch so the first run is not during an incident.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Revocation: one user with &lt;code&gt;$user-&amp;gt;tokens()-&amp;gt;delete()&lt;/code&gt;; everyone by truncating &lt;code&gt;personal_access_tokens&lt;/code&gt;. Sanctum stores only SHA-256 hashes, so a leaked database does not expose usable tokens, but rotate anyway.&lt;/li&gt;
&lt;li&gt;Key rotation: a new &lt;code&gt;APP_KEY&lt;/code&gt; with the old one in &lt;code&gt;APP_PREVIOUS_KEYS&lt;/code&gt;, then every third-party credential the leaked environment could have exposed.&lt;/li&gt;
&lt;li&gt;Forced update: a minimum supported build served from an app-config endpoint, so a client-side vulnerability can be retired without waiting on users.&lt;/li&gt;
&lt;li&gt;Severity levels with a named owner, log retention that survives the incident, and regulatory deadlines written down in advance (GDPR allows 72 hours to notify the supervisory authority).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where to Start on a Live API
&lt;/h2&gt;

&lt;p&gt;If the API is already in production and you have one week, do these in order. Each is a day or less:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Set a Sanctum &lt;code&gt;expiration&lt;/code&gt; and schedule &lt;code&gt;sanctum:prune-expired&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Grep controllers for &lt;code&gt;::findOrFail(&lt;/code&gt; and &lt;code&gt;::find(&lt;/code&gt; on user-owned models; scope or add a policy to each.&lt;/li&gt;
&lt;li&gt;Grep for &lt;code&gt;-&amp;gt;all()&lt;/code&gt; passed into &lt;code&gt;create&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, or &lt;code&gt;fill&lt;/code&gt;; replace with &lt;code&gt;validated()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add the &lt;code&gt;login&lt;/code&gt; limiter and confirm &lt;code&gt;APP_DEBUG&lt;/code&gt; and trusted proxies in production.&lt;/li&gt;
&lt;li&gt;Add request-ID logging and the failed-login listener, then wire one alert.&lt;/li&gt;
&lt;li&gt;Put &lt;code&gt;composer audit&lt;/code&gt; in CI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Workers, webhooks, and the incident runbook follow in the next sprint. If you want help with the work, see &lt;a href="https://puneetpal.dev/services/backend-api-development" rel="noopener noreferrer"&gt;backend API development&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://owasp.org/API-Security/editions/2023/en/0x11-t10" rel="noopener noreferrer"&gt;OWASP API Security Top 10 (2023)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Laravel_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP Laravel Cheat Sheet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Laravel docs: &lt;a href="https://laravel.com/docs/sanctum" rel="noopener noreferrer"&gt;Sanctum&lt;/a&gt;, &lt;a href="https://laravel.com/docs/authorization" rel="noopener noreferrer"&gt;Authorization&lt;/a&gt;, &lt;a href="https://laravel.com/docs/validation" rel="noopener noreferrer"&gt;Validation&lt;/a&gt;, &lt;a href="https://laravel.com/docs/eloquent#mass-assignment" rel="noopener noreferrer"&gt;Mass assignment&lt;/a&gt;, &lt;a href="https://laravel.com/docs/routing#rate-limiting" rel="noopener noreferrer"&gt;Rate limiting&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://puneetpal.dev/insights/laravel-api-security-checklist-2026" rel="noopener noreferrer"&gt;puneetpal.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>backend</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>UIKit to SwiftUI Migration: 2026 Playbook with Code</title>
      <dc:creator>Puneetpal Arneja</dc:creator>
      <pubDate>Mon, 26 Oct 2026 13:28:10 +0000</pubDate>
      <link>https://dev.to/puneetpalarneja/uikit-to-swiftui-migration-2026-playbook-with-code-3ha3</link>
      <guid>https://dev.to/puneetpalarneja/uikit-to-swiftui-migration-2026-playbook-with-code-3ha3</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; migrate UIKit to SwiftUI incrementally. Host new SwiftUI screens in &lt;code&gt;UIHostingController&lt;/code&gt; inside your existing navigation, wrap the UIKit components you keep with &lt;code&gt;UIViewRepresentable&lt;/code&gt;, move state into &lt;code&gt;@Observable&lt;/code&gt; models, and convert leaf screens first. Keep routing in UIKit until a whole flow is SwiftUI, then move that flow to &lt;code&gt;NavigationStack&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Most UIKit codebases I see in 2026 have years of business logic inside view controllers, a coordinator or two, and a handful of SwiftUI screens somebody added in a hurry. Everyone agrees new work goes in SwiftUI. The open question is how to move the rest without freezing delivery for two quarters. This playbook covers the phased model I use, the interop code that holds the two stacks together, the mistakes that show up at the boundary, and a per-screen checklist for your definition of done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Migration Efforts Fail
&lt;/h2&gt;

&lt;p&gt;The failures are rarely about SwiftUI itself. They come from how the two stacks are stitched together. The ones I see most often:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two sources of truth: a view controller property mirrored into an &lt;code&gt;ObservableObject&lt;/code&gt;, with a bug every time one side forgets to update the other.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;NavigationStack&lt;/code&gt; inside a hosting controller already pushed on a &lt;code&gt;UINavigationController&lt;/code&gt;: two navigation bars, two back stacks.&lt;/li&gt;
&lt;li&gt;Screen-view analytics that lived in &lt;code&gt;viewDidAppear&lt;/code&gt; and silently stopped firing when the screen became a SwiftUI view.&lt;/li&gt;
&lt;li&gt;Hosted views that push or present on their own, so the coordinator loses track and deep links break.&lt;/li&gt;
&lt;li&gt;No flag back to the UIKit screen, so the first regression is a hotfix release instead of a config change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is cheap to prevent with a rule set before the first screen moves, and expensive to fix after twenty screens have copied the pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should You Migrate from UIKit to SwiftUI at All?
&lt;/h2&gt;

&lt;p&gt;Not every app needs it. Migration pays off when you are still building features on the UI layer, when UIKit boilerplate is slowing reviews and onboarding, or when you want widgets, Live Activities, or new platform surfaces that are SwiftUI-only. It pays off less in these cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The app is in maintenance mode and the UI rarely changes.&lt;/li&gt;
&lt;li&gt;A screen depends on fine-grained UIKit behaviour, such as complex compositional collection view layouts, custom transitions, or TextKit-based editing. Keep those in UIKit and wrap them.&lt;/li&gt;
&lt;li&gt;A critical launch leaves no room for the team’s SwiftUI learning curve.&lt;/li&gt;
&lt;li&gt;Your user base forces a minimum iOS version where the APIs you need do not exist yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A hybrid app that keeps some UIKit indefinitely is a perfectly good end state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Minimum iOS version trade-offs
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Deployment target&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;th&gt;What you work around&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;iOS 15&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.task&lt;/code&gt; with async/await, &lt;code&gt;ObservableObject&lt;/code&gt;, &lt;code&gt;List&lt;/code&gt; refresh and search&lt;/td&gt;
&lt;td&gt;Only &lt;code&gt;NavigationView&lt;/code&gt;, so keep navigation in UIKit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS 16&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;NavigationStack&lt;/code&gt;, &lt;code&gt;UIHostingConfiguration&lt;/code&gt; for cells, hosting controller &lt;code&gt;sizingOptions&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Still &lt;code&gt;ObservableObject&lt;/code&gt; and &lt;code&gt;@Published&lt;/code&gt; for state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS 17+&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@Observable&lt;/code&gt; and &lt;code&gt;@Bindable&lt;/code&gt;, finer-grained view updates&lt;/td&gt;
&lt;td&gt;Fewest workarounds; my recommended floor in 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Phase 1: Baseline and Readiness (2-3 Weeks)
&lt;/h2&gt;

&lt;p&gt;Before you convert a single screen, record where the app stands so you can tell later whether the migration helped or hurt. Pull these from tools you already have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Crash-free sessions per feature area, and launch time percentiles from the Xcode Organizer or &lt;code&gt;MXAppLaunchMetric&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Time-to-interactive for the three or four highest-traffic screens, measured with &lt;code&gt;os_signpost&lt;/code&gt; intervals so you can compare later.&lt;/li&gt;
&lt;li&gt;Open UI defects per module, so you know which screens are already fragile.&lt;/li&gt;
&lt;li&gt;A list of every &lt;code&gt;UIViewController&lt;/code&gt; subclass with its parent, children, and what it presents. A short script over the source tree does this; it becomes the migration inventory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Classify each screen in the inventory by how hard it will be to move:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Low:&lt;/strong&gt; read-only detail screens and simple forms with no child controllers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Medium:&lt;/strong&gt; moderate state, validation, one or two embedded components.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High:&lt;/strong&gt; custom collection view layouts, interactive transitions, text editing, or anything that owns child view controllers that have not been migrated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start with low and medium screens. The point of the first wave is to produce a pattern the rest of the team can copy, not to move the most code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 2: How Do UIKit and SwiftUI Work Together in One App?
&lt;/h2&gt;

&lt;p&gt;The migration succeeds or fails at the boundary between old and new UI stacks. Apple gives you two directions of interop, and you will use both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SwiftUI inside UIKit:&lt;/strong&gt; &lt;code&gt;UIHostingController&lt;/code&gt; for whole screens and child views, &lt;code&gt;UIHostingConfiguration&lt;/code&gt; for table and collection view cells.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UIKit inside SwiftUI:&lt;/strong&gt; &lt;code&gt;UIViewRepresentable&lt;/code&gt; for views and &lt;code&gt;UIViewControllerRepresentable&lt;/code&gt; for view controllers you are not ready to replace.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Push a SwiftUI screen from UIKit
&lt;/h3&gt;

&lt;p&gt;This is the workhorse of an incremental migration. The UIKit navigation controller and coordinator stay in charge; only the screen content changes. Set the title on the host rather than relying on &lt;code&gt;.navigationTitle&lt;/code&gt; inside the view to bridge across; it is one less thing to debug.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ProfileModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;profileService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UIHostingController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;rootView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ProfileView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Profile"&lt;/span&gt;
&lt;span class="n"&gt;navigationController&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pushViewController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;animated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Embed SwiftUI as a child of a UIKit screen
&lt;/h3&gt;

&lt;p&gt;When only part of a screen is migrating, add the hosting controller as a child view controller so lifecycle and trait changes propagate correctly. Without &lt;code&gt;sizingOptions&lt;/code&gt; the hosted view reports no intrinsic size and Auto Layout gives it zero height.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UIHostingController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;rootView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SummaryCard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sizingOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;intrinsicContentSize&lt;/span&gt;   &lt;span class="c1"&gt;// iOS 16+&lt;/span&gt;
&lt;span class="nf"&gt;addChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;translatesAutoresizingMaskIntoConstraints&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="n"&gt;containerView&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addSubview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;NSLayoutConstraint&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;activate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;topAnchor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constraint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;equalTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;containerView&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;topAnchor&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bottomAnchor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constraint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;equalTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;containerView&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bottomAnchor&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;leadingAnchor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constraint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;equalTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;containerView&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;leadingAnchor&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trailingAnchor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constraint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;equalTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;containerView&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trailingAnchor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;didMove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;toParent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two hosting details worth knowing before they cost you an afternoon: a hosted view above a keyboard can pick up extra padding because the host applies the keyboard safe area, and on iOS 16.4 and later &lt;code&gt;host.safeAreaRegions = []&lt;/code&gt; turns that off; and SwiftUI environment values do not cross the host boundary, so inject dependencies into the &lt;code&gt;rootView&lt;/code&gt; explicitly or with &lt;code&gt;.environment()&lt;/code&gt; on it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Render SwiftUI rows in an existing table or collection view
&lt;/h3&gt;

&lt;p&gt;On iOS 16 and later you can migrate cells before you migrate the list that contains them. Self-sizing works, and the cell keeps its UIKit selection and swipe behaviour:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="n"&gt;cell&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contentConfiguration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UIHostingConfiguration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;OrderRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;order&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;
  
  
  Wrap a UIKit view you are keeping
&lt;/h3&gt;

&lt;p&gt;For a legacy control that is not worth rewriting yet, such as a custom chart, wrap it with &lt;code&gt;UIViewRepresentable&lt;/code&gt;. The coordinator turns delegate callbacks into closures. Keep &lt;code&gt;updateUIView&lt;/code&gt; cheap and idempotent; SwiftUI calls it on every relevant state change.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;LegacyChart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIViewRepresentable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;points&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;ChartPoint&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;onSelect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ChartPoint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Void&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;makeUIView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;ChartView&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ChartView&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delegate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;coordinator&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;view&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;updateUIView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;view&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ChartView&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;coordinator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;onSelect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;onSelect&lt;/span&gt;
        &lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setPoints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;points&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;makeCoordinator&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Coordinator&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;Coordinator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;onSelect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;onSelect&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;Coordinator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;NSObject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;ChartViewDelegate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;onSelect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ChartPoint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Void&lt;/span&gt;
        &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;onSelect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;@escaping&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ChartPoint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;onSelect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;onSelect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;chartView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;view&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ChartView&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;didSelect&lt;/span&gt; &lt;span class="nv"&gt;point&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ChartPoint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;onSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;point&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bridge rules I enforce on every migration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every representable wrapper gets a ticket for its planned retirement, or a note saying it stays.&lt;/li&gt;
&lt;li&gt;One observable model per feature boundary, owned by whoever creates the screen.&lt;/li&gt;
&lt;li&gt;The same dependency container serves UIKit and SwiftUI modules; no second injection scheme.&lt;/li&gt;
&lt;li&gt;Hosted SwiftUI views never push or present directly; they call back to the coordinator.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same feature-boundary discipline is what lets one codebase serve several products. For a worked example, see &lt;a href="https://puneetpal.dev/insights/white-label-ios-architecture-2026" rel="noopener noreferrer"&gt;how a white-label iOS app ships one Swift codebase as many branded apps&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 3: Incremental Screen-by-Screen Migration
&lt;/h2&gt;

&lt;p&gt;Move feature-by-feature, not file-by-file. A feature migration unit should include view, interactions, analytics events, error states, and accessibility checks. The safest unit is a leaf screen: one with no child view controllers, so it can be rebuilt in SwiftUI and swapped in behind a &lt;code&gt;UIHostingController&lt;/code&gt; without touching anything around it.&lt;/p&gt;

&lt;p&gt;Recommended order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read-only detail views&lt;/li&gt;
&lt;li&gt;Settings and profile surfaces&lt;/li&gt;
&lt;li&gt;Simple forms with straightforward validation&lt;/li&gt;
&lt;li&gt;Reusable cells and components inside UIKit lists&lt;/li&gt;
&lt;li&gt;High-traffic flows after pattern stability is proven&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep a short scorecard per sprint: screens migrated, defects introduced in them, and whether feature lead time moved. If defects grow faster than screens for two sprints, stop and fix the pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Should State Management Map from UIKit to SwiftUI?
&lt;/h2&gt;

&lt;p&gt;Pick one state strategy and enforce it. In practice that means a feature model per screen that holds state and logic, with views treated as rendering plus user intent. The model does not know whether it is being shown by a view controller or a SwiftUI view.&lt;/p&gt;

&lt;h3&gt;
  
  
  UIKit concept to SwiftUI equivalent
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;UIKit&lt;/th&gt;
&lt;th&gt;SwiftUI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;View controller holding state and logic&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@Observable&lt;/code&gt; model (iOS 17+) or &lt;code&gt;ObservableObject&lt;/code&gt; (iOS 13-16)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Owning a model in &lt;code&gt;viewDidLoad&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@State&lt;/code&gt; for an &lt;code&gt;@Observable&lt;/code&gt; model, &lt;code&gt;@StateObject&lt;/code&gt; for &lt;code&gt;ObservableObject&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Passing a shared service down the controller tree&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.environment(model)&lt;/code&gt; on the root view and &lt;code&gt;@Environment(Model.self)&lt;/code&gt; where it is read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Target-action and delegates&lt;/td&gt;
&lt;td&gt;Closures and model methods; &lt;code&gt;@Bindable&lt;/code&gt; for two-way edits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KVO and NotificationCenter for UI refresh&lt;/td&gt;
&lt;td&gt;Observation tracking; &lt;code&gt;.onReceive&lt;/code&gt; for remaining publishers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;UITableView&lt;/code&gt; / &lt;code&gt;UICollectionView&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;List&lt;/code&gt; or &lt;code&gt;LazyVStack&lt;/code&gt;, or &lt;code&gt;UIHostingConfiguration&lt;/code&gt; cells first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;UINavigationController&lt;/code&gt; push&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;NavigationStack&lt;/code&gt; with &lt;code&gt;navigationDestination(for:)&lt;/code&gt; (iOS 16+)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A model that works the same whether the screen is hosted from UIKit or lives in a pure SwiftUI flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;Observation&lt;/span&gt;
&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;SwiftUI&lt;/span&gt;

&lt;span class="kd"&gt;@MainActor&lt;/span&gt;
&lt;span class="kd"&gt;@Observable&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;ProfileModel&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;isSaving&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;errorMessage&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="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ProfileService&lt;/span&gt;
    &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ProfileService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;isSaving&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;isSaving&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;errorMessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;localizedDescription&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="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;ProfileView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;@Bindable&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ProfileModel&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;Form&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;TextField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;$model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="kt"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Save"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kt"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&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="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isSaving&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same model can drive a UIKit screen that has not migrated yet. On iOS 17 you observe it with &lt;code&gt;withObservationTracking(_:onChange:)&lt;/code&gt;; on iOS 26 UIKit tracks &lt;code&gt;@Observable&lt;/code&gt; reads in &lt;code&gt;viewWillLayoutSubviews&lt;/code&gt; and &lt;code&gt;updateProperties()&lt;/code&gt; automatically, and the &lt;code&gt;UIObservationTrackingEnabled&lt;/code&gt; Info.plist key enables that back to iOS 18. Either way the view controller stops owning state, which is exactly the shape that makes replacing it trivial.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keep domain and networking layers UI-agnostic so both stacks call the same code.&lt;/li&gt;
&lt;li&gt;No business logic in views, no view code in models. The compiler will not stop you; review has to.&lt;/li&gt;
&lt;li&gt;Load with &lt;code&gt;.task&lt;/code&gt; rather than &lt;code&gt;onAppear&lt;/code&gt; plus a detached &lt;code&gt;Task&lt;/code&gt;; it cancels when the view disappears, so you do not update a model nobody is looking at.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How Do You Migrate Navigation to NavigationStack?
&lt;/h2&gt;

&lt;p&gt;Navigation is often the hardest part because UIKit and SwiftUI have different mental models. My recommendation is two stages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage one: UIKit keeps routing.&lt;/strong&gt; Hosted SwiftUI screens expose closures, and the existing coordinator decides where to go. Do not put a &lt;code&gt;NavigationStack&lt;/code&gt; inside a hosting controller that is already pushed onto a &lt;code&gt;UINavigationController&lt;/code&gt;; you get two navigation bars and two back stacks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;OrderListView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;onSelect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Void&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="kt"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;onSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&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="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// In the UIKit coordinator&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;OrderListView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;orders&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;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;showDetail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;navigationController&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pushViewController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UIHostingController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;rootView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;animated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Stage two: a whole flow moves to SwiftUI.&lt;/strong&gt; Once every screen in a tab or flow is SwiftUI, replace that flow’s navigation controller with a &lt;code&gt;NavigationStack&lt;/code&gt; driven by a typed path. Deep links become an array of routes you assign to the path. Attach &lt;code&gt;navigationDestination(for:)&lt;/code&gt; once, to the root content, not inside a lazy container; a destination declared inside a &lt;code&gt;List&lt;/code&gt; row is registered late and the push can fail silently.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="kt"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Hashable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;OrdersFlow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;@State&lt;/span&gt; &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;View&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;NavigationStack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;OrdersHome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;open&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;navigationDestination&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
                    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;OrderDetailView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SettingsView&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="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;Rules for both stages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Top-level routing stays in one place, even if that place is a UIKit coordinator for another year.&lt;/li&gt;
&lt;li&gt;Deep-link parsing produces route values, never views or view controllers, so both stacks can consume them.&lt;/li&gt;
&lt;li&gt;No ad-hoc push logic inside feature modules, in either stack.&lt;/li&gt;
&lt;li&gt;Log route transitions from the router, so analytics do not depend on which stack rendered the screen.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How Do You Test a UIKit to SwiftUI Migration?
&lt;/h2&gt;

&lt;p&gt;Moving logic into models is what makes the new screens testable without a UI harness. A model test runs in milliseconds and does not care which stack renders the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;Testing&lt;/span&gt;

&lt;span class="kd"&gt;@MainActor&lt;/span&gt;
&lt;span class="kd"&gt;@Test&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;saveClearsSavingFlag&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ProfileModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;MockProfileService&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Ada"&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="cp"&gt;#expect(model.isSaving == false)&lt;/span&gt;
    &lt;span class="cp"&gt;#expect(model.errorMessage == nil)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For visual parity, preview the old and new screens side by side. Since Xcode 15 the &lt;code&gt;#Preview&lt;/code&gt; macro accepts UIKit view controllers as well as SwiftUI views:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="cp"&gt;#Preview("UIKit checkout") {&lt;/span&gt;
    &lt;span class="kt"&gt;UINavigationController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;rootViewController&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CheckoutViewController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cp"&gt;#Preview("SwiftUI checkout") {&lt;/span&gt;
    &lt;span class="kt"&gt;CheckoutView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;preview&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;Then set release gates per migrated screen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Snapshot tests for high-traffic screens, recorded from the UIKit version before it is replaced.&lt;/li&gt;
&lt;li&gt;Unit tests for the model, covering the error and empty states the old view controller handled.&lt;/li&gt;
&lt;li&gt;UI automation for anything that touches money or identity.&lt;/li&gt;
&lt;li&gt;A feature flag back to the UIKit screen, kept for at least one full release.&lt;/li&gt;
&lt;li&gt;A phased App Store release with crash and launch-time thresholds checked each day.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Write the rollback rule down before release, with a number in it. For example: if crash-free sessions for the migrated module fall more than 0.2 percentage points below the Phase 1 baseline, flip the flag back. The exact threshold matters less than having one nobody has to negotiate at 11pm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accessibility and Design System Alignment
&lt;/h2&gt;

&lt;p&gt;SwiftUI gets a lot of accessibility right by default, which makes the regressions it does introduce easy to miss. Check these on every migrated screen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;VoiceOver labels and reading order. Controls built from &lt;code&gt;Image&lt;/code&gt; and &lt;code&gt;Button&lt;/code&gt; need &lt;code&gt;.accessibilityLabel&lt;/code&gt;; grouped rows usually want &lt;code&gt;.accessibilityElement(children: .combine)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Dynamic Type at the largest sizes. Use semantic fonts and &lt;code&gt;@ScaledMetric&lt;/code&gt; for spacing that was a fixed constant in UIKit.&lt;/li&gt;
&lt;li&gt;Colour contrast through tokens shared by both stacks, so a &lt;code&gt;UIColor&lt;/code&gt; asset and its &lt;code&gt;Color&lt;/code&gt; counterpart cannot drift.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;XCUIApplication.performAccessibilityAudit()&lt;/code&gt; (Xcode 15+) in the screen’s UI test, which catches missing labels and clipped text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Share one component library across the coexistence period. A button that looks different depending on which stack rendered it is the kind of inconsistency users notice and nobody files a bug for.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Long Does a UIKit to SwiftUI Migration Take?
&lt;/h2&gt;

&lt;p&gt;These are the planning ranges I start from; codebase shape, team familiarity with SwiftUI, and how much feature work continues in parallel move them in either direction.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Light modernization:&lt;/strong&gt; 6-10 weeks to move a handful of key screens and establish the pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mid-size migration:&lt;/strong&gt; 3-5 months for core flows plus a shared component system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Large enterprise migration:&lt;/strong&gt; 6-12 months, phased by domain, with UIKit remaining in places.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The number that matters at the end is whether the screens you touched now ship faster with fewer defects than they did in Phase 1, not the percentage of SwiftUI.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Measure After Migration Starts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Feature lead time for migrated modules versus the Phase 1 baseline&lt;/li&gt;
&lt;li&gt;Crash-free sessions and launch time for migrated modules, from the same sources as Phase 1&lt;/li&gt;
&lt;li&gt;UI defects found in QA per migrated screen&lt;/li&gt;
&lt;li&gt;How often the rollback flag was actually used&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If those are not improving after the first wave, the pattern is wrong. Fix it before widening scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  UIKit to SwiftUI Migration Checklist (Per Screen)
&lt;/h2&gt;

&lt;p&gt;Put this into the definition of done for every migrated screen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The screen is a leaf, or its child view controllers are already migrated or wrapped.&lt;/li&gt;
&lt;li&gt;Business logic has moved out of the view controller into a model with unit tests.&lt;/li&gt;
&lt;li&gt;The SwiftUI view is hosted with &lt;code&gt;UIHostingController&lt;/code&gt;, and navigation still goes through the coordinator.&lt;/li&gt;
&lt;li&gt;Analytics events fire with the same names and parameters as the UIKit version.&lt;/li&gt;
&lt;li&gt;Loading, empty, and error states match or improve on the old screen.&lt;/li&gt;
&lt;li&gt;Dynamic Type, VoiceOver labels and order, and dark mode are checked.&lt;/li&gt;
&lt;li&gt;Old and new screens are compared in previews or snapshot tests.&lt;/li&gt;
&lt;li&gt;A feature flag can route back to the UIKit screen for one release.&lt;/li&gt;
&lt;li&gt;Crash-free rate and screen performance are watched against the Phase 1 baseline after release.&lt;/li&gt;
&lt;li&gt;The UIKit screen and its flag are deleted once the new screen is stable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  90-Day Migration Execution Checklist
&lt;/h2&gt;

&lt;p&gt;If you want the phases above as a calendar, this is the sequence I use for a team that has to keep shipping while it migrates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Days 1-15:&lt;/strong&gt; Baseline metrics, screen inventory, written bridge rules, and a named owner per feature area.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Days 16-30:&lt;/strong&gt; Hosting and routing helpers, the model template, the rollback flag mechanism, and one reference screen that uses all of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Days 31-50:&lt;/strong&gt; Low-risk screens, analytics parity checks, and the first adjustments to the review checklist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Days 51-70:&lt;/strong&gt; Medium-complexity flows with snapshot tests and a phased release.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Days 71-90:&lt;/strong&gt; One high-value module, a regression review against the baseline, and updated architecture docs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pacing is deliberately slow at the start. Plans that front-load screen count produce QA debt that surfaces two or three releases later. A team already carrying incident load should start with fewer modules and stricter gates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Start
&lt;/h2&gt;

&lt;p&gt;Pick one leaf screen, move its logic into a model, host the SwiftUI version behind a flag, and ship it. Everything else in this playbook is about making that step repeatable.&lt;/p&gt;

&lt;p&gt;If you need hands-on help, I support both complete &lt;a href="https://puneetpal.dev/services/ios-app-development" rel="noopener noreferrer"&gt;iOS app development&lt;/a&gt; and focused &lt;a href="https://puneetpal.dev/services/swiftui-development" rel="noopener noreferrer"&gt;SwiftUI development and UIKit migration&lt;/a&gt;. If you want an outside read on the codebase before committing, start with a &lt;a href="https://puneetpal.dev/services/mobile-architecture-audits" rel="noopener noreferrer"&gt;mobile architecture audit&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/swiftui/uihostingcontroller" rel="noopener noreferrer"&gt;Apple Developer: UIHostingController&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/swiftui/uiviewrepresentable" rel="noopener noreferrer"&gt;Apple Developer: UIViewRepresentable&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/swiftui/uihostingconfiguration" rel="noopener noreferrer"&gt;Apple Developer: UIHostingConfiguration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/swiftui/navigationstack" rel="noopener noreferrer"&gt;Apple Developer: NavigationStack&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/swiftui/migrating-from-the-observable-object-protocol-to-the-observable-macro" rel="noopener noreferrer"&gt;Apple Developer: Migrating from ObservableObject to the Observable macro&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/observation" rel="noopener noreferrer"&gt;Apple Developer: Observation framework&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://puneetpal.dev/insights/swiftui-migration-playbook-2026" rel="noopener noreferrer"&gt;puneetpal.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>ios</category>
      <category>swift</category>
      <category>startup</category>
    </item>
    <item>
      <title>Laravel Sanctum vs Passport for Mobile Apps (2026)</title>
      <dc:creator>Puneetpal Arneja</dc:creator>
      <pubDate>Sat, 24 Oct 2026 01:57:12 +0000</pubDate>
      <link>https://dev.to/puneetpalarneja/laravel-sanctum-vs-passport-for-mobile-apps-2026-100c</link>
      <guid>https://dev.to/puneetpalarneja/laravel-sanctum-vs-passport-for-mobile-apps-2026-100c</guid>
      <description>&lt;p&gt;For a mobile app that talks to its own Laravel API, use Sanctum. It issues one revocable token per device from a login endpoint you control, supports abilities and expiry, and has nothing to configure beyond a trait. Passport is a full OAuth2 server, and the only mobile cases that need one are apps you do not own asking for access to your users’ data, or other servers authenticating as themselves.&lt;/p&gt;

&lt;p&gt;That is the short answer, and it matches what the Laravel docs say. The rest of this article is the reasoning, the parts of each package that matter once the app is in production, and the code for the places where Sanctum’s simplicity leaves a gap. It is one part of a larger guide to &lt;a href="https://puneetpal.dev/insights/laravel-backend-for-mobile-apps-2026" rel="noopener noreferrer"&gt;Laravel as a backend for mobile apps&lt;/a&gt;, which covers versioning, response shape, pagination, and push alongside authentication.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Sanctum Actually Is
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://laravel.com/docs/sanctum" rel="noopener noreferrer"&gt;Sanctum&lt;/a&gt; is two separate features in one package, and only one of them applies to a native app.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API tokens.&lt;/strong&gt; Modelled on GitHub personal access tokens. &lt;code&gt;createToken()&lt;/code&gt; on the user writes a row to &lt;code&gt;personal_access_tokens&lt;/code&gt; with a SHA-256 hash of a random string and returns the plain text once. The client sends it as a &lt;code&gt;Bearer&lt;/code&gt; header and Sanctum looks the hash up on every request. Tokens can carry abilities, which work like scopes, and an optional expiry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SPA cookie authentication.&lt;/strong&gt; No tokens at all. A browser frontend on the same top-level domain logs in through the normal &lt;code&gt;web&lt;/code&gt; guard and Sanctum lets the session cookie authenticate API routes, with CSRF protection intact. The &lt;code&gt;statefulApi()&lt;/code&gt; middleware decides which origins get this treatment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A mobile app uses the first feature only. There is no browser, no cookie jar worth trusting, and no CSRF problem, so the cookie half of Sanctum does not apply. The docs are explicit that API tokens are for mobile apps and third parties while first-party SPAs should use the cookie flow; the mistake worth avoiding is doing it the other way round.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Passport Actually Is
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://laravel.com/docs/passport" rel="noopener noreferrer"&gt;Passport&lt;/a&gt; is a complete OAuth2 authorization server built on &lt;a href="https://oauth2.thephpleague.com/" rel="noopener noreferrer"&gt;league/oauth2-server&lt;/a&gt;. It registers the &lt;code&gt;/oauth/authorize&lt;/code&gt; and &lt;code&gt;/oauth/token&lt;/code&gt; endpoints, stores clients and their redirect URIs, issues RSA-signed JWT access tokens together with refresh tokens, and implements the standard grants: authorization code (with or without PKCE), client credentials, device authorization, and the older password and implicit grants, which are off unless you turn them on.&lt;/p&gt;

&lt;p&gt;The important word is &lt;em&gt;server&lt;/em&gt;. Passport exists so that other applications can obtain access to your API on a user’s behalf, with the user approving a scope list on a consent screen you render. When the only client is your own app, every one of those moving parts is still present and still has to be configured, deployed, and kept secure, but none of them is doing anything you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Mobile Apps Almost Always Want Sanctum
&lt;/h2&gt;

&lt;p&gt;Your own iOS or Android app is a first-party client. The user types their credentials into a screen you built, so there is no delegation to model and no third party to keep at arm’s length. What the app needs from the token system is narrower than OAuth2 and more specific:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One token per installed device, named so the user can recognise it in a sessions list.&lt;/li&gt;
&lt;li&gt;The ability to revoke one device without logging out the others, and all of them at once on a password reset.&lt;/li&gt;
&lt;li&gt;A token that survives app restarts for weeks, not minutes, because re-login on a phone costs you users.&lt;/li&gt;
&lt;li&gt;Optionally, a token that can do less than the user can, so a stolen token from a read-only screen cannot place orders.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sanctum covers each of these with a line or two. The login endpoint below follows the shape in the docs, with an expiry and abilities added, and &lt;code&gt;device_name&lt;/code&gt; supplied by the app from the device model or a user-chosen label.&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="c1"&gt;// routes/api.php&lt;/span&gt;
&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/auth/login'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'required'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'required'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'device_name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'required'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'max:100'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nc"&gt;Hash&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nc"&gt;ValidationException&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withMessages&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'The provided credentials are incorrect.'&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="nv"&gt;$expiresAt&lt;/span&gt; &lt;span class="o"&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;addDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;device_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'orders:read'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'orders:write'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'profile:read'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="nv"&gt;$expiresAt&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="nf"&gt;response&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;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'token'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;plainTextToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'expires_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$expiresAt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toIso8601String&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'throttle:login'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning &lt;code&gt;expires_at&lt;/code&gt; alongside the token is a small addition that pays for itself later: the app can decide when to rotate without parsing anything, and support can see at a glance why a device was signed out.&lt;/p&gt;

&lt;p&gt;Abilities are enforced on routes with the two middleware Sanctum ships, &lt;code&gt;Laravel\Sanctum\Http\Middleware\CheckAbilities&lt;/code&gt; and &lt;code&gt;CheckForAnyAbility&lt;/code&gt;. They are not aliased by default in Laravel 11 and 12, so register them once in &lt;code&gt;bootstrap/app.php&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="c1"&gt;// bootstrap/app.php, inside withMiddleware()&lt;/span&gt;
&lt;span class="nv"&gt;$middleware&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'abilities'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;CheckAbilities&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// token must have every listed ability&lt;/span&gt;
    &lt;span class="s1"&gt;'ability'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;CheckForAnyAbility&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// token must have at least one&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// routes/api.php&lt;/span&gt;
&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;OrderController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&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;middleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'auth:sanctum'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'abilities:orders:read'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;OrderController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'store'&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;middleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'auth:sanctum'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'abilities:orders:write'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// Inside a policy, combine the token check with ownership&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tokenCan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'orders:write'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Revocation is a delete. Logout removes the current device’s row; a password reset, email change, or “sign out everywhere” removes them all. Because only hashes are stored, a leaked database does not yield usable tokens, though you should still rotate after an incident.&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="c1"&gt;// Logout: only this device&lt;/span&gt;
&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/auth/logout'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&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;currentAccessToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&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;noContent&lt;/span&gt;&lt;span class="p"&gt;();&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;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'auth:sanctum'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Password reset listener: every device, then the user logs in again&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RevokeTokensOnPasswordReset&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;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;PasswordReset&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// A sessions screen: revoke one named device the user picked&lt;/span&gt;
&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&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;tokens&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'token_id'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What You Give Up: Refresh Tokens and Rotation
&lt;/h2&gt;

&lt;p&gt;Sanctum has no refresh tokens. A token is valid until it expires or is deleted, and the default is that it never expires, because &lt;code&gt;expiration&lt;/code&gt; in &lt;code&gt;config/sanctum.php&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;. That default is fine for a developer’s personal access token and wrong for a phone that can be lost, so the first production decision is to set it.&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="c1"&gt;// config/sanctum.php: minutes, applies to every token without its own expiry&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'expiration'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// routes/console.php: expired rows stay in the table until this runs&lt;/span&gt;
&lt;span class="nc"&gt;Schedule&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sanctum:prune-expired --hours=24'&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;daily&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once tokens expire you need an answer to “what happens on day 31?” Forcing a re-login is acceptable for some products and a churn problem for others. The alternative is rotation: an authenticated endpoint that issues a replacement with the same name and abilities and deletes the token that called it, in one transaction so a crash between the two statements cannot leave the user with zero tokens or two.&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="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/auth/token/rotate'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;currentAccessToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$expiresAt&lt;/span&gt; &lt;span class="o"&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;addDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$expiresAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;abilities&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$expiresAt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;delete&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;$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;return&lt;/span&gt; &lt;span class="nf"&gt;response&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;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'token'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$fresh&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;plainTextToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'expires_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$expiresAt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toIso8601String&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'auth:sanctum'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'throttle:rotate'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the client, call it when &lt;code&gt;expires_at&lt;/code&gt; is within a few days, on app foreground, and store the new token before discarding the old one. Two details keep this honest. The endpoint requires a valid token, so an expired token cannot be refreshed; that is the trade-off versus an OAuth2 refresh token, which outlives the access token by design. And the endpoint needs its own rate limiter, because a captured token can otherwise be renewed forever by whoever holds it. If a long-lived credential that survives access token expiry is a hard requirement, that is a genuine reason to look at Passport.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Passport Is the Right Choice
&lt;/h2&gt;

&lt;p&gt;Three situations call for a real OAuth2 server, and they are about who the client is rather than how large the app is.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Apps you do not own.&lt;/strong&gt; A partner’s app, a customer’s integration, or a public developer platform where someone else’s code asks for access to your users’ accounts. The user has to see what is being requested and approve it, and the client must never see the user’s password. That is the authorization code grant with PKCE.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Machine-to-machine.&lt;/strong&gt; A reporting job, a warehouse system, or another one of your services calling the API as itself rather than as a user. That is the client credentials grant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real refresh semantics.&lt;/strong&gt; A short-lived access token plus a longer-lived refresh token, with both lifetimes set centrally and refresh tokens revocable on their own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Setup starts with &lt;code&gt;php artisan install:api --passport&lt;/code&gt;, then &lt;code&gt;passport:keys&lt;/code&gt; on each environment to generate the RSA key pair that signs tokens. The user model takes Passport’s &lt;code&gt;HasApiTokens&lt;/code&gt; trait and the &lt;code&gt;OAuthenticatable&lt;/code&gt; interface, and the &lt;code&gt;api&lt;/code&gt; guard in &lt;code&gt;config/auth.php&lt;/code&gt; switches to the &lt;code&gt;passport&lt;/code&gt; driver. Scopes and lifetimes live in a service provider.&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="c1"&gt;// app/Providers/AppServiceProvider.php&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppServiceProvider&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;ServiceProvider&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;Passport&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;tokensCan&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="s1"&gt;'orders:read'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Read your order history'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'orders:write'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Place orders on your behalf'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'profile:read'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Read your profile'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="nc"&gt;Passport&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;defaultScopes&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'profile:read'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

        &lt;span class="nc"&gt;Passport&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;tokensExpireIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CarbonInterval&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;hours&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="nc"&gt;Passport&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;refreshTokensExpireIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CarbonInterval&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;days&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="nc"&gt;Passport&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;personalAccessTokensExpireIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CarbonInterval&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;months&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="c1"&gt;// Opt-in only, and the docs no longer recommend it. Leave it out&lt;/span&gt;
        &lt;span class="c1"&gt;// for a first-party mobile app; use Sanctum for that instead.&lt;/span&gt;
        &lt;span class="c1"&gt;// Passport::enablePasswordGrant();&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;For a mobile app that belongs to someone else, create a public client, which has no secret, since a secret compiled into an app binary is not a secret. The app opens the authorization URL in a system browser sheet (&lt;code&gt;ASWebAuthenticationSession&lt;/code&gt; on iOS, Custom Tabs on Android), receives the code on its redirect URI, and exchanges it together with the code verifier.&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="c1"&gt;// Create the PKCE client once; Passport prints its client ID&lt;/span&gt;
&lt;span class="c1"&gt;// php artisan passport:client --public&lt;/span&gt;

&lt;span class="c1"&gt;// The mobile client builds the challenge from a random 43-128 char verifier&lt;/span&gt;
&lt;span class="nv"&gt;$codeVerifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$codeChallenge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;strtr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;rtrim&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="nb"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$codeVerifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="s1"&gt;'='&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'+/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-_'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Step 1: open in the system browser&lt;/span&gt;
&lt;span class="nv"&gt;$authorizeUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'https://api.example.com/oauth/authorize?'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;http_build_query&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;$clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'redirect_uri'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'com.partner.app://oauth/callback'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'response_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'code'&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;'orders:read profile:read'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'state'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'code_challenge'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$codeChallenge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'code_challenge_method'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'S256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// Step 2: exchange the code; no client_secret for a public client&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="s1"&gt;'https://api.example.com/oauth/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;'grant_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'authorization_code'&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;$clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'redirect_uri'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'com.partner.app://oauth/callback'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'code_verifier'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$codeVerifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'code'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// Protect routes by scope with auth:api and the scope middleware&lt;/span&gt;
&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;OrderController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&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;middleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'auth:api'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CheckToken&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;using&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'orders:read'&lt;/span&gt;&lt;span class="p"&gt;)]);&lt;/span&gt;

&lt;span class="c1"&gt;// Machine-to-machine routes use the client as the resource owner instead&lt;/span&gt;
&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/reports/daily'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;DailyReportController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EnsureClientIsResourceOwner&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;using&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'reports:read'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Passport does not ship a mobile SDK, and the challenge and exchange above would normally run in Swift or Kotlin; the PHP form is here because it is the same algorithm and lints. The server side is the routes Passport registers plus the consent view you provide through &lt;code&gt;Passport::authorizationView()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Passport Costs You Should Price In
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Token size.&lt;/strong&gt; Access tokens are RSA-signed JWTs that encode the client, user, scopes, and expiry, and come out around a kilobyte. A Sanctum token is the row ID, a pipe, and a 40-character random string. Per request that is noise; across a screen that fires a dozen calls on a cellular connection it is measurable, and it is the kind of thing that shows up when you profile a slow list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key management.&lt;/strong&gt; &lt;code&gt;passport:keys&lt;/code&gt; writes a private and public key to &lt;code&gt;storage/&lt;/code&gt;. They must not be in git, every server that validates tokens needs the public key, and rotating the pair invalidates every token signed by the old one. Most teams load them from &lt;code&gt;PASSPORT_PRIVATE_KEY&lt;/code&gt; and &lt;code&gt;PASSPORT_PUBLIC_KEY&lt;/code&gt; environment variables, or point &lt;code&gt;Passport::loadKeysFrom()&lt;/code&gt; at a mounted secrets path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revocation sets a flag rather than deleting the row.&lt;/strong&gt; Expiry is encoded inside the signed token, so the &lt;code&gt;expires_at&lt;/code&gt; column is informational and cannot be edited to shorten a token. To cut one off you call &lt;code&gt;$token-&amp;gt;revoke()&lt;/code&gt;, and separately &lt;code&gt;$token-&amp;gt;refreshToken?-&amp;gt;revoke()&lt;/code&gt;, otherwise the client simply refreshes. Revoked and expired rows accumulate until &lt;code&gt;passport:purge&lt;/code&gt; runs, so schedule it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More tables, more grants, more surface.&lt;/strong&gt; Five tables instead of one, a consent screen to design, redirect URI validation to get right, and the discouraged password and implicit grants sitting one method call away from being enabled by a well-meaning teammate.
&lt;/li&gt;
&lt;/ul&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="c1"&gt;// Revoke one connection, access and refresh token together&lt;/span&gt;
&lt;span class="nv"&gt;$token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Passport&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;token&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;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tokenId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;revoke&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;refreshToken&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;revoke&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Password reset: every token the user has issued to any client&lt;/span&gt;
&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Token&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;revoke&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;refreshToken&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;revoke&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// routes/console.php&lt;/span&gt;
&lt;span class="nc"&gt;Schedule&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'passport:purge'&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;hourly&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Decision Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Sanctum&lt;/th&gt;
&lt;th&gt;Passport&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Client you own (iOS, Android, Flutter)&lt;/td&gt;
&lt;td&gt;Yes, the intended use&lt;/td&gt;
&lt;td&gt;Works, but over-built&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Third-party apps with user consent&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Authorization code + PKCE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Machine-to-machine&lt;/td&gt;
&lt;td&gt;Long-lived token on a service user&lt;/td&gt;
&lt;td&gt;Client credentials grant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token format&lt;/td&gt;
&lt;td&gt;Opaque id|random, hash stored&lt;/td&gt;
&lt;td&gt;RSA-signed JWT, about 1 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refresh tokens&lt;/td&gt;
&lt;td&gt;None; rotate with a custom endpoint&lt;/td&gt;
&lt;td&gt;Built in, separate lifetime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scopes&lt;/td&gt;
&lt;td&gt;Abilities per token&lt;/td&gt;
&lt;td&gt;Scopes declared with tokensCan()&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expiry&lt;/td&gt;
&lt;td&gt;Config minutes or per-token date; null by default&lt;/td&gt;
&lt;td&gt;tokensExpireIn(); one year by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revocation&lt;/td&gt;
&lt;td&gt;Delete the row&lt;/td&gt;
&lt;td&gt;revoke() token and refresh token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secrets to manage&lt;/td&gt;
&lt;td&gt;None beyond APP_KEY&lt;/td&gt;
&lt;td&gt;RSA key pair per environment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;One table&lt;/td&gt;
&lt;td&gt;Clients, codes, tokens, refresh tokens, device codes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Housekeeping&lt;/td&gt;
&lt;td&gt;sanctum:prune-expired&lt;/td&gt;
&lt;td&gt;passport:purge&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Migrating Between Them
&lt;/h2&gt;

&lt;p&gt;The constraint that shapes every mobile migration applies here too: installed app builds keep using whatever token they were issued, for as long as users keep them. You cannot swap the guard on a Tuesday. Plan for a window where both guards accept requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Passport to Sanctum&lt;/strong&gt; is the common direction, usually when a team discovers it adopted OAuth2 for one client it owns. Install Sanctum and add a second guard (&lt;code&gt;sanctum&lt;/code&gt; is already a driver), publish a new login endpoint that issues Sanctum tokens, and ship an app build that uses it. Existing builds keep calling the Passport routes until their tokens expire; shorten &lt;code&gt;tokensExpireIn()&lt;/code&gt; ahead of the switch so that window is weeks rather than a year. Protect shared routes with &lt;code&gt;auth:sanctum,api&lt;/code&gt; so either guard can authenticate during the overlap. Once your build adoption analytics show the old builds are gone, remove the Passport guard, its routes, and the tables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sanctum to Passport&lt;/strong&gt; happens when a partner programme arrives. The snag is that both packages ship a trait called &lt;code&gt;HasApiTokens&lt;/code&gt; with the same method names, so one User model cannot simply use both. The cleanest path is to move the first-party app onto Passport as well, either through the PKCE flow in a system browser sheet or through Passport’s own &lt;code&gt;createToken()&lt;/code&gt; for personal access tokens, which has a similar signature but mints a JWT. The same overlap window applies, with the Sanctum guard kept alive until old builds have gone. If the partner surface is small, it is worth asking first whether a separate, narrowly scoped Passport resource server in front of the same database would cost less than migrating your own app.&lt;/p&gt;

&lt;p&gt;Either way, the migration is mostly a guard switch plus patience. The code that changes is the login endpoint, the route middleware, and the token-revocation paths listed above; business logic should not know which package authenticated the request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Laravel docs: &lt;a href="https://laravel.com/docs/sanctum" rel="noopener noreferrer"&gt;Sanctum&lt;/a&gt;, &lt;a href="https://laravel.com/docs/passport" rel="noopener noreferrer"&gt;Passport&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://oauth2.thephpleague.com/authorization-server/which-grant/" rel="noopener noreferrer"&gt;league/oauth2-server: which grant should I use?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc7636" rel="noopener noreferrer"&gt;RFC 7636: Proof Key for Code Exchange&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc8252" rel="noopener noreferrer"&gt;RFC 8252: OAuth 2.0 for Native Apps&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where This Fits
&lt;/h2&gt;

&lt;p&gt;Pick Sanctum, set an expiry, add the rotation endpoint, and spend the time you saved on the controls that fail security reviews more often than the token library does: ownership checks on every lookup, rate limits on login, and logs that can answer who read what. Those are worked through in the &lt;a href="https://puneetpal.dev/insights/laravel-api-security-checklist-2026" rel="noopener noreferrer"&gt;Laravel API security checklist&lt;/a&gt;. If you would rather have someone build the auth layer and the API around it, see &lt;a href="https://puneetpal.dev/services/backend-api-development" rel="noopener noreferrer"&gt;backend API development&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://puneetpal.dev/insights/laravel-sanctum-vs-passport-mobile-apps-2026" rel="noopener noreferrer"&gt;puneetpal.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>backend</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How Long Does It Take to Build an iOS App? (2026 Timeline)</title>
      <dc:creator>Puneetpal Arneja</dc:creator>
      <pubDate>Sat, 24 Oct 2026 01:57:06 +0000</pubDate>
      <link>https://dev.to/puneetpalarneja/how-long-does-it-take-to-build-an-ios-app-2026-timeline-330b</link>
      <guid>https://dev.to/puneetpalarneja/how-long-does-it-take-to-build-an-ios-app-2026-timeline-330b</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; building an iOS app takes about 3 to 5 weeks for a TestFlight prototype, 4 to 8 weeks for a store-ready MVP, 4 to 8 months for a production app with payments and offline support, and 8 months or more for complex or regulated apps. Those ranges run from kickoff to App Store availability, review included.&lt;/p&gt;

&lt;p&gt;Timelines slip for consistent reasons, and almost none of them are engineering speed. The schedule is usually lost in the weeks before development starts and the weeks after feature work “finishes”, two phases that rarely appear on the plan at all. Here is where the time goes, with ranges you can plan against.&lt;/p&gt;

&lt;p&gt;Timeline and budget move together, so this pairs with &lt;a href="https://puneetpal.dev/insights/ios-app-cost-2026" rel="noopener noreferrer"&gt;what an iOS app costs&lt;/a&gt;, which uses the same tiers. If the ranges below look longer than you have, the lever is scope rather than staffing, which is what &lt;a href="https://puneetpal.dev/insights/mobile-mvp-playbook-2026" rel="noopener noreferrer"&gt;the MVP playbook&lt;/a&gt; is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  iPhone App Development Timeline at a Glance
&lt;/h2&gt;

&lt;p&gt;Kickoff to App Store availability, assuming a dedicated team and decisions made promptly.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;App type&lt;/th&gt;
&lt;th&gt;Timeline&lt;/th&gt;
&lt;th&gt;Team&lt;/th&gt;
&lt;th&gt;What’s included&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prototype&lt;/td&gt;
&lt;td&gt;3-5 weeks&lt;/td&gt;
&lt;td&gt;One developer&lt;/td&gt;
&lt;td&gt;One workflow, mock data, TestFlight only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MVP&lt;/td&gt;
&lt;td&gt;4-8 weeks&lt;/td&gt;
&lt;td&gt;One developer, part-time design&lt;/td&gt;
&lt;td&gt;Auth, a backend, one core workflow built properly, analytics, store launch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Production app&lt;/td&gt;
&lt;td&gt;4-8 months&lt;/td&gt;
&lt;td&gt;One to two developers, design, QA&lt;/td&gt;
&lt;td&gt;Several workflows, payments, push, offline behaviour, a test suite, a real release process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complex or regulated&lt;/td&gt;
&lt;td&gt;8+ months&lt;/td&gt;
&lt;td&gt;Three or more, plus security and backend&lt;/td&gt;
&lt;td&gt;Compliance review, security assessment, and approval gates outside your control&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The most common planning error is quoting the MVP range for a production app. The features look similar on a list; the difference is everything that makes an app supportable. If you are unsure which row you are in, &lt;a href="https://puneetpal.dev/insights/mvp-vs-full-build-2026" rel="noopener noreferrer"&gt;MVP vs full build&lt;/a&gt; walks through that decision.&lt;/p&gt;

&lt;p&gt;Budget follows the same rows. In the cost guide an MVP runs $15K-$40K. The production row splits in two: a standard app at $40K-$80K over 4-6 months, and a complex app at $80K-$150K over 6-8 months. Complex or regulated work starts at $150K. All of those assume US rates.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Are the Phases of iOS App Development?
&lt;/h2&gt;

&lt;p&gt;For an MVP at the top of its range, eight weeks from kickoff to the App Store, the split is roughly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Discovery and scoping: 1 week.&lt;/strong&gt; Which single workflow ships, what the API contract looks like, what is explicitly out. Skipping it moves the time into development, where it is more expensive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design: 1-2 weeks,&lt;/strong&gt; overlapping development once the first flows are settled. Standard iOS components keep this short.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Core development: 3-4 weeks.&lt;/strong&gt; The part everyone estimates, and the part least likely to be the problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integration and hardening: 1-2 weeks.&lt;/strong&gt; Real data, real devices, slow networks, and the edge cases the happy path hid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store preparation and review: 1 week.&lt;/strong&gt; Screenshots, privacy declarations, a demo account for the reviewer, submission, and one likely rejection cycle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A four-week MVP is the same five phases with one workflow, standard components, and a backend that already exists. Discovery and store preparation shrink to days rather than weeks; nothing is skipped.&lt;/p&gt;

&lt;p&gt;A production app at six months, the middle of its range, stretches every phase rather than adding new ones: 2-3 weeks of discovery, 4-6 weeks of design overlapping development, 12-16 weeks of core development, 4-6 weeks of integration and hardening, and 1-2 weeks of store preparation and review. Design and development overlap, which is how 23-33 weeks of phase time fits a roughly 26-week calendar.&lt;/p&gt;

&lt;p&gt;In both cases development is about half the calendar. Teams that compress by cutting discovery and hardening are removing the phases that prevent the overrun.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Slows Down iOS App Development?
&lt;/h2&gt;

&lt;p&gt;A backend being built in parallel is the single largest source of slippage. Mobile work stalls against unfinished or changing endpoints, and the delay is invisible until it is on the critical path. Contract-first API design, with the mobile team working against mocks, is the mitigation.&lt;/p&gt;

&lt;p&gt;After that, in rough order of damage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Design decided during development, where every unresolved screen becomes a blocking question mid-sprint.&lt;/li&gt;
&lt;li&gt;Slow decisions. A team waiting two days for an answer loses more time than any technical problem will cost them.&lt;/li&gt;
&lt;li&gt;Third-party dependencies such as payment providers, identity verification, and enterprise SSO, each of which runs on someone else’s timeline and approval.&lt;/li&gt;
&lt;li&gt;The device and OS matrix, where older devices and the current beta both surface issues late if they are not tested continuously.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How Long Does App Store Review Take?
&lt;/h2&gt;

&lt;p&gt;Review itself is fast in 2026, typically 24 to 48 hours. The cost is a rejection on a first submission, which commonly comes from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Privacy manifest and tracking declarations not matching actual SDK behaviour&lt;/li&gt;
&lt;li&gt;Missing in-app account deletion where an account can be created&lt;/li&gt;
&lt;li&gt;Subscription terms and pricing not presented as required&lt;/li&gt;
&lt;li&gt;Permission prompts without a clear justification string&lt;/li&gt;
&lt;li&gt;Demo credentials that do not work for the reviewer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is avoidable and each costs a round trip of a few days. Plan one rejection cycle into a first launch and treat it as normal rather than as a failure.&lt;/p&gt;

&lt;p&gt;Two other review steps catch teams out. External TestFlight testing needs its own beta review before outside testers can install a build, so the first external beta should not be scheduled for the day before a demo. And while Apple accepts expedited review requests for urgent cases such as a critical bug fix, that is a request rather than a guarantee, so it is not a planning tool. Apple documents the process and current guidance on its &lt;a href="https://developer.apple.com/app-store/review/" rel="noopener noreferrer"&gt;App Review page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Can You Build an iOS App Faster?
&lt;/h2&gt;

&lt;p&gt;These work. Most other accelerations trade schedule for rework:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cut scope, not phases.&lt;/strong&gt; The only reliable lever. Fewer workflows built properly beats more workflows built thinly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Finish design before the sprint that needs it.&lt;/strong&gt; Not before the project; before the sprint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Define the API contract first.&lt;/strong&gt; Mobile and backend then proceed in parallel against mocks instead of sequentially against reality.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put someone with authority in the standup.&lt;/strong&gt; Decision latency is a schedule item.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship to TestFlight from week one.&lt;/strong&gt; Release problems found in week two are cheap; found in week eleven they are the critical path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What does not work: adding developers to a late project, running QA only at the end, and treating store submission as an afterthought.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Estimate Your Own App’s Timeline?
&lt;/h2&gt;

&lt;p&gt;Work through these in order before trusting any single number, including the ones above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pick the row honestly.&lt;/strong&gt; Count the workflows a user must complete on day one, not the features on the wish list. One workflow is an MVP; several, with payments and offline behaviour, is a production app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the backend.&lt;/strong&gt; If it does not exist yet or is changing, assume it is on the critical path and plan the API contract first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count the outside approvals.&lt;/strong&gt; Payment providers, identity checks, enterprise SSO, and compliance sign-off each add a timeline you do not control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the phases estimates leave out.&lt;/strong&gt; Discovery, integration and hardening, store preparation, and one review rejection cycle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Price it alongside the schedule.&lt;/strong&gt; Time and budget move together; the &lt;a href="https://puneetpal.dev/insights/ios-app-cost-2026" rel="noopener noreferrer"&gt;iOS app cost breakdown&lt;/a&gt; uses the same tiers and shows the hours behind each one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the result is longer than you have, cut scope rather than phases. The &lt;a href="https://puneetpal.dev/insights/mobile-mvp-playbook-2026" rel="noopener noreferrer"&gt;mobile MVP playbook&lt;/a&gt; covers how to choose what stays. When you want a delivery plan built around your own constraints, that is part of my &lt;a href="https://puneetpal.dev/services/ios-app-development" rel="noopener noreferrer"&gt;iOS app development&lt;/a&gt; work.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://puneetpal.dev/insights/ios-app-development-timeline-2026" rel="noopener noreferrer"&gt;puneetpal.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>ios</category>
      <category>swift</category>
      <category>startup</category>
    </item>
    <item>
      <title>Laravel API for Mobile Apps: Backend Design Guide (2026)</title>
      <dc:creator>Puneetpal Arneja</dc:creator>
      <pubDate>Sat, 24 Oct 2026 01:57:00 +0000</pubDate>
      <link>https://dev.to/puneetpalarneja/laravel-api-for-mobile-apps-backend-design-guide-2026-3a1a</link>
      <guid>https://dev.to/puneetpalarneja/laravel-api-for-mobile-apps-backend-design-guide-2026-3a1a</guid>
      <description>&lt;p&gt;Laravel works well as the backend for iOS, Android, Flutter, and React Native apps if the API is designed around one fact: you cannot force users to update. Version every route, return one predictable JSON shape, issue revocable per-device tokens, paginate with cursors, and ship a minimum-supported-build check in the first release.&lt;/p&gt;

&lt;p&gt;A web frontend and its API deploy together, so a breaking change is fixed in one release. A mobile client is different. Once a build is approved and installed, it keeps calling your API exactly as it was compiled to, sometimes for years. Every decision below follows from that constraint. This guide covers the design side; for hardening the same API, pair it with the &lt;a href="https://puneetpal.dev/insights/laravel-api-security-checklist-2026" rel="noopener noreferrer"&gt;Laravel API security checklist&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is Laravel a good backend for a mobile app?
&lt;/h2&gt;

&lt;p&gt;For most product apps, yes. Laravel gives a mobile team authentication, queues, scheduled jobs, notifications, file storage, validation, and an ORM out of the box, so a small team can ship a complete API without assembling a framework from parts. It is a weaker fit for workloads dominated by long-lived connections or heavy computation.&lt;/p&gt;

&lt;p&gt;Where it fits well:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Transactional product apps: accounts, orders, bookings, content feeds, subscriptions, admin workflows. This is the bulk of what mobile apps need from a server.&lt;/li&gt;
&lt;li&gt;Small teams who want one conventional codebase that a new hire can read on day one.&lt;/li&gt;
&lt;li&gt;Apps with background work (push notifications, upload processing, syncing with third-party systems), because queues and the scheduler are first-party.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where I would look elsewhere, or add a separate service:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Real-time systems where WebSocket fan-out is the core product rather than a feature.&lt;/li&gt;
&lt;li&gt;CPU-heavy pipelines such as media transcoding or model inference, which belong in dedicated workers.&lt;/li&gt;
&lt;li&gt;Teams whose entire skill set is in another ecosystem. The best backend is one your team can operate at 2 a.m.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How should a Laravel API shape JSON for mobile clients?
&lt;/h2&gt;

&lt;p&gt;Return every resource through an API Resource class and every error through one envelope. Mobile decoders are strict: Swift’s &lt;code&gt;Codable&lt;/code&gt; and Kotlin serializers fail on unexpected types, so a field that is sometimes a string and sometimes null, or an error that is sometimes HTML, becomes a crash or a blank screen in production.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://laravel.com/docs/eloquent-resources" rel="noopener noreferrer"&gt;Laravel API Resources&lt;/a&gt; decouple the response from the database schema, which is exactly what an API serving old app builds needs. Rename a column and the resource keeps emitting the old key.&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderResource&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JsonResource&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;toArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'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;public_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// never the auto-increment key&lt;/span&gt;
            &lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// backed enum, stable strings&lt;/span&gt;
            &lt;span class="s1"&gt;'total'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'amount'&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;total_minor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// integer minor units, not floats&lt;/span&gt;
                &lt;span class="s1"&gt;'currency'&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;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'updated_at'&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;updated_at&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toIso8601String&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rules that keep decoders working across releases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only additive changes within a version. Adding a field is safe; removing, renaming, or changing a type is a new version.&lt;/li&gt;
&lt;li&gt;Money as integer minor units with a currency code. Dates as ISO 8601 with an offset.&lt;/li&gt;
&lt;li&gt;Enums as strings, and clients that map unknown values to a fallback case instead of failing the whole decode. Swift: give the enum a raw value and a custom &lt;code&gt;init(from:)&lt;/code&gt; that falls back to &lt;code&gt;.unknown&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;JSON for every error, including 401, 404, 422, 429, and 500. A mobile client cannot do anything useful with an HTML error page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Laravel decides whether to render an exception as JSON from the &lt;code&gt;Accept&lt;/code&gt; header, and mobile HTTP clients do not always send one. Force it for API routes in &lt;code&gt;bootstrap/app.php&lt;/code&gt; and render your envelope from the same place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// bootstrap/app.php&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Application&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;basePath&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;__DIR__&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="c1"&gt;// ...routing and middleware...&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;withExceptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Exceptions&lt;/span&gt; &lt;span class="nv"&gt;$exceptions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$exceptions&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;shouldRenderJsonWhen&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;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'api/*'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="nv"&gt;$exceptions&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;ApiException&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&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;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                &lt;span class="s1"&gt;'error'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                    &lt;span class="s1"&gt;'code'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;code&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;          &lt;span class="c1"&gt;// stable, machine-readable&lt;/span&gt;
                    &lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMessage&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// display text, safe to change server-side&lt;/span&gt;
                    &lt;span class="s1"&gt;'fields'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="nv"&gt;$e&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="p"&gt;});&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;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;code&lt;/code&gt; is what the app branches on, so it never changes once shipped. The &lt;code&gt;message&lt;/code&gt; is copy you can reword without a release. &lt;code&gt;fields&lt;/code&gt; carries per-field validation messages for 422 responses and is otherwise empty, so the client always has one decoder and one error-handling path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which authentication should a mobile app use with Laravel?
&lt;/h2&gt;

&lt;p&gt;For a first-party app, use &lt;a href="https://laravel.com/docs/sanctum" rel="noopener noreferrer"&gt;Laravel Sanctum&lt;/a&gt; API tokens: one token per device, named after the device, scoped with abilities, and revocable by deleting a row. Reach for Passport only when you need an OAuth2 server for third-party clients. Self-contained JWTs make revocation harder than it needs to be.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sanctum vs Passport vs JWT for mobile apps
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;Sanctum tokens&lt;/th&gt;
&lt;th&gt;Passport (OAuth2)&lt;/th&gt;
&lt;th&gt;JWT package&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Token type&lt;/td&gt;
&lt;td&gt;Opaque, stored hashed in the database&lt;/td&gt;
&lt;td&gt;OAuth2 access and refresh tokens&lt;/td&gt;
&lt;td&gt;Signed, self-contained token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revoke one device&lt;/td&gt;
&lt;td&gt;Delete the token row&lt;/td&gt;
&lt;td&gt;Revoke the token and its refresh token&lt;/td&gt;
&lt;td&gt;Needs a denylist until expiry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refresh flow&lt;/td&gt;
&lt;td&gt;Not built in; use expiry plus re-login or a custom rotate endpoint&lt;/td&gt;
&lt;td&gt;Built in&lt;/td&gt;
&lt;td&gt;Provided by the package&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Third-party clients&lt;/td&gt;
&lt;td&gt;Not designed for it&lt;/td&gt;
&lt;td&gt;Yes, including authorization code with PKCE&lt;/td&gt;
&lt;td&gt;Not designed for it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup and upkeep&lt;/td&gt;
&lt;td&gt;Low, first-party&lt;/td&gt;
&lt;td&gt;Higher: clients, grants, keys&lt;/td&gt;
&lt;td&gt;Moderate, community-maintained&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;Your own iOS, Android, Flutter, or React Native app&lt;/td&gt;
&lt;td&gt;Platforms where partners need delegated access&lt;/td&gt;
&lt;td&gt;Stateless verification across several services&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Issue the token from a login endpoint that takes a &lt;code&gt;device_name&lt;/code&gt;, so users can see and sign out individual devices, and a lost phone can be revoked without logging out everything else:&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="nv"&gt;$token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'device_name'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;    &lt;span class="c1"&gt;// e.g. "iPhone 16 - iOS"&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'orders:read'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'orders:write'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;   &lt;span class="c1"&gt;// abilities checked per route&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;addDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;                &lt;span class="c1"&gt;// per-token expiry&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'token'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;plainTextToken&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On both sides of the token lifecycle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Store the token in the platform keystore: the iOS &lt;a href="https://developer.apple.com/documentation/security/keychain-services" rel="noopener noreferrer"&gt;Keychain&lt;/a&gt; or Android Keystore-backed storage. Never in &lt;code&gt;UserDefaults&lt;/code&gt;, plain shared preferences, or AsyncStorage.&lt;/li&gt;
&lt;li&gt;Set an expiry. Sanctum tokens never expire unless &lt;code&gt;expiration&lt;/code&gt; in &lt;code&gt;config/sanctum.php&lt;/code&gt; is set (minutes) or &lt;code&gt;createToken()&lt;/code&gt; gets a date. Pair it with a scheduled &lt;code&gt;sanctum:prune-expired&lt;/code&gt; so the table does not grow forever.&lt;/li&gt;
&lt;li&gt;Revoke on risk events. A password change, account recovery, or “sign out all devices” should delete the relevant token rows immediately.&lt;/li&gt;
&lt;li&gt;Treat a 401 as a state, not an error. The client clears the stored token and returns to sign-in once, rather than retrying in a loop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the client side of the same problem, the &lt;a href="https://puneetpal.dev/insights/mobile-banking-security-checklist-2026" rel="noopener noreferrer"&gt;mobile banking security checklist&lt;/a&gt; covers token storage, session handling, and the controls a security review expects in the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you version a Laravel API so old app builds keep working?
&lt;/h2&gt;

&lt;p&gt;Put the version in the URL, starting at &lt;code&gt;/api/v1&lt;/code&gt; on day one, and only make additive changes inside a version. Every request carries the app’s version in headers, and a public policy endpoint tells each platform its minimum and recommended version, so you can prompt or force an update before you remove anything an old build depends on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// routes/api.php&lt;/span&gt;
&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/app/version-policy'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;VersionPolicyController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// public, cacheable&lt;/span&gt;

&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'v1'&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;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.version'&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;group&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="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/auth/token'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;AuthController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'store'&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;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'throttle:auth'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'auth:sanctum'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'throttle:api'&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;group&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="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;OrderController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/auth/token'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;AuthController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'destroy'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The contract is small. The client sends &lt;code&gt;X-App-Version&lt;/code&gt; (semver), &lt;code&gt;X-App-Build&lt;/code&gt; and &lt;code&gt;X-App-Platform&lt;/code&gt; (&lt;code&gt;ios&lt;/code&gt; or &lt;code&gt;android&lt;/code&gt;) on every request. &lt;code&gt;GET /app/version-policy?platform=ios&lt;/code&gt; returns &lt;code&gt;minimum&lt;/code&gt;, &lt;code&gt;recommended&lt;/code&gt;, &lt;code&gt;latest&lt;/code&gt;, &lt;code&gt;store_url&lt;/code&gt; and an optional &lt;code&gt;message&lt;/code&gt;. The &lt;code&gt;app.version&lt;/code&gt; middleware answers any request from a version below &lt;code&gt;minimum&lt;/code&gt; with &lt;code&gt;426 Upgrade Required&lt;/code&gt; and the same JSON, and adds &lt;code&gt;Deprecation&lt;/code&gt; and &lt;code&gt;Sunset&lt;/code&gt; headers below &lt;code&gt;recommended&lt;/code&gt;. A Swift client using &lt;a href="https://developer.apple.com/documentation/foundation/urlsession" rel="noopener noreferrer"&gt;URLSession&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;VersionPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Decodable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;minimum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;recommended&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;storeUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;
    &lt;span class="k"&gt;let&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;String&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;fetchVersionPolicy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;VersionPolicy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;components&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;URLComponents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;string&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"https://api.example.com/app/version-policy"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
    &lt;span class="n"&gt;components&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queryItems&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;URLQueryItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"platform"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"ios"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;URLRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;components&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Bundle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;infoDictionary&lt;/span&gt;
    &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;?[&lt;/span&gt;&lt;span class="s"&gt;"CFBundleShortVersionString"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;forHTTPHeaderField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"X-App-Version"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;?[&lt;/span&gt;&lt;span class="s"&gt;"CFBundleVersion"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;forHTTPHeaderField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"X-App-Build"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ios"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;forHTTPHeaderField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"X-App-Platform"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;URLSession&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;guard&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="kt"&gt;HTTPURLResponse&lt;/span&gt;&lt;span class="p"&gt;)?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;URLError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;badServerResponse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;decoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;JSONDecoder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;decoder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;keyDecodingStrategy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;convertFromSnakeCase&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;decoder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;VersionPolicy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&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;A deprecation policy that holds up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Log &lt;code&gt;X-App-Version&lt;/code&gt; on every request. You cannot retire a version without knowing which builds still call it.&lt;/li&gt;
&lt;li&gt;Run versions side by side. &lt;code&gt;v2&lt;/code&gt; controllers reuse the same services and models; only the resources and request validation differ.&lt;/li&gt;
&lt;li&gt;Soft prompt, then hard block. Raise &lt;code&gt;recommended&lt;/code&gt; first, and only raise &lt;code&gt;minimum&lt;/code&gt; once usage of the old version is negligible by your own numbers, not by the calendar.&lt;/li&gt;
&lt;li&gt;Treat a &lt;code&gt;426&lt;/code&gt; from any endpoint as “show the update screen”, so even a build that never fetched the policy shows something sensible.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Header-based versioning (&lt;code&gt;Accept: application/vnd.example.v2+json&lt;/code&gt;) is tidier in theory. In practice a URL prefix is easier to grep in logs, route in a load balancer, and explain to the person writing the Android client.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should pagination and sync work for offline-first apps?
&lt;/h2&gt;

&lt;p&gt;Use cursor pagination for lists and an &lt;code&gt;updated_since&lt;/code&gt; parameter for sync. Offset pagination skips or duplicates rows when data changes between page requests, which happens constantly on a phone with a flaky connection. Cursors are stable, and delta sync means the app downloads only what changed.&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="nv"&gt;$orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&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;orders&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;withTrashed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// include soft-deleted rows as tombstones&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'updated_since'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'updated_at'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'updated_since'&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;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'updated_at'&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;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// tie-breaker: cursor columns must be unique together&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cursorPaginate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'per_page'&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="mi"&gt;100&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;OrderResource&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$orders&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Details that decide whether sync is reliable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deletions must sync too. Soft deletes with a &lt;code&gt;deleted_at&lt;/code&gt; in the resource let the client remove local rows it would otherwise keep forever.&lt;/li&gt;
&lt;li&gt;The client stores the server’s timestamp, not the device clock, as its next &lt;code&gt;updated_since&lt;/code&gt; value. Return it in the response; device clocks drift.&lt;/li&gt;
&lt;li&gt;Writes are idempotent. Accept a client-generated UUID or &lt;code&gt;Idempotency-Key&lt;/code&gt; header on create requests, store it with the row, and return the existing record on a repeat, so a retry after a dropped response does not create a duplicate.&lt;/li&gt;
&lt;li&gt;Conflicts have a rule decided up front: last write wins, server wins, or a 409 with both versions that the app resolves. Undefined behaviour here turns into data loss.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do you send push notifications from Laravel?
&lt;/h2&gt;

&lt;p&gt;Send them from queued jobs, never inside the request. Store each device’s push token against the user and platform, dispatch a queued notification, and deliver through APNs for iOS and Firebase Cloud Messaging for Android. For APNs, use token-based authentication with a &lt;code&gt;.p8&lt;/code&gt; signing key rather than a certificate; the key does not expire and works across all your apps. For FCM, use the HTTP v1 API; the legacy API was shut down in 2024 and some older packages still target it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Register tokens on every launch with an upsert endpoint. Push tokens change, and the app is the only party that knows when.&lt;/li&gt;
&lt;li&gt;Prune invalid tokens when APNs returns 410 or FCM reports &lt;code&gt;UNREGISTERED&lt;/code&gt;, otherwise the queue spends its time on devices that no longer exist.&lt;/li&gt;
&lt;li&gt;Keep payloads small and non-sensitive. Send an identifier and let the app fetch the detail over the authenticated API; lock screens are not private.&lt;/li&gt;
&lt;li&gt;Separate queues by priority so a bulk marketing send cannot delay a time-sensitive transactional alert.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How should mobile apps upload files to a Laravel backend?
&lt;/h2&gt;

&lt;p&gt;Let the app upload directly to object storage with a short-lived presigned URL, then tell the API the upload finished. Streaming large photos or videos through PHP ties up workers on slow mobile connections. Laravel generates presigned S3 upload URLs, so the API only authorizes and records the file.&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="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'headers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$headers&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'s3'&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;temporaryUploadUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'uploads/'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'.jpg'&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;5&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="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'upload_url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'headers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$headers&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A presigned PUT cannot enforce a size limit, so after the app confirms the upload, check the object server-side: &lt;code&gt;Storage::size()&lt;/code&gt; against your cap, the real content type from the bytes rather than the extension, and that the key belongs to the caller. Push any processing such as thumbnails onto a queue. Never trust the file type the client claims.&lt;/p&gt;

&lt;h2&gt;
  
  
  What rate limits does a mobile API need?
&lt;/h2&gt;

&lt;p&gt;At minimum, a general per-user limit for authenticated routes and much stricter limits on login, one-time codes, and password reset. Key limits by user ID when authenticated and by IP otherwise. Mobile carriers put many users behind shared IP addresses, so IP-only limits punish legitimate users.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://laravel.com/docs/routing#rate-limiting" rel="noopener noreferrer"&gt;Laravel’s named rate limiters&lt;/a&gt; express this in a few lines. The numbers below are starting points; tune them from your own traffic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AppServiceProvider::boot()&lt;/span&gt;
&lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'api'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&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="nc"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;perMinute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&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;by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nc"&gt;RateLimiter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'auth'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&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="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;perMinute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&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;by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="s1"&gt;'|'&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
        &lt;span class="nc"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;perMinute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&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;by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;throttle&lt;/code&gt; middleware adds &lt;code&gt;Retry-After&lt;/code&gt; and &lt;code&gt;X-RateLimit-*&lt;/code&gt; headers to the 429 for you; make sure the body is still your error envelope, and make the client honour &lt;code&gt;Retry-After&lt;/code&gt; with jittered backoff. A client that retries instantly on failure turns a brief incident into a self-inflicted outage. The &lt;a href="https://owasp.org/API-Security/editions/2023/en/0x11-t10" rel="noopener noreferrer"&gt;OWASP API Security Top 10&lt;/a&gt; lists unrestricted resource consumption and broken authorization among the most common API risks, and both apply directly to mobile backends.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you monitor on a mobile backend?
&lt;/h2&gt;

&lt;p&gt;Error rates and latency per endpoint, broken down by app version and platform. A backend change that looks healthy overall can break one older build entirely, and you will only see it if requests carry the client build and your dashboards can filter by it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Request context: client platform, build, and a request ID. Generate the ID in middleware, put it in &lt;code&gt;Log::withContext()&lt;/code&gt;, and return it in a response header so support can match an app report to a server log.&lt;/li&gt;
&lt;li&gt;Error tracking on both sides, with the same request ID visible in app crash reports and server exceptions.&lt;/li&gt;
&lt;li&gt;Queue health: backlog depth, failed jobs, and job duration. Laravel Pulse or a hosted APM covers the basics; Horizon adds detail if you run Redis queues.&lt;/li&gt;
&lt;li&gt;Version adoption: requests per build over time, which is the number you need before retiring an API version.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where does Next.js fit next to a Laravel mobile backend?
&lt;/h2&gt;

&lt;p&gt;Beside Laravel, not in front of the mobile app. Next.js works well for an admin dashboard, marketing site, or web version of the product, consuming the same Laravel API or a separate web backend-for-frontend (see &lt;a href="https://puneetpal.dev/services/web-development" rel="noopener noreferrer"&gt;Next.js web development&lt;/a&gt;). The mobile app should call Laravel directly through its versioned API.&lt;/p&gt;

&lt;p&gt;Adding a Next.js layer between the app and Laravel means two services that must both stay compatible with every installed build. Keep the mobile contract in one place. If you are planning the stack as a whole, my &lt;a href="https://puneetpal.dev/services/backend-api-development" rel="noopener noreferrer"&gt;Laravel API backend development&lt;/a&gt; service covers the API, admin tooling, and infrastructure together.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you build first?
&lt;/h2&gt;

&lt;p&gt;Before the first app release, get the decisions that are expensive to reverse in place: the &lt;code&gt;/api/v1&lt;/code&gt; prefix, the response and error envelope, per-device Sanctum tokens with an expiry, the version-policy endpoint with a minimum version, and app version logging. Everything else in this guide can be added later without breaking installed apps. Cursor pagination is the one I would move up the list if the app has any list that grows, because switching from offset to cursor later changes the response shape.&lt;/p&gt;

&lt;p&gt;If you are still scoping the product, the &lt;a href="https://puneetpal.dev/insights/mobile-mvp-playbook-2026" rel="noopener noreferrer"&gt;mobile MVP playbook&lt;/a&gt; covers what to include in a first release, and &lt;a href="https://puneetpal.dev/services/mobile-app-development" rel="noopener noreferrer"&gt;mobile app development&lt;/a&gt; covers the client side. When you want the backend designed and built alongside the app, I offer &lt;a href="https://puneetpal.dev/services/backend-api-development" rel="noopener noreferrer"&gt;Laravel backend and API development for mobile apps&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should a mobile app use Laravel Sanctum or Passport?
&lt;/h3&gt;

&lt;p&gt;Use Sanctum when your own first-party app is the only client. It issues revocable per-device tokens with very little setup. Choose Passport only when you need a real OAuth2 server, for example when third-party developers or partner apps must obtain delegated access to your users' data.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long should a Laravel API keep supporting an old version?
&lt;/h3&gt;

&lt;p&gt;Long enough that almost no active installs still depend on it. Set the window from your own analytics on build adoption rather than the calendar, announce it in-app before removal, and raise the minimum version in the version policy (old builds then get a 426 and an update screen) so the last stragglers see an update prompt instead of broken screens.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can Laravel handle real-time features for a mobile app?
&lt;/h3&gt;

&lt;p&gt;Yes. Laravel broadcasting with Laravel Reverb, or a hosted WebSocket service, covers live updates while the app is in the foreground. Push notifications through APNs and FCM still handle the background case, because mobile operating systems suspend open sockets when the app is not active.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should a mobile app call Laravel directly or go through Next.js?
&lt;/h3&gt;

&lt;p&gt;Call Laravel directly. A mobile client gains little from an extra hop, and every layer is another thing that has to stay backward compatible with old app builds. Next.js earns its place for the admin panel, marketing site, or a web client, not as a proxy in front of the mobile API.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://puneetpal.dev/insights/laravel-backend-for-mobile-apps-2026" rel="noopener noreferrer"&gt;puneetpal.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>backend</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
