EthicsComputerCourses
Join training +91 90532 10052

Project: Coaching Centre Website

WordPress · Lesson 30 of 30 Project 10 min read Updated

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

PartBuilt withLesson to revisit
Pages: Home, About, Results, Contact, Privacy PolicyBlock editorPosts vs Pages
Design and header/footerTwenty Twenty-Five child theme, Site Editortheme.json & Child Themes
Courses with level, fee, duration, timingPlugin: custom post type, taxonomy, metaCustom Post Types & Taxonomies
Course pages and course listBlock templates, block bindings, Query LoopCreating a Block Theme
Enquiry form that saves leadsShortcode, admin-post.php, private post typeContact & 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

  1. Step 1: Create a local site called coaching (see Install WordPress on Your Computer).
  2. Step 2: In Settings, set the timezone to Kolkata, permalinks to Post name, and turn off comments on new posts.
  3. Step 3: Install Twenty Twenty-Five and create the child theme bright-child from the theme.json lesson, with your brand palette. Activate it.
  4. 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
<?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.

PHP
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.

PHP
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:

CourseLevelFee (example)DurationTiming
Class 10 Science FoundationFoundation₹18,000 per year10 monthsMon to Fri, 4 to 6 pm
Class 12 Physics RevisionSenior Secondary₹9,5003 monthsSat and Sun, 9 am to 12 noon
JEE Main Maths Crash CourseCompetitive Exams₹12,0008 weeksMon 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:

HTML
<!-- 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:

PHP
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:

PHP
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:

Output
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:

PHP
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:

Terminal
wp post list --post_type=bf_enquiry --post_status=private --fields=ID,post_title,post_date
Output
+----+------------+---------------------+
| 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):

  1. Content: real fees and timings checked by the owner; Privacy Policy explains what the form collects and why.
  2. 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).
  3. Email: an SMTP plugin with SPF and DKIM, and a live test enquiry.
  4. 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".
  5. SEO: "Discourage search engines" unticked, sitemap submitted, course pages with helpful descriptions.
  6. Backups: daily off-site backups, with one test restore.
  7. 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":

PHP
'course_seats' => 'Seats left (example: 6)',

For the filter:

PHP
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?

2. What does 'capabilities' => [ 'create_posts' => 'do_not_allow' ] do for the enquiry post type?

3. In the course save function, what must happen before saving meta values?

4. Why does the handler check that the course ID belongs to a published course?

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