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

KS Services

A services catalogue as a content type: the service post type, a hierarchical category, drag ordering, icons, and a list shortcode and block.

by Konstantin Sorokin · github.com/kostyasorokin/wp-ks-services · 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-services/archive/refs/heads/main.zip

A services catalogue as a content type. Registers the ks_service post type and the hierarchical ks_service_category taxonomy, and adds what a service page needs: an icon, a subtitle, a question-and-answer block and a price — plus drag ordering for both, category header images, a list shortcode and block, and JSON-LD.

Nothing here is written for one site. Slugs, entity names, listing size, order and menu icon are settings; the icon set, the templates and the schema output are filters. The same plugin runs a law firm's practice areas and a studio's price list.

Contents: Requirements · Install · Settings · Constants · Filters · Shortcode and block · Theme API · Templates · Icons · Development

Requirements

PHP 8.5, WordPress 6.7.

Declared in the plugin header, so WordPress blocks installing and activating it below 8.5. The header is checked at those two moments only, so the bootstrap compares the running version itself before it loads anything: a server downgraded under an already-active plugin gets an admin notice and a plugin that stays out of the way, instead of a parse error on the 8.5-only syntax in src/. Composer's platform_check.php is not used for this — it prints its complaint and a 500 header before it throws, which no catch can take back — and is switched off with "platform-check": false.

Install

cd wp-content/plugins
git clone https://github.com/kostyasorokin/wp-ks-services.git ks-services

Then activate. vendor/ is committed — it holds Composer's autoloader and no packages — so a clone runs as it stands, and git pull is the update. Nothing has to be configured before it works; activation registers the post type and the taxonomy by hand (init does not fire during activation), numbers any categories and services that have no position yet, and flushes the rewrite rules.

A site that already has services under other names adopts the plugin through the constants below, without moving a row.

The settings screen

Services → Settings. Four sections, thirteen fields, all of them stored as plain options under their own names.

Section Option Default What it does
Addresses ksServicesServiceSlug services the address of one service: /services/example/
Addresses ksServicesArchiveSlug services the page listing every service; may be the same string as above
Addresses ksServicesCategorySlug service-category the address of one category archive
Addresses ksServicesCategoryInUrl 0 put the first category inside a service's URL
Naming ksServicesSingularName (empty) what one of them is called
Naming ksServicesPluralName (empty) what several are called; this is the admin menu label
Naming ksServicesCategorySingularName (empty) what one grouping is called
Naming ksServicesCategoryPluralName (empty) what several groupings are called
Archive ksServicesArchiveTitle (empty) the heading over the list of everything
Archive ksServicesArchiveSubtitle (empty) one line under that heading
Archive ksServicesPerPage 12 how many services a listing shows before it paginates
Archive ksServicesFrontOrder 1 listings follow the drag order; off means newest first
Admin ksServicesMenuIcon dashicons-portfolio a dashicons class, or a data: URI

The naming defaults are empty on purpose: an empty option means "use the translated word", so a site that never opens this screen follows the catalogue of whatever language it runs in.

Two things worth knowing before touching the Addresses section. Changing any slug changes public URLs — addresses already indexed will 404 until they are redirected — and the rules are rewritten on the next request, not on save. And with ksServicesCategoryInUrl on, a service's address contains its first category, so moving a service to another category changes its URL. "First" means lowest stored position, term id breaking the tie; the same rule decides which category's icon a service inherits.

A slug that does not clean to anything usable is refused and the previous value kept, with a settings error on the screen.

Configuration in wp-config.php

Everything worth changing day to day is on the settings screen. What is not a setting is the set of strings every stored row is filed against — the post type, the taxonomy and the meta keys. Editing one of those on a site that already has content leaves the existing rows filed under a name nobody registers: the services vanish from the admin and their permalinks fall back to ?p=123.

They are still changeable, from wp-config.php or an mu-plugin, before the first service exists and never after:

Constant Default Holds
KS_SERVICES_POST_TYPE ks_service the post type name
KS_SERVICES_TAXONOMY ks_service_category the taxonomy name
KS_SERVICES_META_ICON serviceIcon post + term meta — sprite symbol id
KS_SERVICES_META_SUBTITLE serviceSubtitle post meta — the card's second line
KS_SERVICES_META_ORDER serviceOrder term meta — the dragged position
KS_SERVICES_META_HERO_IMAGE serviceHeroImage term meta — attachment id
KS_SERVICES_META_FAQ _serviceFaq post meta — [{question, answer}, …]
KS_SERVICES_META_PRICE servicePrice post meta — the price as shown
KS_SERVICES_META_PRICE_AMOUNT _servicePriceAmount post meta — the number, for Offer
KS_SERVICES_META_PRICE_CURRENCY _servicePriceCurrency post meta — ISO currency code

"Before the first service exists" is the whole warning. A constant defined after content is saved does not move anything: the rows keep the old key, the plugin reads the new one, and the site looks as though the data was deleted. If that has already happened, put the constant back to the value the rows use — nothing was lost.

The meta constants exist for one reason worth stating: they are how a site whose services already live under other keys adopts the plugin without migrating a row. Point the constants at what is already in the database and the plugin reads it.

define( 'KS_SERVICES_POST_TYPE', 'kp_service' );
define( 'KS_SERVICES_META_ICON', 'kp_icon' );
define( 'KS_SERVICES_META_ORDER', 'kp_order' );

That is one of the two ways in. The other renames the rows to the plugin's own keys and leaves wp-config.php clean, which is worth the work when the legacy names are nobody's convention and the site would otherwise depend forever on a block of defines that no deploy tracks. Renaming has been done once and taught three things worth knowing before anyone tries it again:

  • Yoast writes its defaults the moment a post type is registered. Merge its option keys with "whatever is already under the new name wins" and the SEO templates somebody actually wrote are dropped in favour of empty defaults. The migrating value has to win.
  • A generated image size is keyed by its name. Crops made under an old size name stay filed under it in _wp_attachment_metadata, so unless that key is re-pointed, WordPress finds nothing at the new name and quietly serves the full-size original.
  • Rows sharing menu_order 0 have no order at all. Two pages can list them differently, and any renumbering silently picks a winner — freeze the sequence the site was actually rendering rather than whatever a fresh sort produces.

An enum's backing value is a compile-time literal and cannot be made to depend on configuration, which is why the door is a constant and the lookup lives in MetaKey::key() — read that, never ->value.

Five more constants are defined by the bootstrap and are not settings: KS_SERVICES_VERSION, KS_SERVICES_MIN_PHP, KS_SERVICES_PATH, KS_SERVICES_URL and KS_SERVICES_FILE. A theme may read them; overriding them is not supported.

The minimum PHP version is in the plugin header's Requires PHP, which is what WordPress enforces at install and activation, and in KS_SERVICES_MIN_PHP, which is what the bootstrap compares the running version against. The Composer platform pin in composer.json is the third. Raise one, raise all three.

Filters

Sixteen of them, all prefixed ksServices and all named in camelCase, which is this plugin's house style rather than WordPress's. Nothing here is a hook a theme has to answer; each one exists because a real site needed the seam.

Filter Fired in Receives Changes
ksServicesPostTypeArgs PostType::register() array $args everything passed to register_post_type() — capabilities, supports, menu_position, REST
ksServicesTaxonomyArgs Taxonomy::register() array $args everything passed to register_taxonomy()
ksServicesUncategorizedSlug PostType::categorySlug() string $slug ('general') the segment standing in for the category of a service filed under none. Only reached with the category-in-URL setting on
ksServicesPerPage Settings::$perPage int $stored the listing page size. Clamped to at least 1 afterwards, so returning 0 does not mean "no limit"
ksServicesPostOrderEnabled PostOrder::enabled() bool $enabled (true) whether the plugin makes the services list table draggable
ksServicesFallbackTemplate TemplateLoader::resolve() string $path, string $themeFile the absolute path of the template the plugin is about to serve. An unreadable path means "leave core's answer alone"
ksServicesSprites IconLibrary::sprites() string[] $paths the sprite files to read, in order. Later wins on a repeated symbol id; unreadable paths are dropped silently
ksServicesIconHtml IconLibrary::render() string $html, string $id, string $class one rendered <svg>, after it is built. Whatever is returned still passes wp_kses() at every call site inside the plugin
ksServicesFallbackIcon IconLibrary::fallbackId() string $id ('briefcase') the symbol drawn when the chosen id is in no sprite. Return '' to draw nothing
ksServicesIconSize IconLibrary::size() int $size (48) the px width and height written into every icon whose caller named no size. A value below 1 is ignored
ksServicesHeroSize TermHeroImage::registerSize() array{int,int,bool} $size ([1920, 640, true]) the crop registered as ksServicesHero. Read on after_setup_theme — see the warning below
ksServicesSchemaEnabled Schema::print() bool $enabled, int $postId whether any JSON-LD is printed for this service at all
ksServicesFaqSchema Schema::print() bool $enabled, int $postId whether the FAQPage graph is printed
ksServicesOfferSchema Schema::serviceGraph() bool $enabled, int $postId whether the Offer is added inside the Service graph
ksServicesDefaultCurrency Schema::serviceGraph(), Services::price() string $code ('UAH') the currency a service that named none is priced in. Both readers use it, so they cannot disagree
ksServicesSchemaData Schema::printGraph() array $data, int $postId, string $kind one finished graph before it is encoded. $kind is 'faq' or 'service'. Return [] to print nothing

ksServicesHeroSize is the one with a timing catch: add_image_size() decides how uploads are cropped, so a size changed after images exist applies only to what is uploaded next. Changing it on a live site means regenerating thumbnails.

The four worth a snippet

Every icon on the site at another size, for a theme whose cards were drawn to a different grid:

add_filter( 'ksServicesIconSize', static fn(): int => 54 );

A site's own drawings. Later sprites win on a repeated id, so this both adds symbols and replaces shipped ones one at a time:

add_filter(
    'ksServicesSprites',
    static function ( array $paths ): array {
        $paths[] = get_stylesheet_directory() . '/assets/icons/services.svg';

        return $paths;
    }
);

Turning off the half of the JSON-LD an SEO plugin already prints. Two FAQPage graphs on one URL are worse than none:

add_filter( 'ksServicesFaqSchema', '__return_false' );

Adding a property the plugin does not know about, without rebuilding the graph:

add_filter(
    'ksServicesSchemaData',
    static function ( array $data, int $postId, string $kind ): array {
        if ( 'service' === $kind ) {
            $data['areaServed'] = [ '@type' => 'City', 'name' => 'Kharkiv' ];
        }

        return $data;
    },
    10,
    3
);

The shortcode and the block

[ks_services] and the Services block (ks-services/services) are two doors into one renderer, so an attribute means the same thing in both. Both draw the same card partial the archive draws — restyle the card once and both follow.

Attribute Type Default Meaning
category string (empty) a category slug; empty lists every category. Ignored when show="categories"
limit number 0 how many, 0 for all of them
columns number 3 grid columns, clamped to 1–4 — past four a card has no room for a title
show string services services lists the services; categories lists the categories instead
[ks_services limit="6" columns="3"]
[ks_services category="family-law" limit="4"]
[ks_services show="categories" columns="4"]

The block carries the same four attributes in blocks/services/block.json and supports wide and full alignment. Rendered when the page is served, so it always lists what is published now.

Two behaviours worth expecting. A list with nothing in it returns an empty string rather than an empty grid — a page that asked for services it has none of should read as though it never asked. And show="categories" uses the same canonical, drag-ordered category list the theme API gives out, with empty categories left out, because a category with nothing filed under it leads to a dead end.

The plugin's front stylesheet (handle ks-services-front) is enqueued only on pages that actually render a list.

The theme API

One class, every method static: KonstantinSorokin\Services\Helpers\Services. Guard the call — deactivating a plugin is an ordinary thing to do, and without the guard doing it fatals every page that calls in, including wp-admin, which is where the plugin would have been switched back on.

use KonstantinSorokin\Services\Helpers\Services;
use KonstantinSorokin\Services\Icons\IconLibrary;

if ( class_exists( Services::class ) ) {
    foreach ( Services::categories() as $category ) {
        printf(
            '<a class="tile" href="%s">%s<span>%s</span></a>',
            esc_url( (string) get_term_link( $category ) ),
            wp_kses( Services::icon( $category ), IconLibrary::allowedTags() ),
            esc_html( $category->name )
        );
    }
}
Method Returns Notes
settings() Settings every setting as a typed property; a live view of the options table, not a snapshot
setting( string $name ) string one option by name, or its default
categories( bool $hideEmpty = true ) WP_Term[] the canonical list, in the dragged order. Sorted in PHP: a category with no stored position sorts last instead of vanishing from the result
services( ?int $categoryId = null, int $limit = 0 ) WP_Post[] in the dragged order, title breaking the tie
groupedByCategory() array<int, WP_Post[]> every service under every category it is filed in, keyed by term id, in one query. A service filed under nothing is left out
icon( int\|WP_Term $subject, string $class, ?int $size ) string the <svg>. A service with no icon of its own inherits its category's
heroImageUrl( null\|int\|WP_Term $subject = null, string $size = 'ksServicesHero' ) string term meta for a category, the featured image for a service, '' for anything else and for "no image"
subtitle( int $postId ) string the one line written to describe a service in a list
faq( int $postId ) array{question: string, answer: string}[] re-sanitised on the way out, so a row written by an import still arrives in the documented shape
price( int $postId ) array{display, amount, currency} display is the sentence, amount the bare number, currency only when there is a number to qualify
card( int $postId ) array{title, url, subtitle, excerpt, icon} everything a card needs, for a theme drawing its own markup
archiveUrl() string '' when the post type was registered without an archive

Icons are markup, not text. Anything from Services::icon() or IconLibrary::render() that is echoed on a page should go through wp_kses( $svg, IconLibrary::allowedTags() ) — that list exists so every caller uses the same one.

Templates

Plain defaults ship for three pages, so a fresh site renders as a catalogue rather than a blog roll:

Page Plugin ships A theme wins with
the archive templates/archive-service.php archive-ks_service.php
one service templates/single-service.php single-ks_service.php
a category templates/taxonomy-service-category.php taxonomy-ks_service_category.php

A theme takes any of them over by simply having the file — no registration, no filter, no add_theme_support(). The theme-side names follow the actual post type and taxonomy, so a site that renamed them by constant overrides the file WordPress itself would have looked for.

Two answers the plugin never overrides. Anything more specific than archive.php / single.php / taxonomy.php / index.php — a theme's single-ks_service-consultation.php, a template chosen in Page Attributes — is somebody's explicit answer for that page and is left alone. And a block theme's, which is wp-includes/template-canvas.php: substituting a classic template there leaves get_header() with no header.php to find, and the page is served as a fragment with no <head> at all.

Listing parts live one folder down so they do not clutter the theme root:

Part A theme overrides it at
templates/parts/service-card.php ks-services/service-card.php

The card takes $args['post_id'] and reads nothing else — deliberately, because the shortcode renders cards from a plain get_posts() result with no setup_postdata(), and a card that relied on the current post would come out blank there. Render one from a theme with TemplateLoader::partial( 'service-card.php', [ 'post_id' => $id ] ).

ksServicesFallbackTemplate is the seam for a plugin that wants to serve a fourth file from somewhere else entirely; a theme has no reason to reach for it.

Icons

A service or a category carries a symbol id in meta. The drawing behind that id comes from an SVG sprite, parsed once per request and spliced inline — no second request, no <use> resolving against the wrong origin, and the shapes stay reachable from the page's own CSS.

The shipped sprite is assets/icons/sprite.svg, with sixteen symbols: briefcase, scales, shield, document, gear, chat, chart, clock, target, star, home, heart, wallet, handshake, globe, wrench.

Three rules decide what is drawn:

  1. A service uses its own icon if it has one.
  2. Otherwise it inherits its first category's — lowest stored position, term id breaking the tie, the same rule the URL uses. Filling in a category therefore dresses every service under it at once.
  3. An id no sprite carries draws the fallback (briefcase, behind ksServicesFallbackIcon), because a card with a hole where its neighbours have an icon reads as broken rather than as plain.

A site adds its own set by filtering ksServicesSprites with the absolute path of another sprite — an ordinary <svg><symbol id="…"><title>…</title>… file. Two things follow from how it is read: a symbol's <title> becomes its label in the picker, and a later sprite replaces a symbol of the same id, so shipped drawings can be overridden one at a time. A sprite that does not parse yields nothing and takes no page down. Symbol ids are matched case-sensitively — camelCase is fine.

The picker keeps a chosen id that has gone missing, marked "not found", so saving a category for an unrelated reason cannot silently drop the choice: the sprite carrying it may be one deactivated plugin away from coming back.

Development

The plugin's own composer.json requires PHP and nothing else. The tooling lives in tools/composer.json and installs into tools/vendor, which is ignored, so PHPUnit and PHPStan can never reach the autoloader that ships.

composer tools     # install PHPStan, PHPUnit, PHPCS into tools/vendor
composer check     # coding standards, static analysis, tests
composer dump      # rebuild the classmap after adding a class

composer dump matters more than usual here: vendor/ is committed, so a class added without re-dumping ships an autoloader that cannot find it. CI checks for exactly that, by dumping again and failing on a diff.

The repository is the development tree, tooling and all; the distributable is built from it with wp dist-archive ., which leaves out every path listed in .distignore.

Translation

Source strings are English; the plugin loads its own catalogues from languages/ on init, because WordPress finds translations by itself only for plugins it installed from wordpress.org.

npm run wp:i18n     # regenerate the POT
npm run gettext     # compile the catalogues, and make-json for the editor script

Icon names are the exception: a sprite names its symbols in <title>, which is markup inside an .svg file that wp i18n make-pot never reads. The sixteen shipped names are written out in IconLibrary::shippedLabel() so a catalogue can reach them.

Hooks are declared where they are answered

#[Hook( 'init', 20 )]
public function register(): void { … }

HookBinder reads those attributes off the objects listed in Plugin::boot() and calls add_filter(). One file says what the plugin consists of, and a renamed method cannot leave a registration pointing at nothing.

Licence

GPL-3.0-or-later.