The brief
A neighbourhood bakery, Daily Bread, needs a small website:
- a home page with a welcome message and opening hours;
- a menu page that lists breads and pastries with prices, which the owner updates herself;
- an About page and a Contact page;
- a clean, fast design that works on phones.
This project uses what the course taught: a block theme with theme.json, pages and navigation, a plugin with a custom post type, post meta and a shortcode, then security, speed and launch checks. Work through it step by step. Everything is written so that you can repeat it with WP-CLI on any site.
A rule that shapes the structure: design lives in the theme, features live in a plugin. If the bakery changes its look in two years, the menu data and the shortcode stay.
Step 1: the theme
The theme is a small block theme with a fixed header (site title and a list of pages), a footer, and templates for pages, single posts and the fallback.
/*
Theme Name: Daily Bread
Description: A warm, simple block theme for a neighbourhood bakery.
Version: 1.0.0
Requires at least: 6.6
Requires PHP: 7.4
License: GNU General Public License v2 or later
Text Domain: daily-bread
*/{
"$schema": "https://schemas.wp.org/trunk/theme.json",
"version": 3,
"settings": {
"appearanceTools": true,
"layout": { "contentSize": "680px", "wideSize": "1100px" },
"color": {
"palette": [
{ "slug": "crust", "name": "Crust", "color": "#8a4b14" },
{ "slug": "flour", "name": "Flour", "color": "#fbf7f0" },
{ "slug": "ink", "name": "Ink", "color": "#2b2118" }
]
},
"typography": {
"fontFamilies": [
{ "slug": "serif", "name": "Serif", "fontFamily": "Georgia, 'Times New Roman', serif" },
{ "slug": "system", "name": "System", "fontFamily": "system-ui, -apple-system, 'Segoe UI', sans-serif" }
]
}
},
"styles": {
"color": { "background": "var(--wp--preset--color--flour)", "text": "var(--wp--preset--color--ink)" },
"typography": { "fontFamily": "var(--wp--preset--font-family--system)", "lineHeight": "1.6" },
"elements": {
"heading": { "typography": { "fontFamily": "var(--wp--preset--font-family--serif)" } },
"link": { "color": { "text": "var(--wp--preset--color--crust)" } }
}
}
}The header uses the Page List block, which shows your published pages as links, so the navigation updates itself when a page is added:
<!-- wp:group {"tagName":"header","layout":{"type":"flex","justifyContent":"space-between"},"style":{"spacing":{"padding":{"top":"1rem","bottom":"1rem"}}}} -->
<header class="wp-block-group" style="padding-top:1rem;padding-bottom:1rem">
<!-- wp:site-title {"level":0,"isLink":true} /-->
<!-- wp:page-list /-->
</header>
<!-- /wp:group --><!-- wp:group {"tagName":"footer","layout":{"type":"constrained"}} -->
<footer class="wp-block-group">
<!-- wp:paragraph {"fontSize":"small"} -->
<p class="has-small-font-size">Daily Bread, 12 High Street. Open Tuesday to Sunday from 7am.</p>
<!-- /wp:paragraph -->
</footer>
<!-- /wp:group -->Pages and posts show their title and content, and the fallback lists posts:
<!-- wp:template-part {"slug":"header","tagName":"header"} /-->
<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
<!-- wp:post-title {"level":1} /-->
<!-- wp:post-content {"layout":{"type":"constrained"}} /-->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer","tagName":"footer"} /--><!-- wp:template-part {"slug":"header","tagName":"header"} /-->
<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
<!-- wp:query {"query":{"inherit":true}} -->
<div class="wp-block-query">
<!-- wp:post-template -->
<!-- wp:post-title {"isLink":true} /-->
<!-- wp:post-excerpt /-->
<!-- /wp:post-template -->
<!-- wp:query-no-results -->
<!-- wp:paragraph --><p>Nothing found.</p><!-- /wp:paragraph -->
<!-- /wp:query-no-results -->
</div>
<!-- /wp:query -->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->wp theme activate daily-breadSuccess: Switched to 'Daily Bread' theme.Step 2: the menu plugin
The menu items are content that belongs to the business, so they go in a plugin. Each item has a title, a description (the editor), and a price stored as post meta. A shortcode, [menu_items], lists them.
<?php
/**
* Plugin Name: Daily Bread Menu
* Description: Menu items with prices, and a [menu_items] shortcode.
* Version: 1.0.0
* Text Domain: daily-bread-menu
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
function daily_bread_register_types() {
register_post_type( 'menu_item', [
'labels' => [ 'name' => 'Menu items', 'singular_name' => 'Menu item', 'add_new_item' => 'Add menu item' ],
'public' => true,
'has_archive' => false,
'menu_icon' => 'dashicons-carrot',
'supports' => [ 'title', 'editor', 'thumbnail' ],
'show_in_rest' => true,
'rewrite' => [ 'slug' => 'menu-item' ],
] );
register_post_meta( 'menu_item', '_price', [
'type' => 'number',
'single' => true,
'default' => 0,
'sanitize_callback' => fn( $value ) => max( 0, round( (float) $value, 2 ) ),
'show_in_rest' => true,
] );
register_taxonomy( 'menu_section', [ 'menu_item' ], [
'labels' => [ 'name' => 'Sections', 'singular_name' => 'Section' ],
'hierarchical' => true,
'show_in_rest' => true,
] );
}
add_action( 'init', 'daily_bread_register_types' );
register_activation_hook( __FILE__, function () {
// Register first, then rebuild the address rules once.
daily_bread_register_types();
flush_rewrite_rules();
} );
/**
* [menu_items section="breads"] lists the items, optionally of one section.
*/
add_shortcode( 'menu_items', function ( $atts ) {
$atts = shortcode_atts( [ 'section' => '' ], $atts, 'menu_items' );
$args = [
'post_type' => 'menu_item',
'posts_per_page' => 50,
'orderby' => 'title',
'order' => 'ASC',
'no_found_rows' => true,
];
if ( '' !== $atts['section'] ) {
$args['tax_query'] = [ [ 'taxonomy' => 'menu_section', 'field' => 'slug', 'terms' => sanitize_title( $atts['section'] ) ] ];
}
$items = get_posts( $args );
if ( ! $items ) {
return '';
}
$html = '<ul class="menu-items">';
foreach ( $items as $item ) {
$price = (float) get_post_meta( $item->ID, '_price', true );
$html .= sprintf(
'<li><span class="menu-name">%s</span> <span class="menu-price">£%s</span></li>',
esc_html( get_the_title( $item ) ),
esc_html( number_format( $price, 2 ) )
);
}
return $html . '</ul>';
} );wp plugin activate daily-bread-menuPlugin 'daily-bread-menu' activated.
Success: Activated 1 of 1 plugins.Step 3: content with WP-CLI
Creating the content from the command line means you can run the same commands on a staging site or the live one. First the sections and menu items, with prices:
wp term create menu_section Breads --slug=breads
wp term create menu_section Pastries --slug=pastries
wp post create --post_type=menu_item --post_title="Sourdough loaf" --post_status=publish --porcelain
wp post create --post_type=menu_item --post_title="Rye bread" --post_status=publish --porcelain
wp post create --post_type=menu_item --post_title="Croissant" --post_status=publish --porcelainSuccess: Created menu_section 2.
Success: Created menu_section 3.
4
5
6<?php
$prices = [ 'Sourdough loaf' => 4.5, 'Rye bread' => 4.0, 'Croissant' => 2.25 ];
$sections = [ 'Sourdough loaf' => 'breads', 'Rye bread' => 'breads', 'Croissant' => 'pastries' ];
foreach ( $prices as $title => $price ) {
$item = get_posts( [ 'post_type' => 'menu_item', 'title' => $title, 'numberposts' => 1 ] )[0];
update_post_meta( $item->ID, '_price', $price );
wp_set_object_terms( $item->ID, $sections[ $title ], 'menu_section' );
echo $item->post_title, ' £', number_format( get_post_meta( $item->ID, '_price', true ), 2 ), ' in ', $sections[ $title ], "\n";
}Sourdough loaf £4.50 in breads
Rye bread £4.00 in breads
Croissant £2.25 in pastriesNow the pages, with the shortcode on the menu page, and the settings to make Home the front page:
wp post create --post_type=page --post_title="Home" --post_name=home --post_status=publish --post_content="<!-- wp:paragraph --><p>Fresh bread and pastries, baked every morning. Open Tuesday to Sunday from 7am.</p><!-- /wp:paragraph -->" --porcelain
wp post create --post_type=page --post_title="Our menu" --post_name=menu --post_status=publish --post_content="[menu_items]" --porcelain
wp post create --post_type=page --post_title="About" --post_name=about --post_status=publish --post_content="<!-- wp:paragraph --><p>We have baked on High Street since opening day.</p><!-- /wp:paragraph -->" --porcelain
wp post create --post_type=page --post_title="Contact" --post_name=contact --post_status=publish --post_content="<!-- wp:paragraph --><p>12 High Street. Telephone: 01234 567890 (example number).</p><!-- /wp:paragraph -->" --porcelain7
8
9
10wp option update show_on_front page
wp option update page_on_front 7
wp option update blogname "Daily Bread"
wp option update blogdescription "Baked fresh every morning"
wp rewrite structure "/%postname%/"Success: Updated 'show_on_front' option.
Success: Updated 'page_on_front' option.
Success: Updated 'blogname' option.
Success: Updated 'blogdescription' option.
Success: Rewrite structure set.
Success: Rewrite rules flushed.(The Home page has ID 7 here: WordPress's sample content uses IDs 1 to 3, and the three menu items took 4 to 6. On your own site, use the ID that wp post create printed for Home.)
Step 4: check the result
Visit the pages the way a visitor would:
GET /HomeGET /menu/Our menuGET /menu/class="menu-name">Croissant
class="menu-price">£2.25
class="menu-name">Rye bread
class="menu-price">£4.00
class="menu-name">Sourdough loaf
class="menu-price">£4.50GET /contact/<title>Contact – Daily BreadOpen the front page in a browser too: look at the header links, the colours from theme.json, and the layout at phone width. The shortcode prints escaped values and a formatted price, and the menu page lists the items alphabetically.
Step 5: review before launch
Use this checklist, built from the whole course:
Content and design
- Every page has a clear title, and the navigation lists the pages visitors need.
- The front page tells visitors what, where and when in the first screen.
- Images (when added) are sized for the page and have alt text.
Code
- Features are in the plugin, not the theme; every file starts with the
ABSPATHcheck; every function and option is prefixed. - Everything printed is escaped, input is sanitised, and write actions check a nonce and a capability (Writing secure WordPress code).
- No test code,
WP_DEBUGdisplay or sample accounts remain.
Security and backups
- Strong admin passwords, two-factor authentication, only the users that are needed,
DISALLOW_FILE_EDITon (Hardening, updates and backups). - An automatic backup of files and database, stored off the server, and a restore that you have tested.
Speed
- Page caching on, current PHP version, images optimised, no unused plugins (Making WordPress fast).
Launch
- Move the site with the steps in Moving a site from local to live: export the database, copy the files, search-replace the address, flush permalinks.
- Check the live site on a phone, test the forms, set up the search engine verification and uptime monitoring.
Common mistakes
- Putting the business data in the theme, so the menu disappears when the design changes.
- Hard-coding the front page ID instead of reading it from what
wp post createprinted. - Printing prices and titles without escaping in the shortcode.
- Skipping the launch checklist, especially the backup and the restore test.
- Building everything on the live site instead of a local or staging copy.
Extensions to try
- Add a Menu custom block (a dynamic block) that does what the shortcode does, with the section as an attribute.
- Add an opening hours option on a settings page and print it in the footer.
- Add an endpoint
/wp-json/daily-bread/v1/menuthat returns the items as JSON (custom endpoints). - Cache the shortcode's output for an hour with a transient, and clear it when a menu item is saved.
- Create a child theme and change the colours without touching the parent (child themes).
Exercise
The owner wants a "Today's special" line on the front page that she changes herself, without editing code or pages. Which feature would you build, and where does the code go?
Show one possible answer
Add a setting in the plugin: a settings page under Settings with one text field, saved in an option with a sanitise callback (see Options and settings pages). Add a shortcode [todays_special] (or a block) that prints esc_html( get_option( 'daily_bread_special' ) ), and put it on the Home page. The code goes in the plugin, because it is a feature of the business, not of the design.
Key points
- The project builds Daily Bread, a bakery site: a block theme with theme.json, pages and a menu, a plugin with a custom post type and a shortcode, and a launch checklist.
- Keep design in the theme and features in a plugin, so the client can change one without losing the other.
- Content is created and configured with WP-CLI, so the build can be repeated on a staging site and on the live site.
- Before launch: check the pages, escaping and permissions, speed, backups and the move from local to live.
Check yourself
Final exam and certificate
Finish all 45 lessons to unlock a 15-question exam on WordPress. Pass it and you get a certificate with a public link you can share.
Sign in to take the exam