[{"data":1,"prerenderedAt":731},["ShallowReactive",2],{"blog-laravel-cortex-adhd-productivity":3,"blog-post-nav":693},{"_path":4,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":8,"description":9,"date":10,"body":11,"_type":687,"_id":688,"_source":689,"_file":690,"_stem":691,"_extension":692},"/blog/laravel-cortex-adhd-productivity","blog",false,"","Building Cortex: An ADHD Productivity App in Laravel + Inertia","Notes on Cortex, a Laravel 13 + Inertia + React productivity app built around a strict service layer, polymorphic tags, and an RFC 5545 iCal feed.","2026-05-12",{"type":12,"children":13,"toc":679},"root",[14,22,29,34,104,109,115,120,129,142,243,256,269,275,296,333,370,407,412,418,431,444,458,463,481,563,589,602,608,636,650,655,661,673],{"type":15,"tag":16,"props":17,"children":18},"element","p",{},[19],{"type":20,"value":21},"text","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.",{"type":15,"tag":23,"props":24,"children":26},"h2",{"id":25},"the-stack",[27],{"type":20,"value":28},"The stack",{"type":15,"tag":16,"props":30,"children":31},{},[32],{"type":20,"value":33},"It's a fairly modern Laravel setup:",{"type":15,"tag":35,"props":36,"children":37},"ul",{},[38,50,60,70,80,90],{"type":15,"tag":39,"props":40,"children":41},"li",{},[42,48],{"type":15,"tag":43,"props":44,"children":45},"strong",{},[46],{"type":20,"value":47},"Laravel 13",{"type":20,"value":49}," on PHP 8.3",{"type":15,"tag":39,"props":51,"children":52},{},[53,58],{"type":15,"tag":43,"props":54,"children":55},{},[56],{"type":20,"value":57},"Inertia v3 + React 19 + TypeScript",{"type":20,"value":59}," for the frontend",{"type":15,"tag":39,"props":61,"children":62},{},[63,68],{"type":15,"tag":43,"props":64,"children":65},{},[66],{"type":20,"value":67},"Tailwind 4",{"type":20,"value":69}," with shadcn/ui (new-york theme)",{"type":15,"tag":39,"props":71,"children":72},{},[73,78],{"type":15,"tag":43,"props":74,"children":75},{},[76],{"type":20,"value":77},"SQLite",{"type":20,"value":79}," in dev, PostgreSQL in prod",{"type":15,"tag":39,"props":81,"children":82},{},[83,88],{"type":15,"tag":43,"props":84,"children":85},{},[86],{"type":20,"value":87},"Pest 4",{"type":20,"value":89}," for tests",{"type":15,"tag":39,"props":91,"children":92},{},[93,95,102],{"type":20,"value":94},"The ",{"type":15,"tag":96,"props":97,"children":99},"code",{"className":98},[],[100],{"type":20,"value":101},"database",{"type":20,"value":103}," queue driver, deliberately no Redis, no Horizon",{"type":15,"tag":16,"props":105,"children":106},{},[107],{"type":20,"value":108},"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.",{"type":15,"tag":23,"props":110,"children":112},{"id":111},"a-strict-layered-architecture",[113],{"type":20,"value":114},"A strict layered architecture",{"type":15,"tag":16,"props":116,"children":117},{},[118],{"type":20,"value":119},"Every feature follows the same shape, and I don't deviate:",{"type":15,"tag":121,"props":122,"children":124},"pre",{"code":123},"Form Request  → validation + authorization\nService       → all business logic, constructor-injected\nEloquent Model → scopes and casts only\nController    → calls the service, returns Inertia or redirect, ≤ 15 lines\nAPI Resource  → shapes JSON output\n",[125],{"type":15,"tag":96,"props":126,"children":127},{"__ignoreMap":7},[128],{"type":20,"value":123},{"type":15,"tag":16,"props":130,"children":131},{},[132,134,140],{"type":20,"value":133},"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 ",{"type":15,"tag":96,"props":135,"children":137},{"className":136},[],[138],{"type":20,"value":139},"TaskService",{"type":20,"value":141},":",{"type":15,"tag":121,"props":143,"children":147},{"code":144,"language":145,"meta":7,"className":146,"style":7},"public function update(Task $task, array $data): Task\n{\n    if (($data['status'] ?? null) === TaskStatus::Done->value && $task->completed_at === null) {\n        $data['completed_at'] = now();\n    }\n\n    $task->update($data);\n\n    return $task->fresh();\n}\n","php","language-php shiki shiki-themes github-dark",[148],{"type":15,"tag":96,"props":149,"children":150},{"__ignoreMap":7},[151,162,171,180,189,198,208,217,225,234],{"type":15,"tag":152,"props":153,"children":156},"span",{"class":154,"line":155},"line",1,[157],{"type":15,"tag":152,"props":158,"children":159},{},[160],{"type":20,"value":161},"public function update(Task $task, array $data): Task\n",{"type":15,"tag":152,"props":163,"children":165},{"class":154,"line":164},2,[166],{"type":15,"tag":152,"props":167,"children":168},{},[169],{"type":20,"value":170},"{\n",{"type":15,"tag":152,"props":172,"children":174},{"class":154,"line":173},3,[175],{"type":15,"tag":152,"props":176,"children":177},{},[178],{"type":20,"value":179},"    if (($data['status'] ?? null) === TaskStatus::Done->value && $task->completed_at === null) {\n",{"type":15,"tag":152,"props":181,"children":183},{"class":154,"line":182},4,[184],{"type":15,"tag":152,"props":185,"children":186},{},[187],{"type":20,"value":188},"        $data['completed_at'] = now();\n",{"type":15,"tag":152,"props":190,"children":192},{"class":154,"line":191},5,[193],{"type":15,"tag":152,"props":194,"children":195},{},[196],{"type":20,"value":197},"    }\n",{"type":15,"tag":152,"props":199,"children":201},{"class":154,"line":200},6,[202],{"type":15,"tag":152,"props":203,"children":205},{"emptyLinePlaceholder":204},true,[206],{"type":20,"value":207},"\n",{"type":15,"tag":152,"props":209,"children":211},{"class":154,"line":210},7,[212],{"type":15,"tag":152,"props":213,"children":214},{},[215],{"type":20,"value":216},"    $task->update($data);\n",{"type":15,"tag":152,"props":218,"children":220},{"class":154,"line":219},8,[221],{"type":15,"tag":152,"props":222,"children":223},{"emptyLinePlaceholder":204},[224],{"type":20,"value":207},{"type":15,"tag":152,"props":226,"children":228},{"class":154,"line":227},9,[229],{"type":15,"tag":152,"props":230,"children":231},{},[232],{"type":20,"value":233},"    return $task->fresh();\n",{"type":15,"tag":152,"props":235,"children":237},{"class":154,"line":236},10,[238],{"type":15,"tag":152,"props":239,"children":240},{},[241],{"type":20,"value":242},"}\n",{"type":15,"tag":16,"props":244,"children":245},{},[246,248,254],{"type":20,"value":247},"Services are always constructor-injected into controllers, never ",{"type":15,"tag":96,"props":249,"children":251},{"className":250},[],[252],{"type":20,"value":253},"new TaskService()",{"type":20,"value":255},". That keeps them mockable and keeps the controller honest about its dependencies.",{"type":15,"tag":16,"props":257,"children":258},{},[259,261,267],{"type":20,"value":260},"I also keep API Resources for every model even though there are no ",{"type":15,"tag":96,"props":262,"children":264},{"className":263},[],[265],{"type":20,"value":266},"/api",{"type":20,"value":268}," routes yet. They're cheap to write now and ready for the day I add Sanctum and a mobile client.",{"type":15,"tag":23,"props":270,"children":272},{"id":271},"polymorphic-tags-and-a-naming-collision",[273],{"type":20,"value":274},"Polymorphic tags and a naming collision",{"type":15,"tag":16,"props":276,"children":277},{},[278,280,286,288,294],{"type":20,"value":279},"Tags are polymorphic via ",{"type":15,"tag":96,"props":281,"children":283},{"className":282},[],[284],{"type":20,"value":285},"morphToMany",{"type":20,"value":287},", so both tasks and notes share one tagging system through a ",{"type":15,"tag":96,"props":289,"children":291},{"className":290},[],[292],{"type":20,"value":293},"taggables",{"type":20,"value":295}," table:",{"type":15,"tag":121,"props":297,"children":299},{"code":298,"language":145,"meta":7,"className":146,"style":7},"public function tags(): MorphToMany\n{\n    return $this->morphToMany(Tag::class, 'taggable');\n}\n",[300],{"type":15,"tag":96,"props":301,"children":302},{"__ignoreMap":7},[303,311,318,326],{"type":15,"tag":152,"props":304,"children":305},{"class":154,"line":155},[306],{"type":15,"tag":152,"props":307,"children":308},{},[309],{"type":20,"value":310},"public function tags(): MorphToMany\n",{"type":15,"tag":152,"props":312,"children":313},{"class":154,"line":164},[314],{"type":15,"tag":152,"props":315,"children":316},{},[317],{"type":20,"value":170},{"type":15,"tag":152,"props":319,"children":320},{"class":154,"line":173},[321],{"type":15,"tag":152,"props":322,"children":323},{},[324],{"type":20,"value":325},"    return $this->morphToMany(Tag::class, 'taggable');\n",{"type":15,"tag":152,"props":327,"children":328},{"class":154,"line":182},[329],{"type":15,"tag":152,"props":330,"children":331},{},[332],{"type":20,"value":242},{"type":15,"tag":16,"props":334,"children":335},{},[336,338,344,346,352,354,360,362,368],{"type":20,"value":337},"There's one gotcha worth flagging. The ",{"type":15,"tag":96,"props":339,"children":341},{"className":340},[],[342],{"type":20,"value":343},"tasks",{"type":20,"value":345}," table has a ",{"type":15,"tag":96,"props":347,"children":349},{"className":348},[],[350],{"type":20,"value":351},"notes",{"type":20,"value":353}," text column (free-text notes on the task itself). That name collides with what would naturally be a ",{"type":15,"tag":96,"props":355,"children":357},{"className":356},[],[358],{"type":20,"value":359},"notes()",{"type":20,"value":361}," relationship for linked notes. So the relationship is called ",{"type":15,"tag":96,"props":363,"children":365},{"className":364},[],[366],{"type":20,"value":367},"linkedNotes()",{"type":20,"value":369}," everywhere instead:",{"type":15,"tag":121,"props":371,"children":373},{"code":372,"language":145,"meta":7,"className":146,"style":7},"public function linkedNotes(): BelongsToMany\n{\n    return $this->belongsToMany(Note::class, 'task_note');\n}\n",[374],{"type":15,"tag":96,"props":375,"children":376},{"__ignoreMap":7},[377,385,392,400],{"type":15,"tag":152,"props":378,"children":379},{"class":154,"line":155},[380],{"type":15,"tag":152,"props":381,"children":382},{},[383],{"type":20,"value":384},"public function linkedNotes(): BelongsToMany\n",{"type":15,"tag":152,"props":386,"children":387},{"class":154,"line":164},[388],{"type":15,"tag":152,"props":389,"children":390},{},[391],{"type":20,"value":170},{"type":15,"tag":152,"props":393,"children":394},{"class":154,"line":173},[395],{"type":15,"tag":152,"props":396,"children":397},{},[398],{"type":20,"value":399},"    return $this->belongsToMany(Note::class, 'task_note');\n",{"type":15,"tag":152,"props":401,"children":402},{"class":154,"line":182},[403],{"type":15,"tag":152,"props":404,"children":405},{},[406],{"type":20,"value":242},{"type":15,"tag":16,"props":408,"children":409},{},[410],{"type":20,"value":411},"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.",{"type":15,"tag":23,"props":413,"children":415},{"id":414},"the-ical-feed",[416],{"type":20,"value":417},"The iCal feed",{"type":15,"tag":16,"props":419,"children":420},{},[421,423,429],{"type":20,"value":422},"The feature I'm happiest with is the calendar feed. Tasks with a due date and completed focus sessions get exposed as a subscribable ",{"type":15,"tag":96,"props":424,"children":426},{"className":425},[],[427],{"type":20,"value":428},".ics",{"type":20,"value":430}," calendar, so they show up in whatever calendar app I already use.",{"type":15,"tag":16,"props":432,"children":433},{},[434,436,442],{"type":20,"value":435},"Calendar clients can't do session auth, so the feed route is authenticated by a token query param instead, there's a separate ",{"type":15,"tag":96,"props":437,"children":439},{"className":438},[],[440],{"type":20,"value":441},"ical_tokens",{"type":20,"value":443}," table for that, and the route lives outside the auth middleware group:",{"type":15,"tag":121,"props":445,"children":447},{"code":446,"language":145,"meta":7,"className":146,"style":7},"Route::get('ical/{userId}/feed.ics', [ICalController::class, 'feed'])->name('ical.feed');\n",[448],{"type":15,"tag":96,"props":449,"children":450},{"__ignoreMap":7},[451],{"type":15,"tag":152,"props":452,"children":453},{"class":154,"line":155},[454],{"type":15,"tag":152,"props":455,"children":456},{},[457],{"type":20,"value":446},{"type":15,"tag":16,"props":459,"children":460},{},[461],{"type":20,"value":462},"The interesting part is actually getting RFC 5545 right. Two things bit me:",{"type":15,"tag":16,"props":464,"children":465},{},[466,471,473,479],{"type":15,"tag":43,"props":467,"children":468},{},[469],{"type":20,"value":470},"Line folding.",{"type":20,"value":472}," 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 ",{"type":15,"tag":96,"props":474,"children":476},{"className":475},[],[477],{"type":20,"value":478},"ICalService",{"type":20,"value":480}," folds every line:",{"type":15,"tag":121,"props":482,"children":484},{"code":483,"language":145,"meta":7,"className":146,"style":7},"private function fold(string $line): string\n{\n    if (strlen($line) \u003C= 75) {\n        return $line;\n    }\n\n    $chunks = str_split($line, 73);\n\n    return implode(\"\\r\\n \", $chunks);\n}\n",[485],{"type":15,"tag":96,"props":486,"children":487},{"__ignoreMap":7},[488,496,503,511,519,526,533,541,548,556],{"type":15,"tag":152,"props":489,"children":490},{"class":154,"line":155},[491],{"type":15,"tag":152,"props":492,"children":493},{},[494],{"type":20,"value":495},"private function fold(string $line): string\n",{"type":15,"tag":152,"props":497,"children":498},{"class":154,"line":164},[499],{"type":15,"tag":152,"props":500,"children":501},{},[502],{"type":20,"value":170},{"type":15,"tag":152,"props":504,"children":505},{"class":154,"line":173},[506],{"type":15,"tag":152,"props":507,"children":508},{},[509],{"type":20,"value":510},"    if (strlen($line) \u003C= 75) {\n",{"type":15,"tag":152,"props":512,"children":513},{"class":154,"line":182},[514],{"type":15,"tag":152,"props":515,"children":516},{},[517],{"type":20,"value":518},"        return $line;\n",{"type":15,"tag":152,"props":520,"children":521},{"class":154,"line":191},[522],{"type":15,"tag":152,"props":523,"children":524},{},[525],{"type":20,"value":197},{"type":15,"tag":152,"props":527,"children":528},{"class":154,"line":200},[529],{"type":15,"tag":152,"props":530,"children":531},{"emptyLinePlaceholder":204},[532],{"type":20,"value":207},{"type":15,"tag":152,"props":534,"children":535},{"class":154,"line":210},[536],{"type":15,"tag":152,"props":537,"children":538},{},[539],{"type":20,"value":540},"    $chunks = str_split($line, 73);\n",{"type":15,"tag":152,"props":542,"children":543},{"class":154,"line":219},[544],{"type":15,"tag":152,"props":545,"children":546},{"emptyLinePlaceholder":204},[547],{"type":20,"value":207},{"type":15,"tag":152,"props":549,"children":550},{"class":154,"line":227},[551],{"type":15,"tag":152,"props":552,"children":553},{},[554],{"type":20,"value":555},"    return implode(\"\\r\\n \", $chunks);\n",{"type":15,"tag":152,"props":557,"children":558},{"class":154,"line":236},[559],{"type":15,"tag":152,"props":560,"children":561},{},[562],{"type":20,"value":242},{"type":15,"tag":16,"props":564,"children":565},{},[566,571,573,579,581,587],{"type":15,"tag":43,"props":567,"children":568},{},[569],{"type":20,"value":570},"Escaping.",{"type":20,"value":572}," Commas, semicolons, backslashes and newlines all have to be escaped in text values, or clients choke on the feed. And the whole document uses ",{"type":15,"tag":96,"props":574,"children":576},{"className":575},[],[577],{"type":20,"value":578},"\\r\\n",{"type":20,"value":580}," line endings, not ",{"type":15,"tag":96,"props":582,"children":584},{"className":583},[],[585],{"type":20,"value":586},"\\n",{"type":20,"value":588},".",{"type":15,"tag":16,"props":590,"children":591},{},[592,594,600],{"type":20,"value":593},"The feed is cached for 60 seconds with ",{"type":15,"tag":96,"props":595,"children":597},{"className":596},[],[598],{"type":20,"value":599},"Cache::remember",{"type":20,"value":601},", because calendar clients poll aggressively and there's no reason to rebuild the same payload on every hit.",{"type":15,"tag":23,"props":603,"children":605},{"id":604},"focus-sessions-and-a-carbon-3-trap",[606],{"type":20,"value":607},"Focus sessions and a Carbon 3 trap",{"type":15,"tag":16,"props":609,"children":610},{},[611,613,619,621,627,629,635],{"type":20,"value":612},"Focus sessions are a simple start/end timer stored server-side. One small but real bug: Carbon 3 returns ",{"type":15,"tag":614,"props":615,"children":616},"em",{},[617],{"type":20,"value":618},"signed",{"type":20,"value":620}," values from ",{"type":15,"tag":96,"props":622,"children":624},{"className":623},[],[625],{"type":20,"value":626},"diffInSeconds",{"type":20,"value":628}," depending on argument order, which meant durations could come out negative. The fix is just an ",{"type":15,"tag":96,"props":630,"children":632},{"className":631},[],[633],{"type":20,"value":634},"abs()",{"type":20,"value":141},{"type":15,"tag":121,"props":637,"children":639},{"code":638,"language":145,"meta":7,"className":146,"style":7},"'duration_seconds' => (int) abs($endedAt->diffInSeconds($session->started_at)),\n",[640],{"type":15,"tag":96,"props":641,"children":642},{"__ignoreMap":7},[643],{"type":15,"tag":152,"props":644,"children":645},{"class":154,"line":155},[646],{"type":15,"tag":152,"props":647,"children":648},{},[649],{"type":20,"value":638},{"type":15,"tag":16,"props":651,"children":652},{},[653],{"type":20,"value":654},"Easy to miss, annoying to debug, so it's documented in the code.",{"type":15,"tag":23,"props":656,"children":658},{"id":657},"what-i-deliberately-havent-built",[659],{"type":20,"value":660},"What I deliberately haven't built",{"type":15,"tag":16,"props":662,"children":663},{},[664,666,671],{"type":20,"value":665},"The whole project has a \"boring on purpose\" philosophy. AI features, push notifications, the mobile app, and the ",{"type":15,"tag":96,"props":667,"children":669},{"className":668},[],[670],{"type":20,"value":266},{"type":20,"value":672}," 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.",{"type":15,"tag":674,"props":675,"children":676},"style",{},[677],{"type":20,"value":678},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":7,"searchDepth":164,"depth":164,"links":680},[681,682,683,684,685,686],{"id":25,"depth":164,"text":28},{"id":111,"depth":164,"text":114},{"id":271,"depth":164,"text":274},{"id":414,"depth":164,"text":417},{"id":604,"depth":164,"text":607},{"id":657,"depth":164,"text":660},"markdown","content:blog:laravel-cortex-adhd-productivity.md","content","blog/laravel-cortex-adhd-productivity.md","blog/laravel-cortex-adhd-productivity","md",[694,698,702,703,707,711,715,719,723,727],{"_path":695,"title":696,"date":697},"/blog/deploying-nuxt-to-cloudflare-workers","Deploying this Nuxt site to Cloudflare Workers","2026-06-06",{"_path":699,"title":700,"date":701},"/blog/building-forever-llm","Building forever-llm: three takes on a model that never stops","2026-05-20",{"_path":4,"title":8,"date":10},{"_path":704,"title":705,"date":706},"/blog/eink-spotify-weather-clock","Building an E-Ink Clock That Shows Spotify, Weather and the Time","2026-05-05",{"_path":708,"title":709,"date":710},"/blog/hetzner-k8s-cluster","Building a K3s Cluster on Hetzner with Terraform and GitOps","2026-04-20",{"_path":712,"title":713,"date":714},"/blog/arduino-uno-q-forza-rev-gauge","A Forza Rev Gauge on the Arduino UNO Q's LED Matrix","2026-04-15",{"_path":716,"title":717,"date":718},"/blog/modular-go-echo-gorm","A Modular Go Web App Pattern with Echo, GORM and golang-migrate","2026-04-05",{"_path":720,"title":721,"date":722},"/blog/self-hosted-ios-web-push","Self-hosting iOS push notifications with Web Push and PWAs","2026-03-25",{"_path":724,"title":725,"date":726},"/blog/lit-web-components-astro-docs","Building a framework-free web component library with Lit and Astro","2026-03-10",{"_path":728,"title":729,"date":730},"/blog/welcome-to-my-blog","About this site","2024-01-15",1781294950571]