The SM-2 Algorithm in Practice: Building a Spaced Repetition System in Laravel
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.
When I built LongTermMemory — an AI-powered study platform that auto-generates Q&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.
What SM-2 Actually Does
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:
- Interval (I): days until the next review
- Ease Factor (EF): a multiplier, starting at 2.5, that adjusts based on performance
The update rules:
- If score < 3 (failure): reset interval to 1, keep EF unchanged
- If score ≥ 3 (success):
new_interval = old_interval * EF, then adjust EF:new_EF = EF + (0.1 - (5 - score) * (0.08 + (5 - score) * 0.02))
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.
The current LongTermMemory implementation is SM-2 inspired but intentionally simplified: fixed intervals, four rating levels instead of six, no adaptive ease factor yet. That last part matters and I’ll be explicit about it.
The Schema
Everything starts with the study_plans table, created in the initial migration:
Schema::create('study_plans', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('project_id');
$table->text('question');
$table->text('answer')->nullable();
$table->timestamp('scheduled_at')->nullable(); // UTC; NULL = new, never studied
$table->integer('batch');
$table->boolean('completed')->default(false);
$table->timestamps();
$table->foreign('project_id')->references('id')->on('projects')->onDelete('cascade');
});
A second migration adds is_strict:
$table->boolean('is_strict')->default(false);
And columns defined in subsequent migrations and populated by the RAG service callback (key_concepts, difficulty_level, session_id) complete the picture:
study_plans
├── id
├── project_id -- FK to projects
├── question -- generated by LLM
├── answer -- generated by LLM
├── key_concepts -- comma-separated string, from LLM
├── difficulty_level -- easy / medium / hard, from LLM
├── scheduled_at -- UTC timestamp; NULL = new item
├── is_strict -- 1 = must wait until scheduled_at; 0 = show anytime
├── completed -- session-level flag
├── batch -- generation batch number
├── session_id -- FK to study_sessions
└── created_at / updated_at
scheduled_at = NULL is the sentinel for “new item, never reviewed.” Once the user rates it for the first time, scheduled_at gets set and it enters the review cycle.
is_strict is a nuance I’ll explain below — it controls whether an item must be held until its exact scheduled time or can float.
The Scheduling Logic: AnswerEvaluation Enum
The entire scheduling decision lives in a PHP 8.1 enum:
enum AnswerEvaluation: string
{
case AGAIN = 'again';
case HARD = 'hard';
case GOOD = 'good';
case EASY = 'easy';
public function getNextSchedule(Carbon $now, object $context): Carbon
{
return match($this) {
self::AGAIN => $now->copy()->addMinute(),
self::HARD => $now->copy()->addMinutes(10),
self::GOOD => $this->calculateNewScheduledAtByTz(4),
self::EASY => $this->calculateNewScheduledAtByTz(8),
};
}
public function getStrictStatus(): int
{
return match($this) {
self::AGAIN, self::HARD => 1,
self::GOOD, self::EASY => 0,
};
}
private function calculateNewScheduledAtByTz(int $delay_days): Carbon
{
$userTime = Carbon::now(request()->user()->timezone ?? null);
$new_scheduled_at = $userTime->addDays($delay_days)->setTime(4, 0);
$new_scheduled_at->setTimezone('UTC');
return $new_scheduled_at;
}
}
The four levels map to:
| Button | Meaning | Next review |
|---|---|---|
| Again | Wrong / blank | 1 minute |
| Hard | Struggled but got it | 10 minutes |
| Good | Remembered well | 4 days |
| Easy | Perfect recall | 8 days |
calculateNewScheduledAtByTz 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.
is_strict and getStrictStatus() encode a review discipline rule:
AGAINandHARD→is_strict = 1. The item must be held until itsscheduled_attime. You failed or struggled — the algorithm wants you to revisit it soon, and it won’t let you skip ahead.GOODandEASY→is_strict = 0. The item can be shown any time on or after its scheduled date. You remembered it well; a bit of flexibility is fine.
The Controller: Evaluating an Answer
The QaItemEvaluation method in StudyPlansController delegates entirely to the enum:
public function QaItemEvaluation(QaItemEvaluationRequest $request): JsonResponse
{
$now = Carbon::now('UTC');
$difficulty = AnswerEvaluation::from($request->difficulty);
$new_scheduled_at = $difficulty->getNextSchedule($now, $this);
$strict_status = $difficulty->getStrictStatus();
$item = StudyPlan::find($request->item_id);
$item->update([
'scheduled_at' => $new_scheduled_at,
'is_strict' => $strict_status,
]);
return response()->json(['new_scheduled_at' => $item->scheduled_at]);
}
AnswerEvaluation::from($request->difficulty) converts the string 'again'|'hard'|'good'|'easy' to the enum case and throws a ValueError if the value is invalid — the QaItemEvaluationRequest form request validates the input before it reaches here.
Fetching the Next Item: Order and Strict Mode
Two private methods handle how items are selected for a session.
getOrderedStudyPlanItems defines the canonical order:
private function getOrderedStudyPlanItems(int $project_id): Collection
{
return StudyPlan::where('project_id', $project_id)
->orderByRaw('scheduled_at IS NULL')
->orderBy('scheduled_at')
->orderBy('id')
->get();
}
orderByRaw('scheduled_at IS NULL') sorts dated items before NULL items. In MySQL, IS NULL 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.
getTodayQaItemsCollection adds the date filter for the current session:
private function getTodayQaItemsCollection(int $project_id): Collection
{
$userEndOfDay = Carbon::now(request()->user()->timezone ?? null)->endOfDay();
$utcEndOfDay = $userEndOfDay->addHours(4)->setTimezone('UTC');
return StudyPlan::where('project_id', $project_id)
->where(function ($query) use ($utcEndOfDay) {
$query->where('scheduled_at', '<=', $utcEndOfDay)
->orWhereNull('scheduled_at');
})
->orderByRaw('scheduled_at IS NULL')
->orderBy('scheduled_at')
->get();
}
endOfDay() 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 setTime(4, 0) — meaning a review triggered today lands at 4 AM tomorrow. Without the buffer, endOfDay() (23:59:59) would exclude those items and they’d only appear the following day.
fetchQaItemFromCollection enforces the strict mode rule at fetch time:
private function fetchQaItemFromCollection(Collection $qa_items)
{
return $qa_items->first(function ($item) {
if ($item->is_strict !== 1) {
return true; // non-strict: always eligible
}
return $item->scheduled_at <= now(); // strict: only if time has passed
});
}
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.
Sessions and Progress Tracking
When the user starts a session, a StudySession record is created and up to 50 items are tagged with its ID:
public function createNewStudySession(CreateNewStudySessionRequest $request): JsonResponse
{
$today_qa_items = $this->getTodayQaItemsCollection($request->project_id);
$estimated_questions = min(count($today_qa_items), $this->today_session_limit); // 50
$study_session = StudySession::create([
'project_id' => $request->project_id,
'estimated_questions' => $estimated_questions,
]);
$subset = $this->getOrderedStudyPlanItems($request->project_id)
->take($this->today_session_limit);
StudyPlan::whereIn('id', $subset->pluck('id'))
->update(['session_id' => $study_session->id, 'completed' => 0]);
return response()->json(['study_session' => $study_session]);
}
When an item is answered, completed is flipped to true and its session_id is set. This powers the two progress bars in the UI:
- Session progress (blue):
completed = 1within the currentsession_id÷estimated_questions - Global study plan progress (green): items with
scheduled_at > now()(future reviews) ÷ total items
$total_answered_questions = StudyPlan::where('project_id', $request->project_id)
->where('scheduled_at', '>', now())
->count();
$session_question_completed = StudyPlan::where('project_id', $request->project_id)
->where('session_id', $study_session_id)
->where('completed', 1)
->count();
The React Evaluation UI
The QAItemDisplay component renders the four evaluation buttons — each maps directly to an AnswerEvaluation enum case:
export type EvaluationDifficulty = 'again' | 'hard' | 'good' | 'easy';
const handleEvaluate = async (difficulty: EvaluationDifficulty) => {
try {
await studyPlansApi.evaluateQAItem({ item_id: item.id, difficulty });
onNextQuestion();
} catch (error) {
onNextQuestion(); // still advance even if API call fails
}
};
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:
<button onClick={() => handleEvaluate('again')} className="... border-red-300 bg-red-50 ...">
<span>Again</span>
<span>1 min or less</span>
<span>I was wrong or didn't remember it at all</span>
</button>
<button onClick={() => handleEvaluate('easy')} className="... border-green-300 bg-green-50 ...">
<span>Easy</span>
<span>8 days</span>
<span>I remembered it perfectly</span>
</button>
New items display a new label (green); items in the review cycle display review (orange) — derived directly from item.scheduled_at === null.
Testing: Factory States
The StudyPlanFactory exposes two states that make scheduling tests readable:
public function due(): static
{
return $this->state(['scheduled_at' => now()->subHour()]);
}
public function future(): static
{
return $this->state(['scheduled_at' => now()->addDay()]);
}
A test for the notification command that checks whether due items are included can write:
StudyPlan::factory()->due()->create(['project_id' => $project->id]);
StudyPlan::factory()->future()->create(['project_id' => $project->id]);
// Only the due item should trigger a notification
Combined with Carbon::setTestNow() for freezing time in timezone tests, these states make it straightforward to test edge cases without constructing timestamps by hand.
What This Is Not (Yet): Full SM-2
Being explicit about the gap between this implementation and the original SM-2:
What’s implemented:
- Four-level self-assessment (maps to SM-2’s 0–2 as fail, 3–4 as hard/good, 5 as easy)
- Short re-show intervals for failures (1 min, 10 min)
- Multi-day intervals for successes (4 days, 8 days)
- Strict mode to enforce minimum wait on failures
- Timezone-aware scheduling at 4 AM local time
- Session-capped daily reviews (50 items)
What’s missing:
- Adaptive ease factor. 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.
- Growing intervals. After the first Good review (4 days), the second should be
4 * EF ≈ 10 days, the third≈ 25 days, and so on. Currently every Good review resets to 4 days. - Interval tracking per item. The schema doesn’t yet store the current interval or ease factor — they’d need to be added as columns on
study_plans.
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 study_plans (current_interval and ease_factor) and updating the scheduling logic in AnswerEvaluation.
What I’d Do Differently
Store interval and ease_factor on study_plans from day one. 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.
Separate the scheduling logic from the enum. The calculateNewScheduledAtByTz method reads request()->user()->timezone directly inside the enum, which couples the scheduling logic to the HTTP request context. A SpacedRepetitionScheduler service that accepts a user timezone as a parameter would be easier to test and reuse.
Cap the minimum interval at the user’s next waking hours. 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 calculateNewScheduledAtByTz logic with setTime(4, 0) already does this for multi-day intervals, but not for the minute-level ones.
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.
The full implementation is part of LongTermMemory, an AI study platform built on Laravel 12, FastAPI, and React 19.
