A home services company needed customers to qualify their project, pay a booking fee, and enter a managed job pipeline. There was no off-the-shelf plugin that owned a booking as a first-class entity. We built one.
WooCommerce can take a payment. It cannot own a booking or run a job lifecycle.
The client needed customers to move through a guided qualifier, pay a booking fee, and enter a structured work pipeline — from initial intake all the way through site visit, quote, scheduling, and completion. That's a lifecycle problem, not a payment problem. Off-the-shelf plugins don't see the distinction.
WooCommerce orders are payment records, not job records. There was no place to hang a job lifecycle, a qualifier answer set, or a status that meant anything to the business.
Every service needed its own qualifier flow — different questions, different options, different logic. That content had to be editable in the admin without a code deploy every time something changed.
If the payment processor ever changed, every piece of booking logic would break. The business needed payment to be one swappable component inside a larger system — not the system itself.
Each layer owns one responsibility. None of them know more than they need to.
Each service is a native WordPress custom post type. Fee, lead time, site-visit requirements, and the entire qualifier flow are configured per-service in the admin — no code deploy to add a new service or change a price.
A dashboard-configurable step builder defines any number of qualifier questions with multiple-choice options per service. Code owns the funnel behavior; the client owns every word of its content. Flow is stored in post meta, never hardcoded.
The booking is a first-class custom database table record — not a WooCommerce order. It owns a 10-state lifecycle from intake to cleared, with validated status transitions, action hooks on every change, and full audit trail.
An interface contract means WooCommerce is one implementation, not a dependency. The booking controller never calls WooCommerce directly — only the adapter. Switching processors is an adapter swap with zero booking-layer changes.
Contact data lives in two layers: the original funnel-session capture (never overwritten — the source-of-intent record) and the final WooCommerce billing details (editable at checkout). Both link to one booking for the CRM layer.
Lifecycle-driven transactional emails for every event. Every subject, body, and recipient is filterable via WordPress hooks — no code deploy to change messaging. Qualifier answers are mapped to human-readable labels in admin emails.
The funnel creates a pending_payment booking and the payment layer advances it to fee_paid. The full vocabulary is defined now — every downstream phase of the job is already modeled so later additions never need to redefine it. The next layer — CRM and post-booking management — picks up exactly where this lifecycle leaves off.
The booking controller is the trust boundary. Fee is always recomputed from the service config — the browser has no ability to set or manipulate the price.
Three launch services seeded with real qualifier flows (lead remediation, painting, concrete). Each fully editable in the WP admin — new services, new questions, new options with no code changes.
A [home_funnel] shortcode surfaces up to 4 featured services with their own taglines, page URLs, and display order — managed entirely from the admin dashboard.
Bookings table in WP admin — searchable, filterable by status, showing both the original funnel capture and the confirmed billing layer side-by-side.
Every booking submission requires a valid wp_rest nonce as a CSRF guard. No anonymous request without a verified nonce reaches the booking logic.
Qualifier answers are validated against the service's step schema on the server. Invalid or missing answers return a clean error — the booking is never created with unvalidated data.
An interface contract enforces what a payment adapter must do. A null adapter supports dev/staging environments. Switching processors never touches booking logic.
Deactivation only flushes rewrite rules. Deleting the plugin preserves all booking data and settings. An optional purge block is present but disabled — for operators who ever want true full removal.
Every field sanitized at the controller before the booking record is created — sanitize_text_field(), sanitize_email(), sanitize_title(). No raw user input touches the database.
booking_created, booking_status_changed, booking_fee — all filterable. The business can customize behavior without ever modifying core plugin code.
We build what the plugins don't. Tell us the problem.