WP Manifestindependent plugin directory
manifest / content / wp-ks-projects

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

0stars
0forks

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.zip

A 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.