Manga Kotoba, Part 1: From Zero to First iOS Screen
The Spark
“The hardest part of a side project isn’t the code — it’s having a problem you genuinely want to solve.”
I’ve been learning Japanese for a while now. Not casually — I mean the full commitment: Anki decks at breakfast, grammar guides on lunch breaks, anime with Japanese subtitles in the evening. And somewhere in all that, I fell in love with manga. Not just as entertainment, but as a learning medium. Real dialogue, furigana on hard words, visual context that makes meaning stick.
But there was a gap — a frustrating one. My vocabulary apps had no idea which manga I was reading. Anki didn’t know about One Piece. WaniKani couldn’t tell me which kanji appear on page 12 of a specific volume. Everything was disconnected. I’d encounter 湿気 (しっけ — humidity) in a beach chapter, look it up, and it’d vanish into a generic deck with no connection to the page where I found it.
That’s where Manga Kotoba was born. An iOS app backed by a real API that maps vocabulary to the page it appears on. Browse your manga library, open any page, see exactly which words you don’t know yet. Mark them as known. Track your progress. Build vocabulary in context.
This is Part 1 of a five-part series about building it from scratch.
Choosing the Stack — Interactive Decision Tree
Choosing a stack for a side project is surprisingly emotional. You want to learn something new, but you also want to ship something. Every decision is a trade-off between exploration and pragmatism. Here’s the exact thought process I went through — click each node to see my reasoning.
🗺 Tech Stack Decision Tree
#[ApiResource]
to an entity and you get GET/POST/PATCH/DELETE endpoints, pagination, filtering, serialisation groups,
and OpenAPI docs — for free. I'd used it on client work before.
The database: MySQL 8 (familiar, reliable, excellent with Doctrine ORM).
Day 1: Raw Symfony — The Domain Model
The very first backend commit after the raw Symfony project was b153737:
feat(db): add initial schema migration for manga platform.
Before a single endpoint, before a single API call from the phone, I sat down and drew out the domain. What are the things in this system?
// The core entities, sketched on paper first Manga → has many Volumes Volume → belongs to Manga, has many Pages Page → belongs to Volume, has many Words (occurrences) Vocabulary → the central dictionary (word, reading, meaning, JLPT) Word → a page-level occurrence linking Page ↔ Vocabulary KnownWord → User ↔ Vocabulary join (words the user has learned) UserLibrary → User ↔ Manga join (the user's personal collection)
That first migration created all ten tables in one go. Here’s the heart of it — the manga and vocabulary
tables that everything else hangs off:
// migrations/Version20260228145912.php $this->addSql('CREATE TABLE manga ( id INT AUTO_INCREMENT NOT NULL, title VARCHAR(255) NOT NULL, description LONGTEXT DEFAULT NULL, cover_url VARCHAR(500) DEFAULT NULL, author VARCHAR(255) DEFAULT NULL, status VARCHAR(10) DEFAULT NULL, total_pages INT NOT NULL DEFAULT 0, PRIMARY KEY(id) ) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB'); $this->addSql('CREATE TABLE vocabulary ( id INT AUTO_INCREMENT NOT NULL, text VARCHAR(255) NOT NULL, reading VARCHAR(255) DEFAULT NULL, meaning LONGTEXT DEFAULT NULL, jlpt_level VARCHAR(2) DEFAULT NULL, part_of_speech VARCHAR(50) DEFAULT NULL, PRIMARY KEY(id) ) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB');
Interactive Entity-Relationship Diagram
🗄 Domain Model — Entity Relationships
The design separates content (Manga/Volume/Page/Vocabulary) from user state (KnownWord/UserLibrary). That separation would prove its worth later when adding progress tracking and subscriptions — the core content never needs to know about any specific user.
The API Platform Magic Moment
After the migration came the entities. And after the entities came the moment that made me laugh out loud in my apartment at 11 PM.
I added #[ApiResource] to Manga.php:
// src/Entity/Manga.php #[ORM\Entity(repositoryClass: MangaRepository::class)] #[ApiResource( operations: [ new GetCollection( normalizationContext: ['groups' => ['manga:read']], security: "is_granted('PUBLIC_ACCESS')", ), new Get( normalizationContext: ['groups' => ['manga:read', 'manga:item']], security: "is_granted('PUBLIC_ACCESS')", ), new Post(security: "is_granted('ROLE_MANAGER')"), new Patch(security: "is_granted('ROLE_MANAGER')"), new Delete(security: "is_granted('ROLE_MANAGER')"), ], )] class Manga { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] #[Groups(['manga:read'])] private ?int $id = null; // ... fields ... }
That’s it. One PHP attribute. Now GET /api/mangas returns paginated JSON.
GET /api/mangas/1 returns a single manga. POST /api/mangas (if you’re ROLE_MANAGER) creates one.
OpenAPI docs appear at /api/docs. Filters, sorting, pagination — all configurable from the same attribute.
manga:read, manga:write) are how you control exactly which fields appear in responses vs. mutations. Get these right early — retrofitting them is painful when the iOS app already expects a specific shape.
The First iOS Screen — Browser Mockup
The iOS side started life as a pure mockup. The commit 97349b3 Browser screen mockup had zero networking —
just hard-coded data and a LazyVGrid. The goal was to feel the UI before wiring up the API. Here’s what
the browse screen looked like conceptually. Click a card to see the detail view:
The LazyVGrid with .adaptive(minimum: 150) columns was the first piece of SwiftUI that genuinely
delighted me. Write it once and it adapts from a 3-column iPad layout down to 2-column on a small iPhone —
no breakpoints, no media queries, just adaptive layout from a single declaration.
// The browse grid — dead simple struct BrowseSeriesView: View { @State private var searchText = "" let columns = [GridItem(.adaptive(minimum: 150), spacing: 16)] var body: some View { NavigationStack { ScrollView { LazyVGrid(columns: columns, spacing: 16) { ForEach(mangas) { manga in NavigationLink( destination: MangaDetailView(slug: manga.slug) ) { MangaCardView(manga: manga) } } } .padding() } .navigationTitle("Browse") } } }
The First SwiftUI Struggles
The mockup was easy. Wiring it to a real API was where the reality checks started coming in thick and fast.
Problem 1 — localhost doesn’t mean the same thing inside the Simulator
Commit 7b21662: “Make it work with the docker localhost”. Three hours of debugging to understand something
that seems obvious in hindsight: the iOS Simulator runs in a separate network namespace. When you type
http://localhost:8080 in Swift, you’re asking the iPhone simulator to connect to port 8080 on its
localhost — which is empty.
The fix: use your Mac’s local IP address instead.
// ❌ Before — connects to the simulator's own loopback enum APIConfig { static let baseURL = URL(string: "http://localhost:8080")! } // ✅ After — connects to the Mac running the Docker container enum APIConfig { static let baseURL = URL(string: "http://192.168.1.42:8080")! }
127.0.0.1). Use ifconfig | grep "inet " | grep -v 127 to find your Mac's local IP. Better yet, add an #if DEBUG block that swaps between your local IP and the production Heroku URL.
Problem 2 — JSON-LD @id / @type fields that Codable doesn’t expect
API Platform returns JSON-LD by default. That means every response includes @id, @type, and hydra:*
fields that Swift’s Codable has never heard of. My first MangaModels.swift had a clean, simple struct:
// What I wanted struct APIMangaItem: Codable { let id: Int let title: String } // What the API actually returned // { // "@id": "/api/mangas/1", // "@type": "Manga", // "id": 1, // "title": "Naruto" // } // → Swift crashes: "No value associated with key @id"
The solution was either switching API Platform to output plain JSON (add format: 'json' to the
GetCollection operation), or adding CodingKeys to ignore unknown fields. I went with the first option —
cleaner responses, no special-casing in the client:
new GetCollection( formats: ['json' => ['application/json']], normalizationContext: ['groups' => ['manga:read']], ),
Problem 3 — Heroku, HTTPS, and App Transport Security
Commit c982321: “General fix, a lot of issues due to heroku usage”. This one was a cluster of smaller
problems all arriving at once the first time I deployed to Heroku and pointed the app at the real URL.
iOS enforces App Transport Security (ATS) by default — all requests must be HTTPS. Heroku gives you HTTPS
for free on .herokuapp.com domains, but the backend was briefly returning HTTP redirects, and the Symfony
TRUSTED_PROXIES setting wasn’t configured for Heroku’s load balancer. The result: infinite redirect loops
or blank responses.
TRUSTED_PROXIES=REMOTE_ADDR in your Heroku config vars and add APP_URL=https://your-app.herokuapp.com. Symfony needs to know it's behind a proxy so it generates correct HTTPS URLs for API responses.
Also: CORS. The iOS simulator sends requests with an Origin header. Without nelmio/cors-bundle configured
correctly, every preflight returned 403. The fix was three lines of YAML, but finding those three lines cost
me an afternoon.
Marking Words as Known — Swipe Demo
Commit b45d99a: “Make it possible to mark a word as known”. This was the feature that made the app feel
real for the first time. Not just a list of words — a thing you could act on.
The interaction model is obvious once you’ve used any flashcard app: swipe right to mark as known, swipe left
to skip. The iOS implementation uses a DragGesture on the word row that translates to a CGAffineTransform
and fires an API call on release if the offset is past a threshold.
Try it here — drag the card or use the buttons:
The SwiftUI implementation behind this is surprisingly tidy. A @GestureState property tracks the drag
offset and drives both the visual transform and the threshold detection:
struct VocabWordRowView: View { let word: VocabWord var onMarkKnown: () -> Void @GestureState private var dragOffset: CGFloat = 0 @State private var finalOffset: CGFloat = 0 private let threshold: CGFloat = 100 var body: some View { let total = finalOffset + dragOffset return HStack { WordContent(word: word) } .offset(x: total) .opacity(1.0 - abs(total) / 200) .gesture( DragGesture() .updating($dragOffset) { value, state, _ in state = value.translation.width } .onEnded { value in if value.translation.width > threshold { onMarkKnown() } else { finalOffset = 0 } } ) } }
@GestureState instead of @State for drag offset means the value automatically resets to zero when the gesture ends — no need to manually reset it in onEnded. This eliminates an entire class of jitter bugs (see the much later commit: "fix: eliminate swipe gesture jitter using @GestureState").
Milestone: Real Data in a Real App
After all the wrangling — the localhost confusion, the JSON-LD chaos, the Heroku HTTPS debugging — there was a moment that made everything feel worth it.
“The first time your app loads real data from your own API is genuinely one of the best feelings in software development.”
I had seeded a handful of manga into the database. I had the Symfony server running on Heroku. I opened
Xcode, hit the Run button, and watched the iOS Simulator boot. The BrowseStore kicked off its URLSession
request. ProgressView("Loading…") appeared on screen.
And then — manga cards. Real ones. With real titles from the database I’d designed, populated through an API I’d built, decoded by a Swift struct I’d written.
The data model I’d sketched on paper was now pixels on a screen that lived in my pocket.
That feeling — small but undeniable — is why side projects exist.
What’s Next
Part 1 ends here: the stack chosen, the domain modelled, the first screen wired to a real API. But there’s a huge gap in the story: where does the actual vocabulary data come from? I haven’t explained how 10,000+ words end up associated with specific manga pages.
That’s what Part 2 is about.
If you’re building something similar — an iOS app on top of a Symfony API, or just learning Japanese through something you actually care about — I’d love to hear about it. The project is private for now but Part 2 will include real code excerpts from the AI pipeline.
じゃあ、またね 👋