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
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.zipA 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_order0 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:
- A service uses its own icon if it has one.
- 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.
- An id no sprite carries draws the fallback (
briefcase, behindksServicesFallbackIcon), 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.