WordPress themes bundle presentation and logic together in the same file, which makes them approachable for beginners but difficult to test and maintain at scale.

Ghost's Handlebars templates enforce a strict data-presentation separation at the cost of expressiveness — there is no straightforward way to perform conditional logic in a Handlebars template without a helper. JekCMS chose a middle path: templates are PHP files, which allows full expressiveness, but business logic belongs in registered hook functions rather than in the template itself.

The functions.php Contract

The functions.php file in the theme root is loaded early in the request cycle, before any template rendering begins. It has access to the complete hook API: add_action, add_filter, remove_action, and remove_filter. These function signatures are intentionally identical to WordPress's hook system — a deliberate design decision to reduce the learning curve for the large number of developers who already know WordPress.

Partials With Scoped Data

Partials live in templates/partials/ and are loaded with the get_partial('name') helper. Unlike WordPress's get_template_part(), JekCMS's implementation accepts a second argument for passing data directly into the partial scope without requiring global variables. This makes partials easier to test in isolation.

Arrays Over Objects

The query layer exposes a set of functions — get_posts(), get_post(), get_categories() — that return plain PHP arrays rather than objects. Plain arrays are easier to serialize, pass between functions, and cache. The trade-off is that array access syntax ($post['title']) is slightly more verbose than property access ($post->title), but the predictability benefit justifies this.

Hook Registration Example

A typical functions.php registers between 5 and 15 hooks depending on theme complexity. The registration order does not matter because hooks are fired by name at specific execution points, but grouping related hooks improves readability.

// functions.php
add_filter('the_content', 'theme_add_toc', 10);
add_filter('the_content', 'theme_lazy_images', 20);
add_action('before_header', 'theme_schema_markup');
add_action('after_footer', 'theme_analytics_scripts');

function theme_add_toc($content) {
 if (substr_count($content, '<h2') < 3) return $content;
 return $toc . $content;
}

The priority parameter (10, 20) controls execution order when multiple functions attach to the same hook. Lower numbers fire first. This matters when one filter depends on the output of another — lazy image injection must run after the TOC generator so anchor links are not wrapped in lazy-load containers.

The Template Hierarchy

JekCMS resolves templates using a fallback chain that balances specificity with simplicity. When a request matches a route, the engine looks for the most specific template first and falls back to more general ones.

  • single-{slug}.php — matches a specific post by slug
  • single-{category}.php — matches posts in a specific category
  • single.php — default for all single post views
  • index.php — ultimate fallback for every route

Category archives follow the same pattern: category-{slug}.php falls back to category.php, then to archive.php, then to index.php. A production theme typically defines 6-8 template files. The built-in Trends theme uses 7 templates and 12 partials.

Directory Layout in Practice

A complete JekCMS theme directory contains between 15 and 30 files depending on complexity. The enforced directory structure ensures that every theme follows the same conventions, which simplifies onboarding and code review.

themes/my-theme/
+-- functions.php # Hook registration (loaded first)
+-- style.css # Theme metadata + main styles
+-- index.php # Ultimate fallback template
+-- single.php # Single post view
+-- page.php # Static page view
+-- category.php # Category archive
+-- search.php # Search results
+-- 404.php # Not found page
+-- templates/partials/
 +-- header.php, footer.php, post-card.php, sidebar.php
+-- assets/
 +-- css/, js/, fonts/, images/

Why Not Blade or Twig?

We evaluated Blade (Laravel) and Twig (Symfony) as potential template engines. Both add a compilation step that produces cached PHP files, which introduces cache invalidation concerns on shared hosting. Plain PHP templates compile to themselves — there is no intermediate step that can break. The measurable performance difference is negligible: plain PHP averaged 2.1ms per render while Twig averaged 2.4ms after cache warm-up.

Practical Tips for Theme Developers

  • Keep functions.php under 200 lines — split large hooks into separate files in an includes/ directory
  • Name partials after their visual role (post-card.php, author-bio.php) rather than their data source
  • Use theme_supports() to declare capabilities like dark mode, sidebar, or breadcrumbs
  • Always pass explicit data to partials instead of relying on parent scope variables
  • Test with JEKCMS_DEBUG=true in .env to catch undefined variable notices early

Asset Pipeline: CSS and JavaScript

Theme assets live in assets/css/ and assets/js/ within the theme directory. In development mode, files are served directly with no caching. In production, JekCMS appends a version hash query parameter (?v=a3f8c1) generated from the file's last modification time.

This allows aggressive browser caching with 1-year expiry headers while ensuring visitors always receive updated files after a deployment. The hash is computed once per request and cached in memory, adding zero overhead to page rendering. Unlike build-tool approaches that concatenate all CSS into one file, JekCMS keeps stylesheets separate so that category-specific or template-specific CSS loads only on the pages that need it, reducing unused CSS on any given page by 40-60%.