Episode 3 – WordPress Plugin Development with WooCommerce
The form
Now we put controls on the page. Four of them:
- Show the sale badge. Yes or no.
- The badge text.
- Show the free shipping notice. Yes or no.
- The free shipping amount.
At the end of this episode the form looks finished, and it saves nothing. That is on purpose. You will see exactly why, and the reason is the whole point of the episode.
We work in class-orb-settings.php, inside the render method only.
Step 1. Read the four settings
Replace the whole body of render with this, for now:
public function render() {
$badge_enabled = get_option( 'orb_badge_enabled', 'yes' );
$badge_text = get_option( 'orb_badge_text', 'Hot Deal' );
$notice_enabled = get_option( 'orb_notice_enabled', 'yes' );
$threshold = get_option( 'orb_threshold', 50 );
?>
<div class="wrap">
<h1>Order Boost</h1>
<p>Badge text is: <?php echo esc_html( $badge_text ); ?></p>
</div>
<?php
}
Save. Reload the page. You see: Badge text is: Hot Deal.
Nothing is in the database. So where did “Hot Deal” come from?
What get_option does
WordPress has a table called wp_options. It is a very simple thing. One column for a name, one column for a value. That is all. Site title, admin email, timezone, and now our four settings. They all sit in the same table.
Think of a wall of numbered lockers. get_option opens one locker by name and shows you what is inside.
get_option( 'orb_badge_text', 'Hot Deal' );
The first value is the locker name. The second value is what you get when the locker is empty. That second value is the default.
This matters more than it looks. A brand new install has no settings. No customer has visited your screen yet. Without a default, your plugin gets nothing back, and a badge with no text prints an empty box. With a default, a fresh install works correctly on day one, before anybody touches anything.
Rule to keep for life: every get_option gets a default. No exceptions.
Why esc_html on the way out
Look at the line that prints the text:
<?php echo esc_html( $badge_text ); ?>
That value came out of the database. You do not know what is in there. A different admin may have typed it. An old import may have put junk in it. A bad plugin may have written to the same locker.
esc_html makes the value safe to print as text. If the value holds <script>, the browser shows those letters and does not run them.
Real world: a printing press. You send it words, and it prints words. You do not want the words to become instructions for the press.
The habit is simple: sanitize on the way in, escape on the way out. We do the “in” half in the next episode. This is the “out” half.
Step 2. Add the form and the table
Now replace the small <div class="wrap"> block with this bigger one. Keep the four get_option lines above it.
?>
<div class="wrap">
<h1>Order Boost</h1>
<form method="post">
<table class="form-table" role="presentation">
</table>
<p class="submit">
<input type="submit" name="orb_save" class="button button-primary" value="Save Changes">
</p>
</form>
</div>
<?php
Save and reload. You see the heading and a blue Save Changes button. The table is empty, so there is nothing between them yet.
method="post"
A browser can send data two ways.
GET puts the data in the address bar. It is a postcard. Anybody near you reads it, the browser keeps it in history, and the server may log it.
POST puts the data in the body of the request. It is a sealed envelope.
The rule is not only about secrecy. GET is for asking. POST is for changing. A search box uses GET. A save button uses POST. Anything that writes to the database uses POST.
No action attribute
The <form> tag has no action. That is deliberate, not lazy. With no action, the browser sends the data back to the same address it is on. Our page is at admin.php?page=order-boost, so the data comes back to admin.php?page=order-boost.
That keeps us on the page after saving, and it keeps the address correct on any site, on any domain. You never hard code a URL.
class="form-table"
This class belongs to WordPress. Every core settings screen uses it. It gives you the two column layout, the grey labels on the left, the spacing, and it stacks correctly on a phone. You get all of that for free, and your plugin looks like it belongs in the admin.
role="presentation" tells screen readers “this table is a layout, not data”. A table of orders is data. A table of form rows is not. Without that word, a screen reader reads out “table, four rows, two columns” to a blind user, which helps nobody.
The button
<input type="submit" name="orb_save" class="button button-primary" value="Save Changes">
Note the name="orb_save". Remember it. In the next episode, that name is how our code knows our form was sent, and not some other form on some other screen.
Step 3. The first row, in detail
Type this inside the <table> tags:
<tr>
<th scope="row"><label for="orb_badge_enabled">Show sale badge</label></th>
<td>
<select name="orb_badge_enabled" id="orb_badge_enabled">
<option value="yes" <?php selected( $badge_enabled, 'yes' ); ?>>Yes</option>
<option value="no" <?php selected( $badge_enabled, 'no' ); ?>>No</option>
</select>
<p class="description">Show a badge on products that are on sale.</p>
</td>
</tr>
Save and reload. You get a proper row: a grey label on the left, a dropdown on the right, and small grey help text under it.
Four things in there deserve your attention.
name and id are not the same job. People mix these up every week.
nameis the label on the parcel that goes to the server. When the form is sent, the server seesorb_badge_enabledbecause ofname. Change thename, and the save code stops finding the value.idis only for the browser. It is how the<label>finds the field.
<label for="..."> must match the id. When they match, a click on the words “Show sale badge” jumps into the dropdown. That is a bigger target for a person with shaky hands, and a screen reader can announce the correct name for the field. When they do not match, nothing errors. The click simply does nothing.
<th scope="row"> says “this cell is the heading for this row, not for this column”. Again, that is for screen readers, and it costs you one word.
selected() is a small WordPress helper. It compares two values, and if they match it prints selected="selected". That is what makes the dropdown open on the correct choice when you come back to the page.
Without it, your setting saves fine, and the page always shows “Yes”. Then a customer writes “the setting does not save”. The setting saved. The screen lied. That is a real ticket, and it is one missing helper.
Step 4. The other three rows
Same shape. Type these under the first row, still inside the table:
<tr>
<th scope="row"><label for="orb_badge_text">Badge text</label></th>
<td>
<input type="text" class="regular-text" name="orb_badge_text" id="orb_badge_text" value="<?php echo esc_attr( $badge_text ); ?>">
</td>
</tr>
<tr>
<th scope="row"><label for="orb_notice_enabled">Show free shipping notice</label></th>
<td>
<select name="orb_notice_enabled" id="orb_notice_enabled">
<option value="yes" <?php selected( $notice_enabled, 'yes' ); ?>>Yes</option>
<option value="no" <?php selected( $notice_enabled, 'no' ); ?>>No</option>
</select>
<p class="description">Show a "spend more for free shipping" message on the cart page.</p>
</td>
</tr>
<tr>
<th scope="row"><label for="orb_threshold">Free shipping threshold</label></th>
<td>
<input type="text" class="small-text" name="orb_threshold" id="orb_threshold" value="<?php echo esc_attr( $threshold ); ?>">
<p class="description">Match this to the minimum order amount of your free shipping method.</p>
</td>
</tr>
Save and reload. All four controls are there, with “Hot Deal” and “50” already in the text boxes.
esc_attr instead of esc_html
Look at the text inputs:
value="<?php echo esc_attr( $badge_text ); ?>"
We used esc_html earlier, and esc_attr here. The difference is position, not taste.
esc_html is for a value that sits between tags, as page text. esc_attr is for a value that sits inside a tag, as an attribute.
Here is why it matters. Say the saved text holds a double quote:
Hot " Deal
The browser reads value="Hot " and then it treats Deal" as new HTML. Your input is broken, and the page is now a hole. A person with admin access can push a script through that hole.
esc_attr turns the quote into ", and the whole thing stays inside the attribute where it belongs.
Same idea as a shipping box. You do not put a hammer in a paper bag. You match the wrapping to the position.
regular-text and small-text
Two more WordPress classes. regular-text is the standard wide input. small-text is a short one for numbers. Use them, and your fields line up with core. Skip them, and your screen looks like a different product.
Now press Save
Change the badge text to Big Sale. Press Save Changes.
The page reloads. The box says Hot Deal again. Your change is gone.
Nothing is broken. This is correct behaviour, and you must understand why.
The form did its job. The browser packed your values in an envelope and posted them to the server. The envelope arrived. Then nothing opened it.
A form is only paper. It collects and it sends. It never saves. Saving is separate code that waits for the envelope, checks who sent it, cleans what is inside, and writes it to the lockers.
We have written none of that yet. So the data hit the floor, WordPress printed the page again, get_option found empty lockers, and the defaults came back.
Keep that model in your head. When a customer says “my settings do not save”, the fault is almost never the form. It is the code that should catch the envelope. It did not run, or it stopped early on a check.
The full render method
Compare with yours, line for line.
public function render() {
$badge_enabled = get_option( 'orb_badge_enabled', 'yes' );
$badge_text = get_option( 'orb_badge_text', 'Hot Deal' );
$notice_enabled = get_option( 'orb_notice_enabled', 'yes' );
$threshold = get_option( 'orb_threshold', 50 );
?>
<div class="wrap">
<h1>Order Boost</h1>
<form method="post">
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="orb_badge_enabled">Show sale badge</label></th>
<td>
<select name="orb_badge_enabled" id="orb_badge_enabled">
<option value="yes" <?php selected( $badge_enabled, 'yes' ); ?>>Yes</option>
<option value="no" <?php selected( $badge_enabled, 'no' ); ?>>No</option>
</select>
<p class="description">Show a badge on products that are on sale.</p>
</td>
</tr>
<tr>
<th scope="row"><label for="orb_badge_text">Badge text</label></th>
<td>
<input type="text" class="regular-text" name="orb_badge_text" id="orb_badge_text" value="<?php echo esc_attr( $badge_text ); ?>">
</td>
</tr>
<tr>
<th scope="row"><label for="orb_notice_enabled">Show free shipping notice</label></th>
<td>
<select name="orb_notice_enabled" id="orb_notice_enabled">
<option value="yes" <?php selected( $notice_enabled, 'yes' ); ?>>Yes</option>
<option value="no" <?php selected( $notice_enabled, 'no' ); ?>>No</option>
</select>
<p class="description">Show a "spend more for free shipping" message on the cart page.</p>
</td>
</tr>
<tr>
<th scope="row"><label for="orb_threshold">Free shipping threshold</label></th>
<td>
<input type="text" class="small-text" name="orb_threshold" id="orb_threshold" value="<?php echo esc_attr( $threshold ); ?>">
<p class="description">Match this to the minimum order amount of your free shipping method.</p>
</td>
</tr>
</table>
<p class="submit">
<input type="submit" name="orb_save" class="button button-primary" value="Save Changes">
</p>
</form>
</div>
<?php
}
Break it on purpose
Break 1. The label link.
Change one id, and leave the label alone:
<select name="orb_badge_enabled" id="orb_badge_enabledx">
Reload. Click the words “Show sale badge”. Nothing happens now. Before, the dropdown took focus.
No error, no warning. Just a small thing that stops working for the people who need it most. Accessibility bugs are quiet like this.
Put the x back where it came from.
Break 2. The layout class.
Change the table class:
<table class="form-tablex" role="presentation">
Reload. The rows collapse into a squashed mess with no spacing.
Put it back. This one shows you that half of “the admin page looks broken” tickets are one wrong class name, not a real fault.
Where we are
The screen is complete. It reads from the database with sane defaults, it escapes every value on the way out, and it posts to itself.
It still throws your data away.
Next episode: we catch the envelope. That is the save handler, and it carries the four checks that every save in WordPress must pass. Is this my form? Is this a person who is allowed? Is this request real, or did some other site force it? Is this data clean?
Two questions first:
- You changed the badge text and pressed save. The value went to the server and then vanished. In one sentence, what is missing?
- We wrote
esc_attrinside the input tag, andesc_htmlbetween tags. What would break if you swapped them, and putesc_htmlinsidevalue="..."?