Go back to articles

Shipping the Projects Feature: From Schema to Showcase

How I designed and built a full projects portfolio feature — from database schema and many-to-many skill associations to a media library picker and public showcase pages — in a single focused session.

Shipping the Projects Feature: From Schema to Showcase

March 19, 2026 — One of the items on my roadmap since the very beginning was a proper projects showcase. Today it shipped — from empty database table to fully functional admin UI and public-facing portfolio grid. Here’s how it came together.

The Requirements

Before writing any code, I mapped out what a “project” actually needs to represent in the context of a professional portfolio:

  • Title and slug for public URLs
  • Description (Markdown) and excerpt for cards
  • Status: active, inactive, completed, archived — because not every project is eternal
  • Start/end dates — with “active” projects having no end date
  • External URL — many projects live elsewhere (GitHub, company sites)
  • Logo/hero image — selected from the media library, not just a URL field
  • Tags — freeform labels like “open-source”, “web”, “data”
  • Skills — linked to the global skills table via many-to-many, so you can CRUD skills and assign them to projects
  • Work Experience link — optionally associate a project with a company/institution
  • Image gallery — for project screenshots and visuals

That’s a lot of relationships packed into one feature. Let’s walk through the implementation.

The Schema Design

Three tables were needed:

projects

The main table with all the metadata — title, slug, description, excerpt, external_url, status (Ecto Enum), dates, logo_url, tags (Postgres array), published flag, display_order, and an optional work_experience_id foreign key.

project_skills

A join table for the many-to-many relationship with skills. Just two foreign keys with a unique index — simple but powerful. This means when you create a skill like “Elixir” in the Skills admin, you can assign it to any project, and vice versa.

project_images

A gallery table that supports both media library references (media_id) and direct URLs. Each image has alt text, caption, and display order for the gallery sort.

The migration creates all three tables with proper indexes, cascading deletes on the join tables, and nilify_all on the work experience FK (so deleting a company doesn’t delete the project).

The Ecto Schema: Associations Done Right

The Project schema uses Ecto’s many_to_many with on_replace: :delete:

many_to_many :skills, Skill,
join_through: "project_skills",
on_replace: :delete

This is the key trick — when you update a project’s skills, Ecto automatically handles inserting and deleting join table rows. No manual SQL needed.

The changeset also required a scrub_empty_id/2 helper — when the “Linked Work Experience” select has no selection, the form sends "", which Ecto can’t cast to an integer. A small function converts "" to nil before cast.

Similarly, validate_url/2 needed to accept local paths like /uploads/... in addition to http:// URLs, since images come from the media library.

The Context Layer

Following the established CRUD pattern in Spotlight.Content, I added the standard functions: list_projects, get_project!, create_project, update_project, delete_project, change_project.

The interesting part is skill association handling:

def create_project(attrs) do
{skill_ids, attrs} = extract_skill_ids(attrs)

%Project{}
|> Project.changeset(attrs)
|> maybe_put_skills(skill_ids)
|> Repo.insert()
end

extract_skill_ids/2 pulls skill_ids out of the attrs map (handling both string and atom keys), converts them to integers, and maybe_put_skills/2 loads the actual Skill records and calls Ecto.Changeset.put_assoc/3. Clean separation of concerns.

The Admin UI: Media Library Picker

The admin form follows the same pattern as Work Experience and Skills — a single LiveView with :index, :new, and :edit actions toggling between list and form views.

The most interesting UI challenge was the logo image selector. Instead of a plain URL text field, I replicated the avatar picker pattern from the Profile page:

  1. A preview thumbnail (or placeholder icon)
  2. A “Choose Image” button that opens a modal
  3. The modal shows a URL input field AND a scrollable grid of media library images
  4. Click any thumbnail to select it — the currently selected image gets a highlighted border
  5. A “Remove” button to clear the selection

The skills selector uses checkbox cards in a responsive grid — each skill from the global skills list gets a checkbox, and checked skills are tracked in a @selected_skill_ids assign.

The Public Pages

Two new templates following the Spotlight design language:

Projects Index (/projects)

A responsive grid (1→2→3 columns) where each project card shows:

  • Logo in a circular frame (or folder icon placeholder)
  • Title as a clickable link (external URL or detail page)
  • Date range with active status badge
  • Linked company name
  • Excerpt text
  • Skill pills (purple) and tag pills (teal)
  • External link domain or “View project” CTA

The hover state uses the same scale-up background transition as the Spotlight template’s original project cards.

Project Detail (/projects/:slug)

A single-column article-style layout with:

  • Back button (matching the articles pattern)
  • Title with date range and status
  • Metadata bar (company link, external URL)
  • Skills and tags
  • Markdown-rendered description
  • Image gallery grid

Helper Functions

A few utility functions in PageHTML make the templates clean:

  • format_project_date_range/1 — “Jan 2024 – Present” or “Jan 2024 – Dec 2025”
  • display_domain/1 — extracts “github.com” from a full URL using URI.parse
  • truncate_text/2 — falls back to truncated description when no excerpt exists

What I Learned

1. Many-to-many in Ecto is elegant

Once you understand put_assoc with on_replace: :delete, managing join tables is almost invisible. The changeset handles everything.

2. Form scrubbing matters

HTML forms send "" for empty selects, not nil. If your schema has integer foreign keys, you need to scrub those values before Ecto’s cast. This is a gotcha that burns everyone once.

3. URL validation needs flexibility

When your app serves media from /uploads/, a strict https?:// validation rejects your own files. Accept local paths too.

4. Reusable patterns accelerate development

Because every admin LiveView follows the same structure (mount → handle_params → apply_action → validate → save), adding a new entity is largely mechanical. The pattern is the productivity.

What’s Next

The gallery image management in the admin UI is the obvious next step — right now project images exist at the schema level but the admin form doesn’t yet manage them inline. I also want to add:

  • [ ] Inline gallery management in the project form
  • [ ] Project filtering by skill/tag on the public page
  • [ ] Featured projects on the homepage
  • [ ] OpenGraph images for project pages

But the foundation is solid. From empty table to public portfolio in one session — that’s the Phoenix developer experience I keep coming back to.


Previous articles: