<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Kamran Khan]]></title><description><![CDATA[Full-Stack Web Developer working with React.js and Laravel. I build dashboards, SaaS applications, and business management systems for clients worldwide. I writ]]></description><link>https://kamrankhandev.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Kamran Khan</title><link>https://kamrankhandev.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 16 Oct 2026 08:21:06 GMT</lastBuildDate><atom:link href="https://kamrankhandev.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Never trust the token in localStorage: why my frontend re-fetches /me on every reload]]></title><description><![CDATA[A token in localStorage proves nothing. It proves that at some point in the past, someone logged in. It doesn't prove the account still exists, that the role hasn't been revoked, or that the token has]]></description><link>https://kamrankhandev.hashnode.dev/never-trust-the-token-in-localstorage-why-my-frontend-re-fetches-me-on-every-reload</link><guid isPermaLink="true">https://kamrankhandev.hashnode.dev/never-trust-the-token-in-localstorage-why-my-frontend-re-fetches-me-on-every-reload</guid><category><![CDATA[React]]></category><category><![CDATA[Laravel]]></category><category><![CDATA[webdev]]></category><category><![CDATA[SaaS]]></category><dc:creator><![CDATA[Kamran Khan]]></dc:creator><pubDate>Thu, 15 Oct 2026 05:14:57 GMT</pubDate><content:encoded><![CDATA[<p>A token in localStorage proves nothing. It proves that at some point in the past, someone logged in. It doesn't prove the account still exists, that the role hasn't been revoked, or that the token hasn't been deleted server-side. The moment your app treats a stored token as proof of identity, you're one revoked account away from a ghost session — a "signed-in" user whose every API call returns 401.</p>
<p>So my frontend never trusts the stored token. On every reload, the AuthProvider calls <code>/me</code> and lets the backend decide who this is:</p>
<pre><code class="language-php">public function me(Request $request): JsonResponse
{
    return response()-&gt;json(['user' =&gt; new UserResource($request-&gt;user())]);
}
</code></pre>
<p>One line. <code>auth:sanctum</code> resolves the token to a user or rejects it; the method just answers "this is who you are" — through <code>UserResource</code>, so the shape matches every other endpoint. And on the React side:</p>
<pre><code class="language-js">useEffect(() =&gt; {
  const stored = localStorage.getItem('saas_token');
  if (!stored) {
    setLoading(false);
    return;
  }
  client
    .get('/auth/me')
    .then((res) =&gt; {
      setUser(res.data.user);
      setToken(stored);
    })
    .catch(() =&gt; {
      localStorage.removeItem('saas_token');
      setUser(null);
      setToken(null);
    })
    .finally(() =&gt; setLoading(false));
}, []);
</code></pre>
<p>Three paths, all deliberate:</p>
<ol>
<li><strong>No token stored</strong> — skip the request entirely, mark loading done. Don't fire an API call you know will 401; the catch block would clean it up anyway, but a request you never send is a request you never debug.</li>
<li><strong>Token valid</strong> — the user object comes from the server, not from memory. Role changes, name changes, account deletions all resolve here, on the first paint. The <code>isAdmin</code> checks downstream read <code>user.role</code> from this fresh object — so revoking someone's admin role takes effect on their next reload, not "whenever the frontend feels like it".</li>
<li><strong>Token dead</strong> — the catch wipes everything: localStorage, user, token. A dead token never lingers. The app lands on the signed-out state honestly, instead of showing a dashboard shell that 401s on every widget.</li>
</ol>
<p>Note the ordering in the catch: remove from localStorage <em>first</em>, then clear state. If the component unmounts mid-cleanup or something throws, the next reload still starts clean. Persistent storage is the source of the ghost session; clear the persistence before anything else.</p>
<p>This is also why login and register don't need a separate "fetch the user" step — they get the user object in the same response as the token, through the same resource. But a reload has no fresh login response. <code>/me</code> is the re-validation that fills the gap.</p>
<p>The rule is simple: the token is a key, not an identity. Keys get you to the door; the person who answers decides who you are. One endpoint, called once per reload, and the ghost session never happens.</p>
<hr />
<p><em>This session-restoration pattern ships in my <a href="https://kamranofficial.gumroad.com/l/mhekoig">Laravel + React SaaS Starter Kit</a> ($19 one-time) — Sanctum auth, the /me re-validation, and an AuthContext that never trusts a stored token.</em></p>
]]></content:encoded></item><item><title><![CDATA[Same 401 whether the email is wrong or the password is: why my login never says which field failed]]></title><description><![CDATA[Most login forms I've reviewed in client work are surprisingly chatty. Wrong email? "User not found." Right email, wrong password? "Incorrect password." Each message feels helpful to the person loggin]]></description><link>https://kamrankhandev.hashnode.dev/same-401-whether-the-email-is-wrong-or-the-password-is-why-my-login-never-says-which-field-failed</link><guid isPermaLink="true">https://kamrankhandev.hashnode.dev/same-401-whether-the-email-is-wrong-or-the-password-is-why-my-login-never-says-which-field-failed</guid><category><![CDATA[Laravel]]></category><category><![CDATA[PHP]]></category><category><![CDATA[SaaS]]></category><category><![CDATA[Security]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Kamran Khan]]></dc:creator><pubDate>Thu, 15 Oct 2026 05:13:48 GMT</pubDate><content:encoded><![CDATA[<p>Most login forms I've reviewed in client work are surprisingly chatty. Wrong email? "User not found." Right email, wrong password? "Incorrect password." Each message feels helpful to the person logging in — and each one is also a free oracle for anyone probing the app.</p>
<p>Here's the attack it enables: feed the endpoint a list of harvested emails. Every response of "incorrect password" confirms that address has an account; every "user not found" doesn't. After a few thousand requests, the attacker owns a verified list of your users' emails — perfect raw material for credential-stuffing and targeted phishing. You gave it away one helpful error message at a time.</p>
<p>My login deliberately can't tell you which half failed:</p>
<pre><code class="language-php">public function login(LoginRequest $request): JsonResponse
{
    $user = User::where('email', $request-&gt;validated('email'))-&gt;first();

    if (! $user || ! Hash::check($request-&gt;validated('password'), $user-&gt;password)) {
        return response()-&gt;json(['message' =&gt; 'Invalid credentials'], 401);
    }

    $token = $user-&gt;createToken('auth-token')-&gt;plainTextToken;

    return response()-&gt;json([
        'user' =&gt; new UserResource($user),
        'token' =&gt; $token,
    ]);
}
</code></pre>
<p>One condition, one response: <code>Invalid credentials</code>, 401. Unknown email and wrong password are indistinguishable from the outside — there is no timing channel in the message and no field-level hint. The React login page renders exactly this string under the form, so the frontend can't accidentally leak the distinction either.</p>
<h2>What about honest validation?</h2>
<p>This isn't the same thing as validation errors. If the email isn't an email, or the password field is empty, the <code>LoginRequest</code> still returns proper 422 field-level errors — that's honest, fixable input feedback, and it reveals nothing about the database. The silence only starts where the database gets involved. The line is: tell the user what's wrong with what they typed; never tell them what's in your users table.</p>
<p>There's a second, quieter benefit: the response shape is identical on both paths, which keeps the frontend's error handling boring. One string, one status code, one render branch. The axios interceptor treats any 401 as a session problem uniformly — this endpoint's 401 fits that contract perfectly.</p>
<p>Security through less information isn't obscurity; it's just not handing out free reconnaissance. Every SaaS login I build gets this treatment, and it's a five-line decision.</p>
<hr />
<p><em>This login-discipline pattern ships in my <a href="https://kamranofficial.gumroad.com/l/mhekoig">Laravel + React SaaS Starter Kit</a> ($19 one-time) — honest 422 validation on the form fields, a single silent 401 for bad credentials, and a Sanctum token on success.</em></p>
]]></content:encoded></item><item><title><![CDATA[Your register endpoint should ignore the role field: the privilege-escalation guard I ship in one line]]></title><description><![CDATA[The scariest mass-assignment bug in a SaaS app is also the quietest one. You write a register endpoint, you add role to the model's $fillable so the admin panel can assign roles, and one day someone p]]></description><link>https://kamrankhandev.hashnode.dev/your-register-endpoint-should-ignore-the-role-field-the-privilege-escalation-guard-i-ship-in-one-line</link><guid isPermaLink="true">https://kamrankhandev.hashnode.dev/your-register-endpoint-should-ignore-the-role-field-the-privilege-escalation-guard-i-ship-in-one-line</guid><category><![CDATA[Laravel]]></category><category><![CDATA[PHP]]></category><category><![CDATA[SaaS]]></category><category><![CDATA[Security]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Kamran Khan]]></dc:creator><pubDate>Thu, 15 Oct 2026 05:12:29 GMT</pubDate><content:encoded><![CDATA[<p>The scariest mass-assignment bug in a SaaS app is also the quietest one. You write a register endpoint, you add <code>role</code> to the model's <code>$fillable</code> so the admin panel can assign roles, and one day someone posts this to your public <code>/register</code>:</p>
<pre><code class="language-json">{
  "name": "Attacker",
  "email": "attacker@example.com",
  "password": "password123",
  "password_confirmation": "password123",
  "role": "admin"
}
</code></pre>
<p>If your controller does <code>User::create($request-&gt;validated())</code> and <code>role</code> passes through validation, you just handed a stranger an admin account. No exploit, no SQL injection — just a form field your endpoint shouldn't have been listening to.</p>
<p>My register method doesn't read <code>role</code> from the request at all:</p>
<pre><code class="language-php">public function register(RegisterRequest $request): JsonResponse
{
    $user = User::create([
        'name' =&gt; $request-&gt;validated('name'),
        'email' =&gt; $request-&gt;validated('email'),
        'password' =&gt; $request-&gt;validated('password'),
        'role' =&gt; 'user',
    ]);

    $token = $user-&gt;createToken('auth-token')-&gt;plainTextToken;

    return response()-&gt;json([
        'user' =&gt; new UserResource($user),
        'token' =&gt; $token,
    ], 201);
}
</code></pre>
<p>Two things make this airtight. First, <code>RegisterRequest</code> has no <code>role</code> rule — so <code>validated()</code> never carries a role through, even if one was sent. Second, <code>'role' =&gt; 'user'</code> is hardcoded server-side at creation. The attacker can post <code>"role": "admin"</code> all day; the value never reaches the model. Privileges are assigned, never requested.</p>
<h2>Where role assignment actually lives</h2>
<p>Role assignment does exist in the kit — it just lives behind the admin gate. <code>StoreUserRequest</code> validates <code>'role' =&gt; ['required', 'string', 'in:admin,user']</code>, and that request is only used by <code>UserController</code>, whose routes sit behind <code>auth:sanctum</code> plus the <code>role:admin</code> middleware:</p>
<pre><code class="language-php">Route::middleware(['auth:sanctum', 'role:admin'])-&gt;group(function () {
    Route::apiResource('users', UserController::class);
});
</code></pre>
<p>So the rule of the system is simple: the only way a <code>role</code> value enters the database is through an admin's hands, on an admin-only route, validated to exactly <code>admin</code> or <code>user</code>. The public register path cannot express the concept of a role at all — not by omission, but by construction. That's the level you want: not "we check it" but "there is nothing to check".</p>
<p>One line of hardening you can steal today: in your own register handler, find where the role comes from. If the answer is anything other than a server-side constant, fix it.</p>
<hr />
<p><em>This is the role-guard pattern I ship in my <a href="https://kamranofficial.gumroad.com/l/mhekoig">Laravel + React SaaS Starter Kit</a> ($19 one-time) — registration always creates a regular user, and only admins behind the role middleware can promote accounts.</em></p>
]]></content:encoded></item><item><title><![CDATA[Every SaaS ships dead ends: the 404 page and redirect map I keep in every kit]]></title><description><![CDATA[Users will hit bad URLs in your app. Not might — will. Deleted users with bookmarked profile links, a client who half-remembers /user instead of /users, a typo'd link in an email. What happens at that]]></description><link>https://kamrankhandev.hashnode.dev/every-saas-ships-dead-ends-the-404-page-and-redirect-map-i-keep-in-every-kit</link><guid isPermaLink="true">https://kamrankhandev.hashnode.dev/every-saas-ships-dead-ends-the-404-page-and-redirect-map-i-keep-in-every-kit</guid><category><![CDATA[React]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Laravel]]></category><dc:creator><![CDATA[Kamran Khan]]></dc:creator><pubDate>Wed, 14 Oct 2026 05:16:24 GMT</pubDate><content:encoded><![CDATA[<p>Users will hit bad URLs in your app. Not might — will. Deleted users with bookmarked profile links, a client who half-remembers <code>/user</code> instead of <code>/users</code>, a typo'd link in an email. What happens at that dead end is a design decision you're making whether you think about it or not. A blank page or a raw JSON error is a decision too — just a bad one.</p>
<p>My kit handles it in about thirty lines across two files. Here's the whole thing.</p>
<h2>The catch-all route</h2>
<p>In <code>App.jsx</code>, the last route in the list is:</p>
<pre><code class="language-jsx">&lt;Route path="*" element={&lt;NotFound /&gt;} /&gt;
</code></pre>
<p>React Router matches routes top to bottom, so the wildcard sits last and catches everything unmatched. The <code>NotFound</code> page is a full-screen, branded dead end — big "404", "Page not found", the honest line "The page you're looking for doesn't exist or was moved", and one indigo "Go home" button linking back to <code>/</code>. No stack trace, no developer furniture. The user did nothing wrong; don't make them feel like they did.</p>
<h2>The explicit /404 route</h2>
<p>Right next to it:</p>
<pre><code class="language-jsx">&lt;Route path="/404" element={&lt;NotFound /&gt;} /&gt;
</code></pre>
<p>Why have both? Because sometimes you want to <em>send</em> someone to the 404 on purpose — a user profile that no longer exists, a resource your loader can't find. Having a real, linkable 404 route means "not found" is a destination your code can navigate to, not just a fallback the router trips over.</p>
<h2>The redirect that cleans up after itself</h2>
<p>Old habits die hard, so the kit keeps one alias:</p>
<pre><code class="language-jsx">&lt;Route path="/home" element={&lt;Navigate to="/" replace /&gt;} /&gt;
</code></pre>
<p><code>/home</code> sends you to <code>/</code>, and the <code>replace</code> prop is the important part: it <em>replaces</em> the history entry instead of pushing. Without <code>replace</code>, the dead alias sits in the browser history — the user presses back and lands on <code>/home</code> again, which bounces them forward again. An infinite loop of politeness. <code>replace</code> means the alias never existed as far as the back button is concerned.</p>
<h2>The basename trick for sub-path deploys</h2>
<p>The router gets its basename from Vite's build config:</p>
<pre><code class="language-js">const basename = import.meta.env.BASE_URL.replace(/\/$/, "") || "/";
</code></pre>
<p>This is the line that lets the same build run at the domain root <em>and</em> under a sub-path like GitHub Pages (<code>/my-app/</code>). Vite's <code>base</code> option bakes the sub-path into <code>BASE_URL</code>; the router reads it at runtime, so every <code>&lt;Link&gt;</code> and every route works in both deployments. Strip the trailing slash, fall back to <code>/</code>. Without this, your 404 page works locally and breaks the moment the app is deployed under a prefix — exactly the kind of bug you discover from a client's screenshot.</p>
<h2>The honest version</h2>
<p>None of this is clever. That's why it belongs in a starter kit: dead-end handling is the kind of thing every developer agrees is important and nobody writes on day one. Ship it on day one. A user who hits a friendly 404 with a way home still trusts your product; a user who hits a blank tab does not.</p>
<p>The 404 page, the redirect map, and the sub-path-safe router setup ship in my Laravel + React SaaS Starter Kit (<a href="https://kamranofficial.gumroad.com/l/mhekoig">https://kamranofficial.gumroad.com/l/mhekoig</a>), a $19 one-time starter with Sanctum auth, role-based access, and an admin dashboard.</p>
]]></content:encoded></item><item><title><![CDATA[One Layout component for your whole SaaS: the sidebar shell every page shares]]></title><description><![CDATA[Every SaaS app has chrome: a sidebar, a top bar, the user chip, a logout button. And every codebase I've inherited rebuilds that chrome on every page, slightly differently each time — different paddin]]></description><link>https://kamrankhandev.hashnode.dev/one-layout-component-for-your-whole-saas-the-sidebar-shell-every-page-shares</link><guid isPermaLink="true">https://kamrankhandev.hashnode.dev/one-layout-component-for-your-whole-saas-the-sidebar-shell-every-page-shares</guid><category><![CDATA[React]]></category><category><![CDATA[Laravel]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Kamran Khan]]></dc:creator><pubDate>Wed, 14 Oct 2026 05:15:04 GMT</pubDate><content:encoded><![CDATA[<p>Every SaaS app has chrome: a sidebar, a top bar, the user chip, a logout button. And every codebase I've inherited rebuilds that chrome on every page, slightly differently each time — different padding on the Users page, a logout button that lives in the header on one screen and a dropdown on another. Drift is inevitable when the shell is copy-pasted.</p>
<p>In my kit, the entire authenticated shell is one component: <code>Layout.jsx</code>. Every protected page renders inside it through react-router's <code>Outlet</code>. New page, same chrome, zero duplication.</p>
<h2>One wrapper for all protected routes</h2>
<p>In <code>App.jsx</code>, the layout itself sits behind the auth guard:</p>
<pre><code class="language-jsx">&lt;Route element={&lt;ProtectedRoute&gt;&lt;Layout /&gt;&lt;/ProtectedRoute&gt;}&gt;
  &lt;Route index element={&lt;Dashboard /&gt;} /&gt;
  &lt;Route path="/users" element={&lt;AdminRoute&gt;&lt;Users /&gt;&lt;/AdminRoute&gt;} /&gt;
&lt;/Route&gt;
</code></pre>
<p>Notice what this buys: the sidebar, the header, the user chip, and the logout button are all inside <code>ProtectedRoute</code>. A signed-out visitor never sees any of them — not even a flash — because the layout is a <em>child</em> of the guard, not a sibling. And the admin-only <code>/users</code> page gets a second, tighter guard (<code>AdminRoute</code>) around just that route. Two layers, each in exactly one place.</p>
<h2>The header knows what page you're on</h2>
<p>The top bar shows a title, and the layout maps the current pathname to a title with a plain object:</p>
<pre><code class="language-js">const pageTitles = {
  '/': 'Dashboard',
  '/users': 'Users',
};
const title = pageTitles[location.pathname] || 'SaaS Kit';
</code></pre>
<p>No route config to thread through, no context to provide, no prop drilling. Adding a page later means adding one line here, one route, one nav link. The fallback (<code>'SaaS Kit'</code>) means an unlisted route still gets a sane header instead of an empty one.</p>
<h2>The nav link that hides itself</h2>
<p>The sidebar's Users link renders only for admins:</p>
<pre><code class="language-jsx">{isAdmin &amp;&amp; (
  &lt;NavLink to="/users" className={linkClass}&gt;Users&lt;/NavLink&gt;
)}
</code></pre>
<p>with <code>isAdmin = user?.role === 'admin'</code> read straight from the auth context. This is the visual twin of the <code>AdminRoute</code> guard on the route itself — the guard is the security, the conditional link is the manners. Never rely on hiding the link alone (anyone can type the URL), but never skip it either: a link to a page you're about to be bounced from is just a broken promise.</p>
<p>Active links get their styling from a single function:</p>
<pre><code class="language-js">const linkClass = ({ isActive }) =&gt;
  isActive ? 'bg-indigo-600 text-white ...' : 'text-slate-300 hover:bg-slate-800 ...';
</code></pre>
<p>One function, one definition of "active". Six months from now, when a client asks for the active colour changed, there's one line to change.</p>
<h2>The user chip and logout, done once</h2>
<p>The bottom of the sidebar shows the signed-in user: an initial avatar, their name, and their role as a badge:</p>
<pre><code class="language-js">const initial = (user?.name || 'U').charAt(0).toUpperCase();
</code></pre>
<p>And logout is a small async handler:</p>
<pre><code class="language-js">const handleLogout = async () =&gt; {
  await logout();
  navigate('/login');
};
</code></pre>
<p>It awaits the context's logout (which hits the API and clears the token) <em>before</em> navigating. Fire-and-forget navigation here is a classic bug: you land on <code>/login</code> while the token is still being revoked, and a quick back-button press resurrects a half-dead session.</p>
<h2>The honest version</h2>
<p>A single layout component sounds boring, and it is. That's the point. The shell is the part of your app users see on every single screen — it's the last place you want five slightly different implementations. One <code>Layout</code>, one <code>Outlet</code>, one title map, one link style. Add pages freely; the chrome never drifts.</p>
<p>This shell — layout, protected routes, admin-gated nav, logout flow — ships in my Laravel + React SaaS Starter Kit (<a href="https://kamranofficial.gumroad.com/l/mhekoig">https://kamranofficial.gumroad.com/l/mhekoig</a>), a $19 one-time starter with Sanctum auth, role-based access, and an admin dashboard.</p>
]]></content:encoded></item><item><title><![CDATA[Your dashboard numbers should be four COUNT(*) queries: the metrics endpoint I ship with every kit]]></title><description><![CDATA[Most dashboards I've inherited show one of two things: numbers hardcoded in the frontend ("Total Users: 1,204" that never changes), or one monster query behind an endpoint that takes two seconds and d]]></description><link>https://kamrankhandev.hashnode.dev/your-dashboard-numbers-should-be-four-count-queries-the-metrics-endpoint-i-ship-with-every-kit</link><guid isPermaLink="true">https://kamrankhandev.hashnode.dev/your-dashboard-numbers-should-be-four-count-queries-the-metrics-endpoint-i-ship-with-every-kit</guid><category><![CDATA[Laravel]]></category><category><![CDATA[webdev]]></category><category><![CDATA[api]]></category><dc:creator><![CDATA[Kamran Khan]]></dc:creator><pubDate>Wed, 14 Oct 2026 05:13:33 GMT</pubDate><content:encoded><![CDATA[<p>Most dashboards I've inherited show one of two things: numbers hardcoded in the frontend ("Total Users: 1,204" that never changes), or one monster query behind an endpoint that takes two seconds and dies under load. Neither is honest.</p>
<p>In my SaaS kit, the dashboard is fed by exactly one endpoint, and that endpoint does four <code>COUNT(*)</code> queries. Nothing else. Here's why that design survived every client project I've put it in.</p>
<h2>The endpoint: GET /dashboard/stats</h2>
<p>The controller is the whole story, so here it is in full:</p>
<pre><code class="language-php">public function stats(): JsonResponse
{
    return response()-&gt;json([
        'total_users' =&gt; User::count(),
        'new_users_this_week' =&gt; User::where('created_at', '&gt;=', now()-&gt;subWeek())-&gt;count(),
        'admins_count' =&gt; User::where('role', 'admin')-&gt;count(),
        'active_tokens' =&gt; PersonalAccessToken::count(),
    ]);
}
</code></pre>
<p>Four numbers, four queries, each one an aggregate the database computes without loading a single row into PHP. That's the principle: <strong>the frontend renders numbers, the database counts them.</strong> The moment you ship full user collections to the frontend and count them in JavaScript, you've coupled your UI to a payload that grows with every signup.</p>
<p>A few deliberate choices in there:</p>
<ul>
<li><strong>It's behind <code>auth:sanctum</code>, not <code>role:admin</code>.</strong> These are cheap aggregates — any signed-in user can see them. The heavier, paginated users list stays behind the admin-only route. Don't gate cheap things; gate expensive things.</li>
<li><strong><code>now()-&gt;subWeek()</code></strong> for the "new this week" window — the boundary is computed server-side at request time, so there's no timezone argument between frontend and backend about what "this week" means.</li>
<li><strong>Active tokens come from Sanctum's own <code>personal_access_tokens</code> table.</strong> No extra tracking infrastructure, no events to fire. Sanctum already knows how many tokens exist; just ask it.</li>
</ul>
<h2>The frontend: one fetch, one loading state, no zombie updates</h2>
<p>The dashboard page fires the stats request and — for admins only — a second request for five recent users, in parallel:</p>
<pre><code class="language-js">const [statsRes, usersRes] = await Promise.all([
  client.get('/dashboard/stats'),
  isAdmin ? client.get('/users', { params: { per_page: 5 } }) : Promise.resolve(null),
]);
</code></pre>
<p>Non-admins skip the second request entirely instead of fetching it and hiding it. That matters: don't burn a database query on rows the viewer is never allowed to see.</p>
<p>And because the dashboard unmounts when you navigate away mid-fetch, there's a cancelled flag in the effect cleanup:</p>
<pre><code class="language-js">useEffect(() =&gt; {
  let cancelled = false;
  async function load() {
    try {
      // ...requests...
      if (cancelled) return;
      setStats(statsRes.data);
    } catch (err) {
      if (!cancelled) setError(...);
    } finally {
      if (!cancelled) setLoading(false);
    }
  }
  load();
  return () =&gt; { cancelled = true; };
}, [isAdmin]);
</code></pre>
<p>Every <code>setState</code> is guarded. Without that, you get the classic React warning — a state update on an unmounted component — the first time a user clicks away before the spinner finishes. It's three lines. Write them every time.</p>
<p>The error path deserves the same care: a failed stats fetch renders a rose banner with the server's message (<code>err.response?.data?.message</code>), falling back to "Failed to load dashboard data." Never a blank dashboard. A dashboard that fails silently is worse than no dashboard — your client will stare at zeroes and assume they have no users.</p>
<h2>The cards: one component, four accents</h2>
<p>The stat cards are a single <code>StatCard</code> component taking <code>label</code>, <code>value</code>, <code>icon</code>, and <code>accent</code>. The accent maps to a Tailwind colour pair (indigo, emerald, amber, rose), and the value falls back to <code>0</code> when stats haven't loaded:</p>
<pre><code class="language-jsx">&lt;StatCard label="Total Users" value={stats?.total_users ?? 0} icon={usersIcon} accent="indigo" /&gt;
</code></pre>
<p>Uniform cards mean uniform dashboards. When a client later asks for "one more metric", the change is a new key in the JSON and a new <code>&lt;StatCard&gt;</code> line — the component never changes shape, so the design never drifts.</p>
<h2>The honest version</h2>
<p>I've shipped dashboards where the numbers came from a cache, from a queue, from a materialised view. All fine at scale. But at the starter-kit scale — dozens to thousands of users — four indexed <code>COUNT(*)</code> queries run in milliseconds and are never stale, never out of sync, never "recomputed nightly". Start honest, optimise when the query log tells you to.</p>
<p>All of this — the stats endpoint, the parallel fetch with the cancelled guard, the StatCard component — ships in my Laravel + React SaaS Starter Kit (<a href="https://kamranofficial.gumroad.com/l/mhekoig">https://kamranofficial.gumroad.com/l/mhekoig</a>), a $19 one-time starter with Sanctum auth, role-based access, and an admin dashboard that reads real rows from day one.</p>
]]></content:encoded></item><item><title><![CDATA[Validate strangers and returning users differently: my register vs login requests]]></title><description><![CDATA[Registration and login look like the same form — email, password, submit. So most codebases validate them the same way. That's a mistake. A stranger creating an account and a returning user proving wh]]></description><link>https://kamrankhandev.hashnode.dev/validate-strangers-and-returning-users-differently-my-register-vs-login-requests</link><guid isPermaLink="true">https://kamrankhandev.hashnode.dev/validate-strangers-and-returning-users-differently-my-register-vs-login-requests</guid><category><![CDATA[Laravel]]></category><category><![CDATA[PHP]]></category><category><![CDATA[webdev]]></category><category><![CDATA[api]]></category><dc:creator><![CDATA[Kamran Khan]]></dc:creator><pubDate>Mon, 12 Oct 2026 21:56:39 GMT</pubDate><content:encoded><![CDATA[<p>Registration and login look like the same form — email, password, submit. So most codebases validate them the same way. That's a mistake. A stranger creating an account and a returning user proving who they are face different risks, and my validation rules say so explicitly. Here are the two FormRequests side by side.</p>
<h2>Register: strict, because the stranger is untrusted</h2>
<pre><code class="language-php">// RegisterRequest
return [
    'name' =&gt; ['required', 'string', 'max:255'],
    'email' =&gt; ['required', 'string', 'email', 'max:255', 'unique:users,email'],
    'password' =&gt; ['required', 'string', 'min:8', 'confirmed'],
];
</code></pre>
<p>Everything required — a new account with no name or no email is a broken record. <code>unique:users,email</code> because a duplicate registration is either a confused user or an account-squatting probe; either way the API should say no. And <code>confirmed</code>: the stranger types the password twice, because a typo at registration means a permanently locked-out account and a support email. The cost of the extra field is one input; the cost of a mistyped password is a lost user.</p>
<p>Note what's <em>not</em> here: no <code>role</code> field. The register handler hardcodes <code>'role' =&gt; 'user'</code>. A role field on the registration request would be a privilege-escalation form with extra steps — the strictest validation rule is the field that doesn't exist.</p>
<h2>Login: minimal, because the check happens elsewhere</h2>
<pre><code class="language-php">// LoginRequest
return [
    'email' =&gt; ['required', 'string', 'email'],
    'password' =&gt; ['required', 'string'],
];
</code></pre>
<p>No <code>min:8</code>. No <code>confirmed</code>. No <code>unique</code>. Login isn't creating anything, so there's nothing to constrain about the shape of the data — the only question is whether the credentials match, and that's the controller's job:</p>
<pre><code class="language-php">if (! $user || ! Hash::check($request-&gt;validated('password'), $user-&gt;password)) {
    return response()-&gt;json(['message' =&gt; 'Invalid credentials'], 401);
}
</code></pre>
<p>Adding <code>min:8</code> to the login password would be actively harmful: a user whose old password is 7 characters (from before a policy change) would get a <em>validation</em> error instead of an <em>authentication</em> attempt — confusing, and it leaks the password policy to anyone probing the endpoint. Login validation should accept anything shaped like credentials and let the hash check decide.</p>
<p>The 401 message is deliberately flat: <code>Invalid credentials</code>, whether the email doesn't exist or the password is wrong. Distinguishing "email not found" from "wrong password" is an account-enumeration gift to attackers. The frontend renders the same message either way; the user who mistyped their email figures it out on the second try.</p>
<h2>The rule</h2>
<p>Match the strictness to the trust level of the actor, not the shape of the form. Strangers creating records get the full gauntlet: required fields, uniqueness, confirmation. Returning users proving identity get the minimum viable validation — then the real check. Two forms, two threat models, two rule sets. Anything else is either annoying your users or informing your attackers.</p>
<hr />
<p><em>These are the exact FormRequests in my <a href="https://kamranofficial.gumroad.com/l/mhekoig">Laravel + React SaaS Starter Kit</a> ($19 one-time) — Sanctum auth with validation that matches the threat model, plus roles and an admin dashboard.</em></p>
]]></content:encoded></item><item><title><![CDATA[Your API should never return the model: what UserResource keeps out of my responses]]></title><description><![CDATA[The most common API leak I see in Laravel apps isn't a missing auth check — it's return $user; in a controller. The framework helpfully serialises the whole model: id, name, email, and, unless you rem]]></description><link>https://kamrankhandev.hashnode.dev/your-api-should-never-return-the-model-what-userresource-keeps-out-of-my-responses</link><guid isPermaLink="true">https://kamrankhandev.hashnode.dev/your-api-should-never-return-the-model-what-userresource-keeps-out-of-my-responses</guid><category><![CDATA[Laravel]]></category><category><![CDATA[PHP]]></category><category><![CDATA[api]]></category><category><![CDATA[webdev]]></category><category><![CDATA[SaaS]]></category><dc:creator><![CDATA[Kamran Khan]]></dc:creator><pubDate>Mon, 12 Oct 2026 21:55:30 GMT</pubDate><content:encoded><![CDATA[<p>The most common API leak I see in Laravel apps isn't a missing auth check — it's <code>return $user;</code> in a controller. The framework helpfully serialises the whole model: id, name, email, and, unless you remembered <code>$hidden</code>, the password hash and remember token riding along in the JSON. Every endpoint that returns a user becomes a small data-exposure audit you have to remember to do.</p>
<p>I don't return models from my API. Every user-shaped response goes through one resource:</p>
<pre><code class="language-php">class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' =&gt; $this-&gt;id,
            'name' =&gt; $this-&gt;name,
            'email' =&gt; $this-&gt;email,
            'role' =&gt; $this-&gt;role,
            'email_verified_at' =&gt; $this-&gt;email_verified_at,
            'created_at' =&gt; $this-&gt;created_at,
            'updated_at' =&gt; $this-&gt;updated_at,
        ];
    }
}
</code></pre>
<p>Seven fields. That's the entire public contract for "a user" in my API — and it's a whitelist, not a blacklist. The difference matters: <code>$hidden</code> on the model is a blacklist ("don't show these"), which means every new sensitive column you add to the users table is exposed by default until someone remembers to hide it. The resource is a whitelist: a new column is <em>invisible</em> to the API until you deliberately add it. New things should be private by default.</p>
<h2>One shape everywhere</h2>
<p>The resource isn't just used on one endpoint. The auth responses use it too:</p>
<pre><code class="language-php">return response()-&gt;json([
    'user' =&gt; new UserResource($user),
    'token' =&gt; $token,
], 201);
</code></pre>
<p>Register, login, <code>/me</code>, the admin users list — all return the same seven-field user object. That consistency is a frontend feature: the React app has one user shape to model, one TypeScript-ish expectation, no "this endpoint includes the role but that one doesn't" surprises. When the frontend team is you, you still benefit — you stop defensive-coding around shape differences that shouldn't exist.</p>
<p>It also composes with pagination for free. <code>UserResource::collection($users)</code> wraps the paginated result and applies the same whitelist to every row, so the admin users table gets fifteen consistently-shaped users per page with zero extra thought.</p>
<h2>The part people skip: the resource is also documentation</h2>
<p>When someone asks "what does the user object look like?", I point at one file. Not a wiki page, not a Postman collection that drifted three months ago — the actual code that produces the response. If the shape changes, the file changes, and the documentation is never stale because it <em>is</em> the implementation.</p>
<p>Seven fields, whitelisted, in one file, used on every endpoint. That's the whole discipline. APIs leak through their shapes long before they leak through their auth.</p>
<hr />
<p><em>This is the response-shaping discipline I ship in my <a href="https://kamranofficial.gumroad.com/l/mhekoig">Laravel + React SaaS Starter Kit</a> ($19 one-time) — every user endpoint returns the same resource, behind Sanctum auth and role middleware.</em></p>
]]></content:encoded></item><item><title><![CDATA[Ship a clickable demo with no backend: the in-memory API seam behind my axios client]]></title><description><![CDATA[Every starter kit has the same demo problem: you want a live, clickable preview on GitHub Pages, but GitHub Pages is static — there is no Laravel backend to call. The usual answers are a video, a pile]]></description><link>https://kamrankhandev.hashnode.dev/ship-a-clickable-demo-with-no-backend-the-in-memory-api-seam-behind-my-axios-client</link><guid isPermaLink="true">https://kamrankhandev.hashnode.dev/ship-a-clickable-demo-with-no-backend-the-in-memory-api-seam-behind-my-axios-client</guid><category><![CDATA[React]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[SaaS]]></category><dc:creator><![CDATA[Kamran Khan]]></dc:creator><pubDate>Mon, 12 Oct 2026 21:52:54 GMT</pubDate><content:encoded><![CDATA[<p>Every starter kit has the same demo problem: you want a live, clickable preview on GitHub Pages, but GitHub Pages is static — there is no Laravel backend to call. The usual answers are a video, a pile of screenshots, or a "coming soon" link. None of them convert. I wanted the real app, clickable, with zero backend. So I built a seam into the API client.</p>
<h2>The seam: one branch in the client factory</h2>
<p>The axios client is created in one place, and that place has exactly one fork:</p>
<pre><code class="language-js">const client = isDemoMode() ? createDemoClient() : axios.create({
  baseURL: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}/api`,
  headers: {
    Accept: 'application/json',
  },
});
</code></pre>
<p><code>isDemoMode()</code> reads the build-time flag <code>VITE_DEMO_MODE</code>. When it's true, every API call the app makes goes to <code>createDemoClient()</code> — an in-memory mock that mirrors the real Laravel endpoints and response shapes. When it's false (the shipped product), it's the real axios client against the real API.</p>
<p>The key decision: <strong>the mock mirrors response shapes, not just endpoints.</strong> It returns the same JSON the Laravel API returns — same fields, same pagination envelope — so the React components can't tell the difference. Login "works" (any credentials, or the seeded admin), the dashboard shows stat cards, the users table paginates. A screenshot can't show you the 401-interceptor redirecting on an expired token; the clickable demo can.</p>
<h2>Why a seam, not a separate demo build</h2>
<p>The alternative is maintaining two frontends: the real app and a demo variant with hardcoded data. That rots in a week — every feature you add to the real app has to be re-faked in the demo, so the demo quietly becomes a lie about what the product does.</p>
<p>The seam inverts the maintenance burden. Components call <code>client.get('/users')</code> either way; the only thing that changes is what <code>client</code> is. New feature, new endpoint? You add it to the mock once, in the same file as the real client, and the demo stays honest. The demo is never more than one endpoint behind the product, because they're the same codebase.</p>
<p>The sample data is seeded in code — an admin (<code>admin@demo.io</code>) plus a handful of users — so the demo is deterministic. Every visitor sees the same dashboard, the same users table. Deterministic demos are debuggable demos: if a buyer reports something weird in the preview, I can reproduce it exactly.</p>
<h2>The rule</h2>
<p>If your product's best sales pitch is "try it", make "try it" cost you nothing to maintain. One branch at client-creation time, a mock that respects your real response shapes, and your demo is a build flag away — always current, always clickable, never a separate codebase to babysit.</p>
<hr />
<p><em>The kit this comes from is my <a href="https://kamranofficial.gumroad.com/l/mhekoig">Laravel + React SaaS Starter Kit</a> ($19 one-time) — Sanctum auth, roles, admin dashboard, and the demo seam above, which is exactly what powers the live preview: <a href="https://kamran1272.github.io/laravel-react-saas-starter-kit/">https://kamran1272.github.io/laravel-react-saas-starter-kit/</a></em></p>
]]></content:encoded></item><item><title><![CDATA[One env var between Vite and Laravel: how I wire the React dev server to the API]]></title><description><![CDATA[Every Laravel + React project starts with the same small annoyance: the frontend runs on localhost:5173, the API runs on localhost:8000, and the two need to talk. The standard advice is to set up a Vi]]></description><link>https://kamrankhandev.hashnode.dev/one-env-var-between-vite-and-laravel-how-i-wire-the-react-dev-server-to-the-api</link><guid isPermaLink="true">https://kamrankhandev.hashnode.dev/one-env-var-between-vite-and-laravel-how-i-wire-the-react-dev-server-to-the-api</guid><category><![CDATA[React]]></category><category><![CDATA[Laravel]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[SaaS]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Kamran Khan]]></dc:creator><pubDate>Mon, 12 Oct 2026 10:15:41 GMT</pubDate><content:encoded><![CDATA[<p>Every Laravel + React project starts with the same small annoyance: the frontend runs on <code>localhost:5173</code>, the API runs on <code>localhost:8000</code>, and the two need to talk. The standard advice is to set up a Vite dev-server proxy. I do not. My kit wires them with a single environment variable, and I have never missed the proxy.</p>
<h2>The whole setup</h2>
<p>In the axios client:</p>
<pre><code class="language-js">const client = axios.create({
  baseURL: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}/api`,
  headers: {
    Accept: 'application/json',
  },
});
</code></pre>
<p>And in <code>.env.example</code>:</p>
<pre><code>VITE_API_URL=http://localhost:8000
</code></pre>
<p>That is the entire mechanism. The React app calls the Laravel API directly, cross-origin, and everything else follows from that one decision.</p>
<h2>Why not a proxy</h2>
<p>A Vite proxy (<code>server.proxy: { '/api': 'http://localhost:8000' }</code>) makes dev feel tidy — same origin, no CORS. But it is dev-only stagecraft. The moment you deploy, the proxy is gone and you need real cross-origin behaviour anyway: proper CORS headers, absolute API URLs, credentials handled explicitly. I would rather deal with the real setup on day one than debug the difference between "works behind the proxy" and "works in production" at 1am during a launch.</p>
<p>So the kit does the honest thing instead:</p>
<ul>
<li><strong>CORS is configured on the backend</strong>, not hidden by the dev server. <code>config/cors.php</code> covers the <code>api/*</code> paths, with allowed origins derived from the frontend URL — so the browser permits the cross-origin calls in development and you control exactly which origins are allowed in production.</li>
<li><strong><code>Accept: application/json</code> is set on every request</strong> at the client level. This is the quiet one. Without it, a validation failure or an unauthenticated request can come back as a Laravel HTML error page or a redirect to <code>/login</code>, and your React code is left parsing HTML it never expected. One header guarantees the API always speaks JSON to the frontend.</li>
</ul>
<h2>The env var contract</h2>
<p><code>VITE_API_URL</code> with a localhost fallback means a fresh clone works with zero configuration — <code>php artisan serve</code> on one terminal, <code>npm run dev</code> on the other, and the app talks to the API. When you deploy, you set <code>VITE_API_URL</code> to the live API URL before building. (Vite bakes env vars in at build time, so this is a build setting, not a runtime one — set it in your build environment, not on the server afterwards.)</p>
<p>One variable, one contract: the frontend always knows where the API lives, in every environment, with no dev-server magic that evaporates in production.</p>
<p>Boring infrastructure is good infrastructure. The clever proxy setup impresses nobody after the first deploy.</p>
<hr />
<p><em>The kit this comes from is my Laravel + React SaaS Starter Kit — Sanctum auth, roles, admin dashboard, and the dev wiring to run it all in five minutes. $19, one-time: <a href="https://kamranofficial.gumroad.com/l/mhekoig">https://kamranofficial.gumroad.com/l/mhekoig</a></em></p>
]]></content:encoded></item><item><title><![CDATA[Your admin API needs a self-destruct guard: why I block admins from deleting their own account]]></title><description><![CDATA[Every admin panel I have ever inherited has the same trap sitting in the users table: a Delete button on every row, including your own. Nobody thinks about it until a client admin — usually while clea]]></description><link>https://kamrankhandev.hashnode.dev/your-admin-api-needs-a-self-destruct-guard-why-i-block-admins-from-deleting-their-own-account</link><guid isPermaLink="true">https://kamrankhandev.hashnode.dev/your-admin-api-needs-a-self-destruct-guard-why-i-block-admins-from-deleting-their-own-account</guid><category><![CDATA[Laravel]]></category><category><![CDATA[PHP]]></category><category><![CDATA[SaaS]]></category><category><![CDATA[api]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Kamran Khan]]></dc:creator><pubDate>Mon, 12 Oct 2026 10:14:21 GMT</pubDate><content:encoded><![CDATA[<p>Every admin panel I have ever inherited has the same trap sitting in the users table: a Delete button on every row, including your own. Nobody thinks about it until a client admin — usually while cleaning up test accounts at 11pm — clicks delete on their own row and locks themselves out of their own product.</p>
<p>I fixed this once, properly, and now it ships in every starter I touch. It is three lines in the controller.</p>
<h2>The guard</h2>
<p>In my kit's <code>UserController@destroy</code>, the first thing that happens is not the delete. It is a self-check:</p>
<pre><code class="language-php">public function destroy(Request $request, User $user): JsonResponse
{
    if ($request-&gt;user()-&gt;is($user)) {
        return response()-&gt;json(['message' =&gt; 'You cannot delete your own account.'], 422);
    }

    $user-&gt;tokens()-&gt;delete();
    $user-&gt;delete();

    return response()-&gt;json(null, 204);
}
</code></pre>
<p><code>$request-&gt;user()-&gt;is($user)</code> uses Eloquent's model comparison, so it is a real identity check, not a loose <code>==</code> on IDs that could be spoofed by type juggling. If the admin targets themselves, they get a 422 with a plain-English message.</p>
<p>Why 422 and not 403? Because 403 says "you are not allowed to do this" — but the admin <em>is</em> allowed to delete users. The request is well-formed and authorised; the action just makes no sense for this particular resource state. 422 (Unprocessable Entity) is the honest status: I understood you, and the answer is no. Your frontend can then show the message verbatim instead of a generic "forbidden" toast.</p>
<h2>Clean up the tokens first</h2>
<p>The second line most people skip: <code>$user-&gt;tokens()-&gt;delete()</code> before <code>$user-&gt;delete()</code>.</p>
<p>This kit uses Laravel Sanctum personal access tokens. If you delete the user row without revoking their tokens, the token rows sit orphaned in <code>personal_access_tokens</code>. They will never authenticate again (no user to resolve to), but they pollute the table — and in my case they would inflate the dashboard's "active sessions" count, which is a straight <code>PersonalAccessToken::count()</code>. One line keeps the data honest.</p>
<h2>Return nothing on success</h2>
<p>A successful delete returns <code>204</code> with no body. There is nothing to say. The frontend already removed the row optimistically; a 204 confirms it. I see APIs returning the deleted user object "for confirmation" — that is a habit from tutorials, not something any client I have built actually needed.</p>
<h2>The frontend half</h2>
<p>On the React side, the users table renders Edit and Delete buttons per row from the same endpoint. The backend guard is the real protection — never trust a disabled button alone, because anyone can call the API directly. But the backend returning a clear 422 message means the UI can display exactly what happened instead of guessing.</p>
<p>This is the kind of unglamorous detail that separates a demo from a product. Nobody will praise your self-delete guard. They will only ever notice its absence, at the worst possible moment.</p>
<hr />
<p><em>I keep a running collection of these small, load-bearing decisions in the Laravel + React SaaS Starter Kit I sell — Sanctum auth, role-based access, the admin dashboard, and the seed data to demo it all in five minutes. It's $19, one-time, here: <a href="https://kamranofficial.gumroad.com/l/mhekoig">https://kamranofficial.gumroad.com/l/mhekoig</a></em></p>
]]></content:encoded></item><item><title><![CDATA[paginate(15) on day one, search on day thirty: what my admin users table actually ships]]></title><description><![CDATA[When developers show me their admin panels, the users table is usually one of two things: a User::all() that will fall over at 500 rows, or a three-week masterpiece with search, filters, column sortin]]></description><link>https://kamrankhandev.hashnode.dev/paginate-15-on-day-one-search-on-day-thirty-what-my-admin-users-table-actually-ships</link><guid isPermaLink="true">https://kamrankhandev.hashnode.dev/paginate-15-on-day-one-search-on-day-thirty-what-my-admin-users-table-actually-ships</guid><category><![CDATA[Laravel]]></category><category><![CDATA[PHP]]></category><category><![CDATA[React]]></category><category><![CDATA[SaaS]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Kamran Khan]]></dc:creator><pubDate>Mon, 12 Oct 2026 10:12:56 GMT</pubDate><content:encoded><![CDATA[<p>When developers show me their admin panels, the users table is usually one of two things: a <code>User::all()</code> that will fall over at 500 rows, or a three-week masterpiece with search, filters, column sorting, and bulk actions that nobody asked for yet.</p>
<p>My starter kit ships the middle path, and I want to defend it, because the middle path is almost always the right call.</p>
<h2>What the endpoint does</h2>
<pre><code class="language-php">public function index(Request $request): AnonymousResourceCollection
{
    $users = User::query()
        -&gt;latest()
        -&gt;paginate(15)
        -&gt;withQueryString();

    return UserResource::collection($users);
}
</code></pre>
<p>Four decisions in four lines:</p>
<ol>
<li><strong><code>latest()</code></strong> — newest users first. In an admin panel, you almost always care about who just signed up. Default chronological order (oldest first) is a database habit, not a product decision.</li>
<li><strong><code>paginate(15)</code></strong> — fifteen rows per page. Not ten, not fifty. Fifteen fits a viewport without scrolling fatigue and keeps every query cheap. Do not bikeshed the number; pick one and move on.</li>
<li><strong><code>withQueryString()</code></strong> — the pagination links carry the current query string forward. Today that barely matters. The day you add <code>?role=admin</code> filtering, your page-2 link will not silently drop the filter. It is a one-word investment in your future self.</li>
<li><strong><code>UserResource::collection()</code></strong> — the response shape is controlled by an API resource, not by whatever columns happen to be on the model. The day you add a <code>last_login_at</code> column, it does not leak into the admin table until you decide it should.</li>
</ol>
<h2>What it deliberately does not do</h2>
<p>There is no search. No filters. No sortable columns.</p>
<p>This is not laziness; it is sequencing. Pagination is load-bearing — without it, the page breaks the moment a client imports their customer list. Search is a feature you add when an admin says "I can't find someone," which in my experience happens around month two, not day one. Every hour you spend building filters nobody requested is an hour your SaaS is not launched.</p>
<p>The honest version of "MVP" is not "broken." It is "complete for what it does."</p>
<h2>The frontend stays dumb</h2>
<p>The React table is a plain presentational component: name, email, a role pill, created date, and Edit/Delete actions per row. It takes <code>users</code>, <code>onEdit</code>, and <code>onDelete</code> as props and knows nothing about pages, totals, or API shapes. The page component that wraps it owns the fetching and the pagination controls.</p>
<p>This split matters more than it looks. When I add search on day thirty, the table component does not change at all — only the data-fetching layer grows a query parameter. Presentational components that do not know about the API are components you never have to rewrite.</p>
<p>Ship the boring table first. Make it correct, paginated, and stable. The fancy table can wait until someone is paying you to build it.</p>
<hr />
<p><em>This is the same pragmatic sequencing I baked into my Laravel + React SaaS Starter Kit — auth, roles, and an admin dashboard with user management that works on day one, not day thirty. $19, one-time: <a href="https://kamranofficial.gumroad.com/l/mhekoig">https://kamranofficial.gumroad.com/l/mhekoig</a></em></p>
]]></content:encoded></item><item><title><![CDATA[Role-based access with one reusable middleware (and a frontend guard to match)]]></title><description><![CDATA[Role checks have a way of rotting. It starts innocently — an if ($user->role !== 'admin') in one controller. Then it's in five controllers, then someone adds a moderator role and you're grepping the c]]></description><link>https://kamrankhandev.hashnode.dev/role-based-access-with-one-reusable-middleware-and-a-frontend-guard-to-match</link><guid isPermaLink="true">https://kamrankhandev.hashnode.dev/role-based-access-with-one-reusable-middleware-and-a-frontend-guard-to-match</guid><category><![CDATA[Laravel]]></category><category><![CDATA[PHP]]></category><category><![CDATA[React]]></category><category><![CDATA[SaaS]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Kamran Khan]]></dc:creator><pubDate>Sun, 11 Oct 2026 08:41:10 GMT</pubDate><content:encoded><![CDATA[<p>Role checks have a way of rotting. It starts innocently — an <code>if ($user-&gt;role !== 'admin')</code> in one controller. Then it's in five controllers, then someone adds a <code>moderator</code> role and you're grepping the codebase hoping you found them all. I've been that person. Never again.</p>
<p>The pattern I use now: <strong>one middleware, one alias, zero role checks in controllers.</strong></p>
<p>The middleware itself is tiny. It takes a variadic list of allowed roles, grabs the authenticated user off the request, and returns a JSON 403 if the user's role isn't in the list:</p>
<pre><code class="language-php">public function handle(Request $request, Closure $next, string ...$roles): Response
{
    $user = $request-&gt;user();

    if (! $user || ! in_array($user-&gt;role, $roles, true)) {
        return response()-&gt;json(['message' =&gt; 'Forbidden'], 403);
    }

    return $next($request);
}
</code></pre>
<p>Registered once as the <code>role</code> alias, it's used at the route level, never in controller code:</p>
<pre><code class="language-php">Route::middleware(['auth:sanctum', 'role:admin'])-&gt;group(function () {
    // every user-management route lives here
});
</code></pre>
<p>Three details worth stealing:</p>
<p><strong>1. Variadic roles, not a boolean.</strong> <code>role:admin</code> today, <code>role:admin,moderator</code> tomorrow — no new middleware, no new alias, no controller changes. The day a client asks for a second privileged role, you change one line in the routes file.</p>
<p><strong>2. JSON 403, not a redirect.</strong> For an API, a non-admin hitting an admin endpoint gets <code>{"message": "Forbidden"}</code> with a 403 status — something the frontend can actually handle. A redirect to a login page (Laravel's default for web routes) would be nonsense for an API consumer. This only works because the whole API is JSON-first: no HTML error pages anywhere, so the frontend never has to guess what it received.</p>
<p><strong>3. Middleware order is the contract.</strong> <code>auth:sanctum</code> runs first, so by the time the role check executes, <code>$request-&gt;user()</code> is guaranteed to exist or the request was already rejected with a 401. The role middleware never has to think about unauthenticated requests as a separate case — it just also handles a null user defensively.</p>
<p>Then the frontend mirrors it. An <code>AdminRoute</code> component wraps every admin page: while auth is still loading it shows a spinner; with no user it redirects to <code>/login</code>; and if <code>user.role !== 'admin'</code> it redirects to <code>/</code> — the admin pages simply don't exist for regular users. Backend enforcement is the real security (never trust the frontend), but the frontend guard is what makes the product feel right: users never see pages they can't use.</p>
<p>The principle: authorisation logic lives in exactly one place per layer. One middleware on the backend, one route guard on the frontend, and the controllers stay blissfully unaware that roles exist.</p>
<p>This exact setup — the variadic <code>role</code> middleware, the admin route group, and the matching <code>AdminRoute</code> guard — ships in my <a href="https://kamranofficial.gumroad.com/l/mhekoig">Laravel + React SaaS Starter Kit</a> ($19 one-time). Roles done properly on day one, not refactored in month three.</p>
]]></content:encoded></item><item><title><![CDATA[One axios client for every API call: Bearer injection and 401 auto-logout]]></title><description><![CDATA[Every React app I've inherited has the same disease: auth headers sprinkled across twenty files. One component reads the token from localStorage, another forgets, a third hardcodes the API URL. Then t]]></description><link>https://kamrankhandev.hashnode.dev/one-axios-client-for-every-api-call-bearer-injection-and-401-auto-logout</link><guid isPermaLink="true">https://kamrankhandev.hashnode.dev/one-axios-client-for-every-api-call-bearer-injection-and-401-auto-logout</guid><category><![CDATA[React]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[SaaS]]></category><category><![CDATA[webdev]]></category><category><![CDATA[api]]></category><dc:creator><![CDATA[Kamran Khan]]></dc:creator><pubDate>Sun, 11 Oct 2026 08:39:51 GMT</pubDate><content:encoded><![CDATA[<p>Every React app I've inherited has the same disease: auth headers sprinkled across twenty files. One component reads the token from localStorage, another forgets, a third hardcodes the API URL. Then the token expires and the user sits on a broken dashboard with no idea why.</p>
<p>The fix is unglamorous: <strong>one axios client module that every API call goes through.</strong> Mine is a single <code>client.js</code>, and it does four jobs:</p>
<p><strong>1. One base URL.</strong> <code>baseURL</code> comes from <code>VITE_API_URL</code> with a localhost fallback, plus <code>/api</code>. Every request in the app is relative to it. Moving from local dev to production is one environment variable, not a find-and-replace across the codebase.</p>
<p><strong>2. JSON everywhere.</strong> A default <code>Accept: application/json</code> header. This matters more than it looks: when something goes wrong server-side, you get a JSON error you can render — never an HTML error page crashing your <code>.json()</code> parse. (My Laravel side holds up its end too: clean JSON 401/403/404/422 responses, no HTML anywhere.)</p>
<p><strong>3. Bearer injection via a request interceptor.</strong> Before every request, the interceptor reads the token from storage and sets <code>Authorization: Bearer &lt;token&gt;</code>. Components never touch headers. They just call <code>client.get('/users')</code> and auth is handled.</p>
<p><strong>4. 401 auto-logout via a response interceptor — with one guard that matters.</strong> On a 401, the interceptor clears the stored token and sends the user to <code>/login</code>. But it skips this when the user is <em>already on</em> <code>/login</code> or <code>/register</code>. Why? Because a wrong password on the login form also comes back as a 401. Without the path check, a failed login would wipe your (already empty) state and bounce you in a redirect loop instead of showing "invalid credentials." That guard is the difference between a pattern that works in the demo and one that works in production.</p>
<p>One more thing this buys you: <strong>a clean seam for demo mode.</strong> My client checks a <code>VITE_DEMO_MODE</code> flag at startup. When it's on (public preview builds), it swaps the real axios instance for an in-memory mock that mirrors the real API's endpoints and response shapes. The rest of the app can't tell the difference — same <code>client.get</code>, same shapes. The preview site is fully clickable with no backend running, and the shipped product defaults to the real Laravel API. One conditional at the boundary, zero conditionals in the components.</p>
<p>The rule I follow: if two files both import axios directly, that's a bug. There is one client, it owns auth, and everything else is just business logic.</p>
<p>This exact client — interceptors, the 401 path guard, and the demo-mode seam — ships in my <a href="https://kamranofficial.gumroad.com/l/mhekoig">Laravel + React SaaS Starter Kit</a> ($19 one-time). Wire it up once and never think about auth headers again.</p>
]]></content:encoded></item><item><title><![CDATA[Your SaaS should survive a page refresh: how session restore actually works]]></title><description><![CDATA[Nothing kills trust in a dashboard faster than this: you log in, everything works, you hit refresh — and for a split second you're staring at the login page. Then it flickers back. Or worse, it doesn']]></description><link>https://kamrankhandev.hashnode.dev/your-saas-should-survive-a-page-refresh-how-session-restore-actually-works</link><guid isPermaLink="true">https://kamrankhandev.hashnode.dev/your-saas-should-survive-a-page-refresh-how-session-restore-actually-works</guid><category><![CDATA[React]]></category><category><![CDATA[Laravel]]></category><category><![CDATA[SaaS]]></category><category><![CDATA[webdev]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Kamran Khan]]></dc:creator><pubDate>Sun, 11 Oct 2026 08:38:39 GMT</pubDate><content:encoded><![CDATA[<p>Nothing kills trust in a dashboard faster than this: you log in, everything works, you hit refresh — and for a split second you're staring at the login page. Then it flickers back. Or worse, it doesn't, and you have to log in again.</p>
<p>I used to handle this badly. My early SPAs re-fetched the user on every route change, or kept the user object only in memory so a refresh wiped it. Both are wrong. The pattern I ship now is simple and boring, which is exactly what auth plumbing should be:</p>
<ol>
<li><strong>The token lives in <code>localStorage</code>.</strong> When login succeeds, the API returns <code>{ token, user }</code> and the token is stored under a single key (<code>saas_token</code> in my case). One key, one place, no exceptions.</li>
<li><strong>On app mount, the token is validated — not trusted.</strong> <code>AuthProvider</code> reads the stored token and calls <code>GET /auth/me</code>. If the token is dead (revoked, expired, garbage), the API says so, the token is deleted from storage, and the user is genuinely logged out. Never trust a token you haven't checked.</li>
<li><strong>A <code>loading</code> flag gates every route.</strong> This is the part people skip. While the restore check is in flight, protected routes render a spinner — not the login page, not the dashboard. <code>ProtectedRoute</code> only decides once <code>loading</code> is false: no token or no user → redirect to <code>/login</code>. That single flag is what kills the login-page flicker.</li>
</ol>
<p>The logout side deserves the same discipline. My logout function calls <code>POST /auth/logout</code> but treats it as best-effort: even if the request fails (server down, network dead), the local token is removed and the user state is cleared. The user's session is over the moment <em>they</em> say so, not the moment the server acknowledges it. A logout button that spins forever because the API is unreachable is a bug, not a feature.</p>
<p>A few things this pattern gets you for free:</p>
<ul>
<li><strong>No prop-drilling auth state.</strong> <code>useAuth()</code> exposes <code>{ user, token, loading, login, register, logout }</code> anywhere in the tree.</li>
<li><strong>Register and login share one path.</strong> Both store the token and set the user the same way — one code path to get right instead of two.</li>
<li><strong>Dead tokens can't linger.</strong> Any failed <code>/auth/me</code> wipes storage immediately, so you never have a "logged in but every request 401s" zombie state.</li>
</ul>
<p>The whole thing is maybe 60 lines. Auth plumbing should be boring enough that you never think about it again — that's the test. If your auth code is exciting, something is wrong.</p>
<p>I ship this exact <code>AuthContext</code> — token persistence, <code>/auth/me</code> restore, the loading gate, best-effort logout — in my <a href="https://kamranofficial.gumroad.com/l/mhekoig">Laravel + React SaaS Starter Kit</a> ($19 one-time). Clone it and your auth is done on day one instead of week two.</p>
]]></content:encoded></item><item><title><![CDATA[10 feature tests that keep my auth flow honest]]></title><description><![CDATA[Auth is the one part of a SaaS where "it works on my machine" is never good enough. A broken dashboard widget is embarrassing. A broken permission check is a data breach. So my starter kit ships with ]]></description><link>https://kamrankhandev.hashnode.dev/10-feature-tests-that-keep-my-auth-flow-honest</link><guid isPermaLink="true">https://kamrankhandev.hashnode.dev/10-feature-tests-that-keep-my-auth-flow-honest</guid><category><![CDATA[Laravel]]></category><category><![CDATA[Testing]]></category><category><![CDATA[PHP]]></category><category><![CDATA[SaaS]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Kamran Khan]]></dc:creator><pubDate>Sat, 10 Oct 2026 05:20:19 GMT</pubDate><content:encoded><![CDATA[<p>Auth is the one part of a SaaS where "it works on my machine" is never good enough. A broken dashboard widget is embarrassing. A broken permission check is a data breach. So my starter kit ships with ten passing feature tests, and I want to walk through what they actually guard — because each one exists for a specific bad day I've seen happen.</p>
<h2>The register tests: guarding the front door</h2>
<pre><code class="language-php">public function test_register_returns_token_and_user(): void
public function test_register_cannot_self_assign_admin_role(): void
</code></pre>
<p>The second one is the interesting one. Registration accepts a name, email, and password — and nothing else. The test tries to sneak <code>role: admin</code> through the register endpoint and asserts it fails. This is a privilege-escalation guard: mass-assignment is one of those bugs that works perfectly in every demo and ruins you in production. The test makes sure the role can never be set by the person signing up.</p>
<h2>The login tests: the 401 has to be JSON</h2>
<pre><code class="language-php">public function test_login_returns_token_and_user(): void
public function test_login_with_wrong_password_returns_401(): void
</code></pre>
<p>The kit is a JSON-first API — there are no HTML error pages anywhere. That matters more than it sounds: a React frontend that gets an HTML login page back from a 401 will happily try to parse it as JSON and die in a confusing way three layers from the actual problem. There's a dedicated test for exactly this:</p>
<pre><code class="language-php">public function test_me_without_accept_header_returns_json_401(): void
</code></pre>
<p>It calls the authenticated <code>/me</code> endpoint without an <code>Accept: application/json</code> header and asserts the 401 comes back as JSON anyway. APIs get called by mobile apps, scripts, and forgetful frontends; the error format shouldn't depend on the caller being polite.</p>
<h2>The permission tests: admins see users, users don't</h2>
<pre><code class="language-php">public function test_non_admin_cannot_list_users(): void
public function test_admin_can_list_users(): void
public function test_me_requires_authentication(): void
</code></pre>
<p>The users list endpoint is admin-only. The negative test — a regular user getting rejected — is the one that matters. Positive tests ("admin can list users") tell you the feature works. Negative tests tell you the lock works. When I'm reviewing auth code, I look for the negative tests first; a permission system with only positive tests is a lock nobody tried to pick.</p>
<h2>What this discipline buys you</h2>
<p>Ten tests run in seconds with <code>php artisan test</code>. They don't prove the absence of bugs — nothing does. What they do is make the scary changes safe: refactoring the middleware, touching the register controller, reordering routes. Each test names a specific disaster (privilege escalation, HTML error leak, data exposure to non-admins) and asserts it stays impossible. When a future me — or a buyer of the kit — changes the auth code at 2 AM, these tests are the colleague looking over their shoulder.</p>
<p>If you're building a SaaS API, write the negative tests first. <code>test_non_admin_cannot_list_users</code> will catch more real-world damage than any happy-path test ever will.</p>
<hr />
<p><em>These tests ship with my <a href="https://kamranofficial.gumroad.com/l/mhekoig">Laravel + React SaaS Starter Kit</a> ($19 one-time) — Sanctum auth, role-based access, and an admin dashboard, all covered. Ten tests, zero excuses.</em></p>
]]></content:encoded></item><item><title><![CDATA[Your SaaS demo should boot in five minutes: the zero-config setup I ship with every kit]]></title><description><![CDATA[Every time I've evaluated a starter kit, the decision happened in the first ten minutes. Not after reading the docs, not after studying the architecture — during the install. If composer install explo]]></description><link>https://kamrankhandev.hashnode.dev/your-saas-demo-should-boot-in-five-minutes-the-zero-config-setup-i-ship-with-every-kit</link><guid isPermaLink="true">https://kamrankhandev.hashnode.dev/your-saas-demo-should-boot-in-five-minutes-the-zero-config-setup-i-ship-with-every-kit</guid><category><![CDATA[Laravel]]></category><category><![CDATA[React]]></category><category><![CDATA[SaaS]]></category><category><![CDATA[PHP]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Kamran Khan]]></dc:creator><pubDate>Sat, 10 Oct 2026 05:18:48 GMT</pubDate><content:encoded><![CDATA[<p>Every time I've evaluated a starter kit, the decision happened in the first ten minutes. Not after reading the docs, not after studying the architecture — during the install. If <code>composer install</code> explodes, if the seeders fight the database, if the demo needs three environment files I don't have, I'm gone. And I'm not unusual. Nobody buys a kit to spend an afternoon configuring it; they buy it to skip configuration.</p>
<p>So when I built my Laravel + React SaaS starter kit, I set one hard rule: a stranger on a fresh machine should go from zip file to a logged-in dashboard in under five minutes. Here's exactly what that took.</p>
<h2>Ship the database pre-seeded</h2>
<p>The biggest friction killer was embarrassingly simple: the kit ships with a pre-seeded <code>database/database.sqlite</code>. SQLite needs zero config — no MySQL server, no Docker, no credentials — and the demo data is already inside. The quick-start docs say this explicitly:</p>
<blockquote>
<p>Do NOT run <code>php artisan migrate --seed</code>. The kit ships with a pre-seeded database, so the demo works out of the box.</p>
</blockquote>
<p>Re-running the seeder is harmless (it's idempotent — more on that in a second), but the point is you never need it for the first boot. <code>migrate:fresh --seed</code> exists only for when you want a clean reset.</p>
<h2>Make the seeder idempotent anyway</h2>
<p>First-boot and day-thirty are different people. On day thirty, a buyer runs the seeder on a half-populated database and nothing should break. So the seeder uses <code>firstOrCreate</code> for the demo admin (<code>admin@demo.io</code>) instead of blind inserts, and it tops the demo users up to nine total rather than inserting a fixed batch:</p>
<pre><code class="language-php">User::firstOrCreate(
    ['email' =&gt; 'admin@demo.io'],
    ['name' =&gt; 'Admin', 'password' =&gt; 'password', 'role' =&gt; 'admin'],
);

// Top the demo users up to 9 total (1 admin + 8 users).
$missing = 9 - User::count();
if ($missing &gt; 0) {
    User::factory($missing)-&gt;create();
}
</code></pre>
<p>Existing records are left untouched; only missing demo data is created. Safe to run on every deploy, every restart, with zero fear.</p>
<h2>Keep the two-server dance to four commands each</h2>
<p>The actual setup is two blocks. Backend:</p>
<pre><code class="language-bash">cd backend
composer install
cp .env.example .env
php artisan key:generate
php artisan serve --port=8000
</code></pre>
<p>API is live at <code>http://localhost:8000/api</code>. Frontend:</p>
<pre><code class="language-bash">cd frontend
npm install
cp .env.example .env   # VITE_API_URL defaults to http://localhost:8000
npm run dev
</code></pre>
<p>Open <code>http://localhost:5173</code> and log in with <code>admin@demo.io</code> / <code>password</code> — or register a fresh account, which gets the <code>user</code> role automatically. That's the whole ceremony. No seed commands, no queue workers, no "now open three terminals and a database GUI."</p>
<h2>Delete the demo furniture before production</h2>
<p>One honest detail: the login page ships with a "Demo credentials" hint box so evaluators find the admin account instantly. That's a feature during evaluation and a liability in production. The README tells the buyer exactly what to remove — the <code>&lt;div&gt;</code> rendering the demo credentials in <code>frontend/src/pages/Login.jsx</code> — before deploying. Little touches like this are what separate a kit someone evaluates from a kit someone ships.</p>
<h2>The real lesson</h2>
<p>None of this is clever engineering. Pre-seeded SQLite, idempotent seeders, four-command setups, a documented cleanup step — it's all just respecting the evaluator's time. The first impression of your product isn't the landing page. It's how many minutes pass before they see it working.</p>
<hr />
<p><em>I packaged all of this — the pre-seeded database, the idempotent seeders, the five-minute setup — into my <a href="https://kamranofficial.gumroad.com/l/mhekoig">Laravel + React SaaS Starter Kit</a> ($19 one-time). If you'd rather skip the two weeks of boilerplate, it's there.</em></p>
]]></content:encoded></item></channel></rss>