Creating Custom Templates for Super Speedy Imports
This guide explains how to create custom import templates for Super Speedy Imports. Templates define how CSV data maps to WordPress content types, what fields are available, and how the import process behaves.
Table of Contents
- Table of Contents
- Template Architecture Overview
- File Locations and Asset Discovery
- Registering External Templates
- Creating a PHP Template Class
- Defining Field Mappings
- Extra Batch Columns and Staging Values
- The mapData Pattern — Per-Row Transforms
- Creating Custom UI Sections
- JavaScript Extension Points
- Additional Options
- Custom Import Stages
- Custom Image-Handling Stages
- Integrating with Related Post Types
- External API Integrations
- Performance Considerations
- Idempotency Contracts
- SQL Identifier Hardening
- Hooks and Filters Reference
- Complete Example
- Tips and Best Practices
Table of Contents
- Template Architecture Overview
- File Locations and Asset Discovery
- Registering External Templates
- Creating a PHP Template Class
- Defining Field Mappings
- Extra Batch Columns and Staging Values
- The mapData Pattern — Per-Row Transforms
- Creating Custom UI Sections
- JavaScript Extension Points
- Additional Options
- Custom Import Stages
- Custom Image-Handling Stages
- Integrating with Related Post Types
- External API Integrations
- Performance Considerations
- Idempotency Contracts
- SQL Identifier Hardening
- Hooks and Filters Reference
- Complete Example
Template Architecture Overview
Super Speedy Imports uses a class-based template system with the following hierarchy:
SSI_ImportTemplate (abstract base class)
├── SSI_PostTemplate (standard WordPress posts)
│ ├── SSI_WooCommerceProductTemplate (WooCommerce products)
│ └── SSI_CustomPostTypeTemplate (any custom post type)
│ └── YourCustomTemplate (your external template)
Each template consists of:
- PHP Class – Handles field definitions, data processing, and import stages
- JavaScript File – Optional UI customizations for the admin interface (auto-discovered)
- CSS File – Optional styling for template-specific admin elements (auto-discovered)
File Locations and Asset Discovery
Built-in Templates (SSI Plugin)
Built-in templates are located within the Super Speedy Imports plugin:
super-speedy-imports/
├── templates/
│ ├── SSI_PostTemplate.php
│ ├── SSI_WooCommerceProductTemplate.php
│ └── SSI_CustomPostTypeTemplate.php
├── assets/
│ └── js/
│ ├── woocommerce-template.js
│ └── custom-post-type-template.js
External Templates (Your Plugin or Theme)
External templates can be located anywhere – in your own plugin or theme. The system automatically discovers JS and CSS files based on your template’s PHP file location.
your-plugin/
├── your-plugin.php
└── ssi-templates/
├── HouzezTemplate.php
├── HouzezTemplate.js # Auto-discovered (same folder)
└── HouzezTemplate.css # Auto-discovered (same folder)
Or with subfolders:
your-theme/
├── functions.php
└── ssi-templates/
├── RealEstateTemplate.php
├── js/
│ └── RealEstateTemplate.js # Auto-discovered (js/ subfolder)
└── css/
└── RealEstateTemplate.css # Auto-discovered (css/ subfolder)
Asset Auto-Discovery Rules
When a template is loaded, the system automatically searches for matching JS and CSS files:
- Same folder as PHP file:
{ClassName}.js,{ClassName}.css - js/ subfolder:
js/{ClassName}.js - css/ subfolder:
css/{ClassName}.css
The class name must match the PHP filename (without extension). For example:
– HouzezTemplate.php → looks for HouzezTemplate.js and HouzezTemplate.css
Registering External Templates
External templates must be registered using the ssi_register_templates action hook. This hook fires after all SSI core files are loaded.
Basic Registration (Plugin)
<?php
// In your plugin's main file or a dedicated file
add_action('ssi_register_templates', 'register_my_custom_templates');
function register_my_custom_templates() {
// Include your template class file
require_once plugin_dir_path(__FILE__) . 'ssi-templates/HouzezTemplate.php';
// Instantiate the template to register it
new HouzezTemplate();
}
Basic Registration (Theme)
<?php
// In your theme's functions.php
add_action('ssi_register_templates', 'register_theme_import_templates');
function register_theme_import_templates() {
require_once get_template_directory() . '/ssi-templates/RealEstateTemplate.php';
new RealEstateTemplate();
}
Multiple Templates
add_action('ssi_register_templates', function() {
$templates_dir = plugin_dir_path(__FILE__) . 'templates/';
require_once $templates_dir . 'PropertyTemplate.php';
require_once $templates_dir . 'AgentTemplate.php';
require_once $templates_dir . 'ListingTemplate.php';
new PropertyTemplate();
new AgentTemplate();
new ListingTemplate();
});
Manual Asset Registration (Advanced)
If auto-discovery doesn’t work for your setup, you can manually register assets using filters:
// Add additional JS files
add_filter('ssi_template_js_files', function($js_files, $class_name, $template) {
if ($class_name === 'MyTemplate') {
$js_files[] = plugins_url('assets/my-template-extra.js', __FILE__);
}
return $js_files;
}, 10, 3);
// Add additional CSS files
add_filter('ssi_template_css_files', function($css_files, $class_name, $template) {
if ($class_name === 'MyTemplate') {
$css_files[] = plugins_url('assets/my-template.css', __FILE__);
}
return $css_files;
}, 10, 3);
Creating a PHP Template Class
Basic Template Structure
Create a new PHP file for your template:
<?php
/**
* HouzezTemplate.php
*
* JS and CSS files are auto-discovered:
* - HouzezTemplate.js (same folder or js/ subfolder)
* - HouzezTemplate.css (same folder or css/ subfolder)
*/
class HouzezTemplate extends SSI_PostTemplate {
public function __construct($import = null) {
parent::__construct($import);
// Set the post type (defaults to 'post')
$this->post_type = 'property';
// Register any custom stages
$this->registerCustomStages();
// Allow plugins to extend this template
do_action('ssi_register_houzez_stages', $this);
}
/**
* Register template info for the dropdown selector
* Required abstract method
*/
protected function addTemplateInfo() {
self::addTemplate([
'name' => 'Houzez Properties',
'class' => 'HouzezTemplate',
'description' => 'Import properties into Houzez theme'
]);
}
/**
* Define available field mappings
* Required abstract method
*/
protected function defineMappings() {
// Call parent to get standard post fields
parent::defineMappings();
// Add or modify field mappings
// (see "Defining Field Mappings" section below)
}
protected function registerCustomStages() {
// Register template-specific stages here
}
}
Important: Asset File Naming
Your JS and CSS files must match the class name (which should match the PHP filename):
| PHP File | Expected JS | Expected CSS |
|---|---|---|
HouzezTemplate.php |
HouzezTemplate.js |
HouzezTemplate.css |
RealEstateTemplate.php |
RealEstateTemplate.js |
RealEstateTemplate.css |
Registration via Hook
Always register external templates using the ssi_register_templates hook:
// In your plugin's main file
add_action('ssi_register_templates', function() {
require_once plugin_dir_path(__FILE__) . 'templates/HouzezTemplate.php';
new HouzezTemplate();
});
// Or in your theme's functions.php
add_action('ssi_register_templates', function() {
require_once get_template_directory() . '/ssi-templates/HouzezTemplate.php';
new HouzezTemplate();
});
Defining Field Mappings
The defineMappings() method defines what fields are available for mapping in the admin UI.
Field Definition Structure
protected function defineMappings() {
parent::defineMappings();
// Define your fields
$this->fieldMappings = array_replace_recursive($this->fieldMappings, [
// Standard post fields (stored in wp_posts table)
'post_title' => [
'description' => 'Product Title', // Label shown in UI
'tooltip' => 'Help text for users', // Tooltip on hover
'required' => true, // Visual indicator
'source' => 'post_title', // Default CSV column
'data_type' => 'TEXT', // Database column type
'hidden' => false // Hide from UI if true
],
// Taxonomies
'taxonomies' => [
'description' => 'Taxonomies',
'options' => $taxonomies, // Array of taxonomy definitions
'source' => 'post_taxonomies'
],
// Post meta fields
'post_meta' => [
'_my_meta_key' => [
'description' => 'My Meta Field',
'tooltip' => 'Description of this field',
'required' => false,
'source' => '_my_meta_key',
'data_type' => 'TEXT',
'section' => 'my_section' // Groups fields into UI sections
],
'_another_meta' => [
'description' => 'Another Field',
'source' => '_another_meta',
'data_type' => 'VARCHAR(255)'
]
]
]);
}
Field Properties Reference
| Property | Type | Description |
|---|---|---|
description |
string | Human-readable label displayed in the UI |
tooltip |
string | Help text shown on hover (supports HTML) |
required |
bool | Shows visual indicator; doesn’t enforce validation |
source |
string | Default CSV column name to map |
data_type |
string | MySQL data type for the import batch table |
hidden |
bool | If true, field is not shown in the UI |
section |
string | Groups fields into named UI sections (works for both post fields and post_meta) |
subsection |
string | Groups fields within a section into collapsible subsections |
Data Types
Common data types for the data_type property:
TEXT– Variable length textLONGTEXT– Large text contentVARCHAR(n)– Fixed max length stringINT/BIGINT– Integer valuesDECIMAL(10,2)– Decimal numbersDATETIME– Date and time values
Extra Batch Columns and Staging Values
Three different things need a column in the per-import batch table, and SSI lets you create each of them differently:
| Need | Mechanism | User-mappable in admin UI? | Auto-inserted as postmeta? |
|---|---|---|---|
| Standard postmeta field | defineMappings()['post_meta'] |
Yes | Yes |
| Standard post field | defineMappings() top level |
Yes (or hidden) | No (writes wp_posts) |
| Staging-only column (never user-mapped) | $this->extra_fields[$name] = '<sql_type>' |
No | No |
| Raw CSV column copied verbatim into a batch column | $this->extra_column_mappings[$batch_col] = '<csv_header>' |
No (hardcoded header) | No |
Use extra_fields when:
- You’re composing a value from multiple inputs in
mapData()and need a slot to store the result (e.g. Houzezfloor_plansis built from 6 raw inputs and stored as one serialised string) - A stage needs a working column that the rest of the pipeline doesn’t see in the UI
- You’re staging a pipe-separated raw input that a later stage will explode and process
public function __construct($import = null, $auth_manager = null) {
parent::__construct($import, $auth_manager);
// Slot for mapData's composed result. Not user-mappable; written by mapData,
// read by insert-postmeta to produce a wp_postmeta row.
$this->extra_fields['floor_plans'] = 'TEXT';
$this->extra_fields['fave_multi_units'] = 'TEXT';
$this->extra_fields['additional_features'] = 'TEXT';
}
Use extra_column_mappings when:
- The CSV column header is fixed in your template’s data model (not user-configurable), and you want it copied verbatim into a prefixed batch column for a stage to read.
WooCommerce uses this for custom_pa_* attribute columns — the column name comes from the template’s config, not from the user, and gets copied straight in. Houzez doesn’t typically use this — its raw inputs are user-mappable through normal defineMappings() slots.
What defineMappings().post_meta actually creates
When you declare a postmeta field:
'post_meta' => [
'fave_property_price' => [
'description' => 'Property Price',
'data_type' => 'DECIMAL(15,2)',
'section' => 'property', 'subsection' => 'Pricing',
],
]
SSI does THREE things behind the scenes:
1. Adds a batch table column fave_property_price DECIMAL(15,2)
2. Renders a CSV-column dropdown in the admin UI under the configured section
3. After load-csv populates the batch column, insert-postmeta inserts a wp_postmeta row (post_id, 'fave_property_price', <value>)
If you only want #1 (the column exists for staging) you want extra_fields instead.
If you want #1 and #2 but NOT #3 (the user maps a CSV column to a slot that mapData() will consume and discard rather than insert as postmeta), declare it under post_meta and have mapData() unset($mappedData['slot_name']) before returning. The “raw input + suppress original” pattern in the next section uses exactly this.
The mapData Pattern — Per-Row Transforms
mapData($mappedData) is the single most powerful extension point in the template system, and the easiest way to build features that look like they’d need new core primitives.
What it is
mapData is a method on your template class. It runs ONCE per CSV row, AFTER all the config-level mappings have resolved (so post fields, post_meta fields, and media fields are already populated in $mappedData). It receives that array, can read or write any key, and returns the transformed array. The result lands in the batch table.
public function mapData($mappedData) {
$mappedData = parent::mapData($mappedData);
// Read what other mappings produced
if (!empty($mappedData['fave_property_url'])) {
$mappedData['product_type'] = 'external';
}
// Compose new fields from existing ones
$mappedData['fave_property_location'] = trim($mappedData['property_latitude'] . ',' . $mappedData['property_longitude'], ',');
// Drop fields you don't want inserted as postmeta
unset($mappedData['_internal_helper_value']);
return $mappedData;
}
SSI_WooCommerceProductTemplate::mapData() is the canonical reference — it handles external products (_product_url presence → product_type='external'), _downloadable auto-detection, stock-status defaults, all in mapData.
When to use mapData vs PHP function mappings
SSI supports two ways to compute a field’s value:
| Approach | Where the code lives | Portable across CSVs? | Best for |
|---|---|---|---|
PHP function mapping {"function": "return function..."} |
In the user’s JSON config (editable) | No — function string references specific CSV column names by header | One-off user-side transforms (e.g. “concat first_name + last_name into title”) |
mapData() override |
In template PHP code | Yes — references batch column names which are fixed in the template | Template-built features that need to compose data from multiple user-mapped slots |
The Houzez floor-plan pattern is a clear mapData case: the user maps 6 CSV columns to 6 template-defined “raw input” slots. mapData reads those slots by their fixed names (not by user-specific CSV headers) and produces the serialised array. The template works on any CSV regardless of column-header naming.
If a feature requires the user to point at multiple CSV columns AND the template needs to combine them, that’s mapData. If the user wants to do a one-off computation visible only to their import, that’s a PHP function mapping.
The “raw input + suppress original” pattern
This is the standard recipe for “user maps N CSV columns → template composes 1 stored value”:
public function mapData($mappedData) {
$mappedData = parent::mapData($mappedData);
// 1. Read the raw inputs (already populated by the standard mapping pipeline)
$titles = array_map('trim', explode('|', $mappedData['fave_plan_titles_raw'] ?? ''));
$sizes = array_map('trim', explode('|', $mappedData['fave_plan_sizes_raw'] ?? ''));
$rooms = array_map('trim', explode('|', $mappedData['fave_plan_rooms_raw'] ?? ''));
// 2. Build the composed result
$records = [];
for ($i = 0; $i < count($titles); $i++) {
if (empty($titles[$i])) continue;
$records[] = [
'fave_plan_title' => $titles[$i],
'fave_plan_size' => $sizes[$i] ?? '',
'fave_plan_rooms' => $rooms[$i] ?? '',
];
}
$mappedData['floor_plans'] = $records ? serialize($records) : '';
// 3. Suppress the raw staging slots so they don't become their own postmeta rows
foreach (['fave_plan_titles_raw', 'fave_plan_sizes_raw', 'fave_plan_rooms_raw'] as $slot) {
unset($mappedData[$slot]);
}
return $mappedData;
}
Three pieces work together:
– The raw slots are declared in defineMappings().post_meta so the user can map them via the standard UI
– extra_fields['floor_plans'] = 'TEXT' reserves the batch column for the composed result (it’s not user-mappable, so it can’t live in post_meta)
– The unset() step at the end of mapData prevents the raw slots from being inserted as their own postmeta rows
The composed floor_plans value gets inserted as a single postmeta row by insert-postmeta. get_post_meta calls maybe_unserialize on read → consumer code sees the array.
Idempotency requirement
mapData() must be a pure function of $mappedData — no side effects (no DB writes, no API calls, no global state mutations). It runs per-row inside a parallel worker context; non-pure mapData breaks idempotency on re-import and can introduce race conditions across workers.
If you need to do something with side effects (write related posts, call an API, query the DB), do it in a custom stage instead.
Reference implementations
templates/SSI_WooCommerceProductTemplate.php::mapData()— handles_product_url→product_type='external',_downloadableauto-detection, stock-status defaultstemplates/SSI_CustomPostTypeTemplate.php::mapData()— setspost_typebased on the user’s additional_options selection
Creating Custom UI Sections
Both post fields AND post_meta fields can be grouped into named sections using the section property. Fields with the same section value are collected together, and a ssi:renderSection event is triggered for each section, allowing template-specific JavaScript to render custom UI.
Within a section, fields can be further organized into subsection groups (e.g., “Main”, “Stock Management”, “Publication Info”).
PHP: Define Fields with Sections and Subsections
// Post fields can have sections too (moves them out of the Post Fields section)
'post_title' => [
'description' => 'Product Title',
'tooltip' => 'The name of the product',
'required' => true,
'section' => 'product', // Section name
'subsection' => 'Main' // Subsection within the section
],
'post_status' => [
'description' => 'Status',
'required' => true,
'section' => 'product',
'subsection' => 'Publication Info'
],
// Post meta fields work the same way
'post_meta' => [
'_sku' => [
'description' => 'SKU',
'tooltip' => 'Stock Keeping Unit',
'required' => true,
'section' => 'product',
'subsection' => 'Main'
],
'_stock' => [
'description' => 'Stock Quantity',
'section' => 'product',
'subsection' => 'Stock Management'
],
'_regular_price' => [
'description' => 'Price',
'section' => 'product',
'subsection' => 'Main'
]
]
Note: When a post field has a section property, it is automatically excluded from the standard “Post Fields” section. If all post fields are moved to custom sections, the Post Fields section is hidden.
JavaScript: Handle Section Events
When the form fields are generated, the main JavaScript triggers a ssi:renderSection event for each non-default section. Your template JavaScript should listen for this event:
(function($) {
if (!window.SSITemplates) {
window.SSITemplates = {};
}
// Listen for section render events
$(document).on('ssi:renderSection', function(event) {
// Only handle your specific section
if (event.sectionName !== 'product') {
return;
}
console.log('Rendering product section');
// Event properties available:
var container = event.container; // jQuery element to append to
var postMetaFields = event.fields; // Array of post_meta keys in this section
var postFields = event.postFields; // Array of post field keys in this section
var data = event.importData; // Full import data object
var templateMappings = event.templateMappings; // Saved field mappings
var csvHeaders = event.csvHeaders; // Available CSV columns
// Create your section UI with ssi-template-section class (required for save handler)
var section = $('<div>', { class: 'ssi-section product-section ssi-template-section' });
var sectionHeader = $('<div>', { class: 'ssi-section-header' });
var sectionContent = $('<div>', { class: 'ssi-section-content' });
sectionHeader.append($('<h2>', { text: 'Product Data' }));
section.append(sectionHeader);
section.append(sectionContent);
// Insert BEFORE .post-fields to position the section higher in the form
var postFieldsContainer = $('#ssi-form-fields-container .post-fields');
if (postFieldsContainer.length) {
section.insertBefore(postFieldsContainer);
} else {
container.append(section);
}
// Render post fields (from wp_posts table)
$.each(postFields, function(index, key) {
var definition = data.defaults[key];
var value = (templateMappings && templateMappings[key]) ?
templateMappings[key] : definition;
// Use generateStandardField for post fields
generateStandardField(sectionContent, key, value, csvHeaders, definition, false);
});
// Render post_meta fields
$.each(postMetaFields, function(index, key) {
var definition = data.defaults.post_meta[key];
var value = (templateMappings &&
templateMappings.post_meta &&
templateMappings.post_meta[key]) ?
templateMappings.post_meta[key] : definition;
// Use generatePostMetaField for meta fields
generatePostMetaField(sectionContent, key, value, csvHeaders);
});
});
console.log('My Custom Template Loaded');
})(jQuery);
Important: The ssi-template-section class is required on your section container. The save handler uses this class to collect fields from custom sections.
Section Event Properties
The ssi:renderSection event includes these properties:
| Property | Type | Description |
|---|---|---|
sectionName |
string | The section identifier from field definitions |
fields |
array | Array of post_meta field keys that belong to this section |
postFields |
array | Array of post field keys that belong to this section |
container |
jQuery | The postmeta fields container element |
importData |
object | Full import data including defaults, config, csv_headers |
templateMappings |
object | Currently saved field mapping configuration |
csvHeaders |
array | Available column names from the CSV file |
Subsection Organization with Custom Ordering
When using subsections, you can define an explicit order for how subsections appear:
// Define explicit subsection order (fields not in this list appear at the end)
var definedSubsectionOrder = [
'Main',
'Stock Management',
'Variable Products',
'Downloadable Products',
'Publication Date',
'Publication Info',
'Other Post Fields'
];
// Build subsection order: defined order first, then any extras
var subsectionOrder = [];
$.each(definedSubsectionOrder, function(i, name) {
if (subsections[name]) {
subsectionOrder.push(name);
}
});
// Add any subsections not in the defined order
$.each(subsections, function(name) {
if ($.inArray(name, subsectionOrder) === -1) {
subsectionOrder.push(name);
}
});
Required vs Optional Fields in Subsections
Within subsections, you can style required and optional fields differently:
$.each(fieldsInSubsection, function(j, field) {
var isRequired = field.definition && field.definition.required;
var fieldClass = isRequired ? 'ssi-required-field' : 'ssi-optional-field ssi-field-hidden';
var wrapper = $('<div>', { class: fieldClass });
// ... generate field ...
});
CSS classes provided by SSI:
– .ssi-required-field – Green background, always visible
– .ssi-optional-field – Grey background
– .ssi-field-hidden – Hidden by default (toggle with “Show All” button)
Adding Hover Hints
For post_meta fields in custom sections, you can add hover hints showing the meta key:
// After generating a post_meta field
var fieldMapping = wrapper.find('.ssi_import_mapping');
fieldMapping.addClass('ssi-section-meta-field');
var metaDesc = fieldMapping.find('.meta-description');
if (metaDesc.length) {
var hint = $('<span>', {
class: 'ssi-meta-key-hint',
text: 'Meta key: ' + key
});
metaDesc.append(hint);
}
Post fields automatically get a “Posts column: field_name” hover hint via the .ssi-column-hint class.
JavaScript Extension Points
Templates can extend the admin UI through several callback functions in the SSITemplates namespace.
Available Callbacks
(function($) {
if (!window.SSITemplates) {
window.SSITemplates = {};
}
/**
* Add custom media fields
* Called after the standard Featured Image field is rendered
*/
SSITemplates.addMediaFields = function(data, templateMappings) {
var container = $('#ssi-form-fields-container .media-fields .ssi-section-content');
// Add a gallery images field
generateMediaField(
container,
'gallery_images', // Field key
'Gallery Images', // Label
data.csv_headers, // Available CSV columns
templateMappings.media // Saved mappings
);
};
/**
* Add custom additional options
* Called after the standard additional options are rendered
*/
SSITemplates.addAdditionalOptions = function(data, templateMappings) {
var container = $('#additional-options .ssi-section-content');
// Add custom UI elements here
};
console.log('My Template Loaded');
})(jQuery);
Available Global Functions
These functions are available globally for generating form fields:
| Function | Description |
|---|---|
generateStandardField(container, key, value, csvHeaders, defaultValue, shouldHide) |
Creates a standard post field row (adds data-field-source="post") |
generateTaxField(container, key, value, csvHeaders, options) |
Creates a taxonomy selector row |
generatePostMetaField(container, key, value, csvHeaders) |
Creates a post meta field row (adds data-field-source="post_meta") |
generateMediaField(container, key, label, csvHeaders, templateMappings) |
Creates a media field row |
generateCheckboxField(container, name, label, checked, description, tooltip) |
Creates a checkbox option |
generateSelectField(container, name, label, options, selectedValue) |
Creates a dropdown option |
generateCSVDropdown(name, csvHeaders, selectedValue, isFunction) |
Creates a CSV column selector |
generateMappingMethodDropdown(name, mappingMethod) |
Creates direct/function toggle |
generateTooltip(tooltipContent) |
Creates a tooltip help icon |
Note on data-field-source attribute: The generateStandardField and generatePostMetaField functions automatically add a data-field-source attribute to the field wrapper. This is used by the save handler to distinguish between post fields and post_meta fields when collecting data from custom sections (.ssi-template-section).
Dynamically Responding to User Input
SSITemplates.addAdditionalOptions = function(data, templateMappings) {
// Listen for changes to additional options
$('#additional-options').on('change', 'select[name="my_option"]', function() {
var selectedValue = $(this).val();
updateUIBasedOnSelection(selectedValue);
});
// Trigger initial update
setTimeout(function() {
var initialValue = $('#additional-options select[name="my_option"]').val();
if (initialValue) {
updateUIBasedOnSelection(initialValue);
}
}, 500);
};
function updateUIBasedOnSelection(value) {
// Show/hide fields based on selection
if (value === 'advanced') {
$('.advanced-fields').show();
} else {
$('.advanced-fields').hide();
}
}
Additional Options
Additional options appear in the “Additional Options” section of the import configuration.
PHP: Define Additional Options
public function getAdditionalOptionsFields() {
// Get parent options first
$options = parent::getAdditionalOptionsFields();
// Add a checkbox option with tooltip
$options['my_checkbox'] = [
'label' => 'Enable Feature', // Short label shown next to checkbox
'description' => '', // Keep empty for clean UI
'tooltip' => 'Detailed explanation of what this feature does. ' .
'Supports HTML for formatting.<br><br>' .
'<a href="https://example.com/docs" target="_blank">Read more</a>',
'type' => 'checkbox',
'value' => false, // Default value (checked/unchecked)
'name' => 'my_checkbox', // Must match the array key
'depends_on' => '' // Empty or name of another option
];
// Add a dependent option (only shown when my_checkbox is checked)
$options['my_dependent_option'] = [
'label' => 'Sub-option',
'description' => '',
'tooltip' => 'This option only appears when the parent checkbox is checked.',
'type' => 'checkbox',
'value' => true,
'name' => 'my_dependent_option',
'depends_on' => 'my_checkbox' // Shows only when my_checkbox is checked
];
// Add a select dropdown
$options['my_select'] = [
'label' => 'Processing Mode',
'description' => 'Choose how data is processed', // Shown below label for selects
'type' => 'select',
'options' => [
['label' => 'Standard Mode', 'value' => 'standard'],
['label' => 'Fast Mode', 'value' => 'fast'],
['label' => 'Safe Mode', 'value' => 'safe']
],
'value' => 'standard', // Default selected value
'name' => 'my_select',
'depends_on' => ''
];
return $options;
}
Option Field Properties
| Property | Type | Description |
|---|---|---|
label |
string | Short label displayed next to the checkbox/select |
description |
string | Additional text (use empty string for checkboxes with tooltips) |
tooltip |
string | Help text shown on hover (supports HTML, links) |
type |
string | checkbox or select |
value |
mixed | Default value (bool for checkbox, string for select) |
name |
string | Field name (must match array key) |
depends_on |
string | Name of parent checkbox (empty for independent options) |
options |
array | For select type only: array of ['label' => '', 'value' => ''] |
### Accessing Options in Import Stages
```php
public function stage_my_custom_stage($template) {
// Access additional options
if (!empty($this->additional_options['my_checkbox'])) {
// Feature is enabled
}
$mode = $this->additional_options['my_select'] ?? 'a';
}
Custom Import Stages
Stages are the sequential steps that execute during an import.
Registering Stages
protected function registerCustomStages() {
// Register a stage with:
// - name: unique identifier
// - priority: execution order (lower = earlier)
// - callback: function to execute
// - description: shown in logs
$this->registerStage(
'process-my-data', // Stage name
150, // Priority (after standard stages)
[$this, 'stage_process_my_data'], // Callback
'Process custom data' // Description
);
}
public function stage_process_my_data($template) {
global $wpdb;
// Access import data
$batch_table = $this->table_names['batch'];
$config = $this->config;
// Perform your processing
$results = $wpdb->get_results("SELECT * FROM {$batch_table} WHERE ...");
// Log progress (CLI only)
if (defined('WP_CLI') && WP_CLI) {
echo "Processed " . count($results) . " items\n";
}
}
Registered Stages Reference
Core Stages (SSI_ImportTemplate)
All templates inherit these core stages:
| Priority | Stage Name | Description |
|---|---|---|
| 10 | load-csv |
Load CSV file into temporary batch table |
| 20 | import-taxonomies |
Process and import taxonomy terms |
| 30 | match-existing |
Match CSV rows to existing posts |
| 40 | update-posts |
Update existing posts with new data |
| 50 | insert-posts |
Create new posts for unmatched rows |
| 60 | update-postmeta |
Update post meta for existing posts |
| 70 | insert-postmeta |
Insert post meta for new posts |
| 100 | upsert-relationships |
Update term relationships |
Post Template Stages (SSI_PostTemplate)
In addition to core stages:
| Priority | Stage Name | Description |
|---|---|---|
| 95 | attach-existing-images |
Attach featured images from media library |
| 100 | upload-remote-images |
Download and attach remote images |
| 1000 | process-deletes |
Delete items missing from import |
WooCommerce Product Template Stages (SSI_WooCommerceProductTemplate)
In addition to Post Template stages:
| Priority | Stage Name | Description |
|---|---|---|
| 100 | process-variable-products |
Link variations to parent products |
| 150 | attach-gallery-images |
Attach product gallery images |
| 175 | fix-attributes |
Fix product attribute assignments |
Unregistering Stages
To remove stages registered by parent templates, use the ssi_filter_stages_to_run filter:
class MyTemplate extends SSI_PostTemplate {
public function __construct($import = null) {
parent::__construct($import);
// Remove stages that don't apply to this template
add_filter('ssi_filter_stages_to_run', [$this, 'removeUnneededStages'], 10, 3);
}
public function removeUnneededStages($stages, $config, $template) {
// Only modify stages for this template type
if (!($template instanceof MyTemplate)) {
return $stages;
}
// Remove the image attachment stage
unset($stages['attach-existing-images']);
// Remove remote image upload
unset($stages['upload-remote-images']);
// Remove delete processing
unset($stages['process-deletes']);
return $stages;
}
}
You can also conditionally remove stages based on configuration:
public function removeUnneededStages($stages, $config, $template) {
if (!($template instanceof MyTemplate)) {
return $stages;
}
// Remove image stages if no featured image is configured
if (empty($config['media']['featured_image'])) {
unset($stages['attach-existing-images']);
unset($stages['upload-remote-images']);
}
// Remove taxonomy stage if no taxonomies configured
if (empty($config['taxonomies'])) {
unset($stages['import-taxonomies']);
unset($stages['upsert-relationships']);
}
return $stages;
}
Conditionally Running Stages
Use the ssi_filter_stages_to_run filter:
public function __construct($import = null) {
parent::__construct($import);
add_filter('ssi_filter_stages_to_run', [$this, 'filterMyStages'], 10, 3);
}
public function filterMyStages($stages, $config, $template) {
// Remove stage if condition not met
if (empty($config['my_field'])) {
unset($stages['process-my-data']);
}
return $stages;
}
Custom Image-Handling Stages
The built-in image stages (upload-remote-images, attach-existing-images) handle two storage targets out of the box: the featured image (_thumbnail_id) and the WooCommerce-style gallery aux table. For any other target — storing an attachment’s ID in an arbitrary postmeta key — you write a small custom stage.
Pattern: image URL → attachment ID → arbitrary postmeta key
The image is already downloaded by SSI’s standard pipeline (because the user mapped a CSV column to media.<your_key> in the config). After upload-remote-images runs, every downloaded URL has a corresponding row in wp_ssi_image_lookup (SSI’s URL → attachment-ID lookup table, see How Imports Work). Your stage joins that table back to the batch row and writes the attachment ID to your target meta key:
$this->registerStage(
'attach-property-video-image', // unique stage name
105, // priority just after upload-remote-images (100)
[$this, 'stage_attach_video_image'],
'Wire video placeholder image into fave_video_image postmeta'
);
public function stage_attach_video_image($template) {
global $wpdb;
$batch_col = 'fave_video_image'; // the batch column with the URL
$meta_key = 'fave_video_image'; // the postmeta key to write the attachment ID to
if (!ssi_is_safe_identifier($batch_col)) return;
// DELETE first so re-imports are idempotent (avoids stale postmeta rows)
$wpdb->query($wpdb->prepare("
DELETE pm FROM {$wpdb->postmeta} pm
INNER JOIN {$this->table_names['batch']} b ON pm.post_id = b.post_id
WHERE pm.meta_key = %s
", $meta_key));
// INSERT the new mapping — join via wp_ssi_image_lookup keyed on url_hash
// (BINARY(16) MD5). Fast even at millions of images; index does the work.
$wpdb->query($wpdb->prepare("
INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value)
SELECT b.post_id, %s, il.attachment_id
FROM {$this->table_names['batch']} b
JOIN {$wpdb->prefix}ssi_image_lookup il
ON il.url_hash = UNHEX(MD5(b.`{$batch_col}`))
WHERE b.`{$batch_col}` IS NOT NULL AND b.`{$batch_col}` != ''
", $meta_key));
$this->logger->logStageMetric('attach-video-image', 'wired', (int) $wpdb->rows_affected);
}
Migrated from 2.54 and earlier: the same pattern used to JOIN
wp_postmeta WHERE meta_key='original_image_url' AND meta_value=…. From SSI 2.55 onwards that postmeta is no longer written — addons must usewp_ssi_image_lookupinstead. The migration is automatic: on first import after upgrading to 2.55, SSI runs a one-shot resync that populates the lookup table from any pre-existing image attachments.
Pattern: gallery with repeated postmeta rows
Houzez and some other themes store gallery images as one wp_postmeta row PER attachment (using add_post_meta rather than the Woo-style comma-joined single row). Same shape as above but the INSERT produces multiple rows:
public function stage_attach_property_gallery($template) {
global $wpdb;
// Pull all (post_id, attachment_id) pairs from the gallery aux table
$rows = $wpdb->get_results("
SELECT gi.post_id, il.attachment_id
FROM {$this->table_names['gallery_images']} gi
JOIN {$wpdb->prefix}ssi_image_lookup il ON il.url_hash = UNHEX(MD5(gi.image_url))
JOIN {$wpdb->posts} a ON a.ID = il.attachment_id AND a.post_type = 'attachment'
");
if (empty($rows)) return;
// Wipe existing repeated postmeta for these properties
$post_ids = array_unique(array_map(fn($r) => (int)$r->post_id, $rows));
$placeholders = implode(',', array_fill(0, count($post_ids), '%d'));
$wpdb->query($wpdb->prepare("
DELETE FROM {$wpdb->postmeta}
WHERE meta_key = 'fave_property_images' AND post_id IN ($placeholders)
", $post_ids));
// Bulk INSERT one row per attachment
$values = [];
foreach ($rows as $r) {
$values[] = $wpdb->prepare('(%d, %s, %d)', $r->post_id, 'fave_property_images', $r->attachment_id);
}
$wpdb->query("INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value) VALUES " . implode(',', $values));
$this->logger->logStageMetric('attach-property-gallery', 'wired', count($rows));
}
Non-image file attachments (PDF/DOCX/etc.)
SSI’s image-download pipeline rejects non-image MIME types via the SSRF/MIME gate (SSI_PostTemplate::checkDownloadedImageMime). For templates that need PDFs or other file types, extend the allow-list via the existing filter:
public function __construct($import = null, $auth_manager = null) {
parent::__construct($import, $auth_manager);
add_filter('ssi_allowed_downloaded_image_mimes', function($allowed) {
return array_merge($allowed, [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
]);
});
}
The filter name says “image_mimes” but conceptually now covers any downloaded file type — the SSRF host/IP gate still applies, only the MIME allow-list is extended. Use the same wire-up stage pattern as above to put resulting attachment IDs into your target postmeta key.
Some imports reference posts of a DIFFERENT CPT (e.g. Houzez properties reference houzez_agent and houzez_agency CPTs). Two valid patterns; pick based on user-experience trade-offs.
Pattern A: Separate imports (recommended)
Tell users to run a separate import for the related CPT first (using SSI_PostTemplate or SSI_CustomPostTypeTemplate), then the main import. In the main CSV, the cross-CPT column contains either the related post’s title or its post_ID. A thin stage resolves title → post_id via a single bulk JOIN before insert-postmeta runs.
Pros: idempotent, fast at any scale (one JOIN), normal SSI shape, makes the dependency explicit. Cons: requires user discipline.
Pattern B: In-template auto-create
The main import auto-creates missing related posts on the fly. Matches the WP All Import experience. Implementation is a stage that:
- SELECTs DISTINCT values from the batch table for that column
- Bulk-looks-them-up in
wp_postsagainst the target CPT - Creates missing ones via
wp_insert_post - Swaps the batch table column from name → resolved post_id
- Subsequent
insert-postmetawrites the resolved IDs
$this->registerStage(
'resolve-property-agents',
65, // BEFORE insert-postmeta (priority 70)
[$this, 'stage_resolve_property_agents'],
'Resolve agent names to houzez_agent post IDs, auto-create missing'
);
public function stage_resolve_property_agents($template) {
$this->resolveCrossCptByTitle('fave_agents', 'houzez_agent');
}
private function resolveCrossCptByTitle($batch_col, $post_type) {
global $wpdb;
if (!ssi_is_safe_identifier($batch_col)) return;
if (!ssi_is_safe_identifier($post_type)) return;
// Skip already-resolved values (pure-digit strings). Lets re-runs of this
// stage on the same batch be a no-op.
$names = $wpdb->get_col($wpdb->prepare("
SELECT DISTINCT `{$batch_col}`
FROM {$this->table_names['batch']}
WHERE `{$batch_col}` IS NOT NULL
AND `{$batch_col}` != ''
AND `{$batch_col}` NOT REGEXP '^[0-9]+$'
"));
if (empty($names)) return;
// Bulk lookup: MIN(ID) per title gives deterministic disambiguation for
// accidental same-title duplicates (oldest wins; document this).
$placeholders = implode(',', array_fill(0, count($names), '%s'));
$existing = $wpdb->get_results($wpdb->prepare("
SELECT post_title, MIN(ID) AS ID
FROM {$wpdb->posts}
WHERE post_type = %s
AND post_status IN ('publish','draft','private')
AND post_title IN ($placeholders)
GROUP BY post_title
", array_merge([$post_type], $names)), OBJECT_K);
// Create missing
$name_to_id = [];
$created = 0;
foreach ($names as $name) {
if (isset($existing[$name])) {
$name_to_id[$name] = (int) $existing[$name]->ID;
continue;
}
$new_id = wp_insert_post([
'post_type' => $post_type,
'post_title' => $name,
'post_status' => 'publish',
'post_name' => sanitize_title($name),
]);
if (!is_wp_error($new_id) && $new_id > 0) {
$name_to_id[$name] = $new_id;
$created++;
}
}
// Swap name → id in the batch table
foreach ($name_to_id as $name => $id) {
$wpdb->query($wpdb->prepare(
"UPDATE {$this->table_names['batch']} SET `{$batch_col}` = %s WHERE `{$batch_col}` = %s",
(string) $id, $name
));
}
$this->logger->logStageMetric("resolve-{$post_type}", 'resolved', count($name_to_id));
$this->logger->logStageMetric("resolve-{$post_type}", 'created', $created);
}
Idempotency: the NOT REGEXP '^[0-9]+$' filter makes second-run-on-same-batch a no-op. On a fresh re-import (rebuilt batch), the column has the name again, gets re-resolved → finds the existing post, swap is identical. No duplicate posts created across re-imports.
Race conditions: this stage MUST be sequential (not in the parallel-worker stage list — which is the default). The orchestrator runs it once with the full batch visible, so two parallel workers can’t both encounter the same name simultaneously and double-create.
Duplicate-title disambiguation: MIN(ID) picks the oldest matching post. Document this so customers know what happens with accidental duplicates and can clean them up if needed.
External API Integrations
When a stage needs to call a third-party API per row (geocoding, currency conversion, image enrichment, etc.) you have to handle three concerns: caching to avoid duplicate calls, rate limiting to be a good API citizen, and documenting an escape hatch for users importing large datasets.
Pattern: cached external API call
Worked example — geocoding addresses via Google’s API:
public function stage_geocode_addresses($template) {
global $wpdb;
$mode = $this->additional_options['geocoding_mode'] ?? 'off';
if ($mode === 'off') return;
$api_key = $this->additional_options['geocoding_api_key'] ?? '';
if (empty($api_key)) {
ssi_report_import_error('Geocoding enabled but no API key configured', false);
return;
}
// Pull DISTINCT inputs we need to geocode
$addresses = $wpdb->get_col("
SELECT DISTINCT fave_property_map_address
FROM {$this->table_names['batch']}
WHERE fave_property_map_address IS NOT NULL AND fave_property_map_address != ''
");
if (empty($addresses)) return;
// Cache lookup
$cache = get_option('ssi_houzez_geocode_cache', []);
$results = [];
$api_calls = 0;
$cache_hits = 0;
foreach ($addresses as $address) {
$cache_key = md5($address);
if (isset($cache[$cache_key])) {
$results[$address] = $cache[$cache_key];
$cache_hits++;
continue;
}
// Be polite — 5 req/sec
if ($api_calls > 0) usleep(200000);
$url = 'https://maps.googleapis.com/maps/api/geocode/json?address='
. rawurlencode($address) . '&key=' . rawurlencode($api_key);
$response = wp_remote_get($url, ['timeout' => 10]);
$api_calls++;
if (is_wp_error($response)) continue;
$body = json_decode(wp_remote_retrieve_body($response), true);
if (empty($body['results'][0])) continue;
$loc = $body['results'][0]['geometry']['location'];
$results[$address] = [
'lat' => $loc['lat'],
'lng' => $loc['lng'],
'formatted' => $body['results'][0]['formatted_address'],
];
$cache[$cache_key] = $results[$address];
}
// Persist the cache (single options write at end, not per-row)
update_option('ssi_houzez_geocode_cache', $cache, false);
// Write resolved values back into the batch table
foreach ($results as $address => $r) {
$wpdb->query($wpdb->prepare("
UPDATE {$this->table_names['batch']}
SET fave_property_location = %s
WHERE fave_property_map_address = %s
", $r['lat'] . ',' . $r['lng'], $address));
}
$this->logger->logStageMetric('geocode-addresses', 'cache_hits', $cache_hits);
$this->logger->logStageMetric('geocode-addresses', 'api_calls', $api_calls);
}
Three things to notice:
1. The cache key is a hash of the input, stored in a wp_options row. Repeated addresses across runs hit the cache instead of the API.
2. The rate limit is internal to the stage (5 req/sec via usleep), tuned per-API.
3. The cache is written once at end-of-stage (update_option) rather than after each API call — much cheaper than N options writes.
Cost-transparent CLI output
Print an estimated cost at the start so users running large imports see what they’re committing to:
$unique_count = count($addresses);
$cost_estimate = $unique_count * 0.005; // Google's roughly-$5-per-1000-requests rate
if (defined('WP_CLI') && WP_CLI) {
echo "About to geocode {$unique_count} unique addresses via Google Geocoding API.\n";
echo "Estimated cost: \$" . number_format($cost_estimate, 2) . "\n";
}
Document the escape hatch
External APIs scale poorly. Always document a pre-processing path for users with large datasets:
“For >5,000 rows, pre-geocode your CSV externally using batch services (Google batch geocoding, positionstack, OpenStreetMap Nominatim) and map the lat/long columns directly to the relevant postmeta. Then leave geocoding-mode off.”
The stage exists for users with small imports who want one-button geocoding. Anyone serious does the pre-processing.
Performance Considerations
Custom stages run on the full batch table (typically thousands to millions of rows). What you do per row matters.
Use bulk SQL, not per-row queries
Bad — N queries:
foreach ($wpdb->get_results("SELECT * FROM batch") as $row) {
$wpdb->query($wpdb->prepare("UPDATE wp_postmeta SET meta_value=%s WHERE post_id=%d AND meta_key=%s",
$row->something, $row->post_id, 'some_key'));
}
Good — one query:
$wpdb->query("
UPDATE {$wpdb->postmeta} pm
INNER JOIN {$this->table_names['batch']} b ON pm.post_id = b.post_id
SET pm.meta_value = b.some_column
WHERE pm.meta_key = 'some_key'
");
At 100k rows the difference is approximately 30 minutes vs 0.5 seconds.
mysqli vs wpdb
SSI’s core stages use direct mysqli (ssi_query, ssi_get_results) for speed at scale. Template stages can use either:
- Use
$wpdbwhen: - Stage processes < a few thousand rows
- You want WordPress hooks to fire (e.g.
wp_insert_posttriggerssave_postactions) -
You need the abstraction (single-statement portability across hosts)
-
Use
ssi_query/ssi_get_resultswhen: - Stage processes 100k+ rows
- You’re doing pure bulk DML and don’t need hooks
- Performance matters
The bulk-DML examples in this guide all use $wpdb for readability. For production templates handling large imports, swap to ssi_query for the hot-path stages.
Stage priorities and parallel-worker compatibility
SSI runs some stages with parallel workers (load-csv, insert-postmeta, etc.). For a stage to be parallel-safe, it must process a SEGMENT of batch rows without depending on other segments. Stages that SELECT DISTINCT across all rows (like cross-CPT resolution, geocoding) MUST be sequential.
Default: stages you register are sequential. Don’t opt in to parallel without auditing for cross-row dependencies.
Don’t do per-row external I/O without caching
Network calls inside a per-row loop are catastrophic at scale. Always: 1. SELECT DISTINCT the unique inputs first 2. Cache by input hash 3. Process unique values one-at-a-time with rate limiting 4. Bulk-UPDATE the results back into the batch table
Idempotency Contracts
Re-running the same import (against the same DB) MUST produce the same result. Stages must be designed for this.
What re-import looks like
wp ssi <import_id> rebuilds the batch table from the CSV and runs every stage. On the second run:
– wp_posts already has the products from the first run
– wp_postmeta already has their meta
– wp_term_relationships already has their taxonomy assignments
– Your stage’s outputs from the first run are also there
Your stage must either NOT WRITE if the data is already correct, or OVERWRITE atomically so the result is the same as a fresh run.
Pattern 1: DELETE-then-INSERT
For postmeta that doesn’t have a unique key on (post_id, meta_key):
// Wipe stale rows for the batch's post_ids
$wpdb->query("DELETE FROM {$wpdb->postmeta} WHERE meta_key = '<key>' AND post_id IN (...)");
// Bulk INSERT the new state
$wpdb->query("INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value) VALUES ...");
This is the canonical pattern for repeated-postmeta gallery images, attachments, etc.
Pattern 2: UPDATE-existing, INSERT-missing
For single-value postmeta, do it in two steps:
// UPDATE rows that exist
$wpdb->query("
UPDATE {$wpdb->postmeta} pm
INNER JOIN {$this->table_names['batch']} b ON pm.post_id = b.post_id
SET pm.meta_value = b.value_column
WHERE pm.meta_key = '<key>' AND b.value_column IS NOT NULL
");
// INSERT rows that don't exist yet
$wpdb->query("
INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value)
SELECT b.post_id, '<key>', b.value_column
FROM {$this->table_names['batch']} b
LEFT JOIN {$wpdb->postmeta} pm ON pm.post_id = b.post_id AND pm.meta_key = '<key>'
WHERE b.value_column IS NOT NULL AND pm.post_id IS NULL
");
This is what SSI’s core update-postmeta and insert-postmeta stages do internally.
Pattern 3: Skip-already-resolved guard
For stages that transform a batch column in-place (like cross-CPT resolution), add a WHERE clause that detects already-transformed values:
SELECT DISTINCT `name_column`
FROM {$this->table_names['batch']}
WHERE `name_column` IS NOT NULL
AND `name_column` != ''
AND `name_column` NOT REGEXP '^[0-9]+$' -- skip already-resolved post IDs
Lets the stage run multiple times on the same batch without doubling up its work.
What breaks idempotency
- Using
add_post_metawithout first deleting existing rows → duplicate postmeta rows on every re-import - Per-row API calls without caching → different timing produces different results
- Stage code with side effects in mapData (DB writes, API calls) → race conditions across workers
- Pivoting data into wp_terms without dedup → orphan terms accumulate across runs
The existing core stages handle all of these correctly. Custom stages must match the contract.
SQL Identifier Hardening
Dynamic SQL identifiers — column names, table names, post_types — that come from user config or template parameters MUST be validated before interpolation. The 2.39 security audit added the ssi_is_safe_identifier() helper for this.
When to use it
Every time you interpolate an identifier into raw SQL:
private function wire_to_meta($batch_col, $meta_key, $post_type) {
// Validate every interpolated identifier
if (!ssi_is_safe_identifier($batch_col)) return;
if (!ssi_is_safe_identifier($meta_key)) return;
if (!ssi_is_safe_identifier($post_type)) return;
global $wpdb;
$wpdb->query($wpdb->prepare("
INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value)
SELECT b.post_id, %s, b.`{$batch_col}`
FROM {$this->table_names['batch']} b
JOIN {$wpdb->posts} p ON p.ID = b.post_id AND p.post_type = %s
", $meta_key, $post_type));
}
What it checks
ssi_is_safe_identifier() rejects strings containing:
– Backticks (`) — break out of backtick identifiers
– Backslashes — escape sequences when NO_BACKSLASH_ESCAPES is off
– Quotes (single + double) — break out of string literals
– Semicolons — statement terminator
– Control chars (\x00-\x1f, \x7f) — null byte, newlines, etc.
– SQL-comment sequences — --, /*, */
– Over-length input (> 64 chars — MySQL identifier limit)
It allows letters, digits, underscore, hyphen, space, #, (, ), ., :, /, @, &, accented chars. Real CSV headers contain these regularly.
What it does NOT cover
ssi_is_safe_identifier() is a blocklist, not a sanitizer. % and _ ARE allowed (legitimate LIKE wildcards). If you’re interpolating into a LIKE pattern, ALSO call $wpdb->esc_like():
$wpdb->prepare("WHERE name LIKE %s", '%' . $wpdb->esc_like($user_input) . '%');
Where you DON’T need it
Inside $wpdb->prepare() with %s / %d placeholders — those are correctly escaped by WP. The hardening is only needed for backtick identifiers (`{$col}`) and IN () lists, which prepare doesn’t help with.
Hooks and Filters Reference
PHP Actions
// External template registration (use this to register your templates!)
do_action('ssi_register_templates');
// Plugin initialization (after SSI core files loaded)
do_action('ssi_init');
// Template initialization
do_action('ssi_template_init', $template);
// Stage registration
do_action('ssi_register_stages', $template);
do_action('ssi_register_post_stages', $template);
do_action('ssi_register_woocommerce_stages', $template);
// Import execution
do_action('ssi_before_import_stages', $template);
do_action('ssi_after_import_stages', $template);
// Taxonomy creation
do_action('sss_maybe_create_taxonomy', $taxonomy_name);
// Admin UI
do_action('ssi_render_admin_sidebar');
PHP Filters
// Register additional template JS files (for manual asset registration)
add_filter('ssi_template_js_files', function($js_files, $class_name, $template) {
if ($class_name === 'MyTemplate') {
$js_files[] = plugins_url('assets/extra.js', __FILE__);
}
return $js_files;
}, 10, 3);
// Register additional template CSS files (for manual asset registration)
add_filter('ssi_template_css_files', function($css_files, $class_name, $template) {
if ($class_name === 'MyTemplate') {
$css_files[] = plugins_url('assets/extra.css', __FILE__);
}
return $css_files;
}, 10, 3);
// Filter stages to run
add_filter('ssi_filter_stages_to_run', function($stages, $config, $template) {
return $stages;
}, 10, 3);
// Filter delete query
add_filter('ssi_filter_deletes', function($where_clause, $import_id) {
return $where_clause . " AND additional_condition";
}, 10, 2);
// Filter update query
add_filter('ssi_filter_updates', function($set_clause) {
return $set_clause;
});
// Modify import utilities
add_filter('ssi_import_utils', function($utils) {
$utils['my-utility'] = [
'slug' => 'my-utility',
'message' => 'Running my utility...',
'file' => 'path/to/utility.php'
];
return $utils;
});
JavaScript Events
// Section rendering event
$(document).on('ssi:renderSection', function(event) {
// event.sectionName - Section identifier
// event.fields - Array of post_meta field keys in this section
// event.postFields - Array of post field keys in this section
// event.container - jQuery container element
// event.importData - Import data object
// event.templateMappings - Saved mappings
// event.csvHeaders - CSV column names
});
Complete Example
Here’s a complete example of an external template for importing events, located in a separate plugin.
Folder Structure
my-events-importer/
├── my-events-importer.php # Plugin main file
└── templates/
├── EventTemplate.php # Template class
├── EventTemplate.js # Auto-discovered JS
└── EventTemplate.css # Auto-discovered CSS (optional)
Plugin Main File (my-events-importer.php)
<?php
/**
* Plugin Name: My Events Importer
* Description: Custom SSI template for importing events
*/
// Register template when SSI is ready
add_action('ssi_register_templates', function() {
require_once plugin_dir_path(__FILE__) . 'templates/EventTemplate.php';
new EventTemplate();
});
PHP Template (templates/EventTemplate.php)
<?php
/**
* EventTemplate.php
*
* Auto-discovered assets:
* - EventTemplate.js (same folder)
* - EventTemplate.css (same folder)
*/
class EventTemplate extends SSI_PostTemplate {
public function __construct($import = null) {
parent::__construct($import);
$this->post_type = 'event';
// Add extra fields for the batch table
$this->extra_fields['_event_date'] = 'DATETIME';
$this->extra_fields['_event_location'] = 'TEXT';
$this->extra_fields['_event_capacity'] = 'INT';
$this->registerEventStages();
}
protected function addTemplateInfo() {
self::addTemplate([
'name' => 'Events',
'class' => 'EventTemplate',
'description' => 'Import events with date, location, and capacity'
]);
}
protected function defineMappings() {
parent::defineMappings();
// Get event-specific taxonomies
$taxonomies_info = get_object_taxonomies('event', 'objects');
$taxonomies = [];
foreach ($taxonomies_info as $taxonomy) {
$taxonomies[$taxonomy->name] = [
'hierarchical' => $taxonomy->hierarchical,
'label' => $taxonomy->label,
'name' => $taxonomy->name
];
}
$this->fieldMappings = array_replace_recursive($this->fieldMappings, [
'post_title' => [
'description' => 'Event Name',
'tooltip' => 'The name of your event',
'required' => true
],
'taxonomies' => [
'options' => $taxonomies
],
'post_meta' => [
'_event_date' => [
'description' => 'Event Date',
'tooltip' => 'Format: YYYY-MM-DD HH:MM:SS',
'required' => true,
'data_type' => 'DATETIME',
'section' => 'event_details'
],
'_event_location' => [
'description' => 'Location',
'tooltip' => 'Venue name or address',
'data_type' => 'TEXT',
'section' => 'event_details'
],
'_event_capacity' => [
'description' => 'Capacity',
'tooltip' => 'Maximum attendees (0 = unlimited)',
'data_type' => 'INT',
'section' => 'event_details'
],
'_ticket_price' => [
'description' => 'Ticket Price',
'data_type' => 'DECIMAL(10,2)',
'section' => 'event_details'
]
]
]);
}
protected function registerEventStages() {
$this->registerStage('validate-event-dates', 75,
[$this, 'stage_validate_event_dates'],
'Validate event dates'
);
}
public function stage_validate_event_dates($template) {
global $wpdb;
// Ensure event dates are in the future or handle past dates
$updated = $wpdb->query("
UPDATE {$this->table_names['batch']}
SET _event_date = NOW()
WHERE _event_date < NOW() OR _event_date IS NULL
");
if (defined('WP_CLI') && WP_CLI) {
echo "Updated {$updated} events with invalid dates\n";
}
}
public function mapData($csvRow) {
$mappedData = parent::mapData($csvRow);
// Set post type
$mappedData['post_type'] = 'event';
// Ensure capacity is numeric
if (isset($mappedData['_event_capacity'])) {
$mappedData['_event_capacity'] = intval($mappedData['_event_capacity']);
}
return $mappedData;
}
public function getAdditionalOptionsFields() {
$options = parent::getAdditionalOptionsFields();
$options['skip_past_events'] = [
'label' => 'Skip past events',
'description' => 'Do not import events with dates in the past',
'type' => 'checkbox',
'value' => true,
'name' => 'skip_past_events',
'depends_on' => ''
];
return $options;
}
protected function getCalculatedMetaFields() {
return array_merge(parent::getCalculatedMetaFields(), [
'_event_date' => '_event_date'
]);
}
}
JavaScript (templates/EventTemplate.js)
(function($) {
if (!window.SSITemplates) {
window.SSITemplates = {};
}
// Handle the event_details section
$(document).on('ssi:renderSection', function(event) {
if (event.sectionName !== 'event_details') {
return;
}
console.log('Event Template: Rendering event details section');
var container = event.container;
var fields = event.fields;
var data = event.data;
var templateMappings = event.templateMappings;
var csvHeaders = event.csvHeaders;
// Create section
var eventSection = $('<div>', { class: 'ssi-section event-details-section' });
var eventSectionHeader = $('<div>', { class: 'ssi-section-header' });
var eventSectionContent = $('<div>', { class: 'ssi-section-content' });
eventSectionHeader.append($('<h2>', { text: 'Event Details' }));
eventSection.append(eventSectionHeader);
eventSection.append(eventSectionContent);
container.append(eventSection);
// Render fields
$.each(fields, function(index, key) {
var defaultValue = data.defaults.post_meta[key];
var value = (templateMappings &&
templateMappings.post_meta &&
templateMappings.post_meta[key]) ?
templateMappings.post_meta[key] : defaultValue;
generatePostMetaField(eventSectionContent, key, value, csvHeaders);
});
});
// Optional: Add custom media fields
SSITemplates.addMediaFields = function(data, templateMappings) {
console.log('Event Template: Adding event image field');
var container = $('#ssi-form-fields-container .media-fields .ssi-section-content');
generateMediaField(
container,
'event_banner',
'Event Banner Image',
data.csv_headers,
templateMappings.media
);
};
console.log('Event Template Loaded');
})(jQuery);
Tips and Best Practices
-
Always call parent methods – When overriding
__construct(),defineMappings(), or other methods, call the parent implementation first. -
Use
array_replace_recursive()– When merging field mappings, use this instead ofarray_merge()to preserve nested arrays. -
Check for CLI output – Wrap echo statements in CLI checks to avoid breaking AJAX responses:
php if (defined('WP_CLI') && WP_CLI) { echo "Processing...\n"; } -
Register stages early – Register stages in the constructor so they’re available when the import runs.
-
Use descriptive section names – Section names should be unique and describe the content they contain.
-
Test with small CSVs first – Always test your template with a small CSV file before running large imports.
-
Log important operations – Use the logger for tracking metrics:
php $this->logger->logStageMetric('stage-name', 'Items Processed', $count);
For more information, refer to the existing templates in the templates/ directory as working examples.