← Blog
/POST · ALFIE MILLS

Building Cortex: An ADHD Productivity App in Laravel + Inertia

Cortex is a personal productivity app I've been building to manage my own ADHD. The idea is to hold tasks, notes, and focus sessions in one place instead of scattering them across five different apps. This post is about how it's put together on the backend, and a couple of decisions I'd defend if you asked me about them in six months.

The stack

It's a fairly modern Laravel setup:

  • Laravel 13 on PHP 8.3
  • Inertia v3 + React 19 + TypeScript for the frontend
  • Tailwind 4 with shadcn/ui (new-york theme)
  • SQLite in dev, PostgreSQL in prod
  • Pest 4 for tests
  • The database queue driver, deliberately no Redis, no Horizon

That last point matters. This is a one-person project I touch a few hours a week, so I made a rule: no infrastructure dependency unless I actually need it. The database queue driver is fine for a single user, and it's one less thing to run.

A strict layered architecture

Every feature follows the same shape, and I don't deviate:

Form Request  → validation + authorization
Service       → all business logic, constructor-injected
Eloquent Model → scopes and casts only
Controller    → calls the service, returns Inertia or redirect, ≤ 15 lines
API Resource  → shapes JSON output

The point of being this rigid is that when I come back cold after weeks away, every file has one obvious job. Models hold scopes and casts, nothing else. Controllers are thin enough to read at a glance. The actual logic lives in services like TaskService:

public function update(Task $task, array $data): Task
{
    if (($data['status'] ?? null) === TaskStatus::Done->value && $task->completed_at === null) {
        $data['completed_at'] = now();
    }

    $task->update($data);

    return $task->fresh();
}

Services are always constructor-injected into controllers, never new TaskService(). That keeps them mockable and keeps the controller honest about its dependencies.

I also keep API Resources for every model even though there are no /api routes yet. They're cheap to write now and ready for the day I add Sanctum and a mobile client.

Polymorphic tags and a naming collision

Tags are polymorphic via morphToMany, so both tasks and notes share one tagging system through a taggables table:

public function tags(): MorphToMany
{
    return $this->morphToMany(Tag::class, 'taggable');
}

There's one gotcha worth flagging. The tasks table has a notes text column (free-text notes on the task itself). That name collides with what would naturally be a notes() relationship for linked notes. So the relationship is called linkedNotes() everywhere instead:

public function linkedNotes(): BelongsToMany
{
    return $this->belongsToMany(Note::class, 'task_note');
}

It's the kind of thing that's invisible until you trip over it, which is exactly why it's written down in the project notes.

The iCal feed

The feature I'm happiest with is the calendar feed. Tasks with a due date and completed focus sessions get exposed as a subscribable .ics calendar, so they show up in whatever calendar app I already use.

Calendar clients can't do session auth, so the feed route is authenticated by a token query param instead, there's a separate ical_tokens table for that, and the route lives outside the auth middleware group:

Route::get('ical/{userId}/feed.ics', [ICalController::class, 'feed'])->name('ical.feed');

The interesting part is actually getting RFC 5545 right. Two things bit me:

Line folding. The spec says content lines must not exceed 75 octets, and longer lines have to be folded with a CRLF followed by a single space. So ICalService folds every line:

private function fold(string $line): string
{
    if (strlen($line) <= 75) {
        return $line;
    }

    $chunks = str_split($line, 73);

    return implode("\r\n ", $chunks);
}

Escaping. Commas, semicolons, backslashes and newlines all have to be escaped in text values, or clients choke on the feed. And the whole document uses \r\n line endings, not \n.

The feed is cached for 60 seconds with Cache::remember, because calendar clients poll aggressively and there's no reason to rebuild the same payload on every hit.

Focus sessions and a Carbon 3 trap

Focus sessions are a simple start/end timer stored server-side. One small but real bug: Carbon 3 returns signed values from diffInSeconds depending on argument order, which meant durations could come out negative. The fix is just an abs():

'duration_seconds' => (int) abs($endedAt->diffInSeconds($session->started_at)),

Easy to miss, annoying to debug, so it's documented in the code.

What I deliberately haven't built

The whole project has a "boring on purpose" philosophy. AI features, push notifications, the mobile app, and the /api layer are all planned but not built. The architecture is laid out so they slot in cleanly later (services for the logic, Resources for the JSON, Sanctum alongside the existing session auth), but I'm not building them until I actually need them. For a side project that has to survive months of neglect between sessions, restraint is the feature.