Project: Coaching Centre Website
In this project you will build a real website for Bright Future Coaching, a coaching centre in Rohtak for classes 9 to 12. By the end, the owner will be able to add courses with fees and timings, parents will browse courses and send enquiries, and every enquiry will be saved in the dashboard and emailed to the office.
You will use almost everything from this course: pages, the Site Editor, a child theme, a custom post type, post meta, block templates, a Query Loop, a plugin with hooks, a secure form, and a launch checklist.
What we are building
| Part | Built with | Lesson to revisit |
|---|---|---|
| Pages: Home, About, Results, Contact, Privacy Policy | Block editor | Posts vs Pages |
| Design and header/footer | Twenty Twenty-Five child theme, Site Editor | theme.json & Child Themes |
| Courses with level, fee, duration, timing | Plugin: custom post type, taxonomy, meta | Custom Post Types & Taxonomies |
| Course pages and course list | Block templates, block bindings, Query Loop | Creating a Block Theme |
| Enquiry form that saves leads | Shortcode, admin-post.php, private post type | Contact & Enquiry Forms |
The plan is like building a school building: first the plot and foundation (a local site), then the classrooms (pages and courses), then the reception desk (enquiry form), and finally the inspection before opening day (launch checklist).
Step 1: Set up the site
- Step 1: Create a local site called
coaching(see Install WordPress on Your Computer). - Step 2: In Settings, set the timezone to Kolkata, permalinks to Post name, and turn off comments on new posts.
- Step 3: Install Twenty Twenty-Five and create the child theme
bright-childfrom the theme.json lesson, with your brand palette. Activate it. - Step 4: Create the pages Home, About, Results, Contact and Privacy Policy. In Settings > Reading, set Home as the static homepage.
You should now see your Home page at http://localhost/coaching/ in your brand colours.
Step 2: The plugin skeleton
All the features go into one plugin, so they survive any future theme change. Create wp-content/plugins/bright-coaching/bright-coaching.php:
<?php
/**
* Plugin Name: Bright Coaching
* Description: Courses, course levels and an enquiry form for Bright Future Coaching.
* Version: 1.0.0
* Requires at least: 6.6
* Requires PHP: 8.0
* Text Domain: bright-coaching
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
const BF_COURSE_FIELDS = [
'course_fee' => 'Fee (example: ₹18,000 per year)',
'course_duration' => 'Duration (example: 10 months)',
'course_timing' => 'Batch timing (example: Mon to Fri, 4 to 6 pm)',
];BF_COURSE_FIELDS lists the three extra course fields in one place. Each key is the meta key; each value is the label shown to the office staff. Adding a fourth field later means adding one line here.
Step 3: Courses, levels, enquiries
Add the registrations below the skeleton. Two post types are created: public courses, and private enquiries that only appear in the admin.
function bf_register_types() {
register_post_type( 'course', [
'labels' => [ 'name' => 'Courses', 'singular_name' => 'Course', 'add_new_item' => 'Add New Course' ],
'public' => true,
'has_archive' => true,
'rewrite' => [ 'slug' => 'courses' ],
'menu_icon' => 'dashicons-welcome-learn-more',
'supports' => [ 'title', 'editor', 'thumbnail', 'excerpt' ],
'show_in_rest' => true,
] );
register_taxonomy( 'course_level', 'course', [
'labels' => [ 'name' => 'Levels', 'singular_name' => 'Level' ],
'hierarchical' => true,
'show_admin_column' => true,
'rewrite' => [ 'slug' => 'level' ],
'show_in_rest' => true,
] );
foreach ( array_keys( BF_COURSE_FIELDS ) as $key ) {
register_post_meta( 'course', $key, [
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => 'sanitize_text_field',
] );
}
register_post_type( 'bf_enquiry', [
'labels' => [ 'name' => 'Enquiries', 'singular_name' => 'Enquiry' ],
'public' => false,
'show_ui' => true,
'menu_icon' => 'dashicons-email-alt',
'supports' => [ 'title' ],
'capabilities' => [ 'create_posts' => 'do_not_allow' ],
'map_meta_cap' => true,
] );
}
add_action( 'init', 'bf_register_types' );
register_activation_hook( __FILE__, function () {
bf_register_types();
flush_rewrite_rules();
} );
register_deactivation_hook( __FILE__, 'flush_rewrite_rules' );For enquiries, public => false keeps them off the website, while show_ui => true shows them in the admin. The create_posts => do_not_allow capability hides the "Add New" button, because enquiries should only come from the form.
Check: Activate Bright Coaching. The admin menu now shows Courses (with a graduation cap) and Enquiries (with an envelope). Visiting /courses/ shows an empty archive instead of "Not Found".
Step 4: A friendly "Course details" box
Office staff should not have to type meta keys. A meta box is a panel on the edit screen with your own fields. It appears in the block editor's sidebar.
add_action( 'add_meta_boxes_course', function () {
add_meta_box( 'bf_course_details', 'Course details', 'bf_course_box', 'course', 'side' );
} );
function bf_course_box( $post ) {
wp_nonce_field( 'bf_save_course', 'bf_course_nonce' );
foreach ( BF_COURSE_FIELDS as $key => $label ) {
printf(
'<p><label for="%1$s">%2$s</label><br><input type="text" class="widefat" id="%1$s" name="%1$s" value="%3$s"></p>',
esc_attr( $key ),
esc_html( $label ),
esc_attr( get_post_meta( $post->ID, $key, true ) )
);
}
}
add_action( 'save_post_course', function ( $post_id ) {
$nonce = isset( $_POST['bf_course_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['bf_course_nonce'] ) ) : '';
if ( ! wp_verify_nonce( $nonce, 'bf_save_course' ) ) {
return;
}
if ( wp_is_post_autosave( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
foreach ( array_keys( BF_COURSE_FIELDS ) as $key ) {
if ( isset( $_POST[ $key ] ) ) {
update_post_meta( $post_id, $key, sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) );
}
}
} );The save function follows the safe order every time: check the nonce, skip autosaves, check the user's capability, then sanitise each value before saving. save_post_course is a variant of save_post that runs only for courses.
Check: Go to Courses > Levels and add "Foundation (Class 9-10)", "Senior Secondary (Class 11-12)" and "Competitive Exams". Add three courses, for example:
| Course | Level | Fee (example) | Duration | Timing |
|---|---|---|---|---|
| Class 10 Science Foundation | Foundation | ₹18,000 per year | 10 months | Mon to Fri, 4 to 6 pm |
| Class 12 Physics Revision | Senior Secondary | ₹9,500 | 3 months | Sat and Sun, 9 am to 12 noon |
| JEE Main Maths Crash Course | Competitive Exams | ₹12,000 | 8 weeks | Mon to Sat, 6 to 8 pm |
Fill the Course details box for each, add a featured image and an excerpt, and publish. The fees are sample numbers for practice only.
Step 5: Course templates in the child theme
Now the courses need a layout. In bright-child, create templates/single-course.html. Each detail uses a Row with a label paragraph and a paragraph bound to the meta field:
<!-- 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-terms {"term":"course_level"} /-->
<!-- wp:post-featured-image /-->
<!-- wp:group {"layout":{"type":"flex","flexWrap":"nowrap"}} -->
<div class="wp-block-group">
<!-- wp:paragraph --><p><strong>Fee:</strong></p><!-- /wp:paragraph -->
<!-- wp:paragraph {"metadata":{"bindings":{"content":{"source":"core/post-meta","args":{"key":"course_fee"}}}}} --><p></p><!-- /wp:paragraph -->
</div>
<!-- /wp:group -->
<!-- wp:post-content /-->
<!-- wp:buttons -->
<div class="wp-block-buttons"><!-- wp:button -->
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="/contact/">Enquire about this course</a></div>
<!-- /wp:button --></div>
<!-- /wp:buttons -->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->Copy the Fee row twice more for course_duration and course_timing, changing the label and the key.
Then create templates/archive-course.html for /courses/: the archive title, and a Query Loop with "inherit":true and a three-column grid of featured image, title, level and excerpt (exactly like the archive exercise in the block theme lesson).
Check: Open /courses/class-10-science-foundation/. You should see the title, "Foundation (Class 9-10)", the image, then "Fee: ₹18,000 per year", the duration and timing, the description and the enquiry button. Open /courses/: three course cards in a grid.
Step 6: Courses on the homepage
Edit the Home page. Add a heading "Our Courses" and a Query Loop block. In its settings, stop it inheriting the template query and set Post type to Courses, 3 items, ordered by title. Inside the loop, keep Featured Image, Title and Excerpt. Add a "View all courses" button linking to /courses/.
Check: The homepage shows three course cards, and new courses appear automatically.
Step 7: The enquiry form
Add the form shortcode to the plugin. The course dropdown is filled from your published courses, so it never goes out of date:
add_shortcode( 'bf_enquiry_form', function () {
if ( isset( $_GET['enquiry'] ) && 'sent' === $_GET['enquiry'] ) {
return '<p class="bf-thanks">Thank you! Our office will call you within one working day.</p>';
}
$courses = get_posts( [ 'post_type' => 'course', 'numberposts' => 50, 'orderby' => 'title', 'order' => 'ASC' ] );
ob_start();
?>
<form class="bf-enquiry" method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="bf_enquiry">
<?php wp_nonce_field( 'bf_enquiry', 'bf_nonce' ); ?>
<p><label>Student name<br><input type="text" name="bf_name" maxlength="80" required></label></p>
<p><label>Parent's mobile number<br><input type="tel" name="bf_phone" pattern="[0-9 ]{10,12}" required></label></p>
<p><label>Course<br>
<select name="bf_course">
<option value="0">Not sure yet</option>
<?php foreach ( $courses as $course ) : ?>
<option value="<?php echo esc_attr( $course->ID ); ?>"><?php echo esc_html( $course->post_title ); ?></option>
<?php endforeach; ?>
</select>
</label></p>
<p hidden><label>Leave this empty <input type="text" name="bf_website" tabindex="-1" autocomplete="off"></label></p>
<p><small>We use these details only to call you about admissions. See our Privacy Policy.</small></p>
<p><button type="submit">Request a call back</button></p>
</form>
<?php
return ob_get_clean();
} );Now the handler. It checks the nonce and honeypot, cleans the input, confirms the course really exists, saves the enquiry as a private post, emails the office and redirects back:
function bf_handle_enquiry() {
$nonce = isset( $_POST['bf_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['bf_nonce'] ) ) : '';
if ( ! wp_verify_nonce( $nonce, 'bf_enquiry' ) ) {
wp_die( 'This form has expired. Please go back, reload the page and try again.' );
}
if ( ! empty( $_POST['bf_website'] ) ) {
wp_die( 'Spam detected.' );
}
$name = sanitize_text_field( wp_unslash( $_POST['bf_name'] ?? '' ) );
$phone = preg_replace( '/\D/', '', wp_unslash( $_POST['bf_phone'] ?? '' ) );
$course_id = absint( $_POST['bf_course'] ?? 0 );
if ( '' === $name || 10 !== strlen( $phone ) ) {
wp_die( 'Please enter the student name and a 10-digit mobile number.' );
}
$course = 'Not sure yet';
if ( $course_id && 'course' === get_post_type( $course_id ) && 'publish' === get_post_status( $course_id ) ) {
$course = get_the_title( $course_id );
}
$enquiry_id = wp_insert_post( [
'post_type' => 'bf_enquiry',
'post_status' => 'private',
'post_title' => $name,
'meta_input' => [ 'bf_phone' => $phone, 'bf_course' => $course ],
] );
wp_mail(
get_option( 'admin_email' ),
'New enquiry: ' . $name,
"Student: $name\nMobile: $phone\nCourse: $course\nEnquiry ID: $enquiry_id"
);
$back = wp_get_referer() ?: home_url( '/' );
wp_safe_redirect( add_query_arg( 'enquiry', 'sent', $back ) );
exit;
}
add_action( 'admin_post_nopriv_bf_enquiry', 'bf_handle_enquiry' );
add_action( 'admin_post_bf_enquiry', 'bf_handle_enquiry' );Why check the course ID? A visitor could edit the form in DevTools and send any number. The check makes sure only a real, published course title is stored. Notice too that the enquiry is saved before the email is sent, so a lead is never lost even if email fails.
Check: Edit the Contact page, add a Shortcode block containing [bf_enquiry_form], and publish. Submit "Isha Gupta", "98123 45678" and "JEE Main Maths Crash Course". You return to the Contact page with the thank-you line, and the office inbox receives:
Subject: New enquiry: Isha Gupta Student: Isha Gupta Mobile: 9812345678 Course: JEE Main Maths Crash Course Enquiry ID: 64
On XAMPP, email usually is not configured, so the message may not arrive locally. The enquiry is still saved, which is exactly why we store it.
Step 8: Useful columns in the Enquiries list
Finally, make the Enquiries screen show the phone and course at a glance:
add_filter( 'manage_bf_enquiry_posts_columns', function ( $columns ) {
return [
'cb' => $columns['cb'],
'title' => 'Student',
'bf_phone' => 'Mobile',
'bf_course' => 'Course',
'date' => 'Received',
];
} );
add_action( 'manage_bf_enquiry_posts_custom_column', function ( $column, $post_id ) {
if ( in_array( $column, [ 'bf_phone', 'bf_course' ], true ) ) {
echo esc_html( get_post_meta( $post_id, $column, true ) );
}
}, 10, 2 );Check: Open Enquiries. Each row shows the student, mobile number, course and the time it arrived. You can also list them with WP-CLI:
wp post list --post_type=bf_enquiry --post_status=private --fields=ID,post_title,post_date+----+------------+---------------------+ | ID | post_title | post_date | +----+------------+---------------------+ | 64 | Isha Gupta | 2026-11-02 17:42:10 | +----+------------+---------------------+
Only Administrators and Editors can see private posts, so parents' numbers stay inside the office.
Step 9: Launch checklist
Before moving the site live (see Backups & Migrating a Site):
- Content: real fees and timings checked by the owner; Privacy Policy explains what the form collects and why.
- Security: strong passwords and 2FA for admins,
DISALLOW_FILE_EDIT, staff accounts with the lowest roles that fit (an office Editor, not a second Administrator). - Email: an SMTP plugin with SPF and DKIM, and a live test enquiry.
- Speed: compressed WebP images and a page cache, but exclude the Contact page from it. A cached copy keeps an old nonce, and after a day the form would show "This form has expired".
- SEO: "Discourage search engines" unticked, sitemap submitted, course pages with helpful descriptions.
- Backups: daily off-site backups, with one test restore.
- Phones: open every page on a real phone, including the menu overlay and the form.
Ideas to extend the project
- Add a Results custom post type with student name, exam and score, shown on the Results page (with written permission from families).
- Turn the form into a PHP-only block with a setting for the default course (see Shortcodes vs Blocks).
- Add a REST endpoint that returns seats left per course for a future mobile app (see The WordPress REST API).
- Export enquiries to CSV for the office every month.
Key points
- Keep site features (courses, enquiries) in a plugin and design in a child theme, so each can change without breaking the other.
- A custom post type with a taxonomy and registered meta models real business data such as courses.
- Meta boxes must check a nonce, skip autosaves, check capabilities and sanitise before saving.
- Block bindings show meta values in block templates without custom PHP templates.
- Save form submissions before emailing them, validate every field, and redirect after success.
- Finish every project with a launch checklist covering content, security, email, speed, SEO, backups and phones.
Exercise
Add a fourth course field, Seats left, to the plugin and show it on the single course page. Then add a filter on the Enquiries screen: a dropdown above the list that shows only enquiries for one course. Hint: use the restrict_manage_posts action to print the dropdown and the pre_get_posts action to filter by the bf_course meta value.
Show solution
Add one line to BF_COURSE_FIELDS, and a matching bound paragraph in single-course.html with "key":"course_seats":
'course_seats' => 'Seats left (example: 6)',For the filter:
add_action( 'restrict_manage_posts', function ( $post_type ) {
if ( 'bf_enquiry' !== $post_type ) {
return;
}
$current = sanitize_text_field( wp_unslash( $_GET['bf_course_filter'] ?? '' ) );
echo '<select name="bf_course_filter"><option value="">All courses</option>';
foreach ( get_posts( [ 'post_type' => 'course', 'numberposts' => 50 ] ) as $course ) {
printf( '<option %s>%s</option>', selected( $current, $course->post_title, false ), esc_html( $course->post_title ) );
}
echo '</select>';
} );
add_action( 'pre_get_posts', function ( $query ) {
if ( is_admin() && $query->is_main_query() && 'bf_enquiry' === $query->get( 'post_type' ) && ! empty( $_GET['bf_course_filter'] ) ) {
$query->set( 'meta_key', 'bf_course' );
$query->set( 'meta_value', sanitize_text_field( wp_unslash( $_GET['bf_course_filter'] ) ) );
}
} );Choosing a course and clicking Filter now lists only that course's enquiries. The is_admin() and is_main_query() checks make sure the filter never affects the public site or other queries.
Quick Quiz
1. Why are enquiries stored as a private custom post type instead of only being emailed?
Saving first means the data is safe even when mail delivery fails.
2. What does 'capabilities' => [ 'create_posts' => 'do_not_allow' ] do for the enquiry post type?
The form uses wp_insert_post(), which is not blocked; only manual creation in the admin is.
3. In the course save function, what must happen before saving meta values?
These checks stop forged requests and unauthorised users from changing data.
4. Why does the handler check that the course ID belongs to a published course?
Never trust form input; validate against real data on the server.
Learn WordPress with a mentor
Free lessons get you started. Our WordPress & Website Building training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
