Submission To Post
Turn each form submission into a WordPress post, with the answers filling the title, content, excerpt, featured image, taxonomy terms and custom fields.
Submission To Post is an add-on for Forms. Every form you edit gains a Submission To Post panel in its sidebar, where you describe one or more posts to create. After an accepted submission has been stored, each enabled configuration whose conditions match is queued to create a post — of the type you chose, with the status, author, date and taxonomy terms you chose, and with the visitor's answers filling the fields you mapped.
It is how a form becomes a submit-a-story page, a job board, a directory listing or a tip line whose entries arrive as real content rather than as rows in a list.
Nothing else about the form changes: validation, messages, redirects, notification emails and the Submissions list all behave as they did before. The submission is recorded either way — creating a post is in addition to the entry, not instead of it.
Where to find it
One place. There is no site-wide setting and no Integrations tab, because everything this needs lives on the form. Edit the form and open the Submission To Post panel in the document sidebar. It sits below the panels Forms provides itself — Submissions, Anti-spam protection, Messages, Redirect and Email Notifications — among the other add-ons, and the order within that group is not fixed.
Forms has to be active
The plugin does nothing on its own. Without XPAC Forms it registers no editor panel, no settings and no post creation, and shows an admin notice asking for Forms instead.
Creating a configuration
Edit the form, open the Submission To Post panel and press the round + button (Add action). The Create Configuration modal opens.
Under Post Settings, choose the Type — which kind of content to create. The list holds every post type on your site that has its own pages, so Posts and Pages plus anything a theme or plugin added. This is the one setting that must be filled in; the panel's list labels each row with the type you picked, which is how you tell two configurations apart.
Choose the Status the new post should have. Read the warning below this list first. A new configuration starts as Draft so visitor-written content waits for review.
Set Visibility, Format, Date and Author if the defaults are not what you want. Each is described in the table below.
Under Fields Mapping, add one row per piece of the post you want filled in. Post Field is the part of the post; Form Field is the question whose answer goes there.
Under Taxonomies, add an Items row for every category, tag or term you want put on every post this configuration creates, regardless of what the visitor answered.
Press Save in the modal — it stays disabled until you have changed something, and Discard closes the modal without keeping the changes — then update the form. Updating the form is what saves the configuration.
A form can hold as many configurations as you like, and each one creates its own post from the same submission — one for the visitor's story and one for a private copy in another post type, say. They run in the order they appear in the list. If a later configuration fails and you retry the delivery, configurations already completed by that delivery are skipped instead of creating duplicate posts.
Published makes visitor-written content live without review
A new configuration starts as Draft. Change it to Published on a public form and accepted visitor content can become live as soon as the background delivery runs. There is no editorial approval step between the form and the post.
Keep Status on Draft or use Pending review and read the new posts before they go out. Publishing straight away is a reasonable choice only for a form that visitors cannot reach — one behind a login, or on a page you gave only to people you trust. If the form owner is not allowed to publish this post type, the delivery reduces a published, scheduled or private status to Draft.
The form owner authorizes every post
The default Form owner option attributes the post to the account that owns the form. Custom User can attribute it to another existing account, but only when the form owner is allowed to edit other authors' posts of this type. If that user is deleted, or the owner loses permission to create the selected type or assign its taxonomies, the delivery fails instead of silently using the background worker's permissions.
The controls
Every one of these applies to the whole configuration, the same way on every submission. Nothing here can be decided by the visitor's answers — only the Fields Mapping rows read what was submitted.
| Control | What it sets | Default |
|---|---|---|
| Visibility | Public, or Password Protected with a password you type. | Public |
| Password | The masked post password, shown only when Visibility is Password Protected. | empty |
| Type | Which kind of content to create. Required. | Post |
| Status | Published, Draft, Pending review, Scheduled, Private — whichever statuses your site offers. | Draft |
| Format | The post format, for post types that use them. Hidden entirely for types that do not. | Standard |
| Date | Entry Date (the moment the form was submitted) or Custom Date & Time. | Entry Date |
| Author | Form owner or Custom User, plus a User picker for the latter. | Form owner |
| Comments | Whether the new post accepts comments. | off |
| Trackbacks & Pingbacks | Whether the new post accepts trackbacks and pingbacks. | off |
| Fields Mapping | Which answer fills which part of the post. | none |
| Taxonomies | Terms put on every post this configuration creates. | none |
The Comments and Trackbacks & Pingbacks switches set the new post's
comment_status and ping_status. Both are closed until you turn them on.
Complete every setting that opens another control
The editor refuses to save Password Protected without a password, Custom Date & Time without a valid date, or Custom User without a selected user. The delivery checks independently: incomplete or malformed settings written through REST, WP-CLI or an import fail before any post is created. Correct the form and retry the failed delivery.
Status and Date decide each other
WordPress will not hold a post in the past or publish one in the future, so these two settings override one another:
- Scheduled with Date left on Entry Date publishes immediately, because the submission's stored moment is not in the future when the delivery runs.
- Published with a Custom Date & Time in the future is scheduled instead, and appears when that moment arrives.
- Custom Date & Time needs a real date. A missing or malformed value fails the delivery before a post is created; a valid value is converted into the site's timezone before WordPress applies the status rules above.
Fields Mapping
Each row pairs one Post Field with one Form Field, and you add as many rows as you need. Form Field lists every eligible field on the form that holds a value, under its label, or under its field name where the label is blank. Password and confirmation controls are write-only and never appear in this list.
Post Field offers these:
| Post Field | What the answer becomes |
|---|---|
| Post Title | The post's title. |
| Post Content | The post's body. |
| Post Excerpt | The post's excerpt. |
| Featured Image | The post's featured image — only meaningful from a File Upload field. |
| Meta Field | A custom field. Choosing this turns the control into a text box; type the field's name into it. |
| A taxonomy name | Terms in that taxonomy, matched from what the visitor answered. The taxonomies of the chosen Type are listed under a — Taxonomies — separator. |
A row with Post Field left empty is ignored. With Form Field empty, an ordinary mapping reads an empty answer; a Featured Image row is invalid and fails the delivery because it does not name a File Upload field. Two rows pointing at the same Post Field are not an error either: for the title, content, excerpt, featured image and custom fields the later row wins, while two rows pointing at the same taxonomy add their terms together.
A legacy, imported, or tampered mapping that names a Password or Confirmation field fails before any post is created. The write-only answer is also absent from Forms' queued delivery payload, so it cannot become post content or metadata through an old configuration.
The mapped title, content and excerpt pass through WordPress's normal post filters: plain formatting survives, while scripts and disallowed embeds do not. Custom-field values do not pass through that HTML filtering; treat a mapped custom field as visitor-controlled data whenever a theme or plugin later renders or acts on it. Literal backslashes are preserved in all four destinations — title, content, excerpt and public custom fields — rather than being consumed as WordPress unslashes the insert. A password typed into Password Protected preserves them too.
Map at least the title, the content or the excerpt
For Posts, Pages and the usual content types, if a configuration fills none of those three — no mapping at all, or mappings the visitor left blank — WordPress's empty-content check refuses the post. The submission is still safely recorded and the visitor is not asked to resend it; the background job appears as a failed delivery with WordPress's reason under XPAC → Forms → Deliveries. A custom post type that removes title/editor support, or a site filtering WordPress's empty-content check, can allow an untitled empty row instead; map meaningful content regardless.
So a form whose Post Title is mapped to an optional question creates posts for the visitors who answered it and a failed delivery for the rest. Map the title to a required field.
A taxonomy mapping only matches terms that already exist
A Post Field set to a taxonomy matches the visitor's answer against the terms you already have — by ID, then by slug, then by name. Nothing new is created. An answer that matches nothing is dropped, and there is no message about it, so this works with a dropdown or checklist built from your existing terms and does not work with a free-text box.
Before creating the post, the delivery verifies that the form owner may assign every configured taxonomy to this post type and that every term selected directly in the configuration still exists. A deleted fixed term is a configuration error and fails before insert; a visitor's unmatched mapped answer is still dropped as described above. Fixed choices and resolved mapped answers are assigned after WordPress creates the post, then read back and compared with the intended term IDs. A permanent configuration error removes the partly created post and fails the delivery; a database write/read failure is retryable and the delivery's deterministic identity prevents a duplicate post on the retry.
Featured Image must map to a File Upload field
The field picker offers only File Upload fields for Featured Image, and the server refuses a stale or hand-written mapping to any other field before it creates a post. From the selected upload it takes the first value that is a real image attachment belonging to this form. A field that accepts a PDF followed by an image therefore uses the image rather than stopping at the PDF.
The form owner must also have WordPress's upload_files capability. Without it,
the delivery refuses the thumbnail instead of letting a background worker publish
media the configuring account could not publish itself.
Publishing an attachment as a post's thumbnail makes it public. Do not use this mapping for private or confidential uploads unless that publication is deliberate.
Meta Field names are taken literally
Whatever you type becomes the custom field's name, with spaces turned into
underscores by the editor and nothing else changed. Two consequences worth knowing.
Typing one
of the reserved words title, content, excerpt or featured_image fills that
part of the post instead of creating a custom field of that name. And typing the
name of a field WordPress, your theme or another plugin already uses hands control
of it to the visitor — page templates and featured images are stored that way — so
use names of your own. WordPress-protected keys, normally those beginning with _,
Editing and removing a configuration
Each row in the panel shows the post type it creates, a pencil (Edit action) that reopens the modal as Edit Configuration, and a red X (Remove action) that asks Are you sure you want to remove the item? before acting.
Removing a row removes the row you chose. Its on/off switch lets you park a
configuration without deleting it; an older configuration with no stored status
is treated as on. With Conditional Logic installed, you can also make a configuration
run only for submissions whose answers match its conditions.
When the post is created
Forms first validates and processes the submission, stores its entry and queues the applicable integrations. Submission To Post then runs as an Action Scheduler delivery, off the visitor's request. The visitor can therefore receive the form's success message before the post exists.
The Deliveries tab is the source of truth. A successful job says Delivered; a
wp_insert_post() refusal is recorded as Failed with its reason and can be retried
after you fix the configuration. Deleting or losing the stored submission payload
removes the data a retry needs. Make sure WP-Cron or its server-cron replacement runs,
as described in the Forms production checklist.
Other integrations are separate jobs with no ordering guarantee. A webhook, for example, may run before the post exists even when both came from the same submission. Use a synchronous Forms pre-submit callback for work another delivery truly depends on, accepting that the visitor then waits for it.
When no post is created
| Situation | What you can observe |
|---|---|
| XPAC Forms is not active | Nothing is registered at all, so no form creates posts. |
| The configuration is switched off | Intentional; the delivery job filters it out. |
| A Conditional Logic condition is not met | Intentional; no post is needed for that submission. |
| The configuration has no Type | The editor always supplies one, but hand-written settings can omit it; the action is skipped. |
| Title, content and excerpt are all empty | The delivery is Failed with WordPress's refusal. See the warning above. |
| The form owner was deleted or cannot create the selected type | The delivery is Failed and no post is kept. Transfer the form to a suitable owner or restore the capability, then retry. |
| The selected post type or status no longer exists | The delivery is Failed and no post is kept. Correct the configuration, then retry. |
| The stored post format is unknown for a type that supports formats | The delivery is Failed before insert. Choose a format the current site supports. |
| A custom author, protected meta key, deleted fixed term or taxonomy is not allowed | The delivery is Failed before the post is created. Correct the configuration or the form owner's capabilities, then retry. |
A Featured Image is configured but the form owner lacks upload_files | The delivery is Failed before the post is created. Grant the capability or remove the mapping. |
For developers
Per-configuration settings live in the form's form_settings post meta on the
xpac-form post type, under a submissionToPost key, registered on the Forms meta
schema and therefore readable and writable over the REST API at
/wp-json/wp/v2/xpac-form/<id>. The default is an empty items array:
{
"submissionToPost": {
"items": [
{
"status": true,
"postVisibility": "public",
"postPassword": "",
"postType": "post",
"postStatus": "draft",
"postFormat": "standard",
"postDateType": "",
"postDate": "",
"postAuthorType": "",
"postAuthor": 0,
"commentStatus": false,
"pingStatus": false,
"postTaxonomies": [{ "taxonomy": "category", "terms": [3] }],
"map": [{ "post": "title", "block": "subject" }]
}
]
}
}postDateType and postAuthorType are each '' or 'custom'; postDate and
postAuthor are read only in the custom case. The empty date type uses the stored
entry timestamp, and the empty author type uses the form owner. postVisibility is
public or protected, and postPassword is read only when it is protected. In a map row,
post is one of title, content, excerpt, featured_image, a
taxonomy:<slug> string, or any other non-empty string, which is treated as a post
meta key; block is the form field's name attribute. Nothing normalises a map
row, so post and block are used exactly as stored.
Items are declared with additionalProperties: true. status is a boolean and
condition declares Conditional Logic's relation and dependency rows. Forms' shared
action-item filter uses $item['status'] ?? true under the module id
xpac-forms-submission-to-post, so an older item with no status still runs. Editor
REST saves and Forms import validate and sanitize the object against the registered
schema. Code that writes post meta directly can bypass REST; the delivery validates
the security-sensitive selections and defaults optional keys, but a direct writer is
still responsible for supplying the registered array/object shapes.
The insert
The public route that starts the process is Forms' existing submit route:
POST /wp-json/xpac/v1/form/<form-id>/<instance>/submitThe plugin registers no REST routes of its own and no blocks. The public route
validates and stores the entry, then Deliveries\Dispatcher schedules
Addon::insertPost() as its own Action Scheduler job. The rebuilt submission runs
without the front-end visitor's WordPress capabilities. The form owner is treated as
the configuring actor instead. Four consequences are worth knowing before you build
on this:
- the owner must exist and hold the target post type's
create_postscapability; post_authoris the form owner unless a permittedpostAuthorType: 'custom'names another existing user;- public, private and future statuses are reduced to
draftwhen the form owner lacks the post type's publish capability; and - fixed and mapped terms are applied with
wp_set_post_terms()after the insert, after verifying that the owner can assign the taxonomy and every configured fixed term still exists. Mapped values are narrowed to existing term IDs first, and the stored relationship is read back before the action is marked complete.
Mapped post meta consults no registered-meta value sanitizer. The key is used
verbatim, except that is_protected_meta() keys are rejected and the form owner must
be allowed to edit that key. Values are stored through update_post_meta() with the
slashing WordPress expects and read back before the action is accepted. Allowlist and
escape them at the point where custom application code consumes them.
wp_insert_post( $postArr, true ) returns a WP_Error on refusal. The callback adds
HTTP status 422 and returns it, so the delivery runner records a permanent failure
instead of automatically repeating the same invalid configuration. An owner can
retry it after correcting the form. A missing postType is skipped; an unregistered
type is a failed delivery.
Each configuration is given a delivery action key made from its position and settings. The key is recorded after its post, terms, thumbnail and format all succeed. A retry therefore resumes at the first unfinished configuration instead of duplicating posts made before a later one failed. Editing or reordering a configuration changes its key and deliberately makes it new work.
Title, content and excerpt are passed through wp_slash() so literal backslashes
survive WordPress's input contract, then pass through kses on the way in:
wp_insert_post()
runs sanitize_post( $postarr, 'db' ), and for a request with no unfiltered_html
user the title_save_pre, content_save_pre and excerpt_save_pre filters are
registered. Visitor-submitted markup is therefore filtered, not raw. Post meta values
are not filtered — meta_input bypasses that path entirely.
A featured_image mapping goes through mappedImageIds(), and its form owner must
hold upload_files. It must name an
xp/form-field-file block; each submitted ID must be an image attachment whose
parent matches the upload-parent filter for that form; duplicates and invalid values
are removed; and the first surviving image becomes the thumbnail. This closes the
old arbitrary-attachment mapping. On a public submit request,
Submission::withoutForgedFileAnswers() first removes attachment IDs supplied in the
body and the uploader replaces them with the IDs it actually created. A delivery retry
keeps the already stored IDs from that accepted entry.
A taxonomy:<slug> mapping resolves each submitted value to a term ID by numeric ID,
then slug, then name. Unmatched values are dropped and no term is ever created. Fixed
term IDs from postTaxonomies are stricter: every one must still exist before insert.
assignTerms() checks wp_set_post_terms(), reads the stored IDs back and returns a
retryable 503-shaped error for a database failure or mismatch.
Hooks
This package fires two shared filter call sites and no add-on-specific hook name. The
editor modal applies xpac-forms-after-settings-modal-content; the featured-image
check applies the dynamic
xpac_form_upload_{$blockName}_parent_post_id filter so it resolves the same expected
attachment parent as Forms' uploader. The generated hook
reference holds their current
signatures and source locations.
Anything you want to change about the post itself is also reachable through
WordPress's wp_insert_post_data and wp_insert_post hooks, or through Forms'
xpac_forms_action_item_status filter for whether a configuration runs.
It attaches to these hooks, all owned elsewhere:
| Hook | What this plugin does on it |
|---|---|
plugins_loaded | Registers itself, or falls back to the dependency notice. |
admin_notices | Renders Please install and activate "XPAC Forms" plugin. when Forms is absent. |
xpac_forms_init | Registers the editor asset, settings schema/defaults and delivery callback. |
xpac_forms_post_script_dependencies | Adds its editor script as a dependency of the form editor's. |
xpac_forms_post_meta_schema | Declares the submissionToPost shape. |
xpac_forms_post_meta_default_values | Declares the empty items default. |
xpac_forms_submit_callbacks | Registers Addon::insertPost() for queued delivery. |
The last hook name is assembled at runtime as xpac_forms_{$action}_callbacks, where
$action is pre_submit then submit. The submit callbacks are scheduled as
independent jobs at the same second; registration order is not execution order and
different workers may run them concurrently.
On the editor side the panel is contributed from JavaScript through the
xpac-forms-form-panels filter. It declares no priority, which is why it sorts below
every core Forms panel and why its position relative to other add-ons' panels is not
stable. Its modal can be extended through the
xpac-forms-after-settings-modal-content filter, which receives the configuration's
current options with an onChange callback and is passed xpac-submission-to-post
as the module it is filtering.
Nothing in the path is licence-gated.