<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://alessandrofuda.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://alessandrofuda.github.io/" rel="alternate" type="text/html" /><updated>2026-06-25T14:41:35+00:00</updated><id>https://alessandrofuda.github.io/feed.xml</id><title type="html">Alessandro Fuda</title><subtitle>Software Engineer</subtitle><entry><title type="html">LongTermMemory Is Now on iOS: Spaced Repetition in Your Pocket</title><link href="https://alessandrofuda.github.io/longtermemory-ios-app/" rel="alternate" type="text/html" title="LongTermMemory Is Now on iOS: Spaced Repetition in Your Pocket" /><published>2026-06-25T00:00:00+00:00</published><updated>2026-06-25T00:00:00+00:00</updated><id>https://alessandrofuda.github.io/longtermemory-ios-app</id><content type="html" xml:base="https://alessandrofuda.github.io/longtermemory-ios-app/"><![CDATA[<p><em>The best study session is the one you actually do. For most people that means five minutes on the train, ten minutes before bed, a quick review during lunch. The LongTermMemory iOS app is built around that reality.</em></p>

<p>I’ve been working on <a href="https://longtermemory.com">LongTermMemory</a> for a while now, and the web application has been available long enough that I’ve watched how people actually use it. The pattern is consistent: they upload materials and generate flashcards on a laptop, then they want to review on their phone. The <a href="https://apps.apple.com/us/app/longtermemory-app/id6779253199">LongTermMemory iOS app</a> closes that loop.</p>

<h2 id="what-the-app-does">What the App Does</h2>

<p>The core of LongTermMemory is AI-powered flashcard generation combined with spaced repetition scheduling. You upload a study document (PDF, PowerPoint, a photo of handwritten notes, or plain text), the AI reads it and produces question-answer pairs from the content, and the spaced repetition algorithm schedules each card at the optimal interval to move it into long-term memory before you’d naturally forget it.</p>

<p>The iOS app brings the review side of that workflow to your iPhone. Your account, your decks, and your progress sync from the web platform, so the workflow looks like this in practice: upload and review generated cards on a computer, then do daily review sessions on your phone whenever you have a spare few minutes.</p>

<p>The app runs on iOS 15.1 or later and also works on Apple Silicon Macs through the Mac App Store. It’s a 34 MB install.</p>

<h2 id="why-mobile-matters-for-spaced-repetition">Why Mobile Matters for Spaced Repetition</h2>

<p>Spaced repetition only works if you show up consistently. The algorithm calculates exactly when each card should surface based on your personal forgetting curve, but if you miss the session the card is due, the whole system loses its precision.</p>

<p>Consistency is much easier when your review queue is on the device you already have with you. A five-minute session on the phone while waiting for coffee does more for long-term retention than an hour of passive re-reading at a desk. That’s not intuition; it’s what decades of memory research (starting with Ebbinghaus in the 1880s) established about the spacing effect.</p>

<p>The review interface in the app is optimized for short sessions. You see the question, produce your answer mentally, flip the card, rate your recall, and move to the next one. The interaction is low-friction by design, because the goal is to remove every excuse not to review.</p>

<h2 id="who-benefits-most">Who Benefits Most</h2>

<p><strong>Students with dense course materials.</strong> Uploading a chapter PDF and getting a study deck in minutes is a different experience from spending two hours in Anki building cards before you can start learning. The AI does the card creation; you do the learning.</p>

<p><strong>Professionals studying for certifications.</strong> AWS, USMLE, CFA, bar exam, NCLEX — these all involve official study guides and reference documents that map well to flashcard generation. Upload the material, get the deck, let the algorithm manage your review calendar.</p>

<p><strong>Anyone who has tried Anki and stopped using it because making cards was too slow.</strong> The research on spaced repetition is unambiguous: it works dramatically better than re-reading. The adoption gap has always been the upfront effort. Automating card creation removes that gap.</p>

<h2 id="a-few-honest-notes">A Few Honest Notes</h2>

<p>The AI card generation performs best on text-dense content. For material that relies heavily on diagrams or flowcharts, the AI works from surrounding text, which means visual concepts may need a few manually added cards to cover properly. For most academic and professional study materials, though, the hit rate is high enough that editing a few cards is far less work than building a deck from scratch.</p>

<p>The app is currently at version 1.0.1. The core review experience is solid; feature depth will grow over time as the platform develops. The web application at <a href="https://longtermemory.com">longtermemory.com</a> remains the more complete environment for uploading and managing content.</p>

<p>The app is free to download and use.</p>

<h2 id="how-to-start">How to Start</h2>

<ol>
  <li>Download the <a href="https://apps.apple.com/us/app/longtermemory-app/id6779253199">LongTermMemory app from the App Store</a></li>
  <li>Sign in or create a free account (the same account works on web and mobile)</li>
  <li>Upload a piece of study material you’re actively working with on the web platform</li>
  <li>Open the app on your phone and start your first review session</li>
  <li>Come back the next day — the algorithm will show you exactly what’s due</li>
</ol>

<p>The spaced repetition system takes care of the scheduling from there. Your job is to show up for the sessions it queues. With the app in your pocket, that’s a much easier commitment to keep.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[The best study session is the one you actually do. For most people that means five minutes on the train, ten minutes before bed, a quick review during lunch. The LongTermMemory iOS app is built around that reality.]]></summary></entry><entry><title type="html">Turn Any Google Doc Into a Study Session With Quick Q&amp;amp;A Generator</title><link href="https://alessandrofuda.github.io/quick-qa-generator-google-docs-addon/" rel="alternate" type="text/html" title="Turn Any Google Doc Into a Study Session With Quick Q&amp;amp;A Generator" /><published>2026-06-15T00:00:00+00:00</published><updated>2026-06-15T00:00:00+00:00</updated><id>https://alessandrofuda.github.io/quick-qa-generator-google-docs-addon</id><content type="html" xml:base="https://alessandrofuda.github.io/quick-qa-generator-google-docs-addon/"><![CDATA[<p><em>Most study material lives in Google Docs: lecture notes, research summaries, technical specs you need to internalize before a certification. The gap between having a document and actually learning its content is where most studying goes wrong. Quick Q&amp;A Generator closes that gap without making you leave the page.</em></p>

<p>I built <a href="https://workspace.google.com/marketplace/app/quick_qa_generator_longtermemory/628940060292">Quick Q&amp;A Generator - LongTermMemory</a> as a Google Docs add-on to solve a problem I kept running into while building <a href="https://longtermemory.com">LongTermMemory</a>: people had great source material, but turning it into active study prompts required switching tools, copy-pasting, or just hoping passive re-reading would work. It doesn’t. Active recall does.</p>

<h2 id="what-it-does">What It Does</h2>

<p>The add-on installs directly into Google Docs and surfaces as a sidebar. Open any document (meeting notes, a chapter summary, a technical RFC), click <strong>Generate</strong>, and within seconds the sidebar shows 3 to 5 core question-and-answer pairs extracted by AI from the active document.</p>

<p>No copy-paste. No switching tabs. No prompt engineering. The AI reads the document, identifies the concepts most worth testing, and frames them as Q&amp;A pairs you can actually study from.</p>

<p>Once you have your pairs, a single <strong>Sync</strong> button pushes the document and its generated Q&amp;A set directly to your LongTermMemory dashboard, where they enter a spaced repetition schedule and become part of your review queue.</p>

<h2 id="why-this-fits-into-a-real-study-flow">Why This Fits Into a Real Study Flow</h2>

<p>The bottleneck in most study workflows is not access to information; it is converting information into a retrievable form. Highlighting and re-reading feel productive but produce weak retention. Q&amp;A pairs force retrieval, which is the mechanism that actually moves knowledge into long-term memory.</p>

<p>The strengths that make this add-on worth using:</p>

<p><strong>It works where the content already lives.</strong> You do not import anything or change your writing habits. Your Google Doc stays your Google Doc; the add-on reads it and generates the study material in place. There is no friction between taking notes and starting to learn from them.</p>

<p><strong>AI identifies what matters.</strong> Writing good flashcards is a skill, and most people write them too broadly or miss the core concept entirely. The add-on extracts the high-signal concepts (the ones likely to show up in a quiz or surface during an exam) rather than turning every sentence into a question.</p>

<p><strong>One-click pipeline to spaced repetition.</strong> Generating Q&amp;A pairs is only half the work. The real value is that syncing them to LongTermMemory puts them on a schedule: the SM-2-inspired algorithm (covered in <a href="/spaced-repetition-sm2-laravel/">this earlier post</a>) ensures you review them at the right intervals, soon after learning, then at increasing delays, so the knowledge sticks rather than fading within a week.</p>

<p><strong>It is free.</strong> There is no paywall for the add-on itself. Install it, use it, sync as many documents as you need.</p>

<p><strong>Zero context switching.</strong> The sidebar interface means you can review the generated questions, compare them against the source text, and decide whether to sync, all without leaving the document. For people who study in focused sessions, this matters.</p>

<h2 id="who-it-is-built-for">Who It Is Built For</h2>

<p>The add-on is useful for anyone who consumes a lot of text and needs to retain it:</p>

<ul>
  <li><strong>Students</strong> turning lecture notes or textbook summaries into active flashcard sets</li>
  <li><strong>Professionals</strong> preparing for technical certifications (AWS, GCP, security exams) from documentation they are already reading</li>
  <li><strong>Lifelong learners</strong> who collect research notes and want a low-friction way to actually internalize them</li>
  <li><strong>Teachers and examiners</strong> who want a fast first draft of quiz questions from course material</li>
</ul>

<p>If you read something in Google Docs and care whether you remember it, this fits.</p>

<h2 id="how-to-get-started">How to Get Started</h2>

<ol>
  <li>Install <a href="https://workspace.google.com/marketplace/app/quick_qa_generator_longtermemory/628940060292">Quick Q&amp;A Generator - LongTermMemory</a> from the Google Workspace Marketplace (free)</li>
  <li>Open any Google Doc you want to study from</li>
  <li>Open the add-on from the <strong>Extensions</strong> menu → <strong>Quick Q&amp;A Generator</strong></li>
  <li>Click <strong>Generate</strong> in the sidebar</li>
  <li>Review the Q&amp;A pairs, then click <strong>Sync</strong> to push them to your LongTermMemory dashboard</li>
</ol>

<p>From there, the spaced repetition engine handles scheduling. Your only job is to show up for the review sessions it queues.</p>

<p>The best study tool is the one that gets out of the way. If your material is already in Google Docs (and for most people it is), having Q&amp;A generation built directly into the editor removes the last excuse not to study actively.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Most study material lives in Google Docs: lecture notes, research summaries, technical specs you need to internalize before a certification. The gap between having a document and actually learning its content is where most studying goes wrong. Quick Q&amp;A Generator closes that gap without making you leave the page.]]></summary></entry><entry><title type="html">Building a RAG-Powered Study App: Laravel + Python Microservices</title><link href="https://alessandrofuda.github.io/building-rag-study-app-laravel-python-microservices/" rel="alternate" type="text/html" title="Building a RAG-Powered Study App: Laravel + Python Microservices" /><published>2026-03-17T00:00:00+00:00</published><updated>2026-03-17T00:00:00+00:00</updated><id>https://alessandrofuda.github.io/building-rag-study-app-laravel-python-microservices</id><content type="html" xml:base="https://alessandrofuda.github.io/building-rag-study-app-laravel-python-microservices/"><![CDATA[<p><em>How I combined Laravel, FastAPI, Celery, Qdrant, and OpenAI into an AI study platform: what worked, what didn’t, and the chunking problem nobody warns you about.</em></p>

<p>A few years ago I was grinding through certification study material , thick PDFs, documentation pages, whitepapers , and kept hitting the same wall: the tools that could help me learn efficiently were either too dumb (static flashcard decks you had to write yourself), too expensive, or didn’t understand <em>my</em> material. What I wanted was something that could read my PDFs and generate questions for me, then schedule those questions based on how well I actually knew them.</p>

<p>So I built it. LongTermMemory is a SaaS study platform that uses Retrieval-Augmented Generation (RAG) to auto-generate question-answer pairs from uploaded materials and implements spaced repetition to move knowledge into long-term memory. This post is a technical walkthrough of the interesting engineering decisions, the mistakes I made, and specifically the one problem that took longer to solve than anything else: chunking.</p>

<hr />

<h2 id="the-architecture-decision-why-two-languages">The Architecture Decision: Why Two Languages?</h2>

<p>My first instinct was to build everything in Laravel. I’ve been writing PHP professionally for years, Laravel is excellent, and managing two runtimes, two Dockerfiles, and two test suites isn’t thrilling.</p>

<p>The problem is that the AI/RAG ecosystem lives in Python. LlamaIndex, LangChain, the OpenAI Python client, all of the tooling for embeddings and vector operations , it’s mature, well-documented, and under active development. The PHP equivalents are either nonexistent or years behind.</p>

<p>The compromise: <strong>Laravel handles everything product-concern</strong> , authentication, billing, user management, the REST API the frontend talks to, email notifications, database schema. <strong>FastAPI + Celery handles everything AI-concern</strong> , document ingestion, chunking, embedding generation, vector storage, Q&amp;A generation. The two services communicate over an internal Docker network.</p>

<p>Here’s the rough topology:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>React (5173)
    │
    ▼
Nginx → PHP-FPM (Laravel 12)      ←→  MySQL
                │
                ▼
         FastAPI (8000)
                │
         Celery Worker  ←──────────── MinIO (raw documents)
                │        ←──────── Redis (broker + job state)
         ┌──────┴──────┐
         ▼             ▼
      Qdrant        OpenAI API
   (vectors)       (embeddings + LLM)
</code></pre></div></div>

<p>Documents live in MinIO (S3-compatible object storage). When a user uploads a PDF, Laravel stores it in MinIO and records the metadata in MySQL. When they trigger Q&amp;A generation, Laravel POSTs a job request to the FastAPI service. Celery picks it up, retrieves the files from MinIO, processes them, and when done POSTs a callback to Laravel with the results.</p>

<p>Here the complete technical documentations: <a href="https://longtermemory-docs.readthedocs.io/en/latest/" target="_blank">ReadTheDocs</a>,
      <a href="https://longtermemory.gitbook.io/longtermemory-docs" target="_blank">GitBook</a></p>

<hr />

<h2 id="async-processing-and-the-push-callback-model">Async Processing and the Push Callback Model</h2>

<p>Document processing is slow. A large PDF can take 30,120 seconds: extract text, chunk it semantically, generate embeddings for each chunk, store vectors in Qdrant, run the LLM to generate Q&amp;A pairs. You can’t hold an HTTP connection open for that long.</p>

<p>The flow is: Laravel calls <code class="language-plaintext highlighter-rouge">POST /api/generate-qa</code> → FastAPI immediately returns a <code class="language-plaintext highlighter-rouge">job_id</code> → Celery picks up the task → when done, Celery calls back to Laravel with the results.</p>

<p>I chose push callbacks over polling for the same reason webhooks are better than polling: the server-side work happens exactly once, at the right time, rather than on every tick of a polling loop.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># When the Celery task finishes, it notifies Laravel directly
</span><span class="k">def</span> <span class="nf">_notify_laravel_job_finished</span><span class="p">(</span><span class="n">job_id</span><span class="p">,</span> <span class="n">project_id</span><span class="p">,</span> <span class="n">job_data</span><span class="p">,</span> <span class="n">settings</span><span class="p">):</span>
    <span class="n">payload</span> <span class="o">=</span> <span class="p">{</span>
        <span class="s">"job_id"</span><span class="p">:</span> <span class="n">job_id</span><span class="p">,</span>
        <span class="s">"project_id"</span><span class="p">:</span> <span class="n">project_id</span><span class="p">,</span>
        <span class="s">"status"</span><span class="p">:</span> <span class="n">job_data</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"status"</span><span class="p">),</span>
        <span class="s">"qa_pairs"</span><span class="p">:</span> <span class="n">job_data</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"qa_pairs"</span><span class="p">,</span> <span class="p">[]),</span>
        <span class="s">"error"</span><span class="p">:</span> <span class="n">job_data</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"error"</span><span class="p">),</span>
    <span class="p">}</span>
    <span class="n">url</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">settings</span><span class="p">.</span><span class="n">laravel_app_url</span><span class="si">}</span><span class="s">/api/job-finished"</span>
    <span class="k">with</span> <span class="n">httpx</span><span class="p">.</span><span class="n">Client</span><span class="p">(</span><span class="n">timeout</span><span class="o">=</span><span class="mf">10.0</span><span class="p">)</span> <span class="k">as</span> <span class="n">client</span><span class="p">:</span>
        <span class="n">client</span><span class="p">.</span><span class="n">post</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="n">json</span><span class="o">=</span><span class="n">payload</span><span class="p">,</span> <span class="n">headers</span><span class="o">=</span><span class="p">{</span><span class="s">"X-API-Key"</span><span class="p">:</span> <span class="n">api_key</span><span class="p">})</span>
</code></pre></div></div>

<p>Laravel receives this at a dedicated callback endpoint, saves the Q&amp;A pairs, and fires an email notification to the user , all immediately when the job finishes.</p>

<h3 id="preventing-duplicate-jobs">Preventing Duplicate Jobs</h3>

<p>One early bug: if a user clicked “Generate Study Plan” twice quickly, two Celery jobs would run in parallel, both writing Q&amp;A pairs to the same project , duplicate questions and double API costs.</p>

<p>The fix is a Redis key per project: <code class="language-plaintext highlighter-rouge">project_job:{project_id}</code>. Before queuing a new task, the API checks if that key exists and the referenced job is still active. If so, it returns HTTP 409. Laravel propagates this to the frontend as “generation already in progress.” The key is cleared when the job completes, fails, or is cancelled.</p>

<hr />

<h2 id="the-hardest-problem-chunking">The Hardest Problem: Chunking</h2>

<p>This is the part nobody really prepares you for when you read RAG tutorials.</p>

<h3 id="naive-chunking-is-terrible">Naive chunking is terrible</h3>

<p>The obvious first approach is fixed-size chunking: split the document into 512-token windows with some overlap. Quick to implement, works on toy examples. In practice the Q&amp;A quality was noticeably bad , questions would reference “the above equation” or “as mentioned in the previous section” with no context for either, because the split happened mid-concept.</p>

<h3 id="semantic-chunking-with-llamaindex">Semantic chunking with LlamaIndex</h3>

<p>LlamaIndex’s <code class="language-plaintext highlighter-rouge">SemanticSplitterNodeParser</code> uses embedding similarity between consecutive sentences to decide where to split. Instead of splitting every N tokens, it splits when the semantic distance between adjacent sentences exceeds a threshold , keeping conceptually related content together.</p>

<p>My implementation uses a two-stage approach: first <code class="language-plaintext highlighter-rouge">SentenceSplitter</code> for structural splits on paragraph breaks, then <code class="language-plaintext highlighter-rouge">SemanticSplitterNodeParser</code> for semantic coherence within those units. The result is chunks that read like coherent paragraphs rather than arbitrary text windows.</p>

<h3 id="the-length-problem">The length problem</h3>

<p>Here’s the thing nobody tells you: <strong>the parameters that work well for a 10-page article are completely wrong for a 300-page textbook.</strong></p>

<p>With the same settings on a long document you get hundreds of tiny chunks, many of them mid-sentence fragments. The LLM generates questions that are too narrow, testing individual sentences rather than concepts. Embedding costs scale linearly with chunk count , a 300-page book produces far more chunks than you’d want.</p>

<p>I discovered this when a user uploaded a comprehensive textbook and the generation took 8 minutes and produced 400+ Q&amp;A pairs, most of them nearly identical questions about adjacent paragraphs.</p>

<p>The fix is dynamic parameter selection based on estimated content length:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">total_tokens</span> <span class="o">=</span> <span class="n">estimated_total_tokens</span> <span class="k">if</span> <span class="n">estimated_total_tokens</span> <span class="k">else</span> <span class="nb">len</span><span class="p">(</span><span class="n">text</span><span class="p">)</span> <span class="o">//</span> <span class="mi">4</span>

<span class="k">if</span> <span class="n">total_tokens</span> <span class="o">&gt;</span> <span class="mi">10_000</span><span class="p">:</span>  <span class="c1"># ~15 pages
</span>    <span class="n">stage1_chunk_size</span> <span class="o">=</span> <span class="mi">2048</span>
    <span class="n">stage2_buffer_size</span> <span class="o">=</span> <span class="mi">3</span>
    <span class="n">stage2_breakpoint_threshold</span> <span class="o">=</span> <span class="mi">97</span>  <span class="c1"># only split at major topic shifts
</span><span class="k">else</span><span class="p">:</span>
    <span class="n">stage1_chunk_size</span> <span class="o">=</span> <span class="mi">1024</span>
    <span class="n">stage2_buffer_size</span> <span class="o">=</span> <span class="mi">1</span>
    <span class="n">stage2_breakpoint_threshold</span> <span class="o">=</span> <span class="mi">95</span>
</code></pre></div></div>

<p>For long content: larger chunk size, wider semantic buffers, higher breakpoint threshold. The result is ~75% fewer chunks for book-length content, with each chunk containing a full concept.</p>

<h3 id="the-breakpoint_percentile_threshold-confusion">The <code class="language-plaintext highlighter-rouge">breakpoint_percentile_threshold</code> confusion</h3>

<p>This took me embarrassingly long to get right. The parameter name suggests a higher value means more splits, but it’s the opposite. The threshold is a percentile of embedding distances across all sentence pairs. Setting it to the 97th percentile means “only split when the distance is in the top 3% of all distances” , only the most dramatic topic shifts trigger a split. <strong>Higher = fewer splits = larger chunks.</strong></p>

<p>My initial instinct was to lower the threshold for long documents. That made things worse. For long documents, you <em>want</em> fewer, larger chunks , you’re looking for major topic boundaries, not every paragraph break.</p>

<h3 id="cost-impact">Cost impact</h3>

<p>Chunk count directly drives OpenAI API costs. Every chunk needs an embedding (input cost). Every chunk generates one Q&amp;A pair (completion cost). If your 200-page textbook creates 800 chunks instead of 200, you’re paying 4x. Adaptive chunking isn’t just a quality improvement , it’s a billing concern.</p>

<hr />

<h2 id="making-qa-generation-actually-good">Making Q&amp;A Generation Actually Good</h2>

<p>Once chunking is right, quality depends on how you use retrieved context and how you prompt the LLM.</p>

<h3 id="rag-retrieval-for-question-generation">RAG retrieval for question generation</h3>

<p>The naive approach: for each chunk, ask the LLM to generate a question. The problem is that a single chunk often lacks context , it references concepts defined elsewhere.</p>

<p>The better approach: before generating a question for a chunk, retrieve the 3 most semantically similar chunks from Qdrant. Include those as “related context” in the prompt. The LLM can now generate questions that test understanding across related concepts.</p>

<p>The 0.7 cosine similarity threshold matters: below it, the “related” chunks aren’t actually related, they just share common words. Including irrelevant context actively hurts question quality.</p>

<h3 id="prompt-engineering">Prompt engineering</h3>

<p>The system prompt is terse and specific , an expert educational content specialist designing for mastery learning. The user message template enforces constraints: the question must test conceptual understanding (not factual recall), be self-contained, and promote long-term retention.</p>

<p>Key insight: <strong>“quality over quantity” as an explicit instruction in the prompt measurably improves output.</strong> Without it, the LLM generates multiple surface-level questions (“What is X?”) instead of one deeper one (“How does X relate to Y, and what are the implications for Z?”).</p>

<p>The LLM returns structured JSON with <code class="language-plaintext highlighter-rouge">question</code>, <code class="language-plaintext highlighter-rouge">answer</code>, <code class="language-plaintext highlighter-rouge">key_concepts</code> (array), and <code class="language-plaintext highlighter-rouge">difficulty_level</code> (easy/medium/hard) , all stored in MySQL and exposed to the frontend for filtering.</p>

<hr />

<h2 id="spaced-repetition">Spaced Repetition</h2>

<p>Spaced repetition schedules reviews at increasing intervals based on recall performance. The SM-2 algorithm is the most widely used variant: performance is rated 1,5, and the next review interval is computed from the previous interval, the performance score, and an ease factor that adjusts over time.</p>

<p>The current schema stores Q&amp;A pairs and their scheduling state together: <code class="language-plaintext highlighter-rouge">scheduled_at = NULL</code> means the item is new and has never been studied. Email reminders use a push model , an hourly artisan command finds users whose local time is 8 AM and sends a single consolidated email listing due items.</p>

<p>The study session UI , answering questions, rating recall quality, seeing the interval adjust , is the next major frontend feature to build.</p>

<hr />

<h2 id="production-gotcha-celery-doesnt-auto-reload">Production Gotcha: Celery Doesn’t Auto-Reload</h2>

<p>The Celery gotcha that everyone hits: <strong>Celery workers do not auto-reload code changes.</strong> FastAPI (via Uvicorn with <code class="language-plaintext highlighter-rouge">--reload</code>) picks up changes automatically. Celery doesn’t. If you modify <code class="language-plaintext highlighter-rouge">celery_tasks.py</code> or any service module it imports and don’t restart the worker, the old code keeps running.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose restart celery-worker
</code></pre></div></div>

<p>The symptom is confusing: your FastAPI endpoints reflect the new code, but background processing behaves as if nothing changed. This is now in every CLAUDE.md and README for the project, and I still forget it regularly.</p>

<hr />

<h2 id="what-id-do-differently">What I’d Do Differently</h2>

<p><strong>Start with semantic chunking from day one.</strong> I started with fixed-size chunks as a “quick first pass” and spent more time undoing that than I would have spent implementing semantic chunking correctly from the start.</p>

<p><strong>Adaptive chunk sizing should be a first-class concern.</strong> I didn’t think about variable document lengths until users started uploading textbooks. PDFs range from a 2-page note to a 500-page manual and need fundamentally different treatment.</p>

<p><strong>Use a proper task result store earlier.</strong> I started tracking Celery job state with ad-hoc Redis key patterns and built the abstraction layer later as things grew. Starting with a clean interface for job state (create, read, update, expire, index by project) would have saved refactoring time.</p>

<p><strong>The push callback model was the right call.</strong> I’ve worked on systems that poll job status from a frontend timer. It always becomes a source of race conditions and extra load. The callback model is simpler to reason about and delivers results faster.</p>

<hr />

<h2 id="open-problems">Open Problems</h2>

<ul>
  <li><strong>Multi-modal documents</strong>: PDFs with diagrams and mathematical notation are common in technical study material. Current text extraction ignores images entirely.</li>
  <li><strong>Self-hosted LLM</strong>: Some users are uncomfortable uploading sensitive professional material to an OpenAI-backed system. LlamaIndex supports provider-swapping; the work is validating quality parity.</li>
  <li><strong>Chunk attribution</strong>: Q&amp;A pairs are stored with no reference back to the specific chunks they were generated from. Adding a <code class="language-plaintext highlighter-rouge">source_chunk_id</code> would enable “show me the source” functionality in the study interface.</li>
</ul>

<hr />

<p>The most interesting engineering happened at the intersection of the two services. The boundary between Laravel and FastAPI isn’t just a language split , it forced clear thinking about which concerns belong where. Auth, billing, user data: PHP. Embeddings, vectors, async AI tasks: Python.</p>

<p>The chunking problem genuinely surprised me. Most RAG resources treat chunking as a detail , pick a size, move on. In practice it’s where the most user-visible quality variation comes from, and adaptive sizing based on document length is not optional if your use case involves documents of wildly different lengths.</p>

<p>If you’re building something similar, the project is at <a href="https://longtermemory.com">longtermemory.com</a>.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[How I combined Laravel, FastAPI, Celery, Qdrant, and OpenAI into an AI study platform: what worked, what didn’t, and the chunking problem nobody warns you about.]]></summary></entry><entry><title type="html">Passwordless Auth in Laravel 12: Implementing Magic Link Login with Sanctum</title><link href="https://alessandrofuda.github.io/magic-link-auth-laravel-sanctum/" rel="alternate" type="text/html" title="Passwordless Auth in Laravel 12: Implementing Magic Link Login with Sanctum" /><published>2026-03-06T00:00:00+00:00</published><updated>2026-03-06T00:00:00+00:00</updated><id>https://alessandrofuda.github.io/magic-link-auth-laravel-sanctum</id><content type="html" xml:base="https://alessandrofuda.github.io/magic-link-auth-laravel-sanctum/"><![CDATA[<p><em>No passwords, no reset flows, no bcrypt. Just an email, a signed URL, and a Sanctum token. Here’s how to implement magic link authentication in Laravel 12 from scratch , including the edge cases that bite you in production.</em></p>

<p>Passwords are a liability. Users forget them, reuse them, and your team ends up maintaining reset flows, email verification, and “remember me” cookie logic for years. For LongTermMemory I went fully passwordless from day one: the only way to log in is to receive a magic link by email. This post walks through the complete implementation , backend in Laravel 12 + Sanctum, frontend in React , including the production gotchas that aren’t in any tutorial.</p>

<hr />

<h2 id="the-flow-at-a-glance">The Flow at a Glance</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. User submits email → POST /api/auth/magic-link
2. Backend generates a signed URL + a short-lived code → sends email
3. User clicks link → GET /auth/magic-login/{user_id}?signature=...
4. Backend validates signature → generates a one-time code → redirects to frontend
5. Frontend receives code → POST /api/auth/exchange
6. Backend validates code → issues Sanctum token → user is authenticated
</code></pre></div></div>

<p>There are two extra branches: <strong>new users</strong> (who need email verification before getting a login link) and an <strong>OTP fallback</strong> (a 6-digit code in the same email for users whose email client breaks links). Both share the same token-issuing endpoint at the end.</p>

<hr />

<h2 id="step-1-send-the-magic-link">Step 1: Send the Magic Link</h2>

<p>The entry point is a single endpoint that accepts an email address:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">magicLink</span><span class="p">(</span><span class="kt">Request</span> <span class="nv">$request</span><span class="p">):</span> <span class="kt">JsonResponse</span>
<span class="p">{</span>
    <span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">validate</span><span class="p">([</span><span class="s1">'email'</span> <span class="o">=&gt;</span> <span class="s1">'required|email'</span><span class="p">]);</span>

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

    <span class="k">if</span> <span class="p">(</span><span class="nv">$user</span> <span class="o">&amp;&amp;</span> <span class="nv">$user</span><span class="o">-&gt;</span><span class="n">email_verified_at</span><span class="p">)</span> <span class="p">{</span>
        <span class="nv">$url</span> <span class="o">=</span> <span class="no">URL</span><span class="o">::</span><span class="nf">temporarySignedRoute</span><span class="p">(</span>
            <span class="s1">'magic.login'</span><span class="p">,</span>
            <span class="nf">now</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">addMinutes</span><span class="p">(</span><span class="mi">15</span><span class="p">),</span>
            <span class="p">[</span><span class="s1">'user_id'</span> <span class="o">=&gt;</span> <span class="nv">$user</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">]</span>
        <span class="p">);</span>
        <span class="nv">$email</span> <span class="o">=</span> <span class="nv">$user</span><span class="o">-&gt;</span><span class="n">email</span><span class="p">;</span>
        <span class="nv">$status</span> <span class="o">=</span> <span class="mi">200</span><span class="p">;</span>
    <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
        <span class="nv">$email</span> <span class="o">=</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="n">email</span><span class="p">;</span>
        <span class="nv">$user</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">createNewUserInDB</span><span class="p">(</span><span class="nv">$email</span><span class="p">);</span>
        <span class="nv">$url</span> <span class="o">=</span> <span class="no">URL</span><span class="o">::</span><span class="nf">temporarySignedRoute</span><span class="p">(</span>
            <span class="s1">'magic.register'</span><span class="p">,</span>
            <span class="nf">now</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">addMinutes</span><span class="p">(</span><span class="mi">15</span><span class="p">),</span>
            <span class="p">[</span><span class="s1">'user_id'</span> <span class="o">=&gt;</span> <span class="nv">$user</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">]</span>
        <span class="p">);</span>
        <span class="nv">$status</span> <span class="o">=</span> <span class="mi">404</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="nv">$otp</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">generateAndSaveOtp</span><span class="p">(</span><span class="nv">$user</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">);</span>
    <span class="nc">Mail</span><span class="o">::</span><span class="nf">to</span><span class="p">(</span><span class="nv">$email</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">send</span><span class="p">(</span><span class="k">new</span> <span class="nc">MagicLoginLink</span><span class="p">(</span><span class="nv">$url</span><span class="p">,</span> <span class="nv">$otp</span><span class="p">));</span>

    <span class="k">return</span> <span class="nf">response</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">json</span><span class="p">([</span><span class="s1">'status'</span> <span class="o">=&gt;</span> <span class="nv">$status</span><span class="p">]);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>A few design decisions here:</p>

<p><strong>Known vs unknown email.</strong> If the email exists and is verified, the user gets a <code class="language-plaintext highlighter-rouge">magic.login</code> link. If the email is new or unverified, a user record is created and they get a <code class="language-plaintext highlighter-rouge">magic.register</code> link (which also marks <code class="language-plaintext highlighter-rouge">email_verified_at</code> on click). Both flows converge at the same code-generation step.</p>

<p><strong><code class="language-plaintext highlighter-rouge">URL::temporarySignedRoute()</code></strong> generates a URL with an HMAC signature and an expiry timestamp baked in. Laravel validates both automatically when you call <code class="language-plaintext highlighter-rouge">$request-&gt;hasValidSignature()</code>. The link expires in 15 minutes , long enough to be usable, short enough to limit exposure.</p>

<p><strong>OTP in the same email.</strong> Every magic link email also contains a 6-digit OTP (<code class="language-plaintext highlighter-rouge">rand(100000, 999999)</code>), valid for 15 minutes. Users on mobile apps or email clients that mangle URLs can type the code instead. Same security properties, different UX.</p>

<p><strong>Never enumerate users.</strong> Both branches return HTTP 200 to the caller , the <code class="language-plaintext highlighter-rouge">$status</code> field inside the JSON body differs (200 vs 404), but the HTTP status code is always 200. This prevents email enumeration via timing or status code differences.</p>

<hr />

<h2 id="step-2-validate-the-signature-and-generate-a-code">Step 2: Validate the Signature and Generate a Code</h2>

<p>When the user clicks the link, the backend validates the signature and exchanges it for a short-lived one-time code:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">magicLogin</span><span class="p">(</span><span class="kt">Request</span> <span class="nv">$request</span><span class="p">,</span> <span class="nv">$user_id</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="nv">$errorResponse</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">validateSignatureOrFail</span><span class="p">(</span><span class="nv">$request</span><span class="p">))</span> <span class="p">{</span>
        <span class="k">return</span> <span class="nv">$errorResponse</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="nv">$user</span> <span class="o">=</span> <span class="nc">User</span><span class="o">::</span><span class="nf">findOrFail</span><span class="p">(</span><span class="nv">$user_id</span><span class="p">);</span>
    <span class="k">return</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">generateCodeAndRedirect</span><span class="p">(</span><span class="nv">$user</span><span class="p">);</span>
<span class="p">}</span>

<span class="k">private</span> <span class="k">function</span> <span class="n">generateCodeAndRedirect</span><span class="p">(</span><span class="kt">User</span> <span class="nv">$user</span><span class="p">,</span> <span class="kt">?string</span> <span class="nv">$redirectTo</span> <span class="o">=</span> <span class="kc">null</span><span class="p">)</span>
<span class="p">{</span>
    <span class="nv">$code</span> <span class="o">=</span> <span class="nc">Str</span><span class="o">::</span><span class="nf">uuid</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">toString</span><span class="p">();</span>

    <span class="no">DB</span><span class="o">::</span><span class="nf">table</span><span class="p">(</span><span class="s1">'magic_login_codes'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">insert</span><span class="p">([</span>
        <span class="s1">'code'</span>       <span class="o">=&gt;</span> <span class="nb">hash</span><span class="p">(</span><span class="s1">'sha256'</span><span class="p">,</span> <span class="nv">$code</span><span class="p">),</span>
        <span class="s1">'user_id'</span>    <span class="o">=&gt;</span> <span class="nv">$user</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">,</span>
        <span class="s1">'expires_at'</span> <span class="o">=&gt;</span> <span class="nf">now</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">addMinutes</span><span class="p">(</span><span class="mi">5</span><span class="p">),</span>
        <span class="s1">'used'</span>       <span class="o">=&gt;</span> <span class="kc">false</span><span class="p">,</span>
        <span class="s1">'created_at'</span> <span class="o">=&gt;</span> <span class="nf">now</span><span class="p">(),</span>
    <span class="p">]);</span>

    <span class="nv">$callbackUrl</span> <span class="o">=</span> <span class="nf">config</span><span class="p">(</span><span class="s1">'app.frontend_url'</span><span class="p">)</span> <span class="mf">.</span> <span class="s1">'/auth/callback?code='</span> <span class="mf">.</span> <span class="nb">urlencode</span><span class="p">(</span><span class="nv">$code</span><span class="p">);</span>

    <span class="k">if</span> <span class="p">(</span><span class="nv">$redirectTo</span> <span class="o">!==</span> <span class="kc">null</span><span class="p">)</span> <span class="p">{</span>
        <span class="nv">$callbackUrl</span> <span class="mf">.</span><span class="o">=</span> <span class="s1">'&amp;redirect_to='</span> <span class="mf">.</span> <span class="nb">urlencode</span><span class="p">(</span><span class="nv">$redirectTo</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="nf">redirect</span><span class="p">(</span><span class="nv">$callbackUrl</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The signed URL is valid for 15 minutes. After validation, a fresh UUID code is generated , but <strong>only the SHA-256 hash is stored</strong>, never the plaintext. This is the same principle as storing hashed passwords: if your database leaks, raw codes can’t be replayed. The code itself lives in the URL for 5 minutes before it expires.</p>

<p>The redirect sends the browser to the React frontend at <code class="language-plaintext highlighter-rouge">/auth/callback?code=&lt;uuid&gt;</code>, which then exchanges it for a Sanctum token.</p>

<hr />

<h2 id="step-3-exchange-the-code-for-a-token">Step 3: Exchange the Code for a Token</h2>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">exchangeCodeWithToken</span><span class="p">(</span><span class="kt">Request</span> <span class="nv">$request</span><span class="p">):</span> <span class="kt">JsonResponse</span>
<span class="p">{</span>
    <span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">validate</span><span class="p">([</span><span class="s1">'code'</span> <span class="o">=&gt;</span> <span class="s1">'required|string'</span><span class="p">]);</span>

    <span class="nv">$record</span> <span class="o">=</span> <span class="no">DB</span><span class="o">::</span><span class="nf">table</span><span class="p">(</span><span class="s1">'magic_login_codes'</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span><span class="s1">'code'</span><span class="p">,</span> <span class="nb">hash</span><span class="p">(</span><span class="s1">'sha256'</span><span class="p">,</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="n">code</span><span class="p">))</span>
        <span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span><span class="s1">'expires_at'</span><span class="p">,</span> <span class="s1">'&gt;'</span><span class="p">,</span> <span class="nf">now</span><span class="p">())</span>
        <span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span><span class="s1">'used'</span><span class="p">,</span> <span class="kc">false</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">first</span><span class="p">();</span>

    <span class="k">if</span> <span class="p">(</span><span class="o">!</span> <span class="nv">$record</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="nf">response</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">json</span><span class="p">([</span><span class="s1">'message'</span> <span class="o">=&gt;</span> <span class="s1">'Invalid or expired code'</span><span class="p">],</span> <span class="mi">401</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="no">DB</span><span class="o">::</span><span class="nf">table</span><span class="p">(</span><span class="s1">'magic_login_codes'</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span><span class="s1">'code'</span><span class="p">,</span> <span class="nb">hash</span><span class="p">(</span><span class="s1">'sha256'</span><span class="p">,</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="n">code</span><span class="p">))</span>
        <span class="o">-&gt;</span><span class="nf">update</span><span class="p">([</span><span class="s1">'used'</span> <span class="o">=&gt;</span> <span class="kc">true</span><span class="p">]);</span>

    <span class="nv">$user</span> <span class="o">=</span> <span class="nc">User</span><span class="o">::</span><span class="nf">findOrFail</span><span class="p">(</span><span class="nv">$record</span><span class="o">-&gt;</span><span class="n">user_id</span><span class="p">);</span>

    <span class="k">if</span> <span class="p">(</span><span class="o">!</span> <span class="nv">$user</span><span class="o">-&gt;</span><span class="n">notifications_enabled</span><span class="p">)</span> <span class="p">{</span>
        <span class="nv">$user</span><span class="o">-&gt;</span><span class="nf">update</span><span class="p">([</span><span class="s1">'notifications_enabled'</span> <span class="o">=&gt;</span> <span class="kc">true</span><span class="p">]);</span>
    <span class="p">}</span>

    <span class="nv">$token</span> <span class="o">=</span> <span class="nv">$user</span><span class="o">-&gt;</span><span class="nf">createToken</span><span class="p">(</span><span class="s1">'auth_token'</span><span class="p">)</span><span class="o">-&gt;</span><span class="n">plainTextToken</span><span class="p">;</span>

    <span class="k">return</span> <span class="nf">response</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">json</span><span class="p">([</span><span class="s1">'token'</span> <span class="o">=&gt;</span> <span class="nv">$token</span><span class="p">]);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Three checks before issuing a token: the hash matches, the code hasn’t expired, and it hasn’t been used before. The <code class="language-plaintext highlighter-rouge">used</code> flag is set to <code class="language-plaintext highlighter-rouge">true</code> immediately after the record is found, before the token is issued. This stops casual replay attempts , though a fully concurrent double-submit at the exact same millisecond could theoretically pass both <code class="language-plaintext highlighter-rouge">where('used', false)</code> queries before either update lands. A proper fix wraps the read-and-update in a database transaction; for a low-traffic auth endpoint this race window is acceptable, but worth noting.</p>

<p>The <code class="language-plaintext highlighter-rouge">notifications_enabled</code> re-enable on login is a deliberate UX choice: users who were auto-disabled after 30 days of inactivity get their reminders back the moment they log in again. Logging in is an implicit signal of renewed interest.</p>

<p><code class="language-plaintext highlighter-rouge">$user-&gt;createToken('auth_token')-&gt;plainTextToken</code> creates a Sanctum personal access token. The frontend stores this in <code class="language-plaintext highlighter-rouge">localStorage</code> as <code class="language-plaintext highlighter-rouge">auth_token</code> and sends it as <code class="language-plaintext highlighter-rouge">Authorization: Bearer {token}</code> on every subsequent request.</p>

<hr />

<h2 id="the-reverse-proxy-signature-gotcha">The Reverse Proxy Signature Gotcha</h2>

<p>In production, the app runs behind Nginx. Signed URLs are generated using <code class="language-plaintext highlighter-rouge">config('app.url')</code> as the base , which might be <code class="language-plaintext highlighter-rouge">https://api.longtermemory.com</code>. But the request that arrives at Laravel’s validation layer may have <code class="language-plaintext highlighter-rouge">http://localhost</code> as its host (the proxy doesn’t forward <code class="language-plaintext highlighter-rouge">X-Forwarded-Proto</code> correctly in all configurations).</p>

<p>Laravel’s <code class="language-plaintext highlighter-rouge">$request-&gt;hasValidSignature()</code> reconstructs the URL from the incoming request to verify the HMAC. If the scheme or host differs from what was signed, validation silently fails.</p>

<p>The fix is a fallback that normalizes the URL against <code class="language-plaintext highlighter-rouge">config('app.url')</code> before validating:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="k">function</span> <span class="n">hasValidAppUrlSignature</span><span class="p">(</span><span class="kt">Request</span> <span class="nv">$request</span><span class="p">,</span> <span class="kt">array</span> <span class="nv">$ignoreQuery</span> <span class="o">=</span> <span class="p">[]):</span> <span class="kt">bool</span>
<span class="p">{</span>
    <span class="c1">// Try standard validation first</span>
    <span class="nv">$standardMethod</span> <span class="o">=</span> <span class="k">empty</span><span class="p">(</span><span class="nv">$ignoreQuery</span><span class="p">)</span>
        <span class="o">?</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">hasValidSignature</span><span class="p">()</span>
        <span class="o">:</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">hasValidSignatureWhileIgnoring</span><span class="p">(</span><span class="nv">$ignoreQuery</span><span class="p">);</span>

    <span class="k">if</span> <span class="p">(</span><span class="nv">$standardMethod</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="kc">true</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="c1">// Fallback: rebuild the URL using config('app.url') as the base</span>
    <span class="nv">$appUrl</span> <span class="o">=</span> <span class="nb">rtrim</span><span class="p">(</span><span class="nf">config</span><span class="p">(</span><span class="s1">'app.url'</span><span class="p">),</span> <span class="s1">'/'</span><span class="p">);</span>
    <span class="nv">$normalizedUrl</span> <span class="o">=</span> <span class="nv">$appUrl</span> <span class="mf">.</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">getRequestUri</span><span class="p">();</span>
    <span class="nv">$normalizedRequest</span> <span class="o">=</span> <span class="nc">Request</span><span class="o">::</span><span class="nf">create</span><span class="p">(</span><span class="nv">$normalizedUrl</span><span class="p">);</span>

    <span class="k">return</span> <span class="no">URL</span><span class="o">::</span><span class="nf">hasValidSignature</span><span class="p">(</span><span class="nv">$normalizedRequest</span><span class="p">,</span> <span class="kc">true</span><span class="p">,</span> <span class="nv">$ignoreQuery</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The test that covers this scenario is worth reading , it sets <code class="language-plaintext highlighter-rouge">APP_URL</code> to HTTPS, generates a signed URL with that scheme, then sends the request as a relative path (simulating a proxy that strips the scheme):</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">test_magic_login_with_redirect_works_when_scheme_differs_from_app_url</span><span class="p">():</span> <span class="kt">void</span>
<span class="p">{</span>
    <span class="nv">$user</span> <span class="o">=</span> <span class="nc">User</span><span class="o">::</span><span class="nf">factory</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">create</span><span class="p">();</span>

    <span class="nv">$httpsUrl</span> <span class="o">=</span> <span class="nb">preg_replace</span><span class="p">(</span><span class="s1">'/^http:/'</span><span class="p">,</span> <span class="s1">'https:'</span><span class="p">,</span> <span class="nf">config</span><span class="p">(</span><span class="s1">'app.url'</span><span class="p">));</span>
    <span class="nf">config</span><span class="p">([</span><span class="s1">'app.url'</span> <span class="o">=&gt;</span> <span class="nv">$httpsUrl</span><span class="p">]);</span>
    <span class="no">URL</span><span class="o">::</span><span class="nf">forceRootUrl</span><span class="p">(</span><span class="nv">$httpsUrl</span><span class="p">);</span>
    <span class="no">URL</span><span class="o">::</span><span class="nf">forceScheme</span><span class="p">(</span><span class="s1">'https'</span><span class="p">);</span>

    <span class="nv">$signedUrl</span> <span class="o">=</span> <span class="no">URL</span><span class="o">::</span><span class="nf">temporarySignedRoute</span><span class="p">(</span><span class="s1">'magic.login.redirect'</span><span class="p">,</span> <span class="nf">now</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">addDays</span><span class="p">(</span><span class="mi">30</span><span class="p">),</span> <span class="p">[</span><span class="s1">'user_id'</span> <span class="o">=&gt;</span> <span class="nv">$user</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">]);</span>
    <span class="nv">$url</span> <span class="o">=</span> <span class="nv">$signedUrl</span> <span class="mf">.</span> <span class="s1">'&amp;redirect_to='</span> <span class="mf">.</span> <span class="nb">urlencode</span><span class="p">(</span><span class="s1">'/study-plan/pr/1'</span><span class="p">);</span>

    <span class="no">URL</span><span class="o">::</span><span class="nf">forceScheme</span><span class="p">(</span><span class="kc">null</span><span class="p">);</span> <span class="c1">// reset so the test request doesn't force https</span>

    <span class="nv">$parsedUrl</span> <span class="o">=</span> <span class="nb">parse_url</span><span class="p">(</span><span class="nv">$url</span><span class="p">);</span>
    <span class="nv">$pathAndQuery</span> <span class="o">=</span> <span class="p">(</span><span class="nv">$parsedUrl</span><span class="p">[</span><span class="s1">'path'</span><span class="p">]</span> <span class="o">??</span> <span class="s1">'/'</span><span class="p">)</span> <span class="mf">.</span> <span class="s1">'?'</span> <span class="mf">.</span> <span class="p">(</span><span class="nv">$parsedUrl</span><span class="p">[</span><span class="s1">'query'</span><span class="p">]</span> <span class="o">??</span> <span class="s1">''</span><span class="p">);</span>

    <span class="nv">$response</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">(</span><span class="nv">$pathAndQuery</span><span class="p">);</span>

    <span class="nv">$response</span><span class="o">-&gt;</span><span class="nf">assertRedirect</span><span class="p">();</span>
    <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">assertStringContainsString</span><span class="p">(</span><span class="s1">'/auth/callback'</span><span class="p">,</span> <span class="nv">$response</span><span class="o">-&gt;</span><span class="n">headers</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">(</span><span class="s1">'Location'</span><span class="p">));</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Without this fallback, every production login fails silently with a redirect to <code class="language-plaintext highlighter-rouge">/login?error=Invalid+or+expired+link</code> , a very confusing bug to diagnose.</p>

<hr />

<h2 id="open-redirect-protection">Open Redirect Protection</h2>

<p>The <code class="language-plaintext highlighter-rouge">magic.login.redirect</code> route accepts a <code class="language-plaintext highlighter-rouge">redirect_to</code> query parameter so that notification emails can deep-link users directly to their study plan after login. But this parameter must not be part of the URL signature , it’s appended after signing because the destination URL is determined at notification send time, not at route generation time.</p>

<p>This means <code class="language-plaintext highlighter-rouge">redirect_to</code> must be validated separately:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">magicLoginWithRedirect</span><span class="p">(</span><span class="kt">Request</span> <span class="nv">$request</span><span class="p">,</span> <span class="nv">$user_id</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">hasValidAppUrlSignature</span><span class="p">(</span><span class="nv">$request</span><span class="p">,</span> <span class="p">[</span><span class="s1">'redirect_to'</span><span class="p">]))</span> <span class="p">{</span>
        <span class="k">return</span> <span class="nf">redirect</span><span class="p">(</span><span class="nf">config</span><span class="p">(</span><span class="s1">'app.frontend_url'</span><span class="p">)</span> <span class="mf">.</span> <span class="s1">'/login?error=...'</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="nv">$user</span> <span class="o">=</span> <span class="nc">User</span><span class="o">::</span><span class="nf">findOrFail</span><span class="p">(</span><span class="nv">$user_id</span><span class="p">);</span>

    <span class="nv">$redirectTo</span> <span class="o">=</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">query</span><span class="p">(</span><span class="s1">'redirect_to'</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="nv">$redirectTo</span> <span class="o">!==</span> <span class="kc">null</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="nf">str_starts_with</span><span class="p">(</span><span class="nv">$redirectTo</span><span class="p">,</span> <span class="s1">'/'</span><span class="p">))</span> <span class="p">{</span>
        <span class="nv">$redirectTo</span> <span class="o">=</span> <span class="kc">null</span><span class="p">;</span> <span class="c1">// reject any absolute or external URL</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">generateCodeAndRedirect</span><span class="p">(</span><span class="nv">$user</span><span class="p">,</span> <span class="nv">$redirectTo</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">hasValidAppUrlSignature($request, ['redirect_to'])</code> is the custom wrapper from the previous section , internally it calls <code class="language-plaintext highlighter-rouge">$request-&gt;hasValidSignatureWhileIgnoring(['redirect_to'])</code>, which validates the HMAC while ignoring that specific query parameter. Then the value itself is checked: only relative paths (starting with <code class="language-plaintext highlighter-rouge">/</code>) are forwarded. Anything else , <code class="language-plaintext highlighter-rouge">http://evil.com/steal</code>, <code class="language-plaintext highlighter-rouge">//evil.com</code>, <code class="language-plaintext highlighter-rouge">javascript:</code> , is silently dropped.</p>

<p>The test covers this:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">test_magic_login_with_redirect_ignores_external_redirect_to</span><span class="p">():</span> <span class="kt">void</span>
<span class="p">{</span>
    <span class="c1">// ... generate signed URL</span>
    <span class="nv">$url</span> <span class="o">=</span> <span class="nv">$signedUrl</span> <span class="mf">.</span> <span class="s1">'&amp;redirect_to='</span> <span class="mf">.</span> <span class="nb">urlencode</span><span class="p">(</span><span class="s1">'http://evil.com/steal'</span><span class="p">);</span>

    <span class="nv">$response</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">(</span><span class="nv">$url</span><span class="p">);</span>

    <span class="nv">$location</span> <span class="o">=</span> <span class="nv">$response</span><span class="o">-&gt;</span><span class="n">headers</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">(</span><span class="s1">'Location'</span><span class="p">);</span>
    <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">assertStringContainsString</span><span class="p">(</span><span class="s1">'/auth/callback'</span><span class="p">,</span> <span class="nv">$location</span><span class="p">);</span>
    <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">assertStringNotContainsString</span><span class="p">(</span><span class="s1">'redirect_to'</span><span class="p">,</span> <span class="nv">$location</span><span class="p">);</span>
    <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">assertStringNotContainsString</span><span class="p">(</span><span class="s1">'evil.com'</span><span class="p">,</span> <span class="nv">$location</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<hr />

<h2 id="the-react-side-handling-strictmodes-double-invocation">The React Side: Handling StrictMode’s Double Invocation</h2>

<p>In React 19 with <code class="language-plaintext highlighter-rouge">&lt;StrictMode&gt;</code>, effects run <strong>twice in development</strong>. For most effects that’s fine. For an auth callback that exchanges a one-time code for a token, it’s a problem: the second call hits the endpoint with a code that’s already been marked <code class="language-plaintext highlighter-rouge">used</code>, gets a 401, and the user sees an auth error.</p>

<p>The fix is a ref guard:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">AuthCallback</span><span class="p">()</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="p">[</span><span class="nx">searchParams</span><span class="p">]</span> <span class="o">=</span> <span class="nx">useSearchParams</span><span class="p">();</span>
  <span class="kd">const</span> <span class="nx">navigate</span> <span class="o">=</span> <span class="nx">useNavigate</span><span class="p">();</span>
  <span class="kd">const</span> <span class="p">{</span> <span class="nx">completeAuthentication</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">usePostAuthFlow</span><span class="p">();</span>
  <span class="kd">const</span> <span class="nx">code</span> <span class="o">=</span> <span class="nx">searchParams</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">code</span><span class="dl">'</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">redirect_to</span> <span class="o">=</span> <span class="nx">searchParams</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">redirect_to</span><span class="dl">'</span><span class="p">);</span>
  <span class="kd">const</span> <span class="p">[</span><span class="nx">error</span><span class="p">,</span> <span class="nx">setError</span><span class="p">]</span> <span class="o">=</span> <span class="nx">useState</span><span class="o">&lt;</span><span class="kr">string</span> <span class="o">|</span> <span class="kc">null</span><span class="o">&gt;</span><span class="p">(</span><span class="kc">null</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">hasExchanged</span> <span class="o">=</span> <span class="nx">useRef</span><span class="p">(</span><span class="kc">false</span><span class="p">);</span>

  <span class="nx">useEffect</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">exchangeCode</span> <span class="o">=</span> <span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">code</span><span class="p">)</span> <span class="p">{</span> <span class="nx">navigate</span><span class="p">(</span><span class="dl">'</span><span class="s1">/login</span><span class="dl">'</span><span class="p">);</span> <span class="k">return</span><span class="p">;</span> <span class="p">}</span>

      <span class="k">if</span> <span class="p">(</span><span class="nx">hasExchanged</span><span class="p">.</span><span class="nx">current</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span> <span class="c1">// prevent StrictMode double-fire</span>
      <span class="nx">hasExchanged</span><span class="p">.</span><span class="nx">current</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>

      <span class="k">try</span> <span class="p">{</span>
        <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">authApi</span><span class="p">.</span><span class="nx">exchange</span><span class="p">(</span><span class="nx">code</span><span class="p">);</span>

        <span class="c1">// Validate redirect_to: relative paths only</span>
        <span class="kd">const</span> <span class="nx">validRedirect</span> <span class="o">=</span> <span class="nx">redirect_to</span> <span class="o">&amp;&amp;</span>
          <span class="nx">redirect_to</span><span class="p">.</span><span class="nx">startsWith</span><span class="p">(</span><span class="dl">'</span><span class="s1">/</span><span class="dl">'</span><span class="p">)</span> <span class="o">&amp;&amp;</span>
          <span class="o">!</span><span class="nx">redirect_to</span><span class="p">.</span><span class="nx">startsWith</span><span class="p">(</span><span class="dl">'</span><span class="s1">//</span><span class="dl">'</span><span class="p">)</span> <span class="o">&amp;&amp;</span>
          <span class="o">!</span><span class="nx">redirect_to</span><span class="p">.</span><span class="nx">includes</span><span class="p">(</span><span class="dl">'</span><span class="s1">://</span><span class="dl">'</span><span class="p">)</span> <span class="o">&amp;&amp;</span>
          <span class="o">!</span><span class="p">[</span><span class="dl">'</span><span class="s1">/login</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">/auth/callback</span><span class="dl">'</span><span class="p">].</span><span class="nx">includes</span><span class="p">(</span><span class="nx">redirect_to</span><span class="p">)</span>
          <span class="p">?</span> <span class="nx">redirect_to</span>
          <span class="p">:</span> <span class="kc">undefined</span><span class="p">;</span>

        <span class="k">await</span> <span class="nx">completeAuthentication</span><span class="p">(</span><span class="nx">response</span><span class="p">.</span><span class="nx">token</span><span class="p">,</span> <span class="nx">validRedirect</span><span class="p">);</span>
      <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="na">err</span><span class="p">:</span> <span class="kr">any</span><span class="p">)</span> <span class="p">{</span>
        <span class="nx">setError</span><span class="p">(</span><span class="nx">err</span><span class="p">.</span><span class="nx">response</span><span class="p">?.</span><span class="nx">data</span><span class="p">?.</span><span class="nx">message</span> <span class="o">||</span> <span class="dl">'</span><span class="s1">Authentication failed</span><span class="dl">'</span><span class="p">);</span>
        <span class="nx">setTimeout</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="nx">navigate</span><span class="p">(</span><span class="dl">'</span><span class="s1">/login</span><span class="dl">'</span><span class="p">),</span> <span class="mi">3000</span><span class="p">);</span>
      <span class="p">}</span>
    <span class="p">};</span>

    <span class="nx">exchangeCode</span><span class="p">();</span>
  <span class="p">},</span> <span class="p">[</span><span class="nx">code</span><span class="p">,</span> <span class="nx">navigate</span><span class="p">,</span> <span class="nx">completeAuthentication</span><span class="p">]);</span>

  <span class="c1">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">useRef</code> persists across re-renders and across StrictMode’s double-mount cycle. Once <code class="language-plaintext highlighter-rouge">hasExchanged.current</code> is set to <code class="language-plaintext highlighter-rouge">true</code>, any subsequent invocation of the effect exits immediately. Note that this guard is intentionally not in the dependency array , it’s a one-shot flag, not reactive state.</p>

<p>The frontend also validates <code class="language-plaintext highlighter-rouge">redirect_to</code> independently, even though the backend already validated it. Defense in depth: the frontend ensures it never navigates to an external URL regardless of what arrives in the URL parameter.</p>

<p>After <code class="language-plaintext highlighter-rouge">completeAuthentication()</code>, the browser auto-detects and sends the user’s timezone to <code class="language-plaintext highlighter-rouge">POST /api/user/update-timezone</code>:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// In usePostAuthFlow or AuthCallback, after token is stored</span>
<span class="kd">const</span> <span class="nx">timezone</span> <span class="o">=</span> <span class="nx">Intl</span><span class="p">.</span><span class="nx">DateTimeFormat</span><span class="p">().</span><span class="nx">resolvedOptions</span><span class="p">().</span><span class="nx">timeZone</span><span class="p">;</span>
<span class="k">await</span> <span class="nx">authApi</span><span class="p">.</span><span class="nx">updateTimezone</span><span class="p">(</span><span class="nx">timezone</span><span class="p">);</span> <span class="c1">// e.g. "Europe/Rome"</span>
</code></pre></div></div>

<p>This powers the timezone-aware 8 AM study reminder emails , but that’s a topic for another post.</p>

<hr />

<h2 id="testing-actingasuser-vs-actingas">Testing: <code class="language-plaintext highlighter-rouge">actingAsUser()</code> vs <code class="language-plaintext highlighter-rouge">actingAs()</code></h2>

<p>Laravel’s built-in <code class="language-plaintext highlighter-rouge">actingAs($user)</code> sets the authenticated user but doesn’t create a real Sanctum token. This is fine for most tests, but breaks any code that calls <code class="language-plaintext highlighter-rouge">$request-&gt;user()-&gt;currentAccessToken()</code> , specifically, the logout endpoint.</p>

<p>The solution is a custom <code class="language-plaintext highlighter-rouge">actingAsUser()</code> helper in <code class="language-plaintext highlighter-rouge">TestCase</code>:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// tests/TestCase.php</span>
<span class="k">protected</span> <span class="k">function</span> <span class="n">actingAsUser</span><span class="p">(</span><span class="kt">int</span> <span class="nv">$planId</span> <span class="o">=</span> <span class="nc">CommercialPlan</span><span class="o">::</span><span class="no">FREE</span><span class="p">):</span> <span class="kt">User</span>
<span class="p">{</span>
    <span class="nv">$user</span> <span class="o">=</span> <span class="nc">User</span><span class="o">::</span><span class="nf">factory</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">withPlan</span><span class="p">(</span><span class="nv">$planId</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">create</span><span class="p">();</span>
    <span class="nv">$token</span> <span class="o">=</span> <span class="nv">$user</span><span class="o">-&gt;</span><span class="nf">createToken</span><span class="p">(</span><span class="s1">'auth_token'</span><span class="p">)</span><span class="o">-&gt;</span><span class="n">plainTextToken</span><span class="p">;</span>
    <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">withHeader</span><span class="p">(</span><span class="s1">'Authorization'</span><span class="p">,</span> <span class="s1">'Bearer '</span> <span class="mf">.</span> <span class="nv">$token</span><span class="p">);</span>
    <span class="k">return</span> <span class="nv">$user</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This creates a real <code class="language-plaintext highlighter-rouge">personal_access_tokens</code> record. The logout test can then assert the token was actually deleted:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">test_logout_deletes_token</span><span class="p">():</span> <span class="kt">void</span>
<span class="p">{</span>
    <span class="nv">$user</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">actingAsUser</span><span class="p">();</span>

    <span class="nv">$response</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">postJson</span><span class="p">(</span><span class="s1">'/api/logout'</span><span class="p">);</span>

    <span class="nv">$response</span><span class="o">-&gt;</span><span class="nf">assertStatus</span><span class="p">(</span><span class="mi">200</span><span class="p">);</span>
    <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">assertDatabaseEmpty</span><span class="p">(</span><span class="s1">'personal_access_tokens'</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>With plain <code class="language-plaintext highlighter-rouge">actingAs()</code>, <code class="language-plaintext highlighter-rouge">currentAccessToken()</code> returns <code class="language-plaintext highlighter-rouge">null</code> and the logout controller throws. With the real token helper, both the controller behavior and the database assertion are tested correctly.</p>

<hr />

<h2 id="what-the-database-looks-like">What the Database Looks Like</h2>

<p>Three tables drive the auth system:</p>

<p><strong><code class="language-plaintext highlighter-rouge">magic_login_codes</code></strong> , short-lived one-time codes:</p>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">id</span><span class="p">,</span> <span class="n">code</span> <span class="p">(</span><span class="n">SHA</span><span class="o">-</span><span class="mi">256</span> <span class="n">hash</span><span class="p">),</span> <span class="n">user_id</span><span class="p">,</span> <span class="n">expires_at</span><span class="p">,</span> <span class="n">used</span> <span class="p">(</span><span class="nb">bool</span><span class="p">),</span> <span class="n">created_at</span>
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">otps</code></strong> , 6-digit fallback codes:</p>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">id</span><span class="p">,</span> <span class="n">user_id</span><span class="p">,</span> <span class="n">otp</span><span class="p">,</span> <span class="n">expires_at</span><span class="p">,</span> <span class="n">used</span> <span class="p">(</span><span class="nb">bool</span><span class="p">),</span> <span class="n">created_at</span><span class="p">,</span> <span class="n">updated_at</span>
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">personal_access_tokens</code></strong> , Sanctum tokens (Laravel manages this table automatically):</p>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">id</span><span class="p">,</span> <span class="n">tokenable_type</span><span class="p">,</span> <span class="n">tokenable_id</span><span class="p">,</span> <span class="n">name</span><span class="p">,</span> <span class="n">token</span> <span class="p">(</span><span class="n">SHA</span><span class="o">-</span><span class="mi">256</span><span class="p">),</span> <span class="n">last_used_at</span><span class="p">,</span> <span class="n">expires_at</span><span class="p">,</span> <span class="n">created_at</span><span class="p">,</span> <span class="n">updated_at</span>
</code></pre></div></div>

<p>Cleanup: the <code class="language-plaintext highlighter-rouge">custom:clean-table-in-db personal_access_tokens</code> artisan command prunes old tokens on a schedule, keeping the table from growing unbounded.</p>

<hr />

<h2 id="what-id-do-differently">What I’d Do Differently</h2>

<p><strong>Separate the “new user” and “returning user” email templates.</strong> Currently both get the same <code class="language-plaintext highlighter-rouge">MagicLoginLink</code> mailable. The register link should have a welcome tone; the login link should be brief. Small thing, but it affects user perception.</p>

<p><strong>Rate-limit the magic link endpoint.</strong> Right now a bad actor can trigger unlimited emails to any address. A simple <code class="language-plaintext highlighter-rouge">RateLimiter::attempt('magic-link:' . $email, 5, fn() =&gt; ..., 60)</code> per email address per minute would be enough.</p>

<p><strong>Store the code in Redis instead of MySQL.</strong> The <code class="language-plaintext highlighter-rouge">magic_login_codes</code> table has high write churn (insert on every login, update on exchange, prune periodically). Redis with a 5-minute TTL is a better fit , auto-expiry, no cleanup job, lower latency.</p>

<hr />

<p>Passwordless auth is one of those features that looks simple until you implement it properly. The signed URL mechanics, the reverse proxy normalization, the open redirect validation, and the StrictMode guard are all edge cases that don’t appear in tutorials but will bite you in production. Hopefully this saves you some debugging time.</p>

<p>The full implementation is part of <a href="https://longtermemory.com">LongTermMemory</a> , an AI-powered study platform built on Laravel 12 and React 19.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[No passwords, no reset flows, no bcrypt. Just an email, a signed URL, and a Sanctum token. Here’s how to implement magic link authentication in Laravel 12 from scratch , including the edge cases that bite you in production.]]></summary></entry><entry><title type="html">Preventing Duplicate Background Jobs in Celery with Redis: A Production Pattern</title><link href="https://alessandrofuda.github.io/celery-redis-job-deduplication/" rel="alternate" type="text/html" title="Preventing Duplicate Background Jobs in Celery with Redis: A Production Pattern" /><published>2026-03-04T00:00:00+00:00</published><updated>2026-03-04T00:00:00+00:00</updated><id>https://alessandrofuda.github.io/celery-redis-job-deduplication</id><content type="html" xml:base="https://alessandrofuda.github.io/celery-redis-job-deduplication/"><![CDATA[<p><em>A user double-clicks “Generate Study Plan”. Two parallel Celery workers start processing the same project simultaneously, doubling OpenAI costs and writing duplicate Q&amp;A pairs to the database. Here’s how to fix it with a Redis index key , and why TTL alone isn’t enough.</em></p>

<hr />

<h2 id="the-bug">The Bug</h2>

<p><a href="https://longtermemory.com">LongTermMemory</a> has a Q&amp;A generation pipeline: users upload documents, and a FastAPI service queues a Celery task that runs a RAG pipeline , chunking documents, generating embeddings with OpenAI, producing Q&amp;A flashcard pairs, and calling back to the Laravel backend with the results.</p>

<p>The pipeline is expensive. A moderate document set can cost several cents in OpenAI tokens and take a minute to complete. A double-click on “Generate Study Plan” would trigger two <code class="language-plaintext highlighter-rouge">POST /api/generate-qa</code> requests in quick succession, each passing the duplicate check (there was none), each creating its own Celery task, both running in parallel on the same project data.</p>

<p>The result: doubled costs, duplicate Q&amp;A pairs in the database, and a callback race where both tasks notify Laravel they’re “done” , potentially with partial results overwriting each other.</p>

<p>The fix is a per-project active job index in Redis.</p>

<hr />

<h2 id="two-redis-keys-two-responsibilities">Two Redis Keys, Two Responsibilities</h2>

<p>The <code class="language-plaintext highlighter-rouge">JobStorage</code> class uses two distinct key namespaces:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">job:{job_id}</code> , stores the full job metadata as a JSON blob (status, progress counters, Q&amp;A pairs, errors). One key per job, 24-hour TTL.</li>
  <li><code class="language-plaintext highlighter-rouge">project_job:{project_id}</code> , stores the currently active <code class="language-plaintext highlighter-rouge">job_id</code> for a project. One key per project, 24-hour TTL.</li>
</ul>

<p>The second key is the deduplication index. Its only purpose is to answer one question at request time: <em>does this project already have a running job?</em></p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">_job_key</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">job_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
    <span class="k">return</span> <span class="sa">f</span><span class="s">"job:</span><span class="si">{</span><span class="n">job_id</span><span class="si">}</span><span class="s">"</span>

<span class="k">def</span> <span class="nf">_project_job_key</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">project_id</span><span class="p">:</span> <span class="nb">int</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
    <span class="k">return</span> <span class="sa">f</span><span class="s">"project_job:</span><span class="si">{</span><span class="n">project_id</span><span class="si">}</span><span class="s">"</span>
</code></pre></div></div>

<p>The TTL is set to 86400 seconds (24 hours) on both key types. This is a safety net , if a task crashes without hitting any of its cleanup paths, the lock releases automatically the next day rather than blocking the project forever.</p>

<hr />

<h2 id="the-index-set-check-clear">The Index: Set, Check, Clear</h2>

<p>Three methods manage the index:</p>

<p><strong><code class="language-plaintext highlighter-rouge">set_project_active_job</code></strong> , called immediately after the job is created in Redis, before the Celery task is queued:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">set_project_active_job</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">project_id</span><span class="p">:</span> <span class="nb">int</span><span class="p">,</span> <span class="n">job_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="n">key</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">_project_job_key</span><span class="p">(</span><span class="n">project_id</span><span class="p">)</span>
    <span class="bp">self</span><span class="p">.</span><span class="n">redis_client</span><span class="p">.</span><span class="n">setex</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="bp">self</span><span class="p">.</span><span class="n">job_ttl</span><span class="p">,</span> <span class="n">job_id</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">setex</code> sets the key with an atomic TTL in one call. No separate <code class="language-plaintext highlighter-rouge">expire</code> needed.</p>

<p><strong><code class="language-plaintext highlighter-rouge">get_project_active_job</code></strong> , called at the start of every <code class="language-plaintext highlighter-rouge">POST /api/generate-qa</code> request:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">get_project_active_job</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">project_id</span><span class="p">:</span> <span class="nb">int</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">str</span><span class="p">]:</span>
    <span class="n">key</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">_project_job_key</span><span class="p">(</span><span class="n">project_id</span><span class="p">)</span>
    <span class="n">job_id</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">redis_client</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">job_id</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
        <span class="k">return</span> <span class="bp">None</span>

    <span class="c1"># Verify the job still exists and is in an active state
</span>    <span class="n">job_data</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">get_job</span><span class="p">(</span><span class="n">job_id</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">job_data</span> <span class="ow">is</span> <span class="bp">None</span> <span class="ow">or</span> <span class="n">job_data</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"status"</span><span class="p">)</span> <span class="ow">not</span> <span class="ow">in</span> <span class="p">(</span><span class="s">"queued"</span><span class="p">,</span> <span class="s">"processing"</span><span class="p">):</span>
        <span class="c1"># Job finished or expired , clean up stale index
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">redis_client</span><span class="p">.</span><span class="n">delete</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
        <span class="k">return</span> <span class="bp">None</span>

    <span class="k">return</span> <span class="n">job_id</span>
</code></pre></div></div>

<p>The key detail: the function doesn’t just check whether the index key exists , it also checks the referenced job’s status. If the job has <code class="language-plaintext highlighter-rouge">status = "completed"</code> or <code class="language-plaintext highlighter-rouge">status = "failed"</code>, or if the <code class="language-plaintext highlighter-rouge">job:{job_id}</code> key has expired, the index is stale and gets deleted. The function returns <code class="language-plaintext highlighter-rouge">None</code>, allowing a new job to proceed.</p>

<p>This handles the edge case where <code class="language-plaintext highlighter-rouge">clear_project_active_job</code> was never called , a task that timed out or was killed by the OS before reaching its exception handlers. Without this check, the 24-hour TTL would be the only safety valve. With it, a new request automatically heals the stale state.</p>

<p><strong><code class="language-plaintext highlighter-rouge">clear_project_active_job</code></strong> , called in the Celery task at every terminal state:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">clear_project_active_job</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">project_id</span><span class="p">:</span> <span class="nb">int</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="n">key</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">_project_job_key</span><span class="p">(</span><span class="n">project_id</span><span class="p">)</span>
    <span class="bp">self</span><span class="p">.</span><span class="n">redis_client</span><span class="p">.</span><span class="n">delete</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
</code></pre></div></div>

<hr />

<h2 id="the-fastapi-endpoint-check-before-queue">The FastAPI Endpoint: Check Before Queue</h2>

<p>The <code class="language-plaintext highlighter-rouge">POST /api/generate-qa</code> endpoint in <code class="language-plaintext highlighter-rouge">routers/qa.py</code> does the duplicate check before creating anything:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">router</span><span class="p">.</span><span class="n">post</span><span class="p">(</span><span class="s">"/generate-qa"</span><span class="p">,</span> <span class="n">response_model</span><span class="o">=</span><span class="n">GenerateQAResponse</span><span class="p">)</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">generate_qa</span><span class="p">(</span><span class="n">request</span><span class="p">:</span> <span class="n">GenerateQARequest</span><span class="p">,</span> <span class="n">settings</span><span class="p">:</span> <span class="n">Settings</span> <span class="o">=</span> <span class="n">Depends</span><span class="p">(</span><span class="n">get_settings</span><span class="p">)):</span>
    <span class="c1"># Check if there's already an active job for this project
</span>    <span class="n">active_job_id</span> <span class="o">=</span> <span class="n">job_storage</span><span class="p">.</span><span class="n">get_project_active_job</span><span class="p">(</span><span class="n">request</span><span class="p">.</span><span class="n">project_id</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">active_job_id</span><span class="p">:</span>
        <span class="n">active_job</span> <span class="o">=</span> <span class="n">job_storage</span><span class="p">.</span><span class="n">get_job</span><span class="p">(</span><span class="n">active_job_id</span><span class="p">)</span>
        <span class="n">active_status</span> <span class="o">=</span> <span class="n">active_job</span><span class="p">[</span><span class="s">"status"</span><span class="p">]</span> <span class="k">if</span> <span class="n">active_job</span> <span class="k">else</span> <span class="s">"unknown"</span>
        <span class="k">raise</span> <span class="n">HTTPException</span><span class="p">(</span>
            <span class="n">status_code</span><span class="o">=</span><span class="mi">409</span><span class="p">,</span>
            <span class="n">detail</span><span class="o">=</span><span class="sa">f</span><span class="s">"A study plan generation is already in progress for this project "</span>
                   <span class="sa">f</span><span class="s">"(status: </span><span class="si">{</span><span class="n">active_status</span><span class="si">}</span><span class="s">). Please wait for it to complete..."</span>
        <span class="p">)</span>

    <span class="c1"># Create job in Redis
</span>    <span class="n">job_id</span> <span class="o">=</span> <span class="nb">str</span><span class="p">(</span><span class="n">uuid</span><span class="p">.</span><span class="n">uuid4</span><span class="p">())</span>
    <span class="n">job_data</span> <span class="o">=</span> <span class="p">{</span><span class="s">"id"</span><span class="p">:</span> <span class="n">job_id</span><span class="p">,</span> <span class="s">"project_id"</span><span class="p">:</span> <span class="n">request</span><span class="p">.</span><span class="n">project_id</span><span class="p">,</span> <span class="s">"status"</span><span class="p">:</span> <span class="s">"queued"</span><span class="p">,</span> <span class="p">...}</span>
    <span class="n">job_storage</span><span class="p">.</span><span class="n">create_job</span><span class="p">(</span><span class="n">job_id</span><span class="p">,</span> <span class="n">job_data</span><span class="p">)</span>
    <span class="n">job_storage</span><span class="p">.</span><span class="n">set_project_active_job</span><span class="p">(</span><span class="n">request</span><span class="p">.</span><span class="n">project_id</span><span class="p">,</span> <span class="n">job_id</span><span class="p">)</span>

    <span class="c1"># Queue Celery task , same UUID used as both job_id and Celery task_id
</span>    <span class="n">task</span> <span class="o">=</span> <span class="n">process_content_task</span><span class="p">.</span><span class="n">apply_async</span><span class="p">(</span>
        <span class="n">args</span><span class="o">=</span><span class="p">[</span><span class="n">job_id</span><span class="p">,</span> <span class="n">request</span><span class="p">.</span><span class="n">project_id</span><span class="p">,</span> <span class="p">...],</span>
        <span class="n">task_id</span><span class="o">=</span><span class="n">job_id</span><span class="p">,</span>
        <span class="n">queue</span><span class="o">=</span><span class="s">"rag_processing"</span>
    <span class="p">)</span>

    <span class="k">return</span> <span class="n">GenerateQAResponse</span><span class="p">(</span><span class="n">job_id</span><span class="o">=</span><span class="n">job_id</span><span class="p">,</span> <span class="n">status</span><span class="o">=</span><span class="s">"queued"</span><span class="p">,</span> <span class="p">...)</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">job_id</code> and the Celery <code class="language-plaintext highlighter-rouge">task_id</code> are the same UUID. This simplifies status polling: <code class="language-plaintext highlighter-rouge">GET /api/generate-qa/{job_id}</code> can look up both <code class="language-plaintext highlighter-rouge">job:{job_id}</code> in Redis and <code class="language-plaintext highlighter-rouge">AsyncResult(job_id)</code> in Celery using a single identifier.</p>

<p>If <code class="language-plaintext highlighter-rouge">get_project_active_job</code> returns a non-null value, the endpoint raises 409 immediately , before allocating a job ID, before writing to Redis, before touching the Celery queue. The duplicate request is rejected at the earliest possible point.</p>

<hr />

<h2 id="the-409-propagation-fastapi--laravel--react">The 409 Propagation: FastAPI → Laravel → React</h2>

<p>The FastAPI service runs as a private Python microservice, not directly accessible from the browser. Requests flow through Laravel, which proxies them to FastAPI. The 409 is intercepted and re-thrown in <code class="language-plaintext highlighter-rouge">StudyPlansController::callPythonRagApi()</code>:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="k">function</span> <span class="n">callPythonRagApi</span><span class="p">(</span><span class="nv">$request</span><span class="p">,</span> <span class="kt">Collection</span> <span class="nv">$documents</span><span class="p">,</span> <span class="kt">Collection</span> <span class="nv">$weblinks</span><span class="p">,</span> <span class="nv">$userNotes</span><span class="p">):</span> <span class="kt">array</span>
<span class="p">{</span>
    <span class="k">try</span> <span class="p">{</span>
        <span class="nv">$response</span> <span class="o">=</span> <span class="nc">Http</span><span class="o">::</span><span class="nf">withHeaders</span><span class="p">([</span>
            <span class="s1">'X-API-Key'</span> <span class="o">=&gt;</span> <span class="nf">config</span><span class="p">(</span><span class="s1">'services.rag-service.api_key'</span><span class="p">),</span>
            <span class="s1">'Accept'</span>    <span class="o">=&gt;</span> <span class="s1">'application/json'</span><span class="p">,</span>
        <span class="p">])</span><span class="o">-&gt;</span><span class="nf">post</span><span class="p">(</span><span class="nf">config</span><span class="p">(</span><span class="s1">'services.rag-service.url'</span><span class="p">)</span> <span class="mf">.</span> <span class="s1">'/api/generate-qa'</span><span class="p">,</span> <span class="p">[</span>
            <span class="s1">'project_id'</span> <span class="o">=&gt;</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="n">project_id</span><span class="p">,</span>
            <span class="s1">'user_id'</span>    <span class="o">=&gt;</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">user</span><span class="p">()</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">,</span>
            <span class="c1">// ...</span>
        <span class="p">]);</span>

        <span class="k">if</span> <span class="p">(</span><span class="nv">$response</span><span class="o">-&gt;</span><span class="nf">status</span><span class="p">()</span> <span class="o">===</span> <span class="mi">409</span><span class="p">)</span> <span class="p">{</span>
            <span class="nv">$detail</span> <span class="o">=</span> <span class="nv">$response</span><span class="o">-&gt;</span><span class="nf">json</span><span class="p">(</span><span class="s1">'detail'</span><span class="p">)</span> <span class="o">??</span> <span class="s1">'A study plan generation is already in progress for this project.'</span><span class="p">;</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nc">HttpException</span><span class="p">(</span><span class="mi">409</span><span class="p">,</span> <span class="nv">$detail</span><span class="p">);</span>
        <span class="p">}</span>

        <span class="k">if</span> <span class="p">(</span><span class="nv">$response</span><span class="o">-&gt;</span><span class="nf">failed</span><span class="p">())</span> <span class="p">{</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nc">Exception</span><span class="p">(</span><span class="s2">"Python RAG service error: </span><span class="si">{</span><span class="nv">$response</span><span class="o">-&gt;</span><span class="nf">body</span><span class="p">()</span><span class="si">}</span><span class="s2">"</span><span class="p">);</span>
        <span class="p">}</span>

    <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nc">HttpException</span> <span class="nv">$e</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">throw</span> <span class="nv">$e</span><span class="p">;</span> <span class="c1">// re-throw to preserve HTTP status code</span>
    <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nc">Exception</span> <span class="nv">$e</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nc">Exception</span><span class="p">(</span><span class="s2">"RAG service error: </span><span class="si">{</span><span class="nv">$e</span><span class="o">-&gt;</span><span class="nf">getMessage</span><span class="p">()</span><span class="si">}</span><span class="s2">"</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="nv">$response</span><span class="o">-&gt;</span><span class="nf">json</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">catch(HttpException $e){ throw $e; }</code> re-throw is load-bearing. Without it, the outer <code class="language-plaintext highlighter-rouge">catch(Exception $e)</code> would catch the <code class="language-plaintext highlighter-rouge">HttpException</code> (which extends <code class="language-plaintext highlighter-rouge">Exception</code>) and wrap it in a plain <code class="language-plaintext highlighter-rouge">Exception("RAG service error: ...")</code>, destroying the 409 status code. The explicit re-throw preserves the <code class="language-plaintext highlighter-rouge">HttpException(409, ...)</code> so it reaches Laravel’s exception handler, which serializes it into a JSON response with the original <code class="language-plaintext highlighter-rouge">detail</code> message. The React frontend receives a 409 with the error text and displays it inline: “A study plan generation is already in progress for this project.”</p>

<p>The message intentionally includes the current job status (<code class="language-plaintext highlighter-rouge">queued</code> or <code class="language-plaintext highlighter-rouge">processing</code>) so the user knows whether the first request is still waiting for a worker or actively running.</p>

<hr />

<h2 id="cleanup-in-the-celery-task">Cleanup in the Celery Task</h2>

<p><code class="language-plaintext highlighter-rouge">clear_project_active_job</code> is called at every terminal exit point in <code class="language-plaintext highlighter-rouge">process_content_task</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Success
</span><span class="n">job_storage</span><span class="p">.</span><span class="n">update_job</span><span class="p">(</span><span class="n">job_id</span><span class="p">,</span> <span class="n">job_data</span><span class="p">)</span>
<span class="n">job_storage</span><span class="p">.</span><span class="n">clear_project_active_job</span><span class="p">(</span><span class="n">project_id</span><span class="p">)</span>
<span class="n">_notify_laravel_job_finished</span><span class="p">(</span><span class="n">job_id</span><span class="p">,</span> <span class="n">project_id</span><span class="p">,</span> <span class="n">job_data</span><span class="p">,</span> <span class="n">settings</span><span class="p">)</span>

<span class="c1"># OpenAI errors (EmbeddingError, LLMError)
</span><span class="k">except</span> <span class="p">(</span><span class="n">EmbeddingError</span><span class="p">,</span> <span class="n">LLMError</span><span class="p">)</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
    <span class="n">error_info</span> <span class="o">=</span> <span class="n">_categorize_openai_error</span><span class="p">(</span><span class="nb">str</span><span class="p">(</span><span class="n">e</span><span class="p">))</span>
    <span class="n">job_storage</span><span class="p">.</span><span class="n">set_job_error</span><span class="p">(</span><span class="n">job_id</span><span class="p">,</span> <span class="n">error_info</span><span class="p">[</span><span class="s">"user_message"</span><span class="p">],</span> <span class="n">error_info</span><span class="p">)</span>
    <span class="n">job_storage</span><span class="p">.</span><span class="n">clear_project_active_job</span><span class="p">(</span><span class="n">project_id</span><span class="p">)</span>
    <span class="n">_notify_laravel_job_finished</span><span class="p">(...)</span>

<span class="c1"># Any other exception
</span><span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
    <span class="n">job_storage</span><span class="p">.</span><span class="n">set_job_error</span><span class="p">(</span><span class="n">job_id</span><span class="p">,</span> <span class="sa">f</span><span class="s">"Unexpected error: </span><span class="si">{</span><span class="nb">str</span><span class="p">(</span><span class="n">e</span><span class="p">)</span><span class="si">}</span><span class="s">"</span><span class="p">,</span> <span class="p">{...})</span>
    <span class="n">job_storage</span><span class="p">.</span><span class="n">clear_project_active_job</span><span class="p">(</span><span class="n">project_id</span><span class="p">)</span>
    <span class="n">_notify_laravel_job_finished</span><span class="p">(...)</span>
</code></pre></div></div>

<p>Three branches, three cleanup calls. This covers every path the task can exit through. The index key is deleted before the Laravel callback is sent , so if Laravel immediately triggers a new generation in response to the failure notification, the check at the top of <code class="language-plaintext highlighter-rouge">generate_qa</code> will find no active job.</p>

<p>The 24-hour TTL is the last line of defense for situations the code can’t handle: a worker process killed by OOM, a Docker container restarted mid-task, a Redis connection error in the cleanup call itself.</p>

<hr />

<h2 id="why-not-celerys-built-in-task-result-backend">Why Not Celery’s Built-In Task Result Backend?</h2>

<p>Celery has a native result backend (Redis, database, or others) that stores task state , <code class="language-plaintext highlighter-rouge">PENDING</code>, <code class="language-plaintext highlighter-rouge">STARTED</code>, <code class="language-plaintext highlighter-rouge">SUCCESS</code>, <code class="language-plaintext highlighter-rouge">FAILURE</code>. It’s tempting to use this directly for deduplication: store the last task ID per project, check <code class="language-plaintext highlighter-rouge">AsyncResult(task_id).state</code>.</p>

<p>The issue is visibility boundaries. The Celery result backend tracks task state from Celery’s perspective. The custom <code class="language-plaintext highlighter-rouge">job:{job_id}</code> Redis key tracks job state from the application’s perspective , including progress counters, Q&amp;A pair counts, error details, and the multi-stage pipeline status that Celery has no concept of. The two states can diverge: a task that’s <code class="language-plaintext highlighter-rouge">STARTED</code> in Celery may be on step 2 of 6 in the pipeline, and the job key reflects that granularity.</p>

<p>The <code class="language-plaintext highlighter-rouge">project_job:{project_id}</code> index is a thin layer on top of the existing job tracking system. It adds one key per project, costs one Redis read per incoming request, and doesn’t require polling Celery at all. The check in <code class="language-plaintext highlighter-rouge">get_project_active_job</code> calls <code class="language-plaintext highlighter-rouge">get_job()</code> (a Redis GET on <code class="language-plaintext highlighter-rouge">job:{job_id}</code>) rather than <code class="language-plaintext highlighter-rouge">AsyncResult(job_id).state</code> , staying within the same storage layer.</p>

<hr />

<h2 id="what-id-do-differently">What I’d Do Differently</h2>

<p><strong>Use <code class="language-plaintext highlighter-rouge">SET NX</code> for atomic lock acquisition.</strong> The current implementation calls <code class="language-plaintext highlighter-rouge">create_job</code> then <code class="language-plaintext highlighter-rouge">set_project_active_job</code> as two separate Redis writes. In theory, two simultaneous requests could both pass the <code class="language-plaintext highlighter-rouge">get_project_active_job</code> check before either has written the index key. Using <code class="language-plaintext highlighter-rouge">SET project_job:{project_id} {job_id} NX EX 86400</code> (set if not exists, with TTL) would make the lock acquisition atomic , only one of the two requests would succeed, and the other would get the key’s existing value on its next read. For the current traffic volume this race window is negligible, but it’s the correct approach at scale.</p>

<p><strong>Expose a job cancellation hook to the frontend.</strong> The <code class="language-plaintext highlighter-rouge">POST /api/generate-qa/{job_id}/cancel</code> endpoint exists on the FastAPI side and calls <code class="language-plaintext highlighter-rouge">celery_app.control.revoke(job_id, terminate=True)</code> followed by <code class="language-plaintext highlighter-rouge">clear_project_active_job</code>. But the React frontend has no cancel button , users who trigger a generation and want to abort it have no way to do so short of waiting it out. Surfacing this as a UI action would also naturally resolve the UX problem that prompted the deduplication fix in the first place.</p>

<p><strong>Log the duplicate attempt for cost attribution.</strong> When a 409 fires, the only record is a <code class="language-plaintext highlighter-rouge">logger.warning()</code> line in the FastAPI service. Persisting a lightweight audit record (project ID, timestamp, rejected job details) would make it easy to track which projects hit the duplicate guard most often , useful data if per-project generation quotas become relevant.</p>

<hr />

<p>The core pattern is simple: one Redis key per project, pointing to the active job ID. The complexity is in the edge cases , stale keys after unexpected termination, the two-key read in <code class="language-plaintext highlighter-rouge">get_project_active_job</code>, the three-branch cleanup in the Celery task. Getting those right is what separates a deduplication scheme that works in testing from one that holds up in production.</p>

<p>The full implementation is part of <a href="https://longtermemory.com">LongTermMemory</a> , an AI study platform built on FastAPI, Celery, Redis, and Laravel 12.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[A user double-clicks “Generate Study Plan”. Two parallel Celery workers start processing the same project simultaneously, doubling OpenAI costs and writing duplicate Q&amp;A pairs to the database. Here’s how to fix it with a Redis index key , and why TTL alone isn’t enough.]]></summary></entry><entry><title type="html">Timezone-Aware Email Notifications in Laravel: Sending at 8 AM in Every User’s Local Time</title><link href="https://alessandrofuda.github.io/timezone-aware-email-notifications-laravel/" rel="alternate" type="text/html" title="Timezone-Aware Email Notifications in Laravel: Sending at 8 AM in Every User’s Local Time" /><published>2026-02-28T00:00:00+00:00</published><updated>2026-02-28T00:00:00+00:00</updated><id>https://alessandrofuda.github.io/timezone-aware-email-notifications-laravel</id><content type="html" xml:base="https://alessandrofuda.github.io/timezone-aware-email-notifications-laravel/"><![CDATA[<p><em>The problem sounds simple: send a study reminder email at 8 AM. The catch is that your users live in Tokyo, Rome, New York, and Nairobi. Here’s how to build a Laravel artisan command that fires for every user at their local 8 AM , including the N+1-avoidance pattern, the deduplication scheme, and the rate-limit stagger that keeps the email provider happy.</em></p>

<hr />

<h2 id="why-send-at-8-am-is-non-trivial">Why “Send at 8 AM” Is Non-Trivial</h2>

<p>A cron job that runs at <code class="language-plaintext highlighter-rouge">0 8 * * *</code> sends email at 8 AM UTC , which is fine for users in London in winter and confusing for everyone else. The standard alternative, running the job every hour and checking whether it’s currently 8 AM in each user’s timezone, introduces its own problems: N+1 queries, duplicate sends when the cron overlaps, and edge cases around NULL timezone values.</p>

<p><a href="https://longtermemory.com">LongTermMemory</a> sends daily study reminder emails to users who have due flashcard items. The requirement: each notification lands at 8 AM in the user’s local time, contains direct links to their study sessions (via magic link deep-links), and fires at most once per day regardless of cron retries.</p>

<p>The implementation is a single artisan command, <code class="language-plaintext highlighter-rouge">custom:send-study-review-notifications</code>, that runs hourly.</p>

<hr />

<h2 id="the-core-idea-collect-timezones-at-target-hour">The Core Idea: Collect Timezones at Target Hour</h2>

<p>Rather than querying users first and then checking their timezones, the command inverts the approach: it starts by collecting every IANA timezone identifier where the current local hour matches the target, then queries only the users in those timezones.</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">protected</span> <span class="nv">$signature</span> <span class="o">=</span> <span class="s1">'custom:send-study-review-notifications {--hour=8 : The local hour to target (0-23)}'</span><span class="p">;</span>

<span class="k">private</span> <span class="k">function</span> <span class="n">getCandidateUserIds</span><span class="p">():</span> <span class="kt">Collection</span>
<span class="p">{</span>
    <span class="nv">$targetHour</span> <span class="o">=</span> <span class="p">(</span><span class="n">int</span><span class="p">)</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">option</span><span class="p">(</span><span class="s1">'hour'</span><span class="p">);</span>
    <span class="nv">$targetTimezones</span> <span class="o">=</span> <span class="nf">collect</span><span class="p">(</span><span class="nb">timezone_identifiers_list</span><span class="p">())</span>
        <span class="o">-&gt;</span><span class="nf">filter</span><span class="p">(</span><span class="k">fn</span> <span class="p">(</span><span class="kt">string</span> <span class="nv">$tz</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nc">Carbon</span><span class="o">::</span><span class="nf">now</span><span class="p">(</span><span class="nv">$tz</span><span class="p">)</span><span class="o">-&gt;</span><span class="n">hour</span> <span class="o">===</span> <span class="nv">$targetHour</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">values</span><span class="p">();</span>

    <span class="k">if</span> <span class="p">(</span><span class="nv">$targetTimezones</span><span class="o">-&gt;</span><span class="nf">isEmpty</span><span class="p">())</span> <span class="p">{</span>
        <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">info</span><span class="p">(</span><span class="s2">"No timezones currently at </span><span class="si">{</span><span class="nv">$targetHour</span><span class="si">}</span><span class="s2">:00."</span><span class="p">);</span>
        <span class="k">return</span> <span class="nf">collect</span><span class="p">();</span>
    <span class="p">}</span>
    <span class="c1">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">timezone_identifiers_list()</code> returns all ~400 valid IANA timezone identifiers. <code class="language-plaintext highlighter-rouge">Carbon::now($tz)-&gt;hour</code> gives the current local hour for each one. At any given moment, roughly 15,25 of those timezones will be at hour 8, depending on DST state.</p>

<p>This is evaluated in PHP, not SQL , a <code class="language-plaintext highlighter-rouge">collect()-&gt;filter()</code> loop over 400 strings is fast enough (microseconds) and avoids the complexity of storing UTC-offset data in MySQL.</p>

<hr />

<h2 id="the-two-query-pattern-candidates-then-due-items">The Two-Query Pattern: Candidates, Then Due Items</h2>

<p>Fetching users and their due items in a single query would require a complex self-join that’s hard to read and harder to extend. The command uses two separate queries:</p>

<p><strong>Query 1 , candidate users:</strong> Who is at 8 AM right now and has at least one study plan?</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$query</span> <span class="o">=</span> <span class="no">DB</span><span class="o">::</span><span class="nf">table</span><span class="p">(</span><span class="s1">'users'</span><span class="p">)</span>
    <span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span><span class="s1">'notifications_enabled'</span><span class="p">,</span> <span class="kc">true</span><span class="p">)</span>
    <span class="o">-&gt;</span><span class="nf">whereIn</span><span class="p">(</span><span class="s1">'timezone'</span><span class="p">,</span> <span class="nv">$targetTimezones</span><span class="p">)</span>
    <span class="o">-&gt;</span><span class="nf">whereExists</span><span class="p">(</span><span class="k">fn</span> <span class="p">(</span><span class="nv">$q</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nv">$q</span><span class="o">-&gt;</span><span class="nf">select</span><span class="p">(</span><span class="no">DB</span><span class="o">::</span><span class="nf">raw</span><span class="p">(</span><span class="mi">1</span><span class="p">))</span>
        <span class="o">-&gt;</span><span class="nf">from</span><span class="p">(</span><span class="s1">'projects'</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">whereColumn</span><span class="p">(</span><span class="s1">'projects.user_id'</span><span class="p">,</span> <span class="s1">'users.id'</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">whereExists</span><span class="p">(</span><span class="k">fn</span> <span class="p">(</span><span class="nv">$q2</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nv">$q2</span><span class="o">-&gt;</span><span class="nf">select</span><span class="p">(</span><span class="no">DB</span><span class="o">::</span><span class="nf">raw</span><span class="p">(</span><span class="mi">1</span><span class="p">))</span>
            <span class="o">-&gt;</span><span class="nf">from</span><span class="p">(</span><span class="s1">'study_plans'</span><span class="p">)</span>
            <span class="o">-&gt;</span><span class="nf">whereColumn</span><span class="p">(</span><span class="s1">'study_plans.project_id'</span><span class="p">,</span> <span class="s1">'projects.id'</span><span class="p">)</span>
        <span class="p">)</span>
    <span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">DB::raw(1)</code> in the <code class="language-plaintext highlighter-rouge">SELECT</code> of the EXISTS subquery is idiomatic SQL: the optimizer ignores the selected value in an EXISTS context, so <code class="language-plaintext highlighter-rouge">SELECT 1</code> signals “I only care whether a row exists.” This is a lint-friendly convention, not a performance trick.</p>

<p><strong>NULL timezone handling.</strong> Users who registered before timezone detection was added have <code class="language-plaintext highlighter-rouge">NULL</code> in the <code class="language-plaintext highlighter-rouge">timezone</code> column. The command treats them as UTC:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="nv">$targetTimezones</span><span class="o">-&gt;</span><span class="nf">contains</span><span class="p">(</span><span class="s1">'UTC'</span><span class="p">))</span> <span class="p">{</span>
    <span class="nv">$query</span><span class="o">-&gt;</span><span class="nf">orWhere</span><span class="p">(</span><span class="k">fn</span> <span class="p">(</span><span class="nv">$q</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nv">$q</span><span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span><span class="s1">'notifications_enabled'</span><span class="p">,</span> <span class="kc">true</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">whereNull</span><span class="p">(</span><span class="s1">'timezone'</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">whereExists</span><span class="p">(</span><span class="k">fn</span> <span class="p">(</span><span class="nv">$q2</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nv">$q2</span><span class="o">-&gt;</span><span class="nf">select</span><span class="p">(</span><span class="no">DB</span><span class="o">::</span><span class="nf">raw</span><span class="p">(</span><span class="mi">1</span><span class="p">))</span>
            <span class="o">-&gt;</span><span class="nf">from</span><span class="p">(</span><span class="s1">'projects'</span><span class="p">)</span>
            <span class="o">-&gt;</span><span class="nf">whereColumn</span><span class="p">(</span><span class="s1">'projects.user_id'</span><span class="p">,</span> <span class="s1">'users.id'</span><span class="p">)</span>
            <span class="o">-&gt;</span><span class="nf">whereExists</span><span class="p">(</span><span class="k">fn</span> <span class="p">(</span><span class="nv">$q3</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nv">$q3</span><span class="o">-&gt;</span><span class="nf">select</span><span class="p">(</span><span class="no">DB</span><span class="o">::</span><span class="nf">raw</span><span class="p">(</span><span class="mi">1</span><span class="p">))</span>
                <span class="o">-&gt;</span><span class="nf">from</span><span class="p">(</span><span class="s1">'study_plans'</span><span class="p">)</span>
                <span class="o">-&gt;</span><span class="nf">whereColumn</span><span class="p">(</span><span class="s1">'study_plans.project_id'</span><span class="p">,</span> <span class="s1">'projects.id'</span><span class="p">)</span>
            <span class="p">)</span>
        <span class="p">)</span>
    <span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The condition only activates when UTC is among the target timezones , at any other hour, NULL-timezone users are simply excluded.</p>

<hr />

<p><strong>Query 2 , due items, grouped by user:</strong> Which projects actually have items to review?</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="k">function</span> <span class="n">getDueProjectsByUser</span><span class="p">(</span><span class="kt">Collection</span> <span class="nv">$candidateUserIds</span><span class="p">):</span> <span class="kt">Collection</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="no">DB</span><span class="o">::</span><span class="nf">table</span><span class="p">(</span><span class="s1">'study_plans'</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nb">join</span><span class="p">(</span><span class="s1">'projects'</span><span class="p">,</span> <span class="s1">'study_plans.project_id'</span><span class="p">,</span> <span class="s1">'='</span><span class="p">,</span> <span class="s1">'projects.id'</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">whereIn</span><span class="p">(</span><span class="s1">'projects.user_id'</span><span class="p">,</span> <span class="nv">$candidateUserIds</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span><span class="k">fn</span> <span class="p">(</span><span class="nv">$q</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nv">$q</span><span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span><span class="k">fn</span> <span class="p">(</span><span class="nv">$q2</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nv">$q2</span>
                <span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span><span class="s1">'study_plans.scheduled_at'</span><span class="p">,</span> <span class="s1">'&lt;='</span><span class="p">,</span> <span class="nc">Carbon</span><span class="o">::</span><span class="nf">now</span><span class="p">())</span>
                <span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span><span class="s1">'study_plans.is_strict'</span><span class="p">,</span> <span class="kc">false</span><span class="p">)</span>
            <span class="p">)</span><span class="o">-&gt;</span><span class="nf">orWhereNull</span><span class="p">(</span><span class="s1">'study_plans.scheduled_at'</span><span class="p">)</span>
        <span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">select</span><span class="p">(</span><span class="s1">'projects.user_id'</span><span class="p">,</span> <span class="s1">'study_plans.project_id'</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">distinct</span><span class="p">()</span>
        <span class="o">-&gt;</span><span class="nf">get</span><span class="p">()</span>
        <span class="o">-&gt;</span><span class="nf">groupBy</span><span class="p">(</span><span class="s1">'user_id'</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The query returns one row per <code class="language-plaintext highlighter-rouge">(user_id, project_id)</code> pair. <code class="language-plaintext highlighter-rouge">-&gt;groupBy('user_id')</code> is a Collection method (not SQL <code class="language-plaintext highlighter-rouge">GROUP BY</code>) that organizes those rows into a keyed structure:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[
    5  =&gt; [ { user_id: 5, project_id: 10 }, { user_id: 5, project_id: 23 } ],
    12 =&gt; [ { user_id: 12, project_id: 31 } ],
]
</code></pre></div></div>

<p>The due item filter mirrors the session fetching logic: an item qualifies if its <code class="language-plaintext highlighter-rouge">scheduled_at &lt;= now()</code> and <code class="language-plaintext highlighter-rouge">is_strict = false</code>, or if <code class="language-plaintext highlighter-rouge">scheduled_at IS NULL</code> (new item, never reviewed). Strict items , those rated <code class="language-plaintext highlighter-rouge">again</code> or <code class="language-plaintext highlighter-rouge">hard</code>, meaning the algorithm wants the user to revisit them soon , are excluded from the notification trigger. They’ll reappear once their countdown elapses.</p>

<p>Two queries. No N+1. No model hydration on the candidate pass (just <code class="language-plaintext highlighter-rouge">pluck('id')</code>).</p>

<hr />

<h2 id="deduplication-with-insertorignore">Deduplication with <code class="language-plaintext highlighter-rouge">insertOrIgnore</code></h2>

<p>The cron runs hourly. At 8:00 AM UTC, the command fires. If it also runs at 8:05 due to a retry or overlap, the same users would get a second email. The <code class="language-plaintext highlighter-rouge">notification_logs</code> table prevents this:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$userToday</span> <span class="o">=</span> <span class="nc">Carbon</span><span class="o">::</span><span class="nf">now</span><span class="p">(</span><span class="nv">$user</span><span class="o">-&gt;</span><span class="n">timezone</span> <span class="o">??</span> <span class="s1">'UTC'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">toDateString</span><span class="p">();</span>
<span class="nv">$projectIds</span> <span class="o">=</span> <span class="nv">$userProjects</span><span class="o">-&gt;</span><span class="nf">pluck</span><span class="p">(</span><span class="s1">'project_id'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">values</span><span class="p">();</span>

<span class="nv">$inserted</span> <span class="o">=</span> <span class="no">DB</span><span class="o">::</span><span class="nf">table</span><span class="p">(</span><span class="s1">'notification_logs'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">insertOrIgnore</span><span class="p">([</span>
    <span class="s1">'user_id'</span>    <span class="o">=&gt;</span> <span class="nv">$user</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">,</span>
    <span class="s1">'type'</span>       <span class="o">=&gt;</span> <span class="s1">'study_review_reminder'</span><span class="p">,</span>
    <span class="s1">'sent_date'</span>  <span class="o">=&gt;</span> <span class="nv">$userToday</span><span class="p">,</span>
    <span class="s1">'created_at'</span> <span class="o">=&gt;</span> <span class="nf">now</span><span class="p">(),</span>
    <span class="s1">'updated_at'</span> <span class="o">=&gt;</span> <span class="nf">now</span><span class="p">(),</span>
<span class="p">]);</span>

<span class="k">if</span> <span class="p">(</span><span class="nv">$inserted</span> <span class="o">===</span> <span class="mi">1</span><span class="p">)</span> <span class="p">{</span>
    <span class="nv">$user</span><span class="o">-&gt;</span><span class="nf">notify</span><span class="p">((</span><span class="k">new</span> <span class="nc">StudyReviewReminder</span><span class="p">(</span><span class="nv">$projectIds</span><span class="p">))</span><span class="o">-&gt;</span><span class="nf">delay</span><span class="p">(</span><span class="mf">...</span><span class="p">));</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">notification_logs</code> has a unique composite index on <code class="language-plaintext highlighter-rouge">(user_id, type, sent_date)</code>. <code class="language-plaintext highlighter-rouge">insertOrIgnore</code> maps to <code class="language-plaintext highlighter-rouge">INSERT IGNORE</code> in MySQL , if a row with that combination already exists, the insert silently does nothing and returns 0. If it succeeds, it returns 1 and the notification is dispatched.</p>

<p>Crucially, the <code class="language-plaintext highlighter-rouge">sent_date</code> is the user’s local date (<code class="language-plaintext highlighter-rouge">Carbon::now($user-&gt;timezone ?? 'UTC')-&gt;toDateString()</code>), not UTC. A user in UTC+14 (Line Islands) whose 8 AM fires at <code class="language-plaintext highlighter-rouge">2026-03-02 18:00 UTC</code> gets <code class="language-plaintext highlighter-rouge">sent_date = 2026-03-03</code> , their local date , so a retry at 18:05 UTC still deduplicates correctly.</p>

<hr />

<h2 id="rate-limiting-1-second-stagger">Rate Limiting: 1-Second Stagger</h2>

<p>Notifications are dispatched via Laravel’s queue. The underlying mail provider (<a href="https://resend.com">Resend</a>, on the free plan) has a 2 req/s rate limit. Queuing all notifications simultaneously would burst well past that.</p>

<p>The fix is incremental dispatch delay:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$user</span><span class="o">-&gt;</span><span class="nf">notify</span><span class="p">(</span>
    <span class="p">(</span><span class="k">new</span> <span class="nc">StudyReviewReminder</span><span class="p">(</span><span class="nv">$projectIds</span><span class="p">))</span><span class="o">-&gt;</span><span class="nf">delay</span><span class="p">(</span><span class="nf">now</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">addSeconds</span><span class="p">(</span><span class="nv">$sentCount</span><span class="p">))</span>
<span class="p">);</span>
<span class="nv">$sentCount</span><span class="o">++</span><span class="p">;</span>
</code></pre></div></div>

<ul>
  <li>User 1 → 0s delay (immediate)</li>
  <li>User 2 → 1s delay</li>
  <li>User 3 → 2s delay</li>
  <li>…</li>
</ul>

<p>The delay is set at dispatch time, before the notification hits the queue. Each notification is processed at least 1 second after the previous one, keeping throughput at ≤ 1 req/s , safely under the 2 req/s ceiling. The comment in the code flags this as a free-plan constraint: on a paid plan with higher rate limits, the stagger can be reduced or removed.</p>

<hr />

<h2 id="the-notification-deep-link-magic-and-signed-unsubscribe-urls">The Notification: Deep-Link Magic and Signed Unsubscribe URLs</h2>

<p>The <code class="language-plaintext highlighter-rouge">StudyReviewReminder</code> notification is queued (<code class="language-plaintext highlighter-rouge">implements ShouldQueue</code>) and builds one action URL per project:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">foreach</span> <span class="p">(</span><span class="nv">$this</span><span class="o">-&gt;</span><span class="n">projectIds</span> <span class="k">as</span> <span class="nv">$projectId</span><span class="p">)</span> <span class="p">{</span>
    <span class="nv">$projectName</span> <span class="o">=</span> <span class="nc">Project</span><span class="o">::</span><span class="nf">find</span><span class="p">(</span><span class="nv">$projectId</span><span class="p">)</span><span class="o">?-&gt;</span><span class="n">name</span> <span class="o">??</span> <span class="s2">"Project #</span><span class="si">{</span><span class="nv">$projectId</span><span class="si">}</span><span class="s2">"</span><span class="p">;</span>
    <span class="nv">$signedUrl</span> <span class="o">=</span> <span class="no">URL</span><span class="o">::</span><span class="nf">temporarySignedRoute</span><span class="p">(</span>
        <span class="s1">'magic.login.redirect'</span><span class="p">,</span>
        <span class="nf">now</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">addDays</span><span class="p">(</span><span class="mi">30</span><span class="p">),</span>
        <span class="p">[</span><span class="s1">'user_id'</span> <span class="o">=&gt;</span> <span class="nv">$notifiable</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">]</span>
    <span class="p">);</span>
    <span class="c1">// redirect_to is appended after signing to avoid %2F double-encoding issues.</span>
    <span class="nv">$actionUrl</span> <span class="o">=</span> <span class="nv">$signedUrl</span> <span class="mf">.</span> <span class="s1">'&amp;redirect_to='</span> <span class="mf">.</span> <span class="nb">urlencode</span><span class="p">(</span><span class="s2">"/study-plan/pr/</span><span class="si">{</span><span class="nv">$projectId</span><span class="si">}</span><span class="s2">"</span><span class="p">);</span>

    <span class="nv">$projects</span><span class="p">[]</span> <span class="o">=</span> <span class="p">[</span><span class="s1">'name'</span> <span class="o">=&gt;</span> <span class="nv">$projectName</span><span class="p">,</span> <span class="s1">'url'</span> <span class="o">=&gt;</span> <span class="nv">$actionUrl</span><span class="p">];</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Each link is a 30-day temporary signed URL for <code class="language-plaintext highlighter-rouge">magic.login.redirect</code> , the same passwordless login route used elsewhere in the app. After validating the signature, the backend generates a short-lived one-time code, then redirects the browser to <code class="language-plaintext highlighter-rouge">/auth/callback?code=...&amp;redirect_to=/study-plan/pr/{projectId}</code>. The user lands directly in their study session, authenticated, without entering any credentials.</p>

<p><code class="language-plaintext highlighter-rouge">redirect_to</code> is appended after signing rather than included in the signed payload because the destination URL is determined at notification send time, and appending a <code class="language-plaintext highlighter-rouge">%2F</code>-encoded path to an already-signed URL would mangle the HMAC. The backend validates <code class="language-plaintext highlighter-rouge">redirect_to</code> separately, accepting only relative paths that start with <code class="language-plaintext highlighter-rouge">/</code>.</p>

<p><strong>The unsubscribe URL</strong> uses a permanent signed route (no expiry):</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$unsubscribeUrl</span> <span class="o">=</span> <span class="no">URL</span><span class="o">::</span><span class="nf">signedRoute</span><span class="p">(</span>
    <span class="s1">'notifications.unsubscribe'</span><span class="p">,</span>
    <span class="p">[</span><span class="s1">'user_id'</span> <span class="o">=&gt;</span> <span class="nv">$notifiable</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">]</span>
<span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">URL::signedRoute()</code> (without <code class="language-plaintext highlighter-rouge">temporary</code>) generates a URL that’s valid indefinitely. When the user clicks it, the <code class="language-plaintext highlighter-rouge">notifications.unsubscribe</code> route validates the signature and sets <code class="language-plaintext highlighter-rouge">notifications_enabled = false</code> on the user record. No login required, no expiry to worry about. The user simply never gets another reminder.</p>

<p>The auto-re-enable on next login: when the user authenticates again (via magic link), the <code class="language-plaintext highlighter-rouge">exchangeCodeWithToken</code> endpoint checks <code class="language-plaintext highlighter-rouge">notifications_enabled</code> and flips it back to <code class="language-plaintext highlighter-rouge">true</code> if it was disabled. Logging in is treated as an implicit signal of renewed interest.</p>

<hr />

<h2 id="testing-with-frozen-time">Testing With Frozen Time</h2>

<p>Timezone logic is notoriously hard to test without time control. <code class="language-plaintext highlighter-rouge">Carbon::setTestNow()</code> makes it tractable:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">protected</span> <span class="k">function</span> <span class="n">tearDown</span><span class="p">():</span> <span class="kt">void</span>
<span class="p">{</span>
    <span class="nc">Carbon</span><span class="o">::</span><span class="nf">setTestNow</span><span class="p">();</span> <span class="c1">// reset after each test</span>
    <span class="k">parent</span><span class="o">::</span><span class="nf">tearDown</span><span class="p">();</span>
<span class="p">}</span>

<span class="k">public</span> <span class="k">function</span> <span class="n">test_sends_notification_to_user_in_8am_timezone</span><span class="p">():</span> <span class="kt">void</span>
<span class="p">{</span>
    <span class="nc">Notification</span><span class="o">::</span><span class="nf">fake</span><span class="p">();</span>

    <span class="c1">// Freeze time so UTC is 08:00</span>
    <span class="nc">Carbon</span><span class="o">::</span><span class="nf">setTestNow</span><span class="p">(</span><span class="nc">Carbon</span><span class="o">::</span><span class="nf">create</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">3</span><span class="p">,</span> <span class="mi">3</span><span class="p">,</span> <span class="mi">8</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="s1">'UTC'</span><span class="p">));</span>

    <span class="nv">$user</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">createUserAtEightAm</span><span class="p">(</span><span class="s1">'UTC'</span><span class="p">);</span>
    <span class="nv">$project</span> <span class="o">=</span> <span class="nc">Project</span><span class="o">::</span><span class="nf">factory</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">create</span><span class="p">([</span><span class="s1">'user_id'</span> <span class="o">=&gt;</span> <span class="nv">$user</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">]);</span>
    <span class="nc">StudyPlan</span><span class="o">::</span><span class="nf">factory</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">due</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">create</span><span class="p">([</span><span class="s1">'project_id'</span> <span class="o">=&gt;</span> <span class="nv">$project</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">]);</span>

    <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">artisan</span><span class="p">(</span><span class="s1">'custom:send-study-review-notifications'</span><span class="p">);</span>

    <span class="nc">Notification</span><span class="o">::</span><span class="nf">assertSentTo</span><span class="p">(</span><span class="nv">$user</span><span class="p">,</span> <span class="nc">StudyReviewReminder</span><span class="o">::</span><span class="n">class</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The test suite covers seven cases:</p>
<ul>
  <li>User at 8 AM in their timezone → notification sent</li>
  <li>User outside the 8 AM window → nothing sent</li>
  <li>User with <code class="language-plaintext highlighter-rouge">notifications_enabled = false</code> → nothing sent</li>
  <li>Existing <code class="language-plaintext highlighter-rouge">notification_logs</code> row for today → <code class="language-plaintext highlighter-rouge">insertOrIgnore</code> skips the send</li>
  <li>Only strict study plans (e.g., rated <code class="language-plaintext highlighter-rouge">again</code> 5 minutes ago) → nothing sent</li>
  <li>All items scheduled in the future → nothing sent</li>
  <li>New items with <code class="language-plaintext highlighter-rouge">scheduled_at IS NULL</code> → notification sent</li>
</ul>

<p>The <code class="language-plaintext highlighter-rouge">due()</code> and <code class="language-plaintext highlighter-rouge">future()</code> factory states from <code class="language-plaintext highlighter-rouge">StudyPlanFactory</code> keep the fixtures readable:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">due</span><span class="p">():</span> <span class="kt">static</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">state</span><span class="p">([</span><span class="s1">'scheduled_at'</span> <span class="o">=&gt;</span> <span class="nf">now</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">subHour</span><span class="p">()]);</span>
<span class="p">}</span>

<span class="k">public</span> <span class="k">function</span> <span class="n">future</span><span class="p">():</span> <span class="kt">static</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">state</span><span class="p">([</span><span class="s1">'scheduled_at'</span> <span class="o">=&gt;</span> <span class="nf">now</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">addDay</span><span class="p">()]);</span>
<span class="p">}</span>
</code></pre></div></div>

<hr />

<h2 id="what-id-do-differently">What I’d Do Differently</h2>

<p><strong>Batch the <code class="language-plaintext highlighter-rouge">Project::find()</code> calls inside the notification.</strong> <code class="language-plaintext highlighter-rouge">StudyReviewReminder::toMail()</code> calls <code class="language-plaintext highlighter-rouge">Project::find($projectId)</code> in a loop , one query per project. For a user with ten projects, that’s ten queries inside a queued job. A single <code class="language-plaintext highlighter-rouge">Project::whereIn('id', $this-&gt;projectIds)-&gt;get()-&gt;keyBy('id')</code> before the loop would reduce it to one.</p>

<p><strong>Add a configurable notification window.</strong> The <code class="language-plaintext highlighter-rouge">--hour</code> option already makes the target hour configurable, but there’s no way to send a <em>second</em> notification (e.g., an evening reminder at 20:00) without running the command with <code class="language-plaintext highlighter-rouge">--hour=20</code> and managing two cron entries. A window-based approach , “send between 7 and 9 AM, once per day” , would be more resilient to users whose 8 AM falls between two hourly runs.</p>

<p><strong>Prune <code class="language-plaintext highlighter-rouge">notification_logs</code> on a schedule.</strong> The table grows by one row per user per day. A weekly cleanup (<code class="language-plaintext highlighter-rouge">DELETE WHERE sent_date &lt; NOW() - INTERVAL 7 DAY</code>) prevents it from becoming a performance liability as the user base scales.</p>

<hr />

<p>Timezone-aware notifications look like a three-liner until you account for NULL timezones, N+1 queries, deduplication across cron retries, and email provider rate limits. The implementation pattern , collect target timezones in PHP, query users with EXISTS, dedup with <code class="language-plaintext highlighter-rouge">insertOrIgnore</code>, stagger dispatch , handles all four without anything exotic.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[The problem sounds simple: send a study reminder email at 8 AM. The catch is that your users live in Tokyo, Rome, New York, and Nairobi. Here’s how to build a Laravel artisan command that fires for every user at their local 8 AM , including the N+1-avoidance pattern, the deduplication scheme, and the rate-limit stagger that keeps the email provider happy.]]></summary></entry><entry><title type="html">SEO for React SPAs Without SSR: Puppeteer Prerendering in Production</title><link href="https://alessandrofuda.github.io/react-spa-seo-puppeteer-prerendering/" rel="alternate" type="text/html" title="SEO for React SPAs Without SSR: Puppeteer Prerendering in Production" /><published>2026-02-19T00:00:00+00:00</published><updated>2026-02-19T00:00:00+00:00</updated><id>https://alessandrofuda.github.io/react-spa-seo-puppeteer-prerendering</id><content type="html" xml:base="https://alessandrofuda.github.io/react-spa-seo-puppeteer-prerendering/"><![CDATA[<p><em>React SPAs are nearly invisible to social media crawlers and slower to index on Google. Here’s how I solved SEO for <a href="https://longtermemory.com">LongTermMemory</a> without migrating to Next.js , using a two-variant routing pattern and a Puppeteer script that prerenders the landing page at build time.</em></p>

<hr />

<h2 id="the-problem">The Problem</h2>

<p>A React SPA with client-side routing serves one thing to every visitor: a nearly empty <code class="language-plaintext highlighter-rouge">index.html</code> with a <code class="language-plaintext highlighter-rouge">&lt;div id="root"&gt;&lt;/div&gt;</code> and a JavaScript bundle. Google’s crawler can execute JavaScript and will eventually index the content, but it does so in a second wave , days or weeks after the initial crawl. Social media crawlers (Facebook, Twitter/X, LinkedIn, Slack) don’t execute JavaScript at all. They see the empty shell, find no <code class="language-plaintext highlighter-rouge">og:title</code> or <code class="language-plaintext highlighter-rouge">og:description</code> meta tags, and either show a blank card or scrape the minimal fallback tags from <code class="language-plaintext highlighter-rouge">&lt;head&gt;</code>.</p>

<p>For a SaaS landing page, this is a real problem. Pricing, FAQ, feature descriptions , all the content that matters for SEO and social sharing , exists only in JavaScript. It never lands in the raw HTML that crawlers read.</p>

<p>The standard answer is server-side rendering: Next.js, Remix, or a similar framework. But LongTermMemory’s frontend is a standalone Vite + React 19 SPA that has been in production for months. Migrating to Next.js would mean rewriting routing, data fetching patterns, authentication callbacks, Stripe integration, and the Tailwind configuration , weeks of work for a feature that benefits one route.</p>

<p>The alternative: <strong>prerender the landing page at build time using Puppeteer</strong>, and serve the resulting static HTML as <code class="language-plaintext highlighter-rouge">dist/index.html</code>.</p>

<hr />

<h2 id="the-architecture-two-landing-page-variants">The Architecture: Two Landing Page Variants</h2>

<p>The core idea is a split: one version of the landing page for authenticated users (the full interactive app), and a separate static version for everyone else (which search engines and crawlers see).</p>

<p>In <code class="language-plaintext highlighter-rouge">App.tsx</code>, the root route renders a <code class="language-plaintext highlighter-rouge">LandingRoute</code> component instead of directly mounting a page:</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// src/App.tsx</span>
<span class="kd">function</span> <span class="nx">LandingRoute</span><span class="p">()</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">isAuthenticated</span> <span class="o">=</span> <span class="o">!!</span><span class="nx">localStorage</span><span class="p">.</span><span class="nx">getItem</span><span class="p">(</span><span class="dl">'</span><span class="s1">auth_token</span><span class="dl">'</span><span class="p">);</span>
  <span class="kd">const</span> <span class="p">[</span><span class="nx">searchParams</span><span class="p">]</span> <span class="o">=</span> <span class="nx">useSearchParams</span><span class="p">();</span>
  <span class="kd">const</span> <span class="nx">unsubscribed</span> <span class="o">=</span> <span class="nx">searchParams</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">unsubscribed</span><span class="dl">'</span><span class="p">)</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">1</span><span class="dl">'</span><span class="p">;</span>

  <span class="k">return</span> <span class="p">(</span>
    <span class="p">&lt;&gt;</span>
      <span class="si">{</span><span class="nx">unsubscribed</span> <span class="o">&amp;&amp;</span> <span class="p">(</span>
        <span class="p">&lt;</span><span class="nt">div</span> <span class="na">className</span><span class="p">=</span><span class="s">"fixed top-4 left-1/2 -translate-x-1/2 z-[60] ..."</span><span class="p">&gt;</span>
          <span class="p">&lt;</span><span class="nt">p</span> <span class="na">className</span><span class="p">=</span><span class="s">"text-sm text-green-800"</span><span class="p">&gt;</span>
            You have successfully disabled your notifications.
          <span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span>
        <span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
      <span class="p">)</span><span class="si">}</span>
      <span class="p">&lt;</span><span class="nc">Suspense</span> <span class="na">fallback</span><span class="p">=</span><span class="si">{</span><span class="p">&lt;</span><span class="nc">PageLoader</span> <span class="p">/&gt;</span><span class="si">}</span><span class="p">&gt;</span>
        <span class="si">{</span><span class="nx">isAuthenticated</span> <span class="p">?</span> <span class="p">&lt;</span><span class="nc">Landing</span> <span class="p">/&gt;</span> <span class="p">:</span> <span class="p">&lt;</span><span class="nc">LandingPublic</span> <span class="p">/&gt;</span><span class="si">}</span>
      <span class="p">&lt;/</span><span class="nc">Suspense</span><span class="p">&gt;</span>
    <span class="p">&lt;/&gt;</span>
  <span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">Landing</code></strong> , the full authenticated experience: upload forms, Stripe checkout, <code class="language-plaintext highlighter-rouge">UserContext</code> for user state, real-time plan limits.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">LandingPublic</code></strong> , a stateless version with no <code class="language-plaintext highlighter-rouge">UserContext</code>, no Stripe, no authenticated API calls. Its only job is to render all the landing page content as static HTML that Puppeteer can capture.</li>
</ul>

<p>Both components look identical to users. The split is invisible at runtime.</p>

<hr />

<h2 id="landingpublic-designed-for-prerendering"><code class="language-plaintext highlighter-rouge">LandingPublic</code>: Designed for Prerendering</h2>

<p><code class="language-plaintext highlighter-rouge">LandingPublic</code> has two responsibilities: look like the real landing page, and be fully renderable by a headless browser.</p>

<p><strong>The <code class="language-plaintext highlighter-rouge">data-prerender-ready</code> marker.</strong> The prerender script needs to know when React has finished mounting. Rather than relying on arbitrary timeouts, <code class="language-plaintext highlighter-rouge">LandingPublic</code> puts a <code class="language-plaintext highlighter-rouge">data-prerender-ready</code> attribute on its root element:</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">&lt;</span><span class="nt">div</span> <span class="na">className</span><span class="p">=</span><span class="s">"min-h-screen bg-slate-50"</span> <span class="na">data-prerender-ready</span><span class="p">&gt;</span>
  <span class="si">{</span><span class="cm">/* ...full page content... */</span><span class="si">}</span>
<span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</code></pre></div></div>

<p>The Puppeteer script waits for <code class="language-plaintext highlighter-rouge">[data-prerender-ready]</code> before proceeding.</p>

<p><strong>Lazy sections with <code class="language-plaintext highlighter-rouge">IntersectionObserver</code>.</strong> Below-fold sections (Pricing, FAQ, Educational modals) use a <code class="language-plaintext highlighter-rouge">LazySection</code> wrapper that only renders children when the container scrolls into view:</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">LazySection</span><span class="p">({</span> <span class="nx">children</span><span class="p">,</span> <span class="nx">fallback</span><span class="p">,</span> <span class="nx">id</span> <span class="p">})</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="p">[</span><span class="nx">visible</span><span class="p">,</span> <span class="nx">setVisible</span><span class="p">]</span> <span class="o">=</span> <span class="nx">useState</span><span class="p">(</span><span class="kc">false</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">ref</span> <span class="o">=</span> <span class="nx">useRef</span><span class="p">(</span><span class="kc">null</span><span class="p">);</span>

  <span class="nx">useEffect</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">observer</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">IntersectionObserver</span><span class="p">(</span>
      <span class="p">([</span><span class="nx">entry</span><span class="p">])</span> <span class="o">=&gt;</span> <span class="p">{</span> <span class="k">if</span> <span class="p">(</span><span class="nx">entry</span><span class="p">.</span><span class="nx">isIntersecting</span><span class="p">)</span> <span class="p">{</span> <span class="nx">setVisible</span><span class="p">(</span><span class="kc">true</span><span class="p">);</span> <span class="nx">observer</span><span class="p">.</span><span class="nx">disconnect</span><span class="p">();</span> <span class="p">}</span> <span class="p">},</span>
      <span class="p">{</span> <span class="na">rootMargin</span><span class="p">:</span> <span class="dl">'</span><span class="s1">200px</span><span class="dl">'</span><span class="p">,</span> <span class="na">threshold</span><span class="p">:</span> <span class="mi">0</span> <span class="p">}</span>
    <span class="p">);</span>
    <span class="nx">observer</span><span class="p">.</span><span class="nx">observe</span><span class="p">(</span><span class="nx">ref</span><span class="p">.</span><span class="nx">current</span><span class="p">);</span>
    <span class="k">return</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">observer</span><span class="p">.</span><span class="nx">disconnect</span><span class="p">();</span>
  <span class="p">},</span> <span class="p">[]);</span>

  <span class="k">return</span> <span class="p">(</span>
    <span class="p">&lt;</span><span class="nt">div</span> <span class="na">ref</span><span class="p">=</span><span class="si">{</span><span class="nx">ref</span><span class="si">}</span> <span class="na">id</span><span class="p">=</span><span class="si">{</span><span class="nx">id</span><span class="si">}</span><span class="p">&gt;</span>
      <span class="si">{</span><span class="nx">visible</span> <span class="p">?</span> <span class="nx">children</span> <span class="p">:</span> <span class="p">(</span><span class="nx">fallback</span> <span class="o">||</span> <span class="p">&lt;</span><span class="nt">div</span> <span class="na">style</span><span class="p">=</span><span class="si">{</span><span class="p">{</span> <span class="na">minHeight</span><span class="p">:</span> <span class="dl">'</span><span class="s1">200px</span><span class="dl">'</span> <span class="p">}</span><span class="si">}</span> <span class="p">/&gt;)</span><span class="si">}</span>
    <span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
  <span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This keeps the initial bundle fast for real users. For Puppeteer, the script needs to scroll through the entire page to fire the observers and reveal all sections before capturing the HTML.</p>

<p><strong>Pricing data from the API.</strong> The static variant still fetches live pricing from the backend:</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">useEffect</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">fetchPlans</span> <span class="o">=</span> <span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">fetchedPlans</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">plansApi</span><span class="p">.</span><span class="nx">getCommercialPlans</span><span class="p">();</span>
    <span class="nx">setPlans</span><span class="p">(</span><span class="nx">fetchedPlans</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">1</span><span class="p">));</span> <span class="c1">// skip Free plan</span>
    <span class="nx">setIsLoading</span><span class="p">(</span><span class="kc">false</span><span class="p">);</span>
  <span class="p">};</span>
  <span class="nx">fetchPlans</span><span class="p">();</span>
<span class="p">},</span> <span class="p">[]);</span>
</code></pre></div></div>

<p>This means the prerendered HTML contains real pricing numbers, not hardcoded values that would go stale.</p>

<p><strong><code class="language-plaintext highlighter-rouge">react-helmet-async</code> for full meta coverage.</strong> All SEO meta tags , Open Graph, Twitter Card, canonical URL, keywords, JSON-LD structured data , are injected via <code class="language-plaintext highlighter-rouge">&lt;Helmet&gt;</code> in <code class="language-plaintext highlighter-rouge">LandingPublic</code>. Because Puppeteer captures the page after JavaScript executes, these tags end up in the final HTML:</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">&lt;</span><span class="nc">Helmet</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">title</span><span class="p">&gt;</span>LongTerm Memory - AI-Powered Study <span class="err">&amp;</span> Exam Preparation<span class="p">&lt;/</span><span class="nt">title</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">meta</span> <span class="na">name</span><span class="p">=</span><span class="s">"description"</span> <span class="na">content</span><span class="p">=</span><span class="s">"Master any subject with AI-powered question-answer generation..."</span> <span class="p">/&gt;</span>
  <span class="p">&lt;</span><span class="nt">meta</span> <span class="na">property</span><span class="p">=</span><span class="s">"og:title"</span> <span class="na">content</span><span class="p">=</span><span class="s">"LongTerm Memory - AI-Powered Study &amp; Exam Preparation"</span> <span class="p">/&gt;</span>
  <span class="p">&lt;</span><span class="nt">meta</span> <span class="na">property</span><span class="p">=</span><span class="s">"og:image"</span> <span class="na">content</span><span class="p">=</span><span class="s">"https://longtermemory.com/og-image.jpg"</span> <span class="p">/&gt;</span>
  <span class="p">&lt;</span><span class="nt">meta</span> <span class="na">name</span><span class="p">=</span><span class="s">"twitter:card"</span> <span class="na">content</span><span class="p">=</span><span class="s">"summary_large_image"</span> <span class="p">/&gt;</span>
  <span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="p">=</span><span class="s">"canonical"</span> <span class="na">href</span><span class="p">=</span><span class="s">"https://longtermemory.com"</span> <span class="p">/&gt;</span>
  <span class="p">&lt;</span><span class="nt">script</span> <span class="na">type</span><span class="p">=</span><span class="s">"application/ld+json"</span><span class="p">&gt;</span>
    <span class="si">{</span><span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span> <span class="dl">"</span><span class="s2">@context</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">https://schema.org</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">@type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">SoftwareApplication</span><span class="dl">"</span><span class="p">,</span> <span class="p">...</span> <span class="p">})</span><span class="si">}</span>
  <span class="p">&lt;/</span><span class="nt">script</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="nc">Helmet</span><span class="p">&gt;</span>
</code></pre></div></div>

<p>The JSON-LD block includes the live pricing offers built from the API response, making it valid structured data for Google’s Rich Results.</p>

<hr />

<h2 id="the-prerender-script">The Prerender Script</h2>

<p><code class="language-plaintext highlighter-rouge">scripts/prerender.mjs</code> runs after the Vite build and overwrites <code class="language-plaintext highlighter-rouge">dist/index.html</code> with the prerendered HTML. Key parts of the script:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="nx">puppeteer</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">puppeteer</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">promises</span> <span class="k">as</span> <span class="nx">fs</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">fs</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">path</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">path</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">fileURLToPath</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">url</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">http</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">http</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">handler</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">serve-handler</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">distPath</span> <span class="o">=</span> <span class="nx">path</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="nx">path</span><span class="p">.</span><span class="nx">dirname</span><span class="p">(</span><span class="nx">fileURLToPath</span><span class="p">(</span><span class="k">import</span><span class="p">.</span><span class="nx">meta</span><span class="p">.</span><span class="nx">url</span><span class="p">)),</span> <span class="dl">'</span><span class="s1">../dist</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">indexPath</span> <span class="o">=</span> <span class="nx">path</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="nx">distPath</span><span class="p">,</span> <span class="dl">'</span><span class="s1">index.html</span><span class="dl">'</span><span class="p">);</span>

<span class="kd">function</span> <span class="nx">tryListenOnPort</span><span class="p">(</span><span class="nx">port</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">((</span><span class="nx">resolve</span><span class="p">,</span> <span class="nx">reject</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">server</span> <span class="o">=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">createServer</span><span class="p">((</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">handler</span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">,</span> <span class="p">{</span> <span class="na">public</span><span class="p">:</span> <span class="nx">distPath</span> <span class="p">}));</span>
    <span class="nx">server</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="dl">'</span><span class="s1">error</span><span class="dl">'</span><span class="p">,</span> <span class="nx">reject</span><span class="p">);</span>
    <span class="nx">server</span><span class="p">.</span><span class="nx">listen</span><span class="p">(</span><span class="nx">port</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">resolve</span><span class="p">({</span> <span class="nx">server</span><span class="p">,</span> <span class="nx">port</span> <span class="p">}));</span>
  <span class="p">});</span>
<span class="p">}</span>

<span class="k">async</span> <span class="kd">function</span> <span class="nx">startServer</span><span class="p">(</span><span class="nx">startPort</span> <span class="o">=</span> <span class="mi">3010</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">maxPort</span> <span class="o">=</span> <span class="nx">startPort</span> <span class="o">+</span> <span class="mi">3</span><span class="p">;</span>
  <span class="k">for</span> <span class="p">(</span><span class="kd">let</span> <span class="nx">port</span> <span class="o">=</span> <span class="nx">startPort</span><span class="p">;</span> <span class="nx">port</span> <span class="o">&lt;=</span> <span class="nx">maxPort</span><span class="p">;</span> <span class="nx">port</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">try</span> <span class="p">{</span>
      <span class="k">return</span> <span class="k">await</span> <span class="nx">tryListenOnPort</span><span class="p">(</span><span class="nx">port</span><span class="p">);</span>
    <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">err</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">if</span> <span class="p">(</span><span class="nx">err</span><span class="p">.</span><span class="nx">code</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">EADDRINUSE</span><span class="dl">'</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>
      <span class="k">throw</span> <span class="nx">err</span><span class="p">;</span>
    <span class="p">}</span>
  <span class="p">}</span>
  <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="s2">`All ports </span><span class="p">${</span><span class="nx">startPort</span><span class="p">}</span><span class="s2">,</span><span class="p">${</span><span class="nx">maxPort</span><span class="p">}</span><span class="s2"> are already in use`</span><span class="p">);</span>
<span class="p">}</span>

<span class="k">async</span> <span class="kd">function</span> <span class="nx">prerenderPage</span><span class="p">()</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="p">{</span> <span class="nx">server</span><span class="p">,</span> <span class="nx">port</span> <span class="p">}</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">startServer</span><span class="p">(</span><span class="mi">3010</span><span class="p">);</span>

  <span class="k">try</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">browser</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">puppeteer</span><span class="p">.</span><span class="nx">launch</span><span class="p">({</span>
      <span class="na">headless</span><span class="p">:</span> <span class="dl">'</span><span class="s1">new</span><span class="dl">'</span><span class="p">,</span>
      <span class="na">args</span><span class="p">:</span> <span class="p">[</span><span class="dl">'</span><span class="s1">--no-sandbox</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">--disable-setuid-sandbox</span><span class="dl">'</span><span class="p">]</span>
    <span class="p">});</span>
    <span class="kd">const</span> <span class="nx">page</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">browser</span><span class="p">.</span><span class="nx">newPage</span><span class="p">();</span>

    <span class="c1">// Simulate unauthenticated user: clear localStorage before any script runs</span>
    <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">evaluateOnNewDocument</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span> <span class="nx">localStorage</span><span class="p">.</span><span class="nx">clear</span><span class="p">();</span> <span class="p">});</span>

    <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">goto</span><span class="p">(</span><span class="s2">`http://localhost:</span><span class="p">${</span><span class="nx">port</span><span class="p">}</span><span class="s2">`</span><span class="p">,</span> <span class="p">{</span>
      <span class="na">waitUntil</span><span class="p">:</span> <span class="dl">'</span><span class="s1">networkidle0</span><span class="dl">'</span><span class="p">,</span>
      <span class="na">timeout</span><span class="p">:</span> <span class="mi">30000</span>
    <span class="p">});</span>

    <span class="c1">// Wait for React to finish mounting</span>
    <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForSelector</span><span class="p">(</span><span class="dl">'</span><span class="s1">[data-prerender-ready]</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span> <span class="na">timeout</span><span class="p">:</span> <span class="mi">10000</span> <span class="p">})</span>
      <span class="p">.</span><span class="k">catch</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">⚠ data-prerender-ready not found, continuing...</span><span class="dl">'</span><span class="p">));</span>

    <span class="c1">// Scroll to trigger all IntersectionObserver-gated sections</span>
    <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">evaluate</span><span class="p">(</span><span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">delay</span> <span class="o">=</span> <span class="nx">ms</span> <span class="o">=&gt;</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">(</span><span class="nx">r</span> <span class="o">=&gt;</span> <span class="nx">setTimeout</span><span class="p">(</span><span class="nx">r</span><span class="p">,</span> <span class="nx">ms</span><span class="p">));</span>
      <span class="k">for</span> <span class="p">(</span><span class="kd">let</span> <span class="nx">y</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">y</span> <span class="o">&lt;</span> <span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">scrollHeight</span><span class="p">;</span> <span class="nx">y</span> <span class="o">+=</span> <span class="mi">400</span><span class="p">)</span> <span class="p">{</span>
        <span class="nb">window</span><span class="p">.</span><span class="nx">scrollTo</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="nx">y</span><span class="p">);</span>
        <span class="k">await</span> <span class="nx">delay</span><span class="p">(</span><span class="mi">100</span><span class="p">);</span>
      <span class="p">}</span>
      <span class="nb">window</span><span class="p">.</span><span class="nx">scrollTo</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">);</span>
    <span class="p">});</span>

    <span class="c1">// Wait for pricing plan cards to appear</span>
    <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForFunction</span><span class="p">(</span>
      <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="kd">const</span> <span class="nx">pricing</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="dl">'</span><span class="s1">#pricing</span><span class="dl">'</span><span class="p">);</span>
        <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">pricing</span><span class="p">)</span> <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
        <span class="k">return</span> <span class="nx">pricing</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="dl">'</span><span class="s1">.rounded-lg.bg-white.p-5</span><span class="dl">'</span><span class="p">).</span><span class="nx">length</span> <span class="o">&gt;=</span> <span class="mi">2</span><span class="p">;</span>
      <span class="p">},</span>
      <span class="p">{</span> <span class="na">timeout</span><span class="p">:</span> <span class="mi">15000</span> <span class="p">}</span>
    <span class="p">).</span><span class="k">catch</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">⚠ Pricing data did not load in time, continuing...</span><span class="dl">'</span><span class="p">));</span>

    <span class="c1">// Final wait for animations</span>
    <span class="k">await</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">(</span><span class="nx">resolve</span> <span class="o">=&gt;</span> <span class="nx">setTimeout</span><span class="p">(</span><span class="nx">resolve</span><span class="p">,</span> <span class="mi">2000</span><span class="p">));</span>

    <span class="kd">const</span> <span class="nx">html</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">content</span><span class="p">();</span>
    <span class="k">await</span> <span class="nx">fs</span><span class="p">.</span><span class="nx">writeFile</span><span class="p">(</span><span class="nx">indexPath</span><span class="p">,</span> <span class="nx">html</span><span class="p">,</span> <span class="dl">'</span><span class="s1">utf8</span><span class="dl">'</span><span class="p">);</span>

    <span class="k">await</span> <span class="nx">browser</span><span class="p">.</span><span class="nx">close</span><span class="p">();</span>
  <span class="p">}</span> <span class="k">finally</span> <span class="p">{</span>
    <span class="nx">server</span><span class="p">.</span><span class="nx">close</span><span class="p">();</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">prerenderPage</span><span class="p">();</span>
</code></pre></div></div>

<p>A few design choices worth noting:</p>

<p><strong><code class="language-plaintext highlighter-rouge">evaluateOnNewDocument</code> vs <code class="language-plaintext highlighter-rouge">evaluate</code>.</strong> Using <code class="language-plaintext highlighter-rouge">evaluateOnNewDocument</code> to clear <code class="language-plaintext highlighter-rouge">localStorage</code> runs the code <em>before any page script executes</em>, including React’s initial render. If you cleared <code class="language-plaintext highlighter-rouge">localStorage</code> after navigation with <code class="language-plaintext highlighter-rouge">evaluate</code>, the React component tree would already have read <code class="language-plaintext highlighter-rouge">auth_token</code>, rendered <code class="language-plaintext highlighter-rouge">Landing</code> instead of <code class="language-plaintext highlighter-rouge">LandingPublic</code>, and it would be too late.</p>

<p><strong><code class="language-plaintext highlighter-rouge">waitUntil: 'networkidle0'</code></strong> waits until there are no more than 0 in-flight network requests for 500ms. This is the right choice here because <code class="language-plaintext highlighter-rouge">LandingPublic</code> fetches pricing data on mount , you need the API response to arrive before capturing the HTML.</p>

<p><strong>The scroll loop.</strong> <code class="language-plaintext highlighter-rouge">IntersectionObserver</code> only fires when elements enter the viewport. A headless browser has a viewport but doesn’t scroll automatically. The 400px step with a 100ms delay gives each observer time to fire and its component time to mount before moving to the next section.</p>

<p><strong>The pricing check</strong> uses <code class="language-plaintext highlighter-rouge">waitForFunction</code> with a DOM selector rather than a fixed timeout. If the API is slow, a 2-second <code class="language-plaintext highlighter-rouge">setTimeout</code> would produce HTML with a loading spinner instead of pricing data. Polling for the actual DOM element is reliable regardless of API latency.</p>

<p><strong>Port cycling</strong> tries 3010 through 3013. CI environments often have ports occupied by other services; retrying automatically avoids flaky build failures.</p>

<hr />

<h2 id="build-commands">Build Commands</h2>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"scripts"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
  </span><span class="nl">"build"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tsc -b &amp;&amp; vite build"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"build:prerender"</span><span class="p">:</span><span class="w"> </span><span class="s2">"npm run build:dev &amp;&amp; node scripts/prerender.mjs"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"build:production"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tsc -b &amp;&amp; vite build --mode production &amp;&amp; node scripts/prerender.mjs"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">build:prerender</code></strong> , development build + prerender (uses <code class="language-plaintext highlighter-rouge">localhost:8080</code> API, for local testing)</li>
  <li><strong><code class="language-plaintext highlighter-rouge">build:production</code></strong> , production build + prerender (uses <code class="language-plaintext highlighter-rouge">https://api.longtermemory.com</code>)</li>
</ul>

<p>The prerender step requires the backend API to be reachable during the build, because <code class="language-plaintext highlighter-rouge">LandingPublic</code> fetches live pricing. In the CI/CD pipeline this means the production build runs against the live API.</p>

<hr />

<h2 id="what-this-solves-and-what-it-doesnt">What This Solves and What It Doesn’t</h2>

<p><strong>Solved:</strong></p>
<ul>
  <li>All landing page text (hero copy, feature descriptions, FAQ answers) is in the raw HTML , Google indexes it in the first crawl wave, no JavaScript execution required</li>
  <li><code class="language-plaintext highlighter-rouge">og:title</code>, <code class="language-plaintext highlighter-rouge">og:description</code>, <code class="language-plaintext highlighter-rouge">og:image</code>, <code class="language-plaintext highlighter-rouge">twitter:card</code> are baked into the HTML , social share previews work correctly on all platforms</li>
  <li>JSON-LD structured data with real pricing is present , eligible for Google Rich Results (price, availability, product type)</li>
  <li><code class="language-plaintext highlighter-rouge">&lt;title&gt;</code> and <code class="language-plaintext highlighter-rouge">&lt;meta name="description"&gt;</code> exist as static HTML, not injected by JavaScript , the minimal fallback in <code class="language-plaintext highlighter-rouge">index.html</code> is a backup, not the primary</li>
</ul>

<p><strong>Not solved:</strong></p>
<ul>
  <li>Dynamic routes (<code class="language-plaintext highlighter-rouge">/study-plan/pr/:id</code>, <code class="language-plaintext highlighter-rouge">/study-session/pr/:id</code>, <code class="language-plaintext highlighter-rouge">/dashboard</code>) are not prerendered , they require authentication anyway, so they don’t need to be indexed</li>
  <li>The <code class="language-plaintext highlighter-rouge">/privacy</code> and <code class="language-plaintext highlighter-rouge">/terms</code> routes are plain React pages with no prerendering , they’re text-heavy and could benefit from it, but haven’t been a priority</li>
  <li>On-page SEO beyond the landing page (canonical tags, sitemap) is handled separately</li>
</ul>

<hr />

<h2 id="the-fallback-layer-indexhtml">The Fallback Layer: <code class="language-plaintext highlighter-rouge">index.html</code></h2>

<p>Before the prerender script runs, <code class="language-plaintext highlighter-rouge">index.html</code> contains minimal static meta tags as a safety net:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;title&gt;</span>LongTerm Memory - AI-Powered Study <span class="err">&amp;</span> Exam Preparation<span class="nt">&lt;/title&gt;</span>
<span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"description"</span> <span class="na">content=</span><span class="s">"Master any subject with AI-powered question-answer generation and spaced repetition..."</span> <span class="nt">/&gt;</span>
</code></pre></div></div>

<p>If the prerender fails (API unreachable, timeout, Puppeteer crash), the build still succeeds and the fallback tags are served. They’re not as rich as the fully rendered HTML , no OG tags, no JSON-LD , but they’re better than nothing and they prevent the build pipeline from blocking on an SEO failure.</p>

<hr />

<h2 id="what-id-do-differently">What I’d Do Differently</h2>

<p><strong>Prerender <code class="language-plaintext highlighter-rouge">/privacy</code> and <code class="language-plaintext highlighter-rouge">/terms</code> too.</strong> These pages are static content and would benefit from prerendering. The current script only handles <code class="language-plaintext highlighter-rouge">/</code>. Extending it to run against multiple routes and write each to its own <code class="language-plaintext highlighter-rouge">dist/{path}/index.html</code> would be straightforward.</p>

<p><strong>Decouple pricing from the prerender.</strong> Requiring the live API to be reachable during the build is a fragile dependency. A better approach: cache the pricing data in a JSON file committed to the repo (updated by a separate scheduled job), and have <code class="language-plaintext highlighter-rouge">LandingPublic</code> read from that file during prerendering. The build would then be fully offline-capable.</p>

<p><strong>Add a prerender verification step.</strong> The script has no post-check that the output HTML actually contains expected content. A simple <code class="language-plaintext highlighter-rouge">grep</code> for a known FAQ string or a pricing number would catch cases where the API timed out and the HTML was captured in a loading state.</p>

<hr />

<p>The full setup , <code class="language-plaintext highlighter-rouge">LandingRoute</code>, <code class="language-plaintext highlighter-rouge">LandingPublic</code>, <code class="language-plaintext highlighter-rouge">LazySection</code>, and the prerender script , took about a day to build and deploy. The authenticated app was entirely untouched. Google Search Console now shows the landing page content indexed in the first crawl, and social share previews work correctly across all platforms.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[React SPAs are nearly invisible to social media crawlers and slower to index on Google. Here’s how I solved SEO for LongTermMemory without migrating to Next.js , using a two-variant routing pattern and a Puppeteer script that prerenders the landing page at build time.]]></summary></entry><entry><title type="html">The SM-2 Algorithm in Practice: Building a Spaced Repetition System in Laravel</title><link href="https://alessandrofuda.github.io/spaced-repetition-sm2-laravel/" rel="alternate" type="text/html" title="The SM-2 Algorithm in Practice: Building a Spaced Repetition System in Laravel" /><published>2026-02-05T00:00:00+00:00</published><updated>2026-02-05T00:00:00+00:00</updated><id>https://alessandrofuda.github.io/spaced-repetition-sm2-laravel</id><content type="html" xml:base="https://alessandrofuda.github.io/spaced-repetition-sm2-laravel/"><![CDATA[<p><em>Spaced repetition is the most evidence-backed technique for long-term memorization. Here’s how to go from the original SM-2 paper to a working Laravel implementation , including the scheduling logic, timezone handling, strict mode, and the honest parts where this diverges from full SM-2.</em></p>

<p>When I built <a href="https://longtermemory.com">LongTermMemory</a> , an AI-powered study platform that auto-generates Q&amp;A pairs from uploaded documents , the spaced repetition engine was the part I most wanted to get right. The AI can generate great questions; spaced repetition is what moves the answers into long-term memory. This post walks through the full implementation: the database schema, the scheduling enum, the item-fetching logic, and the React evaluation UI.</p>

<hr />

<h2 id="what-sm-2-actually-does">What SM-2 Actually Does</h2>

<p>The SM-2 algorithm (Piotr Woźniak, SuperMemo, 1987) schedules reviews at increasing intervals based on how well you recalled each item. After every review, you rate your performance on a scale of 0,5. SM-2 then updates two values per item:</p>

<ul>
  <li><strong>Interval</strong> (I): days until the next review</li>
  <li><strong>Ease Factor</strong> (EF): a multiplier, starting at 2.5, that adjusts based on performance</li>
</ul>

<p>The update rules:</p>
<ul>
  <li>If score &lt; 3 (failure): reset interval to 1, keep EF unchanged</li>
  <li>If score ≥ 3 (success): <code class="language-plaintext highlighter-rouge">new_interval = old_interval * EF</code>, then adjust EF: <code class="language-plaintext highlighter-rouge">new_EF = EF + (0.1 - (5 - score) * (0.08 + (5 - score) * 0.02))</code></li>
</ul>

<p>The key insight: EF drifts down when you struggle and up when recall is easy. Over time, hard items get reviewed more frequently and easy ones less frequently , automatically, without you managing it.</p>

<p>The current LongTermMemory implementation is <strong>SM-2 inspired but intentionally simplified</strong>: fixed intervals, four rating levels instead of six, no adaptive ease factor yet. That last part matters and I’ll be explicit about it.</p>

<hr />

<h2 id="the-schema">The Schema</h2>

<p>Everything starts with the <code class="language-plaintext highlighter-rouge">study_plans</code> table, created in the initial migration:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">Schema</span><span class="o">::</span><span class="nf">create</span><span class="p">(</span><span class="s1">'study_plans'</span><span class="p">,</span> <span class="k">function</span> <span class="p">(</span><span class="kt">Blueprint</span> <span class="nv">$table</span><span class="p">)</span> <span class="p">{</span>
    <span class="nv">$table</span><span class="o">-&gt;</span><span class="nf">id</span><span class="p">();</span>
    <span class="nv">$table</span><span class="o">-&gt;</span><span class="nf">unsignedBigInteger</span><span class="p">(</span><span class="s1">'project_id'</span><span class="p">);</span>
    <span class="nv">$table</span><span class="o">-&gt;</span><span class="nf">text</span><span class="p">(</span><span class="s1">'question'</span><span class="p">);</span>
    <span class="nv">$table</span><span class="o">-&gt;</span><span class="nf">text</span><span class="p">(</span><span class="s1">'answer'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">nullable</span><span class="p">();</span>
    <span class="nv">$table</span><span class="o">-&gt;</span><span class="nf">timestamp</span><span class="p">(</span><span class="s1">'scheduled_at'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">nullable</span><span class="p">();</span>  <span class="c1">// UTC; NULL = new, never studied</span>
    <span class="nv">$table</span><span class="o">-&gt;</span><span class="nf">integer</span><span class="p">(</span><span class="s1">'batch'</span><span class="p">);</span>
    <span class="nv">$table</span><span class="o">-&gt;</span><span class="nf">boolean</span><span class="p">(</span><span class="s1">'completed'</span><span class="p">)</span><span class="o">-&gt;</span><span class="k">default</span><span class="p">(</span><span class="kc">false</span><span class="p">);</span>
    <span class="nv">$table</span><span class="o">-&gt;</span><span class="nf">timestamps</span><span class="p">();</span>

    <span class="nv">$table</span><span class="o">-&gt;</span><span class="nf">foreign</span><span class="p">(</span><span class="s1">'project_id'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">references</span><span class="p">(</span><span class="s1">'id'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">on</span><span class="p">(</span><span class="s1">'projects'</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">onDelete</span><span class="p">(</span><span class="s1">'cascade'</span><span class="p">);</span>
<span class="p">});</span>
</code></pre></div></div>

<p>A second migration adds <code class="language-plaintext highlighter-rouge">is_strict</code>:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$table</span><span class="o">-&gt;</span><span class="nf">boolean</span><span class="p">(</span><span class="s1">'is_strict'</span><span class="p">)</span><span class="o">-&gt;</span><span class="k">default</span><span class="p">(</span><span class="kc">false</span><span class="p">);</span>
</code></pre></div></div>

<p>And columns defined in subsequent migrations and populated by the RAG service callback (<code class="language-plaintext highlighter-rouge">key_concepts</code>, <code class="language-plaintext highlighter-rouge">difficulty_level</code>, <code class="language-plaintext highlighter-rouge">session_id</code>) complete the picture:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">study_plans</span>
<span class="err">├──</span> <span class="n">id</span>
<span class="err">├──</span> <span class="n">project_id</span>          <span class="c1">-- FK to projects</span>
<span class="err">├──</span> <span class="n">question</span>            <span class="c1">-- generated by LLM</span>
<span class="err">├──</span> <span class="n">answer</span>              <span class="c1">-- generated by LLM</span>
<span class="err">├──</span> <span class="n">key_concepts</span>        <span class="c1">-- comma-separated string, from LLM</span>
<span class="err">├──</span> <span class="n">difficulty_level</span>    <span class="c1">-- easy / medium / hard, from LLM</span>
<span class="err">├──</span> <span class="n">scheduled_at</span>        <span class="c1">-- UTC timestamp; NULL = new item</span>
<span class="err">├──</span> <span class="n">is_strict</span>           <span class="c1">-- 1 = must wait until scheduled_at; 0 = show anytime</span>
<span class="err">├──</span> <span class="n">completed</span>           <span class="c1">-- session-level flag</span>
<span class="err">├──</span> <span class="n">batch</span>               <span class="c1">-- generation batch number</span>
<span class="err">├──</span> <span class="n">session_id</span>          <span class="c1">-- FK to study_sessions</span>
<span class="err">└──</span> <span class="n">created_at</span> <span class="o">/</span> <span class="n">updated_at</span>
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">scheduled_at = NULL</code></strong> is the sentinel for “new item, never reviewed.” Once the user rates it for the first time, <code class="language-plaintext highlighter-rouge">scheduled_at</code> gets set and it enters the review cycle.</p>

<p><strong><code class="language-plaintext highlighter-rouge">is_strict</code></strong> is a nuance I’ll explain below , it controls whether an item must be held until its exact scheduled time or can float.</p>

<hr />

<h2 id="the-scheduling-logic-answerevaluation-enum">The Scheduling Logic: <code class="language-plaintext highlighter-rouge">AnswerEvaluation</code> Enum</h2>

<p>The entire scheduling decision lives in a PHP 8.1 enum:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">enum</span> <span class="nc">AnswerEvaluation</span><span class="o">:</span> <span class="n">string</span>
<span class="p">{</span>
    <span class="k">case</span> <span class="no">AGAIN</span> <span class="o">=</span> <span class="s1">'again'</span><span class="p">;</span>
    <span class="k">case</span> <span class="no">HARD</span>  <span class="o">=</span> <span class="s1">'hard'</span><span class="p">;</span>
    <span class="k">case</span> <span class="no">GOOD</span>  <span class="o">=</span> <span class="s1">'good'</span><span class="p">;</span>
    <span class="k">case</span> <span class="no">EASY</span>  <span class="o">=</span> <span class="s1">'easy'</span><span class="p">;</span>

    <span class="k">public</span> <span class="k">function</span> <span class="n">getNextSchedule</span><span class="p">(</span><span class="kt">Carbon</span> <span class="nv">$now</span><span class="p">,</span> <span class="kt">object</span> <span class="nv">$context</span><span class="p">):</span> <span class="kt">Carbon</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="k">match</span><span class="p">(</span><span class="nv">$this</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">self</span><span class="o">::</span><span class="no">AGAIN</span> <span class="o">=&gt;</span> <span class="nv">$now</span><span class="o">-&gt;</span><span class="nb">copy</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">addMinute</span><span class="p">(),</span>
            <span class="k">self</span><span class="o">::</span><span class="no">HARD</span>  <span class="o">=&gt;</span> <span class="nv">$now</span><span class="o">-&gt;</span><span class="nb">copy</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">addMinutes</span><span class="p">(</span><span class="mi">10</span><span class="p">),</span>
            <span class="k">self</span><span class="o">::</span><span class="no">GOOD</span>  <span class="o">=&gt;</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">calculateNewScheduledAtByTz</span><span class="p">(</span><span class="mi">4</span><span class="p">),</span>
            <span class="k">self</span><span class="o">::</span><span class="no">EASY</span>  <span class="o">=&gt;</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">calculateNewScheduledAtByTz</span><span class="p">(</span><span class="mi">8</span><span class="p">),</span>
        <span class="p">};</span>
    <span class="p">}</span>

    <span class="k">public</span> <span class="k">function</span> <span class="n">getStrictStatus</span><span class="p">():</span> <span class="kt">int</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="k">match</span><span class="p">(</span><span class="nv">$this</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">self</span><span class="o">::</span><span class="no">AGAIN</span><span class="p">,</span> <span class="k">self</span><span class="o">::</span><span class="no">HARD</span> <span class="o">=&gt;</span> <span class="mi">1</span><span class="p">,</span>
            <span class="k">self</span><span class="o">::</span><span class="no">GOOD</span><span class="p">,</span> <span class="k">self</span><span class="o">::</span><span class="no">EASY</span>  <span class="o">=&gt;</span> <span class="mi">0</span><span class="p">,</span>
        <span class="p">};</span>
    <span class="p">}</span>

    <span class="k">private</span> <span class="k">function</span> <span class="n">calculateNewScheduledAtByTz</span><span class="p">(</span><span class="kt">int</span> <span class="nv">$delay_days</span><span class="p">):</span> <span class="kt">Carbon</span>
    <span class="p">{</span>
        <span class="nv">$userTime</span> <span class="o">=</span> <span class="nc">Carbon</span><span class="o">::</span><span class="nf">now</span><span class="p">(</span><span class="nf">request</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">user</span><span class="p">()</span><span class="o">-&gt;</span><span class="n">timezone</span> <span class="o">??</span> <span class="kc">null</span><span class="p">);</span>
        <span class="nv">$new_scheduled_at</span> <span class="o">=</span> <span class="nv">$userTime</span><span class="o">-&gt;</span><span class="nf">addDays</span><span class="p">(</span><span class="nv">$delay_days</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">setTime</span><span class="p">(</span><span class="mi">4</span><span class="p">,</span> <span class="mi">0</span><span class="p">);</span>
        <span class="nv">$new_scheduled_at</span><span class="o">-&gt;</span><span class="nf">setTimezone</span><span class="p">(</span><span class="s1">'UTC'</span><span class="p">);</span>
        <span class="k">return</span> <span class="nv">$new_scheduled_at</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The four levels map to:</p>

<table>
  <thead>
    <tr>
      <th>Button</th>
      <th>Meaning</th>
      <th>Next review</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Again</strong></td>
      <td>Wrong / blank</td>
      <td>1 minute</td>
    </tr>
    <tr>
      <td><strong>Hard</strong></td>
      <td>Struggled but got it</td>
      <td>10 minutes</td>
    </tr>
    <tr>
      <td><strong>Good</strong></td>
      <td>Remembered well</td>
      <td>4 days</td>
    </tr>
    <tr>
      <td><strong>Easy</strong></td>
      <td>Perfect recall</td>
      <td>8 days</td>
    </tr>
  </tbody>
</table>

<p><strong><code class="language-plaintext highlighter-rouge">calculateNewScheduledAtByTz</code></strong> is where the timezone handling lives. Rather than scheduling “4 days from now in UTC”, it schedules “4 days from now at 4:00 AM in the user’s local timezone, stored as UTC”. This ensures that a user in Tokyo and a user in Rome both get their review queued for early morning local time, not at some random hour dictated by UTC offset.</p>

<p><strong><code class="language-plaintext highlighter-rouge">is_strict</code> and <code class="language-plaintext highlighter-rouge">getStrictStatus()</code></strong> encode a review discipline rule:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">AGAIN</code> and <code class="language-plaintext highlighter-rouge">HARD</code> → <code class="language-plaintext highlighter-rouge">is_strict = 1</code>. The item must be held until its <code class="language-plaintext highlighter-rouge">scheduled_at</code> time. You failed or struggled , the algorithm wants you to revisit it soon, and it won’t let you skip ahead.</li>
  <li><code class="language-plaintext highlighter-rouge">GOOD</code> and <code class="language-plaintext highlighter-rouge">EASY</code> → <code class="language-plaintext highlighter-rouge">is_strict = 0</code>. The item can be shown any time on or after its scheduled date. You remembered it well; a bit of flexibility is fine.</li>
</ul>

<hr />

<h2 id="the-controller-evaluating-an-answer">The Controller: Evaluating an Answer</h2>

<p>The <code class="language-plaintext highlighter-rouge">QaItemEvaluation</code> method in <code class="language-plaintext highlighter-rouge">StudyPlansController</code> delegates entirely to the enum:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">QaItemEvaluation</span><span class="p">(</span><span class="kt">QaItemEvaluationRequest</span> <span class="nv">$request</span><span class="p">):</span> <span class="kt">JsonResponse</span>
<span class="p">{</span>
    <span class="nv">$now</span>        <span class="o">=</span> <span class="nc">Carbon</span><span class="o">::</span><span class="nf">now</span><span class="p">(</span><span class="s1">'UTC'</span><span class="p">);</span>
    <span class="nv">$difficulty</span> <span class="o">=</span> <span class="nc">AnswerEvaluation</span><span class="o">::</span><span class="nf">from</span><span class="p">(</span><span class="nv">$request</span><span class="o">-&gt;</span><span class="n">difficulty</span><span class="p">);</span>
    <span class="nv">$new_scheduled_at</span> <span class="o">=</span> <span class="nv">$difficulty</span><span class="o">-&gt;</span><span class="nf">getNextSchedule</span><span class="p">(</span><span class="nv">$now</span><span class="p">,</span> <span class="nv">$this</span><span class="p">);</span>
    <span class="nv">$strict_status</span>    <span class="o">=</span> <span class="nv">$difficulty</span><span class="o">-&gt;</span><span class="nf">getStrictStatus</span><span class="p">();</span>

    <span class="nv">$item</span> <span class="o">=</span> <span class="nc">StudyPlan</span><span class="o">::</span><span class="nf">find</span><span class="p">(</span><span class="nv">$request</span><span class="o">-&gt;</span><span class="n">item_id</span><span class="p">);</span>
    <span class="nv">$item</span><span class="o">-&gt;</span><span class="nf">update</span><span class="p">([</span>
        <span class="s1">'scheduled_at'</span> <span class="o">=&gt;</span> <span class="nv">$new_scheduled_at</span><span class="p">,</span>
        <span class="s1">'is_strict'</span>    <span class="o">=&gt;</span> <span class="nv">$strict_status</span><span class="p">,</span>
    <span class="p">]);</span>

    <span class="k">return</span> <span class="nf">response</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">json</span><span class="p">([</span><span class="s1">'new_scheduled_at'</span> <span class="o">=&gt;</span> <span class="nv">$item</span><span class="o">-&gt;</span><span class="n">scheduled_at</span><span class="p">]);</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">AnswerEvaluation::from($request-&gt;difficulty)</code> converts the string <code class="language-plaintext highlighter-rouge">'again'|'hard'|'good'|'easy'</code> to the enum case and throws a <code class="language-plaintext highlighter-rouge">ValueError</code> if the value is invalid , the <code class="language-plaintext highlighter-rouge">QaItemEvaluationRequest</code> form request validates the input before it reaches here.</p>

<hr />

<h2 id="fetching-the-next-item-order-and-strict-mode">Fetching the Next Item: Order and Strict Mode</h2>

<p>Two private methods handle how items are selected for a session.</p>

<p><strong><code class="language-plaintext highlighter-rouge">getOrderedStudyPlanItems</code></strong> defines the canonical order:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="k">function</span> <span class="n">getOrderedStudyPlanItems</span><span class="p">(</span><span class="kt">int</span> <span class="nv">$project_id</span><span class="p">):</span> <span class="kt">Collection</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="nc">StudyPlan</span><span class="o">::</span><span class="nf">where</span><span class="p">(</span><span class="s1">'project_id'</span><span class="p">,</span> <span class="nv">$project_id</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">orderByRaw</span><span class="p">(</span><span class="s1">'scheduled_at IS NULL'</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">orderBy</span><span class="p">(</span><span class="s1">'scheduled_at'</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">orderBy</span><span class="p">(</span><span class="s1">'id'</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">get</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">orderByRaw('scheduled_at IS NULL')</code> sorts dated items before NULL items. In MySQL, <code class="language-plaintext highlighter-rouge">IS NULL</code> returns 1 for nulls and 0 for non-null values, so ordering ascending puts 0 (dated items) before 1 (new items). The result: overdue reviews come first, then new items , which is the correct SM-2 priority.</p>

<p><strong><code class="language-plaintext highlighter-rouge">getTodayQaItemsCollection</code></strong> adds the date filter for the current session:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="k">function</span> <span class="n">getTodayQaItemsCollection</span><span class="p">(</span><span class="kt">int</span> <span class="nv">$project_id</span><span class="p">):</span> <span class="kt">Collection</span>
<span class="p">{</span>
    <span class="nv">$userEndOfDay</span> <span class="o">=</span> <span class="nc">Carbon</span><span class="o">::</span><span class="nf">now</span><span class="p">(</span><span class="nf">request</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">user</span><span class="p">()</span><span class="o">-&gt;</span><span class="n">timezone</span> <span class="o">??</span> <span class="kc">null</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">endOfDay</span><span class="p">();</span>
    <span class="nv">$utcEndOfDay</span>  <span class="o">=</span> <span class="nv">$userEndOfDay</span><span class="o">-&gt;</span><span class="nf">addHours</span><span class="p">(</span><span class="mi">4</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">setTimezone</span><span class="p">(</span><span class="s1">'UTC'</span><span class="p">);</span>

    <span class="k">return</span> <span class="nc">StudyPlan</span><span class="o">::</span><span class="nf">where</span><span class="p">(</span><span class="s1">'project_id'</span><span class="p">,</span> <span class="nv">$project_id</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span><span class="k">function</span> <span class="p">(</span><span class="nv">$query</span><span class="p">)</span> <span class="k">use</span> <span class="p">(</span><span class="nv">$utcEndOfDay</span><span class="p">)</span> <span class="p">{</span>
            <span class="nv">$query</span><span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span><span class="s1">'scheduled_at'</span><span class="p">,</span> <span class="s1">'&lt;='</span><span class="p">,</span> <span class="nv">$utcEndOfDay</span><span class="p">)</span>
                  <span class="o">-&gt;</span><span class="nf">orWhereNull</span><span class="p">(</span><span class="s1">'scheduled_at'</span><span class="p">);</span>
        <span class="p">})</span>
        <span class="o">-&gt;</span><span class="nf">orderByRaw</span><span class="p">(</span><span class="s1">'scheduled_at IS NULL'</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">orderBy</span><span class="p">(</span><span class="s1">'scheduled_at'</span><span class="p">)</span>
        <span class="o">-&gt;</span><span class="nf">get</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">endOfDay()</code> in the user’s timezone, then converted to UTC with a 4-hour buffer. The buffer is necessary because GOOD and EASY items are scheduled at 4:00 AM local time via <code class="language-plaintext highlighter-rouge">setTime(4, 0)</code> , meaning a review triggered today lands at 4 AM tomorrow. Without the buffer, <code class="language-plaintext highlighter-rouge">endOfDay()</code> (23:59:59) would exclude those items and they’d only appear the following day.</p>

<p><strong><code class="language-plaintext highlighter-rouge">fetchQaItemFromCollection</code></strong> enforces the strict mode rule at fetch time:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="k">function</span> <span class="n">fetchQaItemFromCollection</span><span class="p">(</span><span class="kt">Collection</span> <span class="nv">$qa_items</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="nv">$qa_items</span><span class="o">-&gt;</span><span class="nf">first</span><span class="p">(</span><span class="k">function</span> <span class="p">(</span><span class="nv">$item</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="nv">$item</span><span class="o">-&gt;</span><span class="n">is_strict</span> <span class="o">!==</span> <span class="mi">1</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">return</span> <span class="kc">true</span><span class="p">;</span>  <span class="c1">// non-strict: always eligible</span>
        <span class="p">}</span>
        <span class="k">return</span> <span class="nv">$item</span><span class="o">-&gt;</span><span class="n">scheduled_at</span> <span class="o">&lt;=</span> <span class="nf">now</span><span class="p">();</span>  <span class="c1">// strict: only if time has passed</span>
    <span class="p">});</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This iterates the ordered collection and returns the first item that passes the check. A strict item scheduled for 10 minutes from now is skipped in favour of the next non-strict item. Once the 10 minutes have elapsed, it becomes eligible again.</p>

<hr />

<h2 id="sessions-and-progress-tracking">Sessions and Progress Tracking</h2>

<p>When the user starts a session, a <code class="language-plaintext highlighter-rouge">StudySession</code> record is created and up to 50 items are tagged with its ID:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">createNewStudySession</span><span class="p">(</span><span class="kt">CreateNewStudySessionRequest</span> <span class="nv">$request</span><span class="p">):</span> <span class="kt">JsonResponse</span>
<span class="p">{</span>
    <span class="nv">$today_qa_items</span>     <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">getTodayQaItemsCollection</span><span class="p">(</span><span class="nv">$request</span><span class="o">-&gt;</span><span class="n">project_id</span><span class="p">);</span>
    <span class="nv">$estimated_questions</span> <span class="o">=</span> <span class="nb">min</span><span class="p">(</span><span class="nb">count</span><span class="p">(</span><span class="nv">$today_qa_items</span><span class="p">),</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">today_session_limit</span><span class="p">);</span> <span class="c1">// 50</span>

    <span class="nv">$study_session</span> <span class="o">=</span> <span class="nc">StudySession</span><span class="o">::</span><span class="nf">create</span><span class="p">([</span>
        <span class="s1">'project_id'</span>          <span class="o">=&gt;</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="n">project_id</span><span class="p">,</span>
        <span class="s1">'estimated_questions'</span> <span class="o">=&gt;</span> <span class="nv">$estimated_questions</span><span class="p">,</span>
    <span class="p">]);</span>

    <span class="nv">$subset</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">getOrderedStudyPlanItems</span><span class="p">(</span><span class="nv">$request</span><span class="o">-&gt;</span><span class="n">project_id</span><span class="p">)</span>
                   <span class="o">-&gt;</span><span class="nf">take</span><span class="p">(</span><span class="nv">$this</span><span class="o">-&gt;</span><span class="n">today_session_limit</span><span class="p">);</span>

    <span class="nc">StudyPlan</span><span class="o">::</span><span class="nf">whereIn</span><span class="p">(</span><span class="s1">'id'</span><span class="p">,</span> <span class="nv">$subset</span><span class="o">-&gt;</span><span class="nf">pluck</span><span class="p">(</span><span class="s1">'id'</span><span class="p">))</span>
             <span class="o">-&gt;</span><span class="nf">update</span><span class="p">([</span><span class="s1">'session_id'</span> <span class="o">=&gt;</span> <span class="nv">$study_session</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">,</span> <span class="s1">'completed'</span> <span class="o">=&gt;</span> <span class="mi">0</span><span class="p">]);</span>

    <span class="k">return</span> <span class="nf">response</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">json</span><span class="p">([</span><span class="s1">'study_session'</span> <span class="o">=&gt;</span> <span class="nv">$study_session</span><span class="p">]);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>When an item is answered, <code class="language-plaintext highlighter-rouge">completed</code> is flipped to <code class="language-plaintext highlighter-rouge">true</code> and its <code class="language-plaintext highlighter-rouge">session_id</code> is set. This powers the two progress bars in the UI:</p>

<ul>
  <li><strong>Session progress</strong> (blue): <code class="language-plaintext highlighter-rouge">completed = 1</code> within the current <code class="language-plaintext highlighter-rouge">session_id</code> ÷ <code class="language-plaintext highlighter-rouge">estimated_questions</code></li>
  <li><strong>Global study plan progress</strong> (green): items with <code class="language-plaintext highlighter-rouge">scheduled_at &gt; now()</code> (future reviews) ÷ total items</li>
</ul>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$total_answered_questions</span> <span class="o">=</span> <span class="nc">StudyPlan</span><span class="o">::</span><span class="nf">where</span><span class="p">(</span><span class="s1">'project_id'</span><span class="p">,</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="n">project_id</span><span class="p">)</span>
    <span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span><span class="s1">'scheduled_at'</span><span class="p">,</span> <span class="s1">'&gt;'</span><span class="p">,</span> <span class="nf">now</span><span class="p">())</span>
    <span class="o">-&gt;</span><span class="nb">count</span><span class="p">();</span>

<span class="nv">$session_question_completed</span> <span class="o">=</span> <span class="nc">StudyPlan</span><span class="o">::</span><span class="nf">where</span><span class="p">(</span><span class="s1">'project_id'</span><span class="p">,</span> <span class="nv">$request</span><span class="o">-&gt;</span><span class="n">project_id</span><span class="p">)</span>
    <span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span><span class="s1">'session_id'</span><span class="p">,</span> <span class="nv">$study_session_id</span><span class="p">)</span>
    <span class="o">-&gt;</span><span class="nf">where</span><span class="p">(</span><span class="s1">'completed'</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
    <span class="o">-&gt;</span><span class="nb">count</span><span class="p">();</span>
</code></pre></div></div>

<hr />

<h2 id="the-react-evaluation-ui">The React Evaluation UI</h2>

<p>The <code class="language-plaintext highlighter-rouge">QAItemDisplay</code> component renders the four evaluation buttons , each maps directly to an <code class="language-plaintext highlighter-rouge">AnswerEvaluation</code> enum case:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">type</span> <span class="nx">EvaluationDifficulty</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">again</span><span class="dl">'</span> <span class="o">|</span> <span class="dl">'</span><span class="s1">hard</span><span class="dl">'</span> <span class="o">|</span> <span class="dl">'</span><span class="s1">good</span><span class="dl">'</span> <span class="o">|</span> <span class="dl">'</span><span class="s1">easy</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">handleEvaluate</span> <span class="o">=</span> <span class="k">async</span> <span class="p">(</span><span class="nx">difficulty</span><span class="p">:</span> <span class="nx">EvaluationDifficulty</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">try</span> <span class="p">{</span>
    <span class="k">await</span> <span class="nx">studyPlansApi</span><span class="p">.</span><span class="nx">evaluateQAItem</span><span class="p">({</span> <span class="na">item_id</span><span class="p">:</span> <span class="nx">item</span><span class="p">.</span><span class="nx">id</span><span class="p">,</span> <span class="nx">difficulty</span> <span class="p">});</span>
    <span class="nx">onNextQuestion</span><span class="p">();</span>
  <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">onNextQuestion</span><span class="p">();</span> <span class="c1">// still advance even if API call fails</span>
  <span class="p">}</span>
<span class="p">};</span>
</code></pre></div></div>

<p>The buttons appear only when the answer is visible , forcing the user to actually read the answer before rating themselves. Each button shows the next-review interval inline so users understand what they’re committing to:</p>

<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">&lt;</span><span class="nt">button</span> <span class="na">onClick</span><span class="p">=</span><span class="si">{</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">handleEvaluate</span><span class="p">(</span><span class="dl">'</span><span class="s1">again</span><span class="dl">'</span><span class="p">)</span><span class="si">}</span> <span class="na">className</span><span class="p">=</span><span class="s">"... border-red-300 bg-red-50 ..."</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">span</span><span class="p">&gt;</span>Again<span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">span</span><span class="p">&gt;</span>1 min or less<span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">span</span><span class="p">&gt;</span>I was wrong or didn't remember it at all<span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span>

<span class="p">&lt;</span><span class="nt">button</span> <span class="na">onClick</span><span class="p">=</span><span class="si">{</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">handleEvaluate</span><span class="p">(</span><span class="dl">'</span><span class="s1">easy</span><span class="dl">'</span><span class="p">)</span><span class="si">}</span> <span class="na">className</span><span class="p">=</span><span class="s">"... border-green-300 bg-green-50 ..."</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">span</span><span class="p">&gt;</span>Easy<span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">span</span><span class="p">&gt;</span>8 days<span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">span</span><span class="p">&gt;</span>I remembered it perfectly<span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span>
</code></pre></div></div>

<p>New items display a <code class="language-plaintext highlighter-rouge">new</code> label (green); items in the review cycle display <code class="language-plaintext highlighter-rouge">review</code> (orange) , derived directly from <code class="language-plaintext highlighter-rouge">item.scheduled_at === null</code>.</p>

<hr />

<h2 id="testing-factory-states">Testing: Factory States</h2>

<p>The <code class="language-plaintext highlighter-rouge">StudyPlanFactory</code> exposes two states that make scheduling tests readable:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">due</span><span class="p">():</span> <span class="kt">static</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">state</span><span class="p">([</span><span class="s1">'scheduled_at'</span> <span class="o">=&gt;</span> <span class="nf">now</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">subHour</span><span class="p">()]);</span>
<span class="p">}</span>

<span class="k">public</span> <span class="k">function</span> <span class="n">future</span><span class="p">():</span> <span class="kt">static</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">state</span><span class="p">([</span><span class="s1">'scheduled_at'</span> <span class="o">=&gt;</span> <span class="nf">now</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">addDay</span><span class="p">()]);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>A test for the notification command that checks whether due items are included can write:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">StudyPlan</span><span class="o">::</span><span class="nf">factory</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">due</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">create</span><span class="p">([</span><span class="s1">'project_id'</span> <span class="o">=&gt;</span> <span class="nv">$project</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">]);</span>
<span class="nc">StudyPlan</span><span class="o">::</span><span class="nf">factory</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">future</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">create</span><span class="p">([</span><span class="s1">'project_id'</span> <span class="o">=&gt;</span> <span class="nv">$project</span><span class="o">-&gt;</span><span class="n">id</span><span class="p">]);</span>

<span class="c1">// Only the due item should trigger a notification</span>
</code></pre></div></div>

<p>Combined with <code class="language-plaintext highlighter-rouge">Carbon::setTestNow()</code> for freezing time in timezone tests, these states make it straightforward to test edge cases without constructing timestamps by hand.</p>

<hr />

<h2 id="what-this-is-not-yet-full-sm-2">What This Is Not (Yet): Full SM-2</h2>

<p>Being explicit about the gap between this implementation and the original SM-2:</p>

<p><strong>What’s implemented:</strong></p>
<ul>
  <li>Four-level self-assessment (maps to SM-2’s 0,2 as fail, 3,4 as hard/good, 5 as easy)</li>
  <li>Short re-show intervals for failures (1 min, 10 min)</li>
  <li>Multi-day intervals for successes (4 days, 8 days)</li>
  <li>Strict mode to enforce minimum wait on failures</li>
  <li>Timezone-aware scheduling at 4 AM local time</li>
  <li>Session-capped daily reviews (50 items)</li>
</ul>

<p><strong>What’s missing:</strong></p>
<ul>
  <li><strong>Adaptive ease factor.</strong> Real SM-2 adjusts the per-item EF based on history. An item you consistently rate Easy slowly gets a longer interval; one you rate Hard repeatedly gets reviewed more often. The current implementation uses fixed intervals regardless of history.</li>
  <li><strong>Growing intervals.</strong> After the first Good review (4 days), the second should be <code class="language-plaintext highlighter-rouge">4 * EF ≈ 10 days</code>, the third <code class="language-plaintext highlighter-rouge">≈ 25 days</code>, and so on. Currently every Good review resets to 4 days.</li>
  <li><strong>Interval tracking per item.</strong> The schema doesn’t yet store the current interval or ease factor , they’d need to be added as columns on <code class="language-plaintext highlighter-rouge">study_plans</code>.</li>
</ul>

<p>The fixed intervals are a pragmatic first pass that still produces the core benefit of spaced repetition: items you fail come back soon; items you know well come back later. Adding the adaptive ease factor is the next step , it requires two new columns on <code class="language-plaintext highlighter-rouge">study_plans</code> (<code class="language-plaintext highlighter-rouge">current_interval</code> and <code class="language-plaintext highlighter-rouge">ease_factor</code>) and updating the scheduling logic in <code class="language-plaintext highlighter-rouge">AnswerEvaluation</code>.</p>

<hr />

<h2 id="what-id-do-differently">What I’d Do Differently</h2>

<p><strong>Store <code class="language-plaintext highlighter-rouge">interval</code> and <code class="language-plaintext highlighter-rouge">ease_factor</code> on <code class="language-plaintext highlighter-rouge">study_plans</code> from day one.</strong> Adding them later means a migration plus updating the scheduling logic, and any items reviewed before the migration have no history. Start with the columns even if they’re unused initially.</p>

<p><strong>Separate the scheduling logic from the enum.</strong> The <code class="language-plaintext highlighter-rouge">calculateNewScheduledAtByTz</code> method reads <code class="language-plaintext highlighter-rouge">request()-&gt;user()-&gt;timezone</code> directly inside the enum, which couples the scheduling logic to the HTTP request context. A <code class="language-plaintext highlighter-rouge">SpacedRepetitionScheduler</code> service that accepts a user timezone as a parameter would be easier to test and reuse.</p>

<p><strong>Cap the minimum interval at the user’s next waking hours.</strong> Scheduling a review for “1 minute from now” at 11:50 PM means it’ll appear at 11:51 PM. A smarter implementation would schedule short-interval items for the next morning if the user is at end of day , the <code class="language-plaintext highlighter-rouge">calculateNewScheduledAtByTz</code> logic with <code class="language-plaintext highlighter-rouge">setTime(4, 0)</code> already does this for multi-day intervals, but not for the minute-level ones.</p>

<hr />

<p>Spaced repetition looks deceptively simple on paper , a few intervals, a rating, a timestamp. The complexity is in the details: timezone handling, strict mode for failures, item ordering, progress tracking across sessions. The fixed-interval foundation works; the adaptive ease factor is the next layer to build on top of it.</p>

<p>The full implementation is part of LongTermMemory, an AI study platform built on Laravel 12, FastAPI, and React 19.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Spaced repetition is the most evidence-backed technique for long-term memorization. Here’s how to go from the original SM-2 paper to a working Laravel implementation , including the scheduling logic, timezone handling, strict mode, and the honest parts where this diverges from full SM-2.]]></summary></entry><entry><title type="html">Two-Stage Semantic Chunking for RAG in Python: Structural Splitting + Semantic Coherence</title><link href="https://alessandrofuda.github.io/semantic-chunking-rag-python-llamaindex/" rel="alternate" type="text/html" title="Two-Stage Semantic Chunking for RAG in Python: Structural Splitting + Semantic Coherence" /><published>2026-01-29T00:00:00+00:00</published><updated>2026-01-29T00:00:00+00:00</updated><id>https://alessandrofuda.github.io/semantic-chunking-rag-python-llamaindex</id><content type="html" xml:base="https://alessandrofuda.github.io/semantic-chunking-rag-python-llamaindex/"><![CDATA[<p><em>Fixed-size chunking splits text at arbitrary token boundaries, cutting mid-sentence and blending unrelated topics into the same chunk. Here’s how to build a two-stage pipeline with LlamaIndex , structural splitting first, semantic coherence second , and why adaptive sizing matters for long documents.</em></p>

<hr />

<h2 id="the-problem-with-fixed-size-chunking">The Problem With Fixed-Size Chunking</h2>

<p>The simplest chunking strategy is a sliding window: split every N tokens with M tokens of overlap. It’s easy to implement and works reasonably well on clean, uniform text. It breaks down in two common situations.</p>

<p><strong>Mid-sentence splits.</strong> A chunk that ends at token 512 may cut a sentence in half. The embedding for that chunk represents a dangling thought , and when the retriever pulls it back, the LLM receives incomplete context. Overlap helps but doesn’t eliminate the problem: two consecutive chunks now share a sentence fragment, both pulling each other slightly off-topic.</p>

<p><strong>Topic bleed.</strong> A 1,024-token window over a textbook chapter will often straddle two sections , the end of “Cellular Respiration” and the start of “Photosynthesis.” The embedding averages those topics, making the chunk a poor match for queries about either one.</p>

<p>The alternative is semantic chunking: let the content’s own structure guide the split points.</p>

<p>LongTermMemory’s <code class="language-plaintext highlighter-rouge">DocumentProcessor</code> uses a two-stage pipeline , structural splitting followed by semantic coherence , implemented in about 90 lines of Python using LlamaIndex.</p>

<hr />

<h2 id="the-architecture-at-a-glance">The Architecture at a Glance</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Raw text
    │
    ▼
Stage 1: SentenceSplitter         ← structural: paragraph breaks, chapter boundaries
    │         (respects "\n\n")
    ▼
Stage 2: SemanticSplitterNodeParser  ← semantic: merge/split by embedding similarity
    │         (OpenAI embeddings)
    ▼
Structural heading extraction     ← heuristics on first line + node metadata
    │
    ▼
LLM title fallback                ← GPT-3.5-turbo when no heading found
    │
    ▼
TextChunk objects → Qdrant
</code></pre></div></div>

<hr />

<h2 id="stage-1-structural-splitting-with-sentencesplitter">Stage 1: Structural Splitting With <code class="language-plaintext highlighter-rouge">SentenceSplitter</code></h2>

<p>The first stage uses LlamaIndex’s <code class="language-plaintext highlighter-rouge">SentenceSplitter</code> to break the document into structurally coherent pieces. The key parameter is <code class="language-plaintext highlighter-rouge">separator="\n\n"</code> , the splitter preferentially splits on paragraph breaks before falling back to sentence boundaries:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">sentence_splitter</span> <span class="o">=</span> <span class="n">SentenceSplitter</span><span class="p">(</span>
    <span class="n">chunk_size</span><span class="o">=</span><span class="n">stage1_chunk_size</span><span class="p">,</span>
    <span class="n">chunk_overlap</span><span class="o">=</span><span class="n">stage1_chunk_overlap</span><span class="p">,</span>
    <span class="n">separator</span><span class="o">=</span><span class="s">"</span><span class="se">\n\n</span><span class="s">"</span><span class="p">,</span>  <span class="c1"># Split on paragraph breaks
</span><span class="p">)</span>
<span class="n">initial_nodes</span> <span class="o">=</span> <span class="n">sentence_splitter</span><span class="p">.</span><span class="n">get_nodes_from_documents</span><span class="p">([</span><span class="n">llama_doc</span><span class="p">])</span>
</code></pre></div></div>

<p>With <code class="language-plaintext highlighter-rouge">chunk_size=1024</code>, each initial node is at most 1,024 tokens. But because <code class="language-plaintext highlighter-rouge">"\n\n"</code> is the preferred split point, a section that ends at token 900 followed by a paragraph break will produce a 900-token chunk , no mid-paragraph split , rather than running over into the next section to pad out to 1,024 tokens.</p>

<p>This stage doesn’t require any API call. It’s pure text processing, fast and free.</p>

<hr />

<h2 id="stage-2-semantic-coherence-with-semanticsplitternodeparser">Stage 2: Semantic Coherence With <code class="language-plaintext highlighter-rouge">SemanticSplitterNodeParser</code></h2>

<p>The second stage takes the structural chunks from Stage 1 and re-examines their boundaries using embedding similarity. Adjacent sentences are grouped by semantic similarity , if two consecutive sentences are closely related, they stay in the same chunk; if similarity drops below a threshold, a new split is inserted.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">semantic_splitter</span> <span class="o">=</span> <span class="n">SemanticSplitterNodeParser</span><span class="p">(</span>
    <span class="n">buffer_size</span><span class="o">=</span><span class="n">stage2_buffer_size</span><span class="p">,</span>
    <span class="n">breakpoint_percentile_threshold</span><span class="o">=</span><span class="n">stage2_breakpoint_threshold</span><span class="p">,</span>
    <span class="n">embed_model</span><span class="o">=</span><span class="bp">self</span><span class="p">.</span><span class="n">embed_model</span><span class="p">,</span>
<span class="p">)</span>
<span class="n">semantic_nodes</span> <span class="o">=</span> <span class="n">semantic_splitter</span><span class="p">.</span><span class="n">get_nodes_from_documents</span><span class="p">(</span>
    <span class="p">[</span><span class="n">LlamaDocument</span><span class="p">(</span><span class="n">text</span><span class="o">=</span><span class="n">node</span><span class="p">.</span><span class="n">get_content</span><span class="p">(),</span> <span class="n">metadata</span><span class="o">=</span><span class="n">node</span><span class="p">.</span><span class="n">metadata</span><span class="p">)</span>
     <span class="k">for</span> <span class="n">node</span> <span class="ow">in</span> <span class="n">initial_nodes</span><span class="p">]</span>
<span class="p">)</span>
</code></pre></div></div>

<p>The Stage 1 nodes are re-wrapped as <code class="language-plaintext highlighter-rouge">LlamaDocument</code> objects before being passed to the semantic splitter. This is necessary because <code class="language-plaintext highlighter-rouge">SemanticSplitterNodeParser.get_nodes_from_documents</code> expects <code class="language-plaintext highlighter-rouge">Document</code> inputs, not <code class="language-plaintext highlighter-rouge">TextNode</code> inputs , passing <code class="language-plaintext highlighter-rouge">initial_nodes</code> directly would raise a type error.</p>

<p><strong><code class="language-plaintext highlighter-rouge">buffer_size</code></strong> controls how many surrounding sentences are included when computing the embedding for a sentence. <code class="language-plaintext highlighter-rouge">buffer_size=1</code> means each sentence is embedded with one sentence of context on each side; <code class="language-plaintext highlighter-rouge">buffer_size=3</code> means three sentences of context. A larger buffer makes the embeddings smoother and more stable, reducing over-splitting on long content.</p>

<p><strong><code class="language-plaintext highlighter-rouge">breakpoint_percentile_threshold</code></strong> sets how high the similarity drop must be before a split is inserted. At <code class="language-plaintext highlighter-rouge">95</code>, only the most semantically divergent sentence boundaries become chunk boundaries , the splitter produces fewer, larger chunks. At <code class="language-plaintext highlighter-rouge">97</code>, even fewer splits.</p>

<p>The <code class="language-plaintext highlighter-rouge">embed_model</code> is <code class="language-plaintext highlighter-rouge">OpenAIEmbedding(model="text-embedding-3-small")</code>, initialized once in <code class="language-plaintext highlighter-rouge">DocumentProcessor.__init__</code> and reused across all documents in the job.</p>

<hr />

<h2 id="adaptive-sizing-short-vs-long-content">Adaptive Sizing: Short vs. Long Content</h2>

<p>The parameters above are not fixed , they switch based on estimated document length:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">total_tokens</span> <span class="o">=</span> <span class="n">estimated_total_tokens</span> <span class="k">if</span> <span class="n">estimated_total_tokens</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span> <span class="k">else</span> <span class="nb">len</span><span class="p">(</span><span class="n">text</span><span class="p">)</span> <span class="o">//</span> <span class="mi">4</span>

<span class="k">if</span> <span class="n">total_tokens</span> <span class="o">&gt;</span> <span class="n">settings</span><span class="p">.</span><span class="n">long_content_threshold</span><span class="p">:</span>   <span class="c1"># default: 10,000 tokens
</span>    <span class="n">stage1_chunk_size</span> <span class="o">=</span> <span class="n">settings</span><span class="p">.</span><span class="n">long_chunk_size</span>              <span class="c1"># 2048
</span>    <span class="n">stage1_chunk_overlap</span> <span class="o">=</span> <span class="n">settings</span><span class="p">.</span><span class="n">long_chunk_overlap</span>        <span class="c1"># 200
</span>    <span class="n">stage2_buffer_size</span> <span class="o">=</span> <span class="n">settings</span><span class="p">.</span><span class="n">long_buffer_size</span>            <span class="c1"># 3
</span>    <span class="n">stage2_breakpoint_threshold</span> <span class="o">=</span> <span class="n">settings</span><span class="p">.</span><span class="n">long_breakpoint_threshold</span>  <span class="c1"># 97
</span><span class="k">else</span><span class="p">:</span>
    <span class="n">stage1_chunk_size</span> <span class="o">=</span> <span class="mi">1024</span>
    <span class="n">stage1_chunk_overlap</span> <span class="o">=</span> <span class="mi">200</span>
    <span class="n">stage2_buffer_size</span> <span class="o">=</span> <span class="mi">1</span>
    <span class="n">stage2_breakpoint_threshold</span> <span class="o">=</span> <span class="mi">95</span>
</code></pre></div></div>

<p>The token estimate is <code class="language-plaintext highlighter-rouge">len(text) // 4</code> , one token per four characters, the standard approximation. At 10,000 tokens the threshold is around 40,000 characters, or roughly 15,20 pages of dense text.</p>

<p><strong>Why larger chunks for long content?</strong> Each call to <code class="language-plaintext highlighter-rouge">SemanticSplitterNodeParser</code> embeds every sentence in every Stage 1 node. A 100-page textbook at standard settings (chunk_size=1024) produces ~40 Stage 1 nodes, each of which the semantic splitter processes sentence-by-sentence , potentially hundreds of embedding API calls. At long-content settings (chunk_size=2048, buffer_size=3, threshold=97), the Stage 1 pass produces fewer, larger nodes, the semantic pass is less aggressive about splitting, and the total embedding count drops substantially.</p>

<p>The tradeoff is retrieval granularity: larger chunks are coarser, but for long documents the alternative is prohibitive API cost and latency.</p>

<p>All five parameters are configurable via environment variables, so the thresholds can be tuned without a code change.</p>

<hr />

<h2 id="the-fallback-structural-splitting-only">The Fallback: Structural Splitting Only</h2>

<p>If no OpenAI API key is provided , or if embedding model initialization fails , the semantic stage is skipped:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="bp">self</span><span class="p">.</span><span class="n">embed_model</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
    <span class="n">logger</span><span class="p">.</span><span class="n">warning</span><span class="p">(</span><span class="s">"No embedding model provided, skipping semantic splitting"</span><span class="p">)</span>
    <span class="c1"># Fallback to sentence splitter results
</span>    <span class="n">semantic_nodes</span> <span class="o">=</span> <span class="n">initial_nodes</span>
<span class="k">else</span><span class="p">:</span>
    <span class="n">semantic_splitter</span> <span class="o">=</span> <span class="n">SemanticSplitterNodeParser</span><span class="p">(...)</span>
    <span class="n">semantic_nodes</span> <span class="o">=</span> <span class="n">semantic_splitter</span><span class="p">.</span><span class="n">get_nodes_from_documents</span><span class="p">(...)</span>
</code></pre></div></div>

<p>Stage 1 output is used as-is. The chunks are structurally clean (paragraph-respecting, size-bounded) but not semantically optimized. For development, testing, or cost-sensitive environments where embedding costs matter more than retrieval quality, this is a usable fallback.</p>

<hr />

<h2 id="chunk-enrichment-section-titles">Chunk Enrichment: Section Titles</h2>

<p>After chunking, each <code class="language-plaintext highlighter-rouge">TextChunk</code> gets a <code class="language-plaintext highlighter-rouge">section_title</code> , a short label that tells the Q&amp;A generator what the chunk is about. This improves Q&amp;A quality: a chunk labeled “The Krebs Cycle” produces more focused questions than unlabeled prose.</p>

<p>Title assignment happens in <code class="language-plaintext highlighter-rouge">append_section_title_to_chunks</code>, with two priority levels:</p>

<p><strong>Priority 1 , structural heading extraction.</strong> <code class="language-plaintext highlighter-rouge">_extract_structural_heading</code> scans each node’s metadata and content for heading signals:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 1. Check node metadata
</span><span class="k">if</span> <span class="s">'header'</span> <span class="ow">in</span> <span class="n">node</span><span class="p">.</span><span class="n">metadata</span><span class="p">:</span>
    <span class="k">return</span> <span class="n">node</span><span class="p">.</span><span class="n">metadata</span><span class="p">[</span><span class="s">'header'</span><span class="p">]</span>
<span class="k">if</span> <span class="s">'section'</span> <span class="ow">in</span> <span class="n">node</span><span class="p">.</span><span class="n">metadata</span><span class="p">:</span>
    <span class="k">return</span> <span class="n">node</span><span class="p">.</span><span class="n">metadata</span><span class="p">[</span><span class="s">'section'</span><span class="p">]</span>

<span class="c1"># 2. Heuristics on first line
</span><span class="n">first_line</span> <span class="o">=</span> <span class="n">lines</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="n">strip</span><span class="p">()</span>
<span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">first_line</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">100</span> <span class="ow">and</span> <span class="nb">len</span><span class="p">(</span><span class="n">first_line</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">3</span><span class="p">:</span>
    <span class="c1"># Numbered section: "3.1 The Krebs Cycle", "Chapter 5"
</span>    <span class="k">if</span> <span class="n">re</span><span class="p">.</span><span class="n">match</span><span class="p">(</span><span class="sa">r</span><span class="s">'^(\d+\.)*\d+\s+'</span><span class="p">,</span> <span class="n">first_line</span><span class="p">)</span> <span class="ow">or</span> \
       <span class="n">re</span><span class="p">.</span><span class="n">match</span><span class="p">(</span><span class="sa">r</span><span class="s">'^(Chapter|Section|Part)\s+\d+'</span><span class="p">,</span> <span class="n">first_line</span><span class="p">,</span> <span class="n">re</span><span class="p">.</span><span class="n">IGNORECASE</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">first_line</span>
    <span class="c1"># Standard academic keywords
</span>    <span class="k">if</span> <span class="n">re</span><span class="p">.</span><span class="n">match</span><span class="p">(</span><span class="sa">r</span><span class="s">'^(Introduction|Conclusion|Abstract|Methods?|Results?|Discussion|...)\s*$'</span><span class="p">,</span>
                <span class="n">first_line</span><span class="p">,</span> <span class="n">re</span><span class="p">.</span><span class="n">IGNORECASE</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">first_line</span><span class="p">.</span><span class="n">strip</span><span class="p">()</span>
    <span class="c1"># Title case, ≤10 words
</span>    <span class="k">if</span> <span class="n">first_line</span><span class="p">.</span><span class="n">istitle</span><span class="p">()</span> <span class="ow">and</span> <span class="nb">len</span><span class="p">(</span><span class="n">first_line</span><span class="p">.</span><span class="n">split</span><span class="p">())</span> <span class="o">&lt;=</span> <span class="mi">10</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">first_line</span>
    <span class="c1"># All caps, 3,10 words
</span>    <span class="k">if</span> <span class="n">first_line</span><span class="p">.</span><span class="n">isupper</span><span class="p">()</span> <span class="ow">and</span> <span class="mi">3</span> <span class="o">&lt;=</span> <span class="nb">len</span><span class="p">(</span><span class="n">first_line</span><span class="p">.</span><span class="n">split</span><span class="p">())</span> <span class="o">&lt;=</span> <span class="mi">10</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">first_line</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">title</code> metadata key from LlamaIndex is intentionally filtered: if it equals <code class="language-plaintext highlighter-rouge">"document"</code> (the placeholder passed during wrapping), or if it looks like a filename or file path, it’s discarded.</p>

<p><strong>Priority 2 , LLM-generated title.</strong> When no structural heading is found, <code class="language-plaintext highlighter-rouge">generate_chunk_title_with_llm</code> calls GPT-3.5-turbo with the first 500 characters of the chunk:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">response</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">openai_client</span><span class="p">.</span><span class="n">chat</span><span class="p">.</span><span class="n">completions</span><span class="p">.</span><span class="n">create</span><span class="p">(</span>
    <span class="n">model</span><span class="o">=</span><span class="s">"gpt-3.5-turbo"</span><span class="p">,</span>
    <span class="n">messages</span><span class="o">=</span><span class="p">[</span>
        <span class="p">{</span><span class="s">"role"</span><span class="p">:</span> <span class="s">"system"</span><span class="p">,</span> <span class="s">"content"</span><span class="p">:</span> <span class="s">"You are a concise summarizer. Generate only the title, nothing else."</span><span class="p">},</span>
        <span class="p">{</span><span class="s">"role"</span><span class="p">:</span> <span class="s">"user"</span><span class="p">,</span>   <span class="s">"content"</span><span class="p">:</span> <span class="sa">f</span><span class="s">"Generate a concise title (maximum 10 words)...</span><span class="se">\n\n</span><span class="s">Text: </span><span class="si">{</span><span class="n">truncated_content</span><span class="si">}</span><span class="s">"</span><span class="p">}</span>
    <span class="p">],</span>
    <span class="n">max_tokens</span><span class="o">=</span><span class="mi">30</span><span class="p">,</span>
    <span class="n">temperature</span><span class="o">=</span><span class="mf">0.3</span><span class="p">,</span>
    <span class="n">timeout</span><span class="o">=</span><span class="mf">10.0</span>
<span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">max_tokens=30</code> bounds the response. <code class="language-plaintext highlighter-rouge">temperature=0.3</code> keeps the title deterministic. The first 500 characters are enough to capture the chunk’s topic without sending the full chunk , which would be wasteful for long chunks and isn’t needed for a title.</p>

<p>If the LLM returns a title longer than 15 words (the model occasionally ignores the 10-word instruction), it’s truncated to 10 words. If the LLM call fails after two retries, <code class="language-plaintext highlighter-rouge">section_title</code> is set to <code class="language-plaintext highlighter-rouge">None</code> and a <code class="language-plaintext highlighter-rouge">logger.error</code> is emitted.</p>

<hr />

<h2 id="the-textchunk-data-model">The <code class="language-plaintext highlighter-rouge">TextChunk</code> Data Model</h2>

<p>Every chunk coming out of the pipeline is a Pydantic model:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">TextChunk</span><span class="p">(</span><span class="n">BaseModel</span><span class="p">):</span>
    <span class="n">content</span><span class="p">:</span> <span class="nb">str</span>
    <span class="n">chunk_index</span><span class="p">:</span> <span class="nb">int</span>
    <span class="n">page_number</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">int</span><span class="p">]</span> <span class="o">=</span> <span class="bp">None</span>
    <span class="n">section_title</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="bp">None</span>
    <span class="n">token_count</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">int</span><span class="p">]</span> <span class="o">=</span> <span class="bp">None</span>
    <span class="n">document_id</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="n">document_path</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s">""</span>
    <span class="n">filename</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s">""</span>
    <span class="n">description</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s">""</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">token_count</code> is the <code class="language-plaintext highlighter-rouge">len(content) / 4</code> estimate computed in <code class="language-plaintext highlighter-rouge">convert_chunks_to_text_chunks</code>. <code class="language-plaintext highlighter-rouge">document_id</code>, <code class="language-plaintext highlighter-rouge">document_path</code>, <code class="language-plaintext highlighter-rouge">filename</code>, and <code class="language-plaintext highlighter-rouge">description</code> are populated by the Celery task after the chunk is returned from <code class="language-plaintext highlighter-rouge">chunk_document</code> , the processor itself doesn’t know about the database record, only the content.</p>

<hr />

<h2 id="the-complete-pipeline-chunk_document">The Complete Pipeline: <code class="language-plaintext highlighter-rouge">chunk_document</code></h2>

<p>The public entry point is <code class="language-plaintext highlighter-rouge">chunk_document</code>, which orchestrates the full sequence:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">chunk_document</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">document_path</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">original_filename</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="n">TextChunk</span><span class="p">]:</span>
    <span class="c1"># 1. Download from MinIO
</span>    <span class="n">content</span><span class="p">,</span> <span class="n">content_type</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">download_document</span><span class="p">(</span><span class="n">document_path</span><span class="p">)</span>

    <span class="c1"># 2. Extract text (PDF / DOCX / XLSX)
</span>    <span class="n">file_ext</span> <span class="o">=</span> <span class="n">original_filename</span><span class="p">.</span><span class="n">lower</span><span class="p">().</span><span class="n">split</span><span class="p">(</span><span class="s">'.'</span><span class="p">)[</span><span class="o">-</span><span class="mi">1</span><span class="p">]</span>
    <span class="k">if</span> <span class="n">file_ext</span> <span class="o">==</span> <span class="s">'pdf'</span><span class="p">:</span>
        <span class="n">text</span><span class="p">,</span> <span class="n">page_count</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">extract_text_from_pdf</span><span class="p">(</span><span class="n">content</span><span class="p">)</span>
    <span class="k">elif</span> <span class="n">file_ext</span> <span class="o">==</span> <span class="s">'docx'</span><span class="p">:</span>
        <span class="n">text</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">extract_text_from_docx</span><span class="p">(</span><span class="n">content</span><span class="p">)</span>
    <span class="k">elif</span> <span class="n">file_ext</span> <span class="o">==</span> <span class="s">'xlsx'</span><span class="p">:</span>
        <span class="n">text</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">extract_text_from_xlsx</span><span class="p">(</span><span class="n">content</span><span class="p">)</span>

    <span class="c1"># 3. Two-stage semantic chunking
</span>    <span class="n">semantic_chunks</span><span class="p">,</span> <span class="n">structural_headings</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">semantic_chunk_text</span><span class="p">(</span><span class="n">text</span><span class="p">,</span> <span class="n">document_title</span><span class="o">=</span><span class="n">original_filename</span><span class="p">)</span>

    <span class="c1"># 4. Wrap in TextChunk objects (adds token_count)
</span>    <span class="n">chunks</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">convert_chunks_to_text_chunks</span><span class="p">(</span><span class="n">semantic_chunks</span><span class="p">)</span>

    <span class="c1"># 5. Enrich with section titles (structural heading → LLM fallback)
</span>    <span class="bp">self</span><span class="p">.</span><span class="n">append_section_title_to_chunks</span><span class="p">(</span><span class="n">chunks</span><span class="p">,</span> <span class="n">structural_headings</span><span class="p">)</span>

    <span class="k">return</span> <span class="n">chunks</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">page_count</code> from the PDF extractor is not currently propagated into <code class="language-plaintext highlighter-rouge">TextChunk.page_number</code> , that field is populated separately when the Celery task has per-page data available. For weblinks, <code class="language-plaintext highlighter-rouge">semantic_chunk_text</code> is called directly (bypassing <code class="language-plaintext highlighter-rouge">chunk_document</code>) with a pre-computed token estimate passed as <code class="language-plaintext highlighter-rouge">estimated_total_tokens</code> to avoid a redundant <code class="language-plaintext highlighter-rouge">len(text) // 4</code> computation.</p>

<hr />

<h2 id="what-id-do-differently">What I’d Do Differently</h2>

<p><strong>Cache the structural heading extraction result from Stage 1 into Stage 2.</strong> The current pipeline runs <code class="language-plaintext highlighter-rouge">_extract_structural_heading</code> on Stage 2 output , nodes that the semantic splitter may have merged or split relative to Stage 1 nodes. Headings that appeared at the start of a Stage 1 node may no longer appear at the start of the corresponding Stage 2 node. Passing heading metadata through the node pipeline (rather than re-extracting from content) would be more reliable.</p>

<p><strong>Use a token counter instead of <code class="language-plaintext highlighter-rouge">len(text) // 4</code>.</strong> The character-to-token ratio varies significantly across languages and content types , code, Chinese text, and LaTeX all have different ratios. <code class="language-plaintext highlighter-rouge">tiktoken</code> with the <code class="language-plaintext highlighter-rouge">cl100k_base</code> encoding would give exact counts for GPT and embedding models at negligible cost.</p>

<p><strong>Batch the LLM title calls.</strong> <code class="language-plaintext highlighter-rouge">append_section_title_to_chunks</code> calls <code class="language-plaintext highlighter-rouge">generate_chunk_title_with_llm</code> one chunk at a time in a loop. For a document with 40 chunks needing LLM titles, that’s 40 sequential API calls. A single prompt with all chunk previews, or a batch of parallel async calls, would reduce wall-clock time substantially.</p>

<p><strong>Propagate <code class="language-plaintext highlighter-rouge">page_number</code> from the PDF extractor.</strong> PyMuPDF’s block-based extraction processes the document page by page. The page number is available during extraction but not carried into <code class="language-plaintext highlighter-rouge">TextChunk</code>. For Q&amp;A generation, knowing the source page is useful for generating citations and for debugging retrieval quality.</p>

<hr />

<p>The two-stage approach costs one embedding API call per document at index time , the semantic stage processes every sentence in every Stage 1 node. For a 50-page document on short-content settings, that’s on the order of a few hundred embedding vectors. The payoff is chunks that respect both document structure and semantic boundaries, which translates directly to fewer garbage retrievals when a user’s flashcard session asks the RAG pipeline for context.</p>

<p>The full implementation is part of <a href="https://longtermemory.com">LongTermMemory</a> , an AI study platform built on FastAPI, LlamaIndex, Qdrant, and Laravel 12.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Fixed-size chunking splits text at arbitrary token boundaries, cutting mid-sentence and blending unrelated topics into the same chunk. Here’s how to build a two-stage pipeline with LlamaIndex , structural splitting first, semantic coherence second , and why adaptive sizing matters for long documents.]]></summary></entry></feed>