Template to make the network work. Data and structure synthesis content . For developers, this is our coolest superpower – get some data and then have it work for us, rendering it in whatever form we need. An array of objects can be turned into a table, a list of cards, a chart, or anything we think is most useful to the user. Whether the data is a blog post in our own Markdown file or the latest global exchange rate, the markup and the ultimate user experience are up to us front-end developers.
PHP is a powerful language for templates that provides many ways to merge data with tags. Let's look at an example of using data to build HTML forms.
Want to get started now? Jump to the implementation section.
In PHP, we can inline the variable into a string literal using double quotes, so if we have a variable $name = 'world'
we can write echo "Hello, {$name}"
which prints the expected "Hello, world". For more complex templates, we can always concatenate strings, for example: echo "Hello, " . $name . "."
.
For old programmers, there is also printf("Hello, %s", $name)
. For multi-line strings, Heredoc can be used (similar to Hello, = $name ?>
). All of these options are great, but things can get messy when a lot of inline logic is needed. If we need to build composite HTML strings, such as forms or navigation, then the complexity may be infinite, because HTML elements can be nested with each other.
Before we keep doing what we want to do, it’s worth a minute to think about what we don’t want to do. Consider the excerpt from line 170-270 of WordPress core code class-walker-nav-menu.php
:
<?php // class-walker-nav-menu.php // ... $output .= $indent . '<li' . $id . $class_names . '?> '; // ... $item_output = $args->before; $item_output .= ' <a .="">'; $item_output .= $args->link_before . $title . $args->link_after; $item_output .= '</a> '; $item_output .= $args->after; // ... $output .= apply_filters( 'walker_nav_menu_start_el', $item_output, $item, $depth, $args ); // ... $output .= "{$n}";
To build the navigation, in this function we use a variable $output
, which is a very long string, and we keep adding content to it. This type of code has a very specific and limited order of operation. If we want to<a></a>
Add a property that we have to access $attributes
before it runs. If we want to<a></a>
Optionally nest one<span></span>
Or a<img alt="Building a Form in PHP Using DOMDocument" >
, we need to write a brand new code block, replacing the middle part of line 7 with about 4-10 new lines of code, depending on what we want to add. Now imagine you need to optionally add<span></span>
, then optionally add<img alt="Building a Form in PHP Using DOMDocument" >
, or in<span></span>
Add internally or later. This itself is three if statements, making the code harder to read. When concatenating strings like this, it's easy to end up with string pasta, which is fun to say but painful to maintain.
The essence of the problem is that when we try to infer HTML elements, we are not thinking about strings . The browser just consumes strings, and PHP outputs strings. But our mental model is more like a DOM—elements are arranged in a tree-like structure, and each node has many potential properties, characteristics and child nodes.
Wouldn't it be great if there is a structured, highly expressive way to build a tree?
enter……
PHP 5 adds DOM modules to its non-strictly typed lineup. Its main entry point is the DOMDocument class, which is intended to be similar to the JavaScript DOM of the Web API. If you have ever used document.createElement
, or for some of our ages, use jQuery's $('
Hi there!
$dom = new DOMDocument();
Now we can add a DOMElement to it:
$p = $dom->createElement('p');
The string 'p' represents the element type we want, so other valid strings can be 'div', 'img', etc.
Once we have an element, we can set its properties:
$p->setAttribute('class', 'headline');
We can add child nodes to it:
$span = $dom->createElement('span', 'This is a headline'); // The second parameter fills the textContent of the element $p->appendChild($span);
Finally, get the complete HTML string at once:
$dom->appendChild($p); $htmlString = $dom->saveHTML(); echo $htmlString;
Note that this coding style allows our code to be organized according to our mental model—the document contains elements; elements can have any number of attributes; elements are nested with each other without any information about each other. The "HTML is just a string" part appears at the end, once our structure is in place. "Document" is slightly different from the actual DOM here, because it does not need to represent the entire document, but is just an HTML block. In fact, if you need to create two similar elements, you can save an HTML string using saveHTML()
, further modify the DOM "document", and then save a new HTML string by calling saveHTML()
again.
Suppose we need to build a form on the server using data from the CRM provider and our own tags. The API response from CRM looks like this:
{ "submit_button_label": "Submit now!", "fields": [ { "id": "first-name", "type": "text", "label": "First name", "required": true, "validation_message": "First name is required.", "max_length": 30 }, { "id": "category", "type": "multiple_choice", "label": "Choose all categories that apply", "required": false, "field_metadata": { "multi_select": true, "values": [ { "value": "travel", "label": "Travel" }, { "value": "marketing", "label": "Marketing" } ] } } ] }
This example does not use the exact data structure of any particular CRM, but it is quite representative.
And suppose we want our tags to look like this:
<label> <input type="text" placeholder=" " id="first-name" required maxlength="30"> First name <em>First name is required.</em> </label> <label> </label> <div> <input type="checkbox" value="travel" id="category-travel" name="category"> <label for="category-travel">Travel</label> </div> <div> <input type="checkbox" value="marketing" id="category-marketing" name="category"> <label for="category-marketing">Marketing</label> </div> Choose all categories that apply
What is the placeholder " "? This is a little trick that allows us to track whether the field is empty in CSS without using JavaScript. As long as the input is empty, it matches input:placeholder-shown
, but the user will not see any visible placeholder text. This is exactly what we can do when we control the markup!
Now that we know what we want to achieve, here is the game plan:
So let's build our process and solve some technical problems:
<?php function renderForm ($endpoint) { // Get data from the API and convert it into a PHP object $formResult = file_get_contents($endpoint); $formContent = json_decode($formResult); $formFields = $formContent->fields; // Start building the DOM $dom = new DOMDocument(); $form = $dom->createElement('form'); // Iterate over fields and build each field foreach ($formFields as $field) { // TODO: perform some operations on field data} // Get HTML output $dom->appendChild($form); $htmlString = $dom->saveHTML(); echo $htmlString; }
So far, we have fetched and parsed the data, initialized our DOMDocument and echoed its output. What do we want to do with each field? First, let's build the container element, in our example, it should be<label></label>
, and tags common to all field types:
<?php // ... // Iterate over fields and build each field foreach ($formFields as $field) { // Build <label>container``` $element = $dom->createElement('label'); $element->setAttribute('class', 'field'); // Reset the input value $label = null; // If a tag is set, add `` <span>` if ($field->label) { $label = $dom->createElement('span', $field->label); $label->setAttribute('class', 'label'); } // Add tag to `` <label>` if ($label) $element->appendChild($label); }</label></span><p> Since we are in a loop and PHP does not scope variables in the loop, we reset the <code>$label</code> element on each iteration. Then, if the field has a label, we build the element. Finally, we append it to the container element.</p><p> Please note that we use the <code>setAttribute</code> method to set the class. Unlike the Web API, unfortunately, there is no special treatment for class lists. They are just another attribute. If we have some very complex class logic, since it is Just PHP™, we can create an array and inline it: <code>$label->setAttribute('class', implode($labelClassList))</code> .</p><h3> Single input</h3><p> Since we know that the API will only return specific field types, we can switch types and write specific code for each type:</p><pre class="brush:php;toolbar:false"> <?php // ... // Iterate over fields and build each field foreach ($formFields as $field) { // Build <label>container``` $element = $dom->createElement('label'); $element->setAttribute('class', 'field'); // Reset the input value $input = null; $label = null; // If a tag is set, add `` <span>` // ... // Build input element switch ($field->type) { case 'text': case 'email': case 'telephone': $input = $dom->createElement('input'); $input->setAttribute('placeholder', ' '); if ($field->type === 'email') $input->setAttribute('type', 'email'); if ($field->type === 'telephone') $input->setAttribute('type', 'tel'); break; } }</span><p> Now let's deal with text areas, single checkboxes, and hidden fields:</p><pre class="brush:php;toolbar:false"> <?php // ... // Iterate over fields and build each field foreach ($formFields as $field) { // Build <label>container``` $element = $dom->createElement('label'); $element->setAttribute('class', 'field'); // Reset the input value $input = null; $label = null; // If a tag is set, add `` <span>` // ... // Build input element switch ($field->type) { //... case 'text_area': $input = $dom->createElement('textarea'); $input->setAttribute('placeholder', ' '); if ($rows = $field->field_metadata->rows) $input->setAttribute('rows', $rows); break; case 'checkbox': $element->setAttribute('class', 'field single-checkbox'); $input = $dom->createElement('input'); $input->setAttribute('type', 'checkbox'); if ($field->field_metadata->initially_checked === true) $input->setAttribute('checked', 'checked'); break; case 'hidden': $input = $dom->createElement('input'); $input->setAttribute('type', 'hidden'); $input->setAttribute('value', $field->field_metadata->value); $element->setAttribute('hidden', 'hidden'); $element->setAttribute('style', 'display: none;'); $label->textContent = ''; break; } }</span><p> Notice some new actions we've done with checkboxes and hidden situations? We not only created<code><input></code> element; we are still changing <em>the container</em><code><label></label></code> element! For a single checkbox field, we want to modify the container's class so that we can horizontally align the checkboxes and labels;<code><label></label></code> The container should also be completely hidden.</p><p> Now, if we just connect the string, then it cannot be changed at this point. We have to add a bunch of if statements about element types and their metadata at the top of the block. Or, perhaps worse, we start switching earlier and copy-paste a lot of public code between each branch.</p><p> And that's the real benefit of using a builder like DOMDocument - everything is still editable and everything is still structured until we click <code>saveHTML()</code> .</p><h3> Nested loop elements</h3><p> Let's add<code><select></select></code> The logic of the element:</p><pre class="brush:php;toolbar:false"> <?php // ... // Iterate over fields and build each field foreach ($formFields as $field) { // Build <label>container``` $element = $dom->createElement('label'); $element->setAttribute('class', 'field'); // Reset the input value $input = null; $label = null; // If a tag is set, add `` <span>` // ... // Build input element switch ($field->type) { //... case 'select': $element->setAttribute('class', 'field select'); $input = $dom->createElement('select'); $input->setAttribute('required', 'required'); if ($field->field_metadata->multi_select === true) $input->setAttribute('multiple', 'multiple'); $options = []; // Track whether there is a preselected option $optionSelected = false; foreach ($field->field_metadata->values as $value) { $option = $dom->createElement('option', htmlspecialchars($value->label)); // If there is no value, skip if (!$value->value) continue; // Set preselected options if ($value->selected === true) { $option->setAttribute('selected', 'selected'); $optionSelected = true; } $option->setAttribute('value', $value->value); $options[] = $option; } // If there is no preselected option, build an empty placeholder option if ($optionSelected === false) { $emptyOption = $dom->createElement('option'); // Set options to hide, disable and select foreach (['hidden', 'disabled', 'selected'] as $attribute) $emptyOption->setAttribute($attribute, $attribute); $input->appendChild($emptyOption); } // Add options in the array to ` <select>` foreach ($options as $option) { $input->appendChild($option); } break; } }</select></span><p> OK, there are a lot to do here, but the underlying logic is the same. External settings<code><label></label></code> After that, we create a<code><option></option></code> array, append it to it.</p><p> We've done some here, too<code><select></select></code> Special trick: If there is no pre-select option, we will add an already selected empty placeholder option, but the user cannot select it. The goal is to use CSS to transfer our<code><label></label></code> As a "placeholder", but this technology can be used in a variety of designs. By appending it to <code>$input</code> before appending other options, we make sure it is the first option in the tag.</p><p> Let's deal with it now<code><fieldset></fieldset></code> Radio buttons and check boxes in:</p><pre class="brush:php;toolbar:false"> <?php // ... // Iterate over fields and build each field foreach ($formFields as $field) { // Build <label>container``` $element = $dom->createElement('label'); $element->setAttribute('class', 'field'); // Reset the input value $input = null; $label = null; // If a tag is set, add `` <span>` // ... // Build input element switch ($field->type) { // ... case 'multiple_choice': $choiceType = $field->field_metadata->multi_select === true ? 'checkbox' : 'radio'; $element->setAttribute('class', "field {$choiceType}-group"); $input = $dom->createElement('fieldset'); // Build a selection `` for each option in the fieldset</span> foreach ($field->field_metadata->values as $choiceValue) { $choiceField = $dom->createElement('div'); $choiceField->setAttribute('class', 'choice'); // Use field ID to select ID to set a unique ID $choiceID = "{$field->id}-{$choiceValue->value}"; // Build`<input> `Element $choice = $dom->createElement('input'); $choice->setAttribute('type', $choiceType); $choice->setAttribute('value', $choiceValue->value); $choice->setAttribute('id', $choiceID); $choice->setAttribute('name', $field->id); $choiceField->appendChild($choice); // Build ` <label>``element $choiceLabel = $dom->createElement('label', $choiceValue->label); $choiceLabel->setAttribute('for', $choiceID); $choiceField->appendChild($choiceLabel); $input->appendChild($choiceField); } break; } }</label><p> So, first we determine whether the field set should be a checkbox or a radio button. Then we set the container class accordingly and build<code><fieldset></fieldset></code> . After that, we iterate over the available options and build one for each option<code><div> , which contains one<code><input></code> And one<code><label></label></code> . Note that we set the container class on line 21 using regular PHP string interpolation and create a unique ID in line 30 for each option.<h3> Fragment</h3> <p> The last type we have to add is a little more complex than it seems. Many forms contain description fields that are not inputs, but just some HTML that we need to print between the other fields.</p> <p> We need to use another DOMDocument method <code>createDocumentFragment()</code> . This allows us to add arbitrary HTML without using the DOM structure:</p> <pre class="brush:php;toolbar:false"> <?php // ... // Iterate over fields and build each field foreach ($formFields as $field) { // Build <label>container``` $element = $dom->createElement('label'); $element->setAttribute('class', 'field'); // Reset the input value $input = null; $label = null; // If a tag is set, add `` <span>` // ... // Build input element switch ($field->type) { //... case 'instruction': $element->setAttribute('class', 'field text'); $fragment = $dom->createDocumentFragment(); $fragment->appendXML($field->text); $input = $dom->createElement('p'); $input->appendChild($fragment); break; } }</span><p> At this point you might be wondering how we get an object named <code>$input</code> which actually represents a static<code><p> element. The goal is to use a common variable name for each iteration of the field loop, so in the end we can always add it with <code>$element->appendChild($input)</code> regardless of the actual field type. So, yes, naming things is hard.</p> <h3> verify</h3> <p> The API we are using provides a separate verification message for each required field. If a commit error occurs, we can display the error inline with the field instead of showing a common "Oops, your wrong" message at the bottom.</p> <p> Let's add the validation text to each element:</p> <pre class="brush:php;toolbar:false"> <?php // ... // Iterate over fields and build each field foreach ($formFields as $field) { // Build <label>container``` $element = $dom->createElement('label'); $element->setAttribute('class', 'field'); // Reset the input value $input = null; $label = null; $validation = null; // If a tag is set, add `` <span>` // ... // If a verification message is set, add `` <em>` if (isset($field->validation_message)) { $validation = $dom->createElement('em'); $fragment = $dom->createDocumentFragment(); $fragment->appendXML($field->validation_message); $validation->appendChild($fragment); $validation->setAttribute('class', 'validation-message'); $validation->setAttribute('hidden', 'hidden'); // initially hidden, if there is an error in the field, it will be unhidden using Javascript} // Build input element switch ($field->type) { // ... } }</em></span><p> That's all! No need to deal with field type logic – just conditionally build one element for each field.</p><h3> Integrate all content</h3><p> So what happens after we build all the field elements? We need to add <code>$input</code> , <code>$label</code> and <code>$validation</code> objects to the DOM tree we are building. We can also take advantage of this opportunity to add public properties, such as <code>required</code> . We will then add the submit button, which is separate from the fields in this API.</p><pre class="brush:php;toolbar:false"> <?php function renderForm ($endpoint) { // Get data from API and convert it into PHP object // ... // Start building the DOM $dom = new DOMDocument(); $form = $dom->createElement('form'); // Iterate over fields and build each field foreach ($formFields as $field) { // Build <label>container``` $element = $dom->createElement('label'); $element->setAttribute('class', 'field'); // Reset the input value $input = null; $label = null; $validation = null; // If a tag is set, add `` <span>` // ... // If a verification message is set, add `` <em>` // ... // Build input element switch ($field->type) { // ... } // Add input element if ($input) { $input->setAttribute('id', $field->id); if ($field->required) $input->setAttribute('required', 'required'); if (isset($field->max_length)) $input->setAttribute('maxlength', $field->max_length); $element->appendChild($input); if ($label) $element->appendChild($label); if ($validation) $element->appendChild($validation); $form->appendChild($element); } } // Build the submit button $submitButtonLabel = $formContent->submit_button_label; $submitButtonField = $dom->createElement('div'); $submitButtonField->setAttribute('class', 'field submit'); $submitButton = $dom->createElement('button', $submitButtonLabel); $submitButtonField->appendChild($submitButton); $form->appendChild($submitButtonField); // Get HTML output $dom->appendChild($form); $htmlString = $dom->saveHTML(); echo $htmlString; }</em></span></label><p> Why do we check if <code>$input</code> is true? Since we reset it to null at the top of the loop and build it only if the type matches the switch case we expect, this ensures that we don't accidentally include unexpected elements that our code cannot handle correctly.</p><p> Look, a custom HTML form!</p><h3> Additional content: rows and columns</h3><p> As you know, many form builders allow authors to set rows and columns for fields. For example, a row might contain both first and last name fields, each in a separate 50% width column. So, you might ask, how can we achieve this? Of course, let’s illustrate the loop-friendliness of DOMDocument by giving examples!</p><p> Our API response contains grid data as shown below:</p><pre class="brush:php;toolbar:false"> { "submit_button_label": "Submit now!", "fields": [ { "id": "first-name", "type": "text", "label": "First name", "required": true, "validation_message": "First name is required.", "max_length": 30, "row": 1, "column": 1 }, { "id": "category", "type": "multiple_choice", "label": "Choose all categories that apply", "required": false, "field_metadata": { "multi_select": true, "values": [ { "value": "travel", "label": "Travel" }, { "value": "marketing", "label": "Marketing" } ] }, "row": 2, "column": 1 } ] }
We assume that adding data-column
property is enough to set the width, but each row requires its own element (i.e. without a CSS grid).
Before we dig deeper, let's consider what we need to add rows. The basic logic is as follows:
Now, what should we do if we are concatenating strings? Probably adding a string similar to ' <div>'</div>
every time a new line is reached. This "reverse HTML string" is always very confusing to me, so I can only imagine what it feels like to be my IDE. Most importantly, because the browser automatically closes open tags, a simple typo can lead to countless nested<div> . It's like it's fun, but it's the opposite. So, what is the structured approach to dealing with this problem? Thank you for your question. First, let's add row traces before the loop and build an extra row container element. We will then make sure to append each container <code>$element
to its $rowElement
instead of directly appending to $form
.
<?php function renderForm ($endpoint) { // Get data from API and convert it into PHP object // ... // Start building the DOM $dom = new DOMDocument(); $form = $dom->createElement('form'); // Initialize row tracking $row = 0; $rowElement = $dom->createElement('div'); $rowElement->setAttribute('class', 'field-row'); // Iterate over fields and build each field foreach ($formFields as $field) { // Build <label>container``` $element = $dom->createElement('label'); $element->setAttribute('class', 'field'); $element->setAttribute('data-row', $field->row); $element->setAttribute('data-column', $field->column); // Add input element to line if ($input) { // ... $rowElement->appendChild($element); $form->appendChild($rowElement); } } // ... }</label><p> So far we've just added another around the field<code><div> . Let's build a <em>new</em> row element for each row within the loop:<pre class="brush:php;toolbar:false"> <?php // ... // Initialize row tracking $row = 0; $rowElement = $dom->createElement('div'); $rowElement->setAttribute('class', 'field-row'); // Iterate over fields and build each field foreach ($formFields as $field) { // ... // If we reach a new row, create a new $rowElement if ($field->row > $row) { $row = $field->row; $rowElement = $dom->createElement('div'); $rowElement->setAttribute('class', 'field-row'); } // Build input element switch ($field->type) { // ... // Add input element to line if ($input) { // ... $rowElement->appendChild($element); // Automatically deduplicate $form->appendChild($rowElement); } } }
We just need to overwrite the $rowElement
object as a new DOM element, which PHP treats as a new unique object. So at the end of each loop we just append the current $rowElement
- if it's the same element as the previous iteration, the form is updated; if it's a new element, it's appended to the end.
Forms are a great use case for object-oriented templates. Given the snippets in WordPress core code, it can be considered that nested menus are also a good use case. Any task that marks follow complex logic is a good candidate for this approach. DOMDocument can output any XML, so you can also use it to build RSS feeds from post data.
Here is the complete code snippet of our form. Feel free to adjust it to any form API you find yourself working on. This is the official documentation and it is helpful to understand the available APIs.
We haven't even mentioned that DOMDocument can parse existing HTML and XML. You can then use the XPath API to find elements, which is somewhat similar to cheerio on document.querySelector
or Node.js. The learning curve is a bit steep, but it is a very powerful API for handling external content.
Fun fact: Microsoft Office files ending in x (such as .xlsx) are XML files. Don't tell the marketing department, but you can parse Word documents on the server and output HTML.
The most important thing is to remember that templates are superpowers. Being able to build the right markup for the right situation can be the key to getting a great user experience.
The above is the detailed content of Building a Form in PHP Using DOMDocument. For more information, please follow other related articles on the PHP Chinese website!