Portfolio for Craft CMS

Lite / Free · Pro / $49

A portfolio is a content model, not a page

Craft has no post type to register. A portfolio there means a section, an entry type, nine fields, a category group and a tag group, built by hand in the right order — and then templates hardcoded to every handle. Portfolio builds all of it from one command, shows you the plan before it writes a line, and remembers what it built, so your templates ask for the client rather than for portfolioClient.

Portfolio

The plan, before anything is written

This is the build screen, and it is planning for real against a site that already has some of what a portfolio needs. Every piece comes back as create, reuse or conflict. Change the handle, untick a field, or press Build it and then plan again.

Portfolio → Portfolios → Build a portfolio
Fields
Action Kind Handle Note

Untick Summary and the conflict disappears. Type about and the Single section conflicts instead. Type portfolio and everything is a create. Then press Build it and look at the plan again.

The sample site already has a work section, a workCategories group, two Plain Text fields and an Assets field it can reuse — and a workSummary Dropdown it will not reshape into Plain Text. That is the conflict, and the reason the Build button stays off until you resolve it.

Templates ask for a role, never a handle

Every field Portfolio builds plays a role — summary, featuredImage, client, categories. The role map is keyed by field UID, so renaming portfolioClient to clientName in the control panel breaks nothing. And items() returns an ordinary entry query, so pagination, eager-loading and {% cache %} behave exactly as they always do.

twig
{# Lite: the whole query API. items() is an ordinary entry query. #}
{% set items = craft.portfolio.items({ category: 'branding', limit: 12 }).all() %}

{% for item in items %}
  {% set image = (item|portfolioField('featuredImage')).one() %}
  <a href="{{ item.url }}">
    {% if image %}<img src="{{ image.getUrl({ width: 800 }) }}" alt="">{% endif %}
    {{ item.title }} — {{ item|portfolioField('client') }}
  </a>
{% endfor %}

{# Categories that have items, with counts, in one query #}
{% for filter in craft.portfolio.filters() %}
  <a href="{{ filter.category.url }}">{{ filter.category.title }} ({{ filter.count }})</a>
{% endfor %}

{# Ranked by how many categories and tags overlap #}
{% set more = craft.portfolio.related(entry, 3) %}

{# Pro: all of the above in one tag — filter bar, masonry, lightbox #}
{{ craft.portfolio.grid({ layout: 'masonry', columns: 4 }) }}

Features

The scaffolding a Craft developer would otherwise do by hand — plus the part a hand-built section can never have, which is a record of what was built.

Plan before you write

Every section, entry type, field and group is reported as create, reuse or conflict before anything is written — in the control panel, and with --dryRun on the console. Building a content model is not undoable in any sense an author would recognise.

Never edits what it didn't create

A handle already taken by the right type is reused exactly as it is, settings untouched. A handle taken by the wrong type is a conflict, and the build stops.

  • Safe to run twice — the second run creates nothing
  • A two-year-old field is never reshaped because its handle matched

Templates that survive a rename

The role map lives in project config, keyed by field UID. Rename a field, a section or a group in the CP and every template keeps rendering — and status tells you if a field was deleted outright.

A query API, not a widget

items, item, categories, tags, filters, related, next and prev — all in Lite. Criteria Portfolio doesn't know are passed straight to the entry query, so limit, search, with and orderBy work as usual.

  • A category and a tag together mean both, not either
  • A category that doesn't exist returns nothing, not everything
  • Structures keep the order the author dragged

Starter templates on disk

Working index, item, category and tag templates, written into your site when you ask — never on install, and never over a file you already have. They read by role, so they are yours the moment they land.

Tag archives that route

Craft tags have no URIs. Portfolio registers /portfolio/tag/<slug> straight to a template — no controller in the path, so the page caches like any other.

A filterable grid in one tag

Pro's craft.portfolio.grid(): grid, masonry or list, a category filter bar with counts, and a keyboard-accessible lightbox over each item's images.

  • Filters in the browser, so an archive stays one cacheable response
  • CSS and JS inlined once per request — no reliance on {{ head() }}

CreativeWork structured data

Pro emits schema.org CreativeWork from the item template: summary as description, categories as genre, tags as keywords, and the client as sourceOrganization — which is exactly what a client is.

Adopt the section you already built

Pro maps the fields of a hand-built portfolio onto roles. Nothing is created or edited; the same templates, grid and JSON-LD then work against a section that predates the plugin.

Frequently Asked Questions

The questions worth answering before you install it.

Start free, upgrade when you need to

Lite is free and builds one complete portfolio with the whole query API. Pro is $49 with a $39/year renewal, and adds unlimited portfolios, adopting a section, the grid and structured data.