Until now a Course lived at /catalog/42. Functional, forgettable, and useless to a search engine or anyone pasting a link into a chat. This slice gives every Course a Slug — a readable, keyword-rich identifier derived from its title — so the same Course is now /catalog/scrum-master-fundamentos-pratica-2026.
The Buy-button-equivalent here — “lowercase the title and replace spaces with hyphens” — is the least interesting part. The decision worth writing down is about when a slug is allowed to change.
A slug that freezes on Publish
A title gets edited a lot while a Course is being built and almost never after. An SEO-friendly URL has the opposite need: it must stay stable, because once a link is shared or indexed, changing it is a broken link.
So the Slug is keyed to the Course’s lifecycle, not to the title directly:
- While Draft — the Slug regenerates on every title change. The Instructor is still finding the words; the URL follows along.
- On Publish — the Slug freezes. From that point, editing the title leaves the URL untouched, forever.
This lives in the changeset, and the trick is to key the freeze off the persisted status rather than the incoming one — so a Course that’s already Published carries "published" on its struct and the slug logic simply steps aside:
defp maybe_put_slug(changeset, %{status: "published"}), do: changeset
defp maybe_put_slug(changeset, _course) do
case get_field(changeset, :title) do
title when is_binary(title) and title != "" ->
case Slug.slugify(title) do
"" -> add_error(changeset, :title, "title needs URL-usable characters")
slug ->
changeset
|> put_change(:slug, slug)
|> unique_constraint(:title, name: :courses_slug_index,
message: "that URL is already in use")
end
_ -> changeset # missing title is validate_required's job
end
end
There’s no slug input on the course form — the Instructor only ever edits the title, and the URL is derived for them. That’s also why both error messages attach to :title: with no slug field on screen, the message has to surface next to the thing the Instructor can actually change.
Slugification, and the empty-title trap
The algorithm is unglamorous on purpose: transliterate accents to ASCII (so Prática → pratica), lowercase, collapse each run of non-alphanumerics into a single hyphen, trim the ends, and cap at ~80 characters on a hyphen boundary so a word is never cut in half.
"Scrum Master: Fundamentos & Prática (2026)" → "scrum-master-fundamentos-pratica-2026"
The edge case that actually needs a decision: what if the title is all emoji, or all CJK, and transliterates to nothing? An empty slug can’t be a URL. Rather than inventing a course-42 fallback (which would defeat the whole point), the save is blocked with “title needs URL-usable characters.” A Course you can’t address is a bug, not a silent default.
~25 links that updated themselves
Roughly two dozen templates and controllers built links like ~p"/catalog/#{course.id}". Rewriting each by hand is exactly the kind of change where one gets missed. Phoenix’s Phoenix.Param protocol does it in one place instead:
defimpl Phoenix.Param, for: Forgia.Courses.Course do
def to_param(%{slug: slug}), do: slug
end
Now ~p"/catalog/#{course}" — a Course struct, not its id — emits the slug automatically. The change across the codebase became “pass the struct, not the field,” and verified routes did the rest. The public catalog resolves the other direction through get_course_by_slug!/1, and an unknown slug raises straight into a 404.
It works end to end:
GET /catalog → /catalog/scrum-master-fundamentos-pratica-2026
/catalog/agile-coaching-mastery (no numeric ids)
GET /catalog/scrum-master-fundamentos-pratica-2026 → 200 (title renders)
GET /catalog/does-not-exist → 404
GET /catalog/1 (old numeric id) → 404
That last line is deliberate. Forgia is pre-launch — no numeric URL has ever been shared or indexed — so there’s no 301-redirect or rel=canonical machinery to carry old links forward. Routing is slug-only, and the old numeric form simply 404s. Building that backward-compat layer now would be maintaining a bridge to a shore nobody stands on.
The one place -2 is allowed
Live, two Courses can never share a slug: the database has a NOT NULL + unique index on the column, and a collision is a validation error the Instructor resolves by changing the title. No silent auto-suffixing — scrum-basics-2 would be a confusing URL nobody asked for.
But the one-time backfill migration faces a problem the live flow never does: pre-existing Courses with duplicate titles. It can’t ask anyone to rename anything, and it can’t crash. So the migration — and only the migration — appends -2, -3 to disambiguate:
defp disambiguate(base, taken) do
if MapSet.member?(taken, base) do
Stream.iterate(2, &(&1 + 1))
|> Enum.find_value(fn n ->
candidate = "#{base}-#{n}"
if MapSet.member?(taken, candidate), do: false, else: candidate
end)
else
base
end
end
This is a migration-only exception, called out as such in the code, so nobody later mistakes it for the live rule and “helpfully” adds auto-suffixing to the changeset.
Why it’s a value object
The Slug isn’t a table or a feature — it’s a property of how a Course is identified in public. It earned an entry in CONTEXT.md next to Course, and the derivation logic lives in its own Forgia.Courses.Slug module: a pure function, no Ecto, no Repo, trivially testable. The changeset decides when to apply it; the value object decides what a slug is. Keeping those two apart is what made the freeze rule a one-line guard instead of a tangle.