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.
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.
| 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.
{# 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.
Lite is, and it is not a trial: it builds the whole content model, writes the starter templates, and includes the entire query API and every console command, for one portfolio. Pro is $49 with a $39/year renewal, and adds unlimited portfolios, adopting an existing section, craft.portfolio.grid() and CreativeWork JSON-LD.
No. A field, section or group whose handle the blueprint wants is either reused exactly as it is — if it is the right type — or reported as a conflict that stops the build. Portfolio never edits the settings of anything it finds.
A structure or channel section, an entry type with a two-tab field layout, a category group with archive URIs, a tag group, and up to nine fields: summary, description (CKEditor if you have it), featured image, gallery, client, completed date, project URL, categories and tags. Untick any you don't want.
You can. What a hand-built section can't give you is a record of which field plays which role — and that is what lets templates survive renames, lets related() know what to compare, and lets the grid and JSON-LD work with no configuration.
No. On Pro, adopt it: point each role at one of your existing fields. Nothing is created or edited, and the query API, the grid and the structured data work against it from then on.
None. A portfolio lives in project config, referencing sections and fields by UID, so it deploys with the content model it describes. Uninstalling leaves your section, fields and entries exactly where they are.
So an archive page stays one cacheable response. Server-side filtering means a query string per filter and a cache entry per combination; a portfolio of a few hundred projects is cheap to send whole. Category archives still give every category a real URL.
Craft CMS 5.3+ and PHP 8.2+. No build step and no runtime dependencies; CKEditor is used for the description when it is installed, never required.
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.