WordPress

Modeling a client's portfolio with custom post types instead of pages

WP

A photography client's site had grown into dozens of individual Pages, one per photo shoot, manually linked together and reordered by hand every time new work went up. It worked, barely, but it was fragile, and every new shoot meant editing at least three different pages, the shoot's own page, a navigation menu, and whichever category-style landing page needed the new thumbnail added to it.

Why Pages stopped being the right tool

WordPress Pages are meant for mostly-static, hierarchical content, an About page, a Contact page, maybe a handful of service pages underneath a parent. They're not built with any concept of shared structure across many similar items, no built-in way to say "these forty pages are all the same kind of thing and should list themselves automatically," which is exactly what a portfolio of photo shoots actually is. Every workaround we'd been using, a manually maintained list shortcode, hand-edited navigation, was really us building a weaker version of what a custom post type already gives you for free.

What we built instead

We modeled it as a custom post type, gallery, with a custom taxonomy for shoot categories like weddings or portraits. Archive templates handle the listing and filtering automatically instead of a manually maintained list, and adding a new shoot is now just publishing a post rather than editing three different pages to update navigation.

  • register_post_type for gallery, with support for a featured image, an excerpt, and a small set of custom fields for shoot date and location.
  • register_taxonomy for shoot_category, hierarchical like a category rather than freeform like a tag, since the client wanted a fixed, curated set of categories rather than an open-ended folksonomy.
  • A custom archive-gallery.php template that queries and paginates automatically, and a taxonomy-shoot_category.php template that does the same filtered to one category, both handled entirely by WordPress's template hierarchy without any custom routing code on our part.

The migration itself

Moving forty-some existing Pages into the new post type wasn't glamorous work, we wrote a small one-off script using WP-CLI to read each Page's content and featured image and recreate it as a gallery post with the right taxonomy term attached, rather than doing it by hand through the admin one at a time. Even with the script doing the heavy lifting, we spent an afternoon manually checking every migrated shoot against its original page to make sure a featured image or a caption hadn't gotten dropped somewhere in translation.

What the client noticed immediately

The client's own reaction was the clearest validation, publishing a new shoot went from something they'd nervously ask us to help with, worried about breaking the navigation, to something they did confidently themselves within a week of the change, because there was no navigation to break anymore, just a new post that automatically showed up in the right places. That's really the whole point of choosing the right content model, not making our own life easier as developers, though it does that too, but making the actual editing experience match how the client already thinks about their own content.

The lesson that generalizes

It's a pattern we should have reached for from day one on that project. Any time content is naturally repeating and categorizable, case studies, team members, products, testimonials, that's a signal for a custom post type rather than more Pages, and we're now asking that question explicitly at the start of every new WordPress project rather than defaulting to Pages and only reaching for a custom post type once the Pages approach has already started creaking under its own weight.

Handling the photos within each shoot

Each shoot needed more than one photo, the featured image alone wasn't enough, clients expect a small gallery per shoot. We used a repeatable custom field, a comma-separated list of attachment IDs managed through a metabox with a simple "Add Images" button that opens the native WordPress media library, rather than reaching for a full gallery plugin that would have brought a lot of unused functionality along with it. It's not the most elegant data structure, a proper relationship table would be cleaner, but it's stored in a single postmeta row, easy to query, and easy enough for the client to manage without training beyond a five-minute screen-share the first time they added a new shoot.

SEO benefits that came along for free

Once every shoot had its own URL with a descriptive slug, weddings/smith-family-fall-2014 rather than a generic page ID, and archive pages were auto-generating from the taxonomy structure, we noticed a meaningful uptick in search traffic to individual shoot pages over the following couple of months, presumably because each shoot could now rank for its own specific search terms, a couple, a location, a season, rather than all of them competing to be found through one crowded "our work" page. We didn't set out to solve an SEO problem, we set out to solve a content management problem, but a sane URL and content structure turned out to help with both at once, which isn't a coincidence, search engines and human editors tend to want the same thing, clearly organized, individually addressable content.

A follow-up request: featuring specific shoots on the homepage

A couple of weeks after launch the client asked if certain shoots could be manually pinned to the homepage regardless of publish date, their favorite wedding shoot from two years ago, say, rather than always showing the most recent work first. We added a simple boolean custom field, featured, checked through a metabox, and a small tweak to the homepage query to pull featured shoots first before falling back to recency. It's a small addition, but it's exactly the kind of request that's trivial to bolt onto a custom post type and would have been another manually-maintained special case under the old Pages-based structure.

Training the client on the new workflow

We spent about half an hour on a screen-share walking the client through publishing their first new shoot under the new system, adding a featured image, picking a category, adding the gallery images, hitting publish. That's genuinely all it took, a single half-hour session, compared to what had been an ongoing low-level dependency on us for anything involving new work going up, since the old Pages-based approach required editing navigation markup that wasn't safe for a non-technical client to touch directly. Reducing that dependency was worth as much to the client relationship as the SEO improvement, arguably more, since it changed how they experienced working with us day to day rather than only showing up in an analytics report they might not even check.

What we'd add if we rebuilt this today

With a bit more hindsight, we'd add one more taxonomy from the start, a simple "featured location" or region taxonomy separate from the shoot-type category, since this client's clients often search by location as much as by the type of shoot, and retrofitting a second taxonomy onto forty existing posts after the fact, while not difficult, is more work than including it in the original migration script would have been. It's a reminder that content modeling benefits from at least a rough conversation with the client about how they think their own future clients will actually search and browse, rather than only modeling the categories obvious from the existing content on day one.

The taxonomy naming decision we almost got wrong

We also nearly named the taxonomy category instead of shoot_category, reusing WordPress's default taxonomy rather than registering our own, which would have worked initially but mixed this client's shoot categories in with any blog category the same install might eventually need for a companion blog. Registering a dedicated taxonomy cost us nothing extra to set up and avoided a naming collision we would have had to untangle later if the client ever added blog content to the same site, which is exactly the kind of small decision that's nearly free to get right up front and genuinely annoying to unwind after the fact.

Keeping the old URLs from breaking

The last piece we almost forgot: the original Pages had already been indexed by search engines and bookmarked by a few of the client's own past clients, so simply deleting them after migration would have quietly broken every one of those links. We set up a small set of redirect rules mapping each old page URL to its new gallery post URL before removing the originals, rather than letting them 404 and hoping nobody noticed, which took an extra hour but meant the migration was invisible to anyone arriving from an old link, search result or otherwise.

← Back to the journal

Have a project in mind?
Let’s talk.

Tell us where you are and where you want to go. We'll map the fastest route between the two.

Currently accepting new clients