Virtual Post Type
Serve virtual (non-database) content by injecting it dynamically into the WordPress query cycle.
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.zipServe 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_postsandsetup_postdata()instead of SQL hacks - Template hierarchy support:
single-{post-type}.phpworks 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
- Download the plugin and extract it to
wp-content/plugins/wp-virtual-post-type/ - Run
composer install --no-devin the plugin directory - 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