KS Projects
A portfolio archive as a content type: the project post type, a taxonomy to file work under, and the two metaboxes a case study needs.
by Konstantin Sorokin · github.com/kostyasorokin/wp-ks-projects · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/kostyasorokin/wp-ks-projects/archive/refs/heads/main.zipA portfolio archive as a content type. Registers the project post type and the
projects taxonomy, and adds the two metaboxes a case study needs: the card
shown in a grid, and the credits that say who did what.
Presentation belongs to the theme. The plugin ships a plain default for each of the three project screens so a fresh site works out of the box, and your theme takes any of them over by having a file of the same name — no setting, no deregistering. That split is what lets the archive survive a redesign.
Install
Drop the folder in wp-content/plugins/ and activate. Nothing else is needed;
activation writes the rewrite rules for you.
What it gives you
A post type, project. Hierarchical, REST-enabled, with page-attributes,
so a project can be nested and can pick a page template. /project/example/ and
/projects/ out of the box, both slugs editable.
A taxonomy, projects. Flat, because what a portfolio files work under is a
mix of platform, client and kind of work — "WordPress", "Laravel", "Logotypes" —
and those do not nest without inventing parents nobody would agree on.
A card metabox owning three meta keys, all visible in the custom fields panel and over REST:
| Key | Use |
|---|---|
projectTitle |
The short name, for a grid where the full post title is a sentence |
projectSubtitle |
One line under it |
projectDomain |
The address the work went live at |
A credits metabox, "About the project", owning three more. These carry a leading underscore, so they are protected and stay out of the custom fields panel:
| Key | Shape |
|---|---|
_projectTask |
string — what the client asked for |
_projectRoles |
array of [ 'role' => string, 'name' => string ] |
_projectWorkDone |
array of strings |
All three are opted into post revisions, which meta is not by default. That
matters here: while this block lived in post_content, rolling a revision back
brought it with everything else, and moving it to fields would have quietly
ended that.
Templates
Three defaults ship with the plugin, and your theme beats every one of them by simply having a file of the same name:
| File | Renders |
|---|---|
archive-project.php |
/projects/ |
single-project.php |
/project/example/ |
taxonomy-projects.php |
/projects/wordpress/ |
Put any of those in your theme and the plugin's copy stops being loaded. There
is nothing to switch off and nothing to deregister — the check is
locate_template(), so it is the same rule WordPress uses for everything else.
The defaults exist so a fresh site renders as a portfolio rather than as a blog roll, and their markup is deliberately plain: semantic elements and the classes WordPress itself emits, with nothing from any CSS framework. A default that carried a design would look wrong on every theme except the one it came from. They are a floor, not a suggestion — expect to replace them.
The archive templates run the main loop rather than a WP_Query of their
own, so they honour whatever the site set on pre_get_posts and paginate
against the same result set the pagination links were built from.
A template per project
A case study sometimes wants its own layout. Put a PHP file in a projects/
folder in your theme — named after the archive slug, so it follows if you
rename the section — and it appears in a Template box on the project's
editing screen:
wp-content/themes/your-child-theme/projects/kerimovpartners.com.php
The file renders in place of the post content and nothing else. The heading,
the tags, the task and the credits block are still your single-project.php, so
a template is a body rather than a page: no get_header(), no duplicated
chrome, and a change to the frame reaches every project at once.
<?php
/**
* Template Name: Kerimov & Partners
*/
defined( 'ABSPATH' ) || exit;
?>
<div class="container">
<h2><?php the_title(); ?></h2>
</div>
Template Name: is used as the label when a file declares one; without it the
filename is the label, so a folder of files named after their clients works with
no headers at all. The child theme is searched before the parent.
The folder is made for you when the plugin is activated and again when you
switch theme — the two moments creating a directory is a deliberate act rather
than a side effect of loading a page. Projects → Settings shows the path it
is looking at, lists what it found, and offers a one-click Create the folder
when it is missing. On a host where the theme directory is not writable, or with
DISALLOW_FILE_MODS set, nothing is written and the screen says so instead.
The chooser is on the project's editing screen and in Quick Edit, and the projects list gains a Template column, so which projects have their own layout is answerable at a glance.
Nothing is written to the post content and nothing is deleted: the editor keeps whatever is in it, and turning the template off brings it straight back. If the file goes missing — renamed, deleted, or a deploy that did not carry it — the page falls back to the stored content rather than rendering an empty body, and the chooser keeps showing the missing name so the choice is not silently lost.
Themes that would rather call it themselves can: Projects::template() returns
the rendered body, or '' when there is no template.
Settings
Projects → Settings. Three URL slugs and three values a theme may read.
| Setting | Default | |
|---|---|---|
| Project slug | project |
/project/example/ |
| Archive slug | projects |
/projects/ |
| Tag slug | projects |
/projects/wordpress/ |
| Archive title | Projects |
for a theme to print |
| Archive subtitle | — | for a theme to print |
| Featured project IDs | — | read back through Projects::homeIds() |
A slug may contain a slash, so work/case is a valid project slug. Saving one
rewrites the rules on the next request; a value that is not a usable slug is
refused and the previous one kept, with a notice above the form.
The archive slug and the tag slug are allowed to be the same string. /projects/
then lists everything and /projects/wordpress/ narrows it, which reads
correctly — WordPress resolves the overlap by rule order.
What is deliberately not a setting
The post type and taxonomy names. Those are the strings every row in
posts.post_type and term_taxonomy.taxonomy is stored against; editing one on
a site that already has content leaves every existing project filed under a type
nobody registers any more. They vanish from the admin list and get_permalink()
falls back to ?p=123 for each. It is a one-click way to lose an archive, so
the screen does not offer it.
Both can still be changed — from wp-config.php or an mu-plugin, before the
first project exists and never after:
define( 'KS_PROJECTS_POST_TYPE', 'case' );
define( 'KS_PROJECTS_TAXONOMY', 'case-tags' );
For themes
One class, one import, no global functions:
use KonstantinSorokin\Projects\Helpers\Projects;
echo Projects::tags();
| Method | |
|---|---|
Projects::tags() |
string — the term list as a nav |
Projects::menu() |
echoes the projects nav menu location |
Projects::pagination() |
echoes; uses the theme's own helper if it has one |
Projects::editEntry() |
echoes; same |
Projects::entryDate() |
string — <time datetime=…> |
Projects::card() |
array — title, subtitle, domain |
Projects::task() |
string — what the client asked for |
Projects::credits() |
string — roles and work done, plain markup |
Projects::template() |
string — the project's own template, rendered |
Projects::homeIds() |
int[] — the featured list |
Projects::setting() |
string — any setting above |
Projects::archivePerPage() |
int, filterable — 400 |
Projects::pagePerPage() |
int, filterable — 8 |
Projects::homePerPage() |
int, filterable — 16 |
Every method is static and nothing holds state, so there is nothing to construct. A theme calling any of them has taken a dependency on the plugin — which is the intended bargain, since without it there is no post type for a portfolio template to render.
A theme meant to work with the plugin absent can say so:
if ( class_exists( Projects::class ) ) {
echo Projects::tags();
}
Layout
ks-projects.php bootstrap: constants, the two guards, Plugin::boot()
src/
Plugin.php what the plugin consists of, in one list
PostType.php register_post_type()
Taxonomy.php register_taxonomy()
Attribute/Hook.php #[Hook( 'init', 20 )]
Support/HookBinder.php attributes → add_filter()
Helpers/Projects.php the API themes call
Meta/MetaKey.php the six meta keys as an enum
Metabox/
Metabox.php abstract: the nonce and the four save guards, once
CardMetabox.php
CreditsMetabox.php
Settings/
Slug.php a URL slug that cannot exist invalid
Settings.php every setting as a typed property
Field.php one row of the settings screen
SettingsScreen.php
Rewrite.php the deferred rewrite flush
assets/ metabox CSS and JS
languages/ POT, plus ru_RU and uk
Hooks are declared where they are answered
#[Hook( 'init', 20 )]
public function registerMeta(): void { … }
HookBinder reads those attributes off the services listed in Plugin::boot()
and calls add_filter(). There is no directory scan and no compiled cache: one
file says what exists, and nothing can fall out of step with the source.
Measured on PHP 8.5, binding twenty hooks across ten classes costs 0.006 ms —
about a hundredth of a single database query, which is why there is nothing here
worth caching.
PHP 8.5
The plugin uses the language rather than working around it: the pipe operator in
Slug::clean(), asymmetric visibility (public private(set)) so a Slug is
readable everywhere and writable only by its own constructor, property hooks on
Settings, #[\NoDiscard] on the methods whose return value is the answer,
and a backed enum for the meta keys.
It also refuses to run below 8.5 rather than crashing. Requires PHP in the
plugin header is checked at install and activation and never again, so a server
rolled back to 8.4 would otherwise hit Composer's platform_check.php, which
throws from inside the autoloader and takes the whole site down — wp-admin
included. The bootstrap checks the version first, in syntax old enough to parse
on the versions it rejects, and leaves a sentence in wp-admin instead.
Composer
require is php: >=8.5 and nothing else, and vendor/ is committed — it holds
Composer's autoloader and no packages — so the repository runs the moment it is
cloned.
Every plugin's vendor/ declares Composer\Autoload\ClassLoader and
Composer\InstalledVersions under those exact names, and PHP hands them to
whichever plugin loaded first. That is the usual way two WordPress plugins break
each other, and the reason is always a shared package at different versions.
This one ships none, so there is nothing to disagree about — and the bootstrap
still checks that its classes actually resolved, so the worst case is a notice
rather than a blank page.
The development tooling lives in tools/composer.json and installs into
tools/vendor, which is ignored. PHPUnit and PHPStan therefore cannot reach the
autoloader that ships.
composer tools # install PHPStan, PHPUnit, PHPCS
composer check # coding standards, static analysis, tests
composer dump # rebuild the classmap after adding a class
Translation
Source strings are English. Russian and Ukrainian ship with the plugin.
npm run wp:i18n # regenerate the POT
npm run gettext # compile both catalogues
Requirements
WordPress 6.7, PHP 8.5.
Licence
GPL-3.0-or-later.