<?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>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>
