WP Manifestindependent plugin directory
manifest / content / wp-virtual-post-type

Virtual Post Type

Serve virtual (non-database) content by injecting it dynamically into the WordPress query cycle.

by Dimitri Avenel · github.com/amund/wp-virtual-post-type

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/amund/wp-virtual-post-type/archive/refs/heads/main.zip

Serve virtual (non-database) content by injecting it dynamically into the WordPress query cycle.

Overview

VirtualPostType allows you to serve content from external APIs (real estate listings, product catalogs, etc.) without storing them in WordPress's database. The content appears as regular WordPress posts, respecting the full template hierarchy, pagination, and all native WordPress features.

Features

  • True CPT registration: Virtual posts use a real registered post type, enabling proper template hierarchy resolution
  • Clean query integration: Uses pre_get_posts and setup_postdata() instead of SQL hacks
  • Template hierarchy support: single-{post-type}.php works naturally
  • Single post support: Each virtual post resolves to a single entry via external ID
  • Object caching: Virtual posts are cached via WordPress's native object cache
  • 404 handling: Proper 404 detection when external data is unavailable
  • Extensible: Filters and actions for every stage of the lifecycle

Requirements

  • WordPress 6.4+
  • PHP 8.3+
  • Composer

Installation

Via Composer (Recommended)

composer require virtualposttype/wp-virtual-post-type

Then activate the plugin in WordPress.

Manual Installation

  1. Download the plugin and extract it to wp-content/plugins/wp-virtual-post-type/
  2. Run composer install --no-dev in the plugin directory
  3. Activate the plugin in WordPress

Usage

Register a virtual post type in your theme's functions.php or a custom plugin:

use VirtualPostType\VirtualPostType;

add_action('init', function () {
    $vpt = new VirtualPostType('ad', [
        'label' => 'Property Listings',

        // Rewrite rule pattern
        'rewrite_rule' => '^(acheter|louer)/[^/]+/[^/]+/(\d+)/?$',
        'match_group' => 2,  // The regex group containing the external ID

     // Callback to fetch a single post
        // Return an array or any object - il sera stocké dans virtual_data
        'single_callback' => function (string $id) {
            return $api->getAd($id);  // Returns Ad object or null for 404
        },

        // Callbacks pour extraire les champs du post
        'title_callback' => fn($data) => $data->getTitle(),
        'content_callback' => fn($data) => $data->getContent(),
        'excerpt_callback' => fn($data) => $data->getExcerpt(),

        // Callback pour archive URL (index.php?virtual_ad=archive)
        'archive_callback' => function () {
            return $api->getAds();
        },

        // Callback for archive URL (index.php?virtual_ad=archive)
        'archive_callback' => function (array $filters) {
            return $api->getAds($filters);  // Returns array or null for 404
        },

        // Map external data fields to post meta
        'meta_mapping' => [
            'ville' => 'data.ville.value',
            'prix' => 'data.prix.value',
            'surface' => 'data.surface_habitable.value',
        ],
    ]);

    $vpt->register();
});

URL Structure

With the above configuration:

  • Single post: /acheter/nantes-44300/beautiful-apartment/123456/
  • Archive: /acheter/

The rewrite rule captures 123456 as the external ID and passes it to single_callback.

Data Format

Le single_callback retourne n'importe quel type (array, objet, etc.). Il sera stocké dans $post->virtual_data.

'single_callback' => function (string $id) {
    $ad = new Ad($id);

    if (!$ad->exists()) {
        return null;  // Triggers 404
    }

    return $ad;  // L'objet Ad est stocké dans virtual_data
},

Les champs title, content, excerpt sont extraits via des callbacks :

'title_callback' => fn($data) => $data->getTitle(),
'content_callback' => fn($data) => $data->getContent(),
'excerpt_callback' => fn($data) => $data->getExcerpt(),

Si aucun callback n'est défini, les champs restent vides.

Dans ton template :

/** @var \WP_Post $post */
$ad = $post->virtual_data;  // Instance de Ad
echo $ad->getTitle();
echo $ad->getPrice();

Archive Callback

When you register an archive_callback, l'URL index.php?virtual_ad=archive le déclenche :

'archive_callback' => function () {
    return $api->getAds();
},

Auto-generated Rewrite Rules

If you don't provide a custom rewrite_rule, the plugin auto-generates one from the slug:

Slug: ad
Rule: /ad/([^/]+)/?$  → index.php?virtual_ad=$matches[1]

Advanced Options

Custom CPT Arguments

Pass any WordPress register_post_type() argument via cpt_args:

'cpt_args' => [
    'supports' => ['title', 'editor', 'thumbnail'],
    'hierarchical' => true,
    'menu_position' => 5,
],

Rewrite Match Group

When using a custom rewrite_rule with multiple capture groups, specify which group contains the external ID:

'rewrite_rule' => '^(acheter|louer)/[^/]+/(\d+)/?$',
'match_group' => 2,  // The second capture group

Creating Virtual Posts Programmatically

use VirtualPostType\VirtualPostType;

$vpt = new VirtualPostType('ad');

// Retourne directement un WP_Post
$post = $vpt->create_post(
    'ext-123',
    'Beautiful Apartment',
    'Full content...',
    'Short excerpt...',
    ['ville' => 'Nantes', 'prix' => 250000]
);

Template Hierarchy

Virtual posts follow WordPress's native template hierarchy:

single-ad.php
singular.php
index.php

You can override the template via the virtual_post_type_template filter:

add_filter('virtual_post_type_template', function ($template, $post_type, $external_id) {
    if ($post_type === 'ad' && $external_id === '123456') {
        return get_template_directory() . '/single-ad-premium.php';
    }
    return $template;
}, 10, 3);

Hooks

Actions

Hook Description
virtual_post_type_404 Fires when a virtual post returns 404
virtual_post_type_data Filter the raw data before creating the VirtualPost
virtual_post_type_post Filter the VirtualPost object before injection

Filters

Filter Description
virtual_post_type_template Override the template file for a virtual post
virtual_post_type_single_template_hierarchy Modify the template hierarchy for single virtual posts

404 Handling

When single_callback returns null, the plugin properly triggers a 404:

'single_callback' => function (string $id) {
    $data = $api->getAd($id);

    if (!$data) {
        return null;  // 404
    }

    return $data;
},

You can hook into 404 events:

add_action('virtual_post_type_404', function (string $type, string $id) {
    error_log("Virtual post 404: {$type} / {$id}");
});

Testing

# Install dependencies
composer install

# Run tests
php phpunit.phar

# Or with Composer (if installed)
./vendor/bin/phpunit

Project Structure

wp-virtual-post-type/
├── composer.json
├── wp-virtual-post-type.php          # Main plugin file
├── src/
│   ├── Plugin.php                    # Bootstrap
│   ├── VirtualPostType.php           # Per-type configuration & registration
│   └── Integration/
│       └── TemplateIntegration.php   # Template hierarchy integration
├── tests/
│   ├── bootstrap.php                 # Test bootstrap with WP mocks
│   ├── autoload.php                  # Simple PSR-4 autoloader
│   └── Unit/
│       ├── Integration/TemplateIntegrationTest.php
│       ├── PluginTest.php
│       └── VirtualPostTypeTest.php
├── phpunit.xml.dist
└── README.md