Creating Custom Templates for Super Speedy Imports

June 5, 2026

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

  1. Template Architecture Overview
  2. File Locations and Asset Discovery
  3. Registering External Templates
  4. Creating a PHP Template Class
  5. Defining Field Mappings
  6. Extra Batch Columns and Staging Values
  7. The mapData Pattern — Per-Row Transforms
  8. Creating Custom UI Sections
  9. JavaScript Extension Points
  10. Additional Options
  11. Custom Import Stages
  12. Custom Image-Handling Stages
  13. Integrating with Related Post Types
  14. External API Integrations
  15. Performance Considerations
  16. Idempotency Contracts
  17. SQL Identifier Hardening
  18. Hooks and Filters Reference
  19. 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:

  1. Same folder as PHP file: {ClassName}.js, {ClassName}.css
  2. js/ subfolder: js/{ClassName}.js
  3. 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 text
  • LONGTEXT – Large text content
  • VARCHAR(n) – Fixed max length string
  • INT / BIGINT – Integer values
  • DECIMAL(10,2) – Decimal numbers
  • DATETIME – 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. Houzez floor_plans is 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_urlproduct_type='external', _downloadable auto-detection, stock-status defaults
  • templates/SSI_CustomPostTypeTemplate.php::mapData() — sets post_type based 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 use wp_ssi_image_lookup instead. 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.


Integrating with Related Post Types

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:

  1. SELECTs DISTINCT values from the batch table for that column
  2. Bulk-looks-them-up in wp_posts against the target CPT
  3. Creates missing ones via wp_insert_post
  4. Swaps the batch table column from name → resolved post_id
  5. Subsequent insert-postmeta writes 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 $wpdb when:
  • Stage processes < a few thousand rows
  • You want WordPress hooks to fire (e.g. wp_insert_post triggers save_post actions)
  • You need the abstraction (single-statement portability across hosts)

  • Use ssi_query / ssi_get_results when:

  • 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_meta without 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

  1. Always call parent methods – When overriding __construct(), defineMappings(), or other methods, call the parent implementation first.

  2. Use array_replace_recursive() – When merging field mappings, use this instead of array_merge() to preserve nested arrays.

  3. 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"; }

  4. Register stages early – Register stages in the constructor so they’re available when the import runs.

  5. Use descriptive section names – Section names should be unique and describe the content they contain.

  6. Test with small CSVs first – Always test your template with a small CSV file before running large imports.

  7. 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.

×
1/1