Directory Theme class reference
PrivateLet — Class reference
The 34 classes that make _pl behave differently from the shared Directory core. Generated from the theme source.
AdminProperty AdminProperty.php
AdminProperty — "PremiumPress ▸ My Property", the owner's whole back office. A PrivateLet owner has one property, so they do not want a listings table with one row in it. They want their cottage: what it is, what it costs, and which nights are spoken for. That is the three tabs here. Everything this screen writes goes through classes that are already tested on their own — Ppt_pl\Property, Ppt\Stays\Rates and Ppt\Stays\BlockedDates — so this file is deliberately thin: routing, nonces, capability checks, and handing $_POST to a save method. No pricing arithmetic and no date maths live here.
Amenities Amenities.php
Amenities — what the property has, as a real TAXONOMY the owner can extend. This started as a fixed array in post meta. A fixed array cannot be extended by the person who owns the cottage, which is the one thing a "what's here" list must allow: no shipped catalogue knows about the boat mooring, the pizza oven or the paddock for the pony. So the list is listing_pl_amenity terms — seeded once with the shipped vocabulary, and added to from the My Property screen itself. HIERARCHICAL, two levels: the five shipped groups (Kitchen, Comfort, Outside, Practical, Access) are parent terms and every amenity is a child. The screen already grouped them that way, so the tree costs nothing to render and gives an owner's own additions somewhere sensible to live. NOT publicly queryable and no admin UI of its own: search is off on this profile, so a /amenity/<slug>/ archive would 404, and the owner manages the vocabulary from the one screen they actually use rather than a WordPress taxonomy table. PrivateLet only — registered from inc/_pl/, which Theme::boot() wires under the privatelet profile alone.
BookingFlow BookingFlow.php
BookingFlow — the guest's whole journey, at /book/. Three steps, each a plain form post to the same page: 1. dates when, and who is coming — answered against the real calendar 2. details who you are, with the itemised price beside it 3. done the stay is created, and the guest is handed their own booking link NO ACCOUNT, AT ANY POINT. A guest booking a cottage will not register first, and the appointment engine's sign-in wall is the single biggest reason a private let loses a booking. Ppt\Stays\Stays::create() takes a name and an email; user_id stays 0. NO SESSION EITHER. Each step carries the previous answers in hidden fields, so the flow survives a refresh, a back button and a shared link, and the server keeps no half-finished state that would have to be expired. The dates are re-checked on the final submit regardless of what the hidden fields say — a quote a guest has had open in a tab for an hour is not a reservation. Every price shown comes from Ppt\Stays\Rates::quote(), the same call the owner's Pricing screen validates against, so a guest is never quoted a figure the owner did not set.
DemoBooking DemoBooking.php
DemoBooking — /book/ on a DEMO site that has no priced property. A demo site needs no listings: its homepages are previews that price from their own block fields. /book/ was the one PrivateLet page with no such fallback. It read a real property, so a demo nobody had seeded (or whose property was trashed: pl10, 2026-10-02) said "This property is not taking bookings yet" under every Book button. So, in demo mode with no priced property, /book/ runs on a built-in SAMPLE cottage: the same values Sample data would create (_pl\DemoProperty::data()), priced by the same Rates::quoteFrom() and checked by the same Availability::evaluate() a real property uses, with a few sample booked nights so the calendar looks like a let cottage. Nothing is read from or written to the database. The last step shows the confirmation but saves no stay and sends no email (BookingFlow::stepConfirm). A priced real property always wins, demo or not: then this class stays out of the way.
DemoProperty DemoProperty.php
DemoProperty — the sample data for a PrivateLet site: ONE bookable property. Every other line seeds a catalogue (Tools\SampleData::screenRows). PrivateLet has no curated niche set, so that fell back to the Directory's trades niche and a PL demo got 19 plumbers and roofers — and still could not take a booking, because /book/ (BookingFlow) needs a property with a nightly price, not 19 listings (pl10, 2026-10-02: "This property is not taking bookings yet"). So the Sample data button, the setup installer and the one-click demo login all come here on this profile instead, and get "Willow Cottage": sleeps 6, 3 bed / 2 bath, dogs welcome, £145 a night with its cleaning and pet fees, a weekly discount, a 25% deposit and a two-night minimum — the record the owner's My Property screens edit — plus the Thatch design's photos as its gallery and a ticked amenity list. Idempotent, and it never overwrites an owner's property: - the site has no property -> one is created, demo-marked, so Tools → Sample data → Remove takes it away again; - the property is the one made here -> refreshed to the demo values (photos are only added when it has none); - the property is the OWNER's record -> reused, and only its EMPTY fields are filled (a price only when it has none), so /book/ quotes without anything they typed changing.
Expiry Expiry.php
PPT — listing expiry / "Listing lifetime". The Settings ▸ Listings "Listing lifetime" (days) governs how long a listing stays live. 0 = never expires. A value > 0 stamps an expiry timestamp on each listing when it's published; a daily cron then runs the configured "On expiry" action (listings_expiry_action: nothing / draft / pending / trash) once the time is up. The expiry timestamp meta is SHARED with the pricing-plan expiry (PricingPlans::LISTING_EXPIRES_META). A listing that carries a pricing plan is governed by that plan's own duration (set in the editor / at checkout), so the global lifetime only applies to listings WITHOUT a plan. Either way, the cron here enforces whatever expiry timestamp a listing ends up with, and both editors show the time remaining.
Gallery Gallery.php
Single-listing gallery styles. The admin picks one of four layouts on the Design ▸ Listings tab (stored in the ppt_design option via Branding), and the single-listing template renders the chosen layout for the listing's images. standard — large hero photo + thumbnail strip (click to swap) [default] grid — all photos in a tiled grid (first one featured) carousel — a swipeable/scroll-snap slider with prev/next tall — full-width photos stacked vertically (good for portraits)
Geocoder Geocoder.php
Bulk geocoder — fills in lat/lng for existing listings that have an address but no coordinates (e.g. listings created before the editor's map picker existed), so they get a precise map marker and the "Distance from me" feature. Uses the site's Maps provider (Settings ▸ API keys): Google / Mapbox geocoding APIs (their key), or OpenStreetMap Nominatim (keyless, rate-limited to ~1 req/sec with an identifying UA). A small box on the PPT Listings screen runs it in batches over AJAX. Listings that can't be geocoded are flagged (_ppt_geo_failed) so they aren't retried forever.
ListingActions ListingActions.php
Per-listing visitor actions shown on the single-listing section nav: - Add to favorites — toggles the listing in the member's saved list (user meta ppt_favorites); the account page shows them. Logged-in only; guests are routed to sign-in. - Report — flags the listing to the site admin. The reason is stored as a comment on the listing (type ppt_report, a custom approval status so it stays hidden from the front-end and the normal comment-moderation tabs) and also emailed to the admin. Reports are reviewed in the admin Comments screen's dedicated "Reports" tab. Open to guests and members.
ListingCard ListingCard.php
The canonical listing card — the single, theme-wide way a business/listing is shown as a card. Every directory surface (search results, single-page "related", and the live listing-grid blocks) renders through here, so a listing looks the same everywhere and the demo-vs-live data split lives in ONE place. Data-source agnostic: fromPost() maps a real listing_type record and fromSample() maps a curated demo row into the same normalized shape, which render() draws. The markup is built on the theme design tokens, so the one card automatically adopts each design's colours/fonts. Its CSS is enqueued site-wide (assets/css/listing-card.css) — the card only emits markup. Normalized shape: name, link, img, cat, rating, desc, desc_long, price, city, dist, featured (bool), highlighted (bool), badge (string), id badge is an optional ribbon label (e.g. "New") for callers that need one; when empty it falls back to "Featured" if featured is set.
ListingEditor ListingEditor.php
Listing editor — the shared brain behind the two listing-edit screens: - Admin : PPT-styled screen at ?page=ppt_listings&edit=<id> (Chrome shell), rendered by Admin\Listings when the edit param is present. - Member : front-end screen at /account/listing/<id>/ inside the Member Hub, rendered by Account\MembersPage for a listing the member owns. Both POST to admin-post.php (action ppt_listing_save) and run through the one save() below, so the field set, sanitising and persistence live in a single place. The mode (admin|member) decides which extras are honoured: admins get status, author, slug, featured/verified and the "edit only" custom fields; members get a friendly subset scoped to their own listing. Fields = core (title, description, category, tags, featured image, gallery, FAQ) + every field defined in the Custom Fields admin (Admin\Fields / option ppt_fields), stored as post meta keyed by the field key — which is what the single-listing template already reads.
ListingFaq ListingFaq.php
Single-listing FAQ. Reads per-listing FAQ items from the faq post meta (an array of ['q' => …, 'a' => …]). A live listing shows only its own saved FAQ — when it has none the section is omitted rather than filled with fabricated entries. Demo/preview mode supplies sample FAQ via DemoContent so a fresh design isn't empty. Populate a listing's own FAQ by saving faq meta, inject a site-wide default set via the ppt_listing_faq_default filter, or adjust the final list with the ppt_listing_faq filter.
ListingHours ListingHours.php
Business hours display for the single-listing sidebar. Reads the business_hours meta written by the listing editor (ListingEditor::HOURS_META) and renders a Google-Business-style day list with an "Open now / Closed now" badge computed in the site's timezone. Times are formatted with the site's time format.
ListingLocation ListingLocation.php
Single-listing "Location" section — the richer location block: the full formatted address, an interactive map (ListingMap, precise when the listing has coordinates), a "Get directions" button, and a "Distance from you" control (browser geolocation → straight-line distance to the listing). Self-contained: it prints its own scoped CSS + JS once, so single.php just calls render().
ListingMap ListingMap.php
The single-listing "Location" map. Uses the site's Maps provider setting (Settings ▸ API keys — the same ppt_maps_provider filter the search map reads). When the listing has stored coordinates (lat/lng, set by the editor's map picker) the map centres on them precisely and drops a named marker; otherwise it falls back to geocoding the address string: google / mapbox(fallback) → a keyless Google Maps embed osm → a Leaflet map (marker from coords, else Nominatim) Map libraries load from their CDNs — the owner-approved front-end map exception.
ListingPricing ListingPricing.php
Single-listing pricing plans. A listing owner enters one or more plans / packages on the submission form (a name, a price, and a short description); they're stored in the pricing_plans post meta as a list of ['name' => …, 'price' => float|null, 'desc' => …]. The LOWEST priced plan is mirrored into the searchable price meta (Ppt_pl\SearchPage::PRICE_META) by the editor on save, so the price range filter, the price sort, and the result-card price all work — that's the link between "enter your pricing" and "find listings by price". Prices are stored in the site's base currency; render() converts + formats via Ppt\Content\Currencies for the viewer's chosen currency.
ListingSections ListingSections.php
PPT — configurable single-listing sections. The admin (Design ▸ Listings ▸ "Listing page sections") gets a sortable list of on/off toggles for the optional feature blocks a listing page can carry — Booking, Business hours, Maps & Location, FAQ, Reviews. Turning one OFF removes it from BOTH the single listing page and the listing submission/edit form; the order of the list controls the order the (main-column) sections appear in. Config lives in the shared design option (Branding::OPTION = ppt_design) under the listing_sections key: { order:[keys…], enabled:{key:0|1} }. Anything not saved yet defaults to ON, in the catalog order — so existing sites are unchanged until the admin edits the list.
ListingViews ListingViews.php
PPT listing analytics — records a "hit" every time a single listing page is viewed, and serves that data back to the listing owner as a graph in the member area (/account ▸ My listings ▸ the analytics icon). Each view stores the listing id, the timestamp (UTC), and — when the visitor is logged in — their user id + display name, so an owner can see who has been looking. Bots/crawlers are skipped so counts reflect real visitors. The owner (and admins) can read the analytics for a listing over the AJAX endpoint, which returns a daily series (for the chart), rolling-window totals (7/30/60/90 days) and a list of recent named viewers. Storage: a custom table {prefix}ppt_listing_views (dbDelta on init).
LiveProperty LiveProperty.php
LiveProperty — a PrivateLet homepage shows the OWNER'S property, not the design's. Every _pl design ships a made-up cottage: its rooms, its headline, its "built in 1682" welcome. Until this pass none of what the owner entered under My Property ▸ About reached the homepage, so a live site advertised somebody else's house. On a LIVE site (never a preview, the showroom or a builder's sample render) this overlay, called once per block from Blocks\Renderer, swaps three things in: rooms — every rooms-type block (Rooms, Room Tour, Spaces, Bedrooms, Inside) shows the gallery photos the owner has named, in gallery order. Once there is one real room, ONLY real rooms show: a demo room beside a real one would be a room the guest will not find. hero — the About ▸ Description tagline as the headline, the summary under it, and the gallery (cover first) in the hero's picture slot(s). intro — the welcome/about/story block: the property name as its title, the about text in its paragraphs, and a gallery photo for its picture. PRECEDENCE (agreed with Mark 2026-10-01): an edit the owner made to the block in the Site Generator — or with the Live Text Editor — wins; then the property's data; then the design's own demo copy. "Edited" is tested per field (per group for images and room rows) by comparing with the block's defaults, because a saved design stores every field whether or not anyone touched it. Demo pictures are recognised by their theme path, so a saved design whose image URL carries an older version or CDN host still counts as untouched. Pass 2 (same day) adds: facts rows (owner numbers, topped up with notable amenities), amenity lists and grouped amenity cards, nearby places, and feature sections (pool, garden, fire, deck, view) that are left out when the owner did not tick that amenity. Still demo, deliberately: hero location lines, prices, ratings, the host block, house rules/directions, and sections with no matching amenity (sauna, building, holiday park).
Media Media.php
Listing images — the single, WordPress-native resolver for the PPT image system. A listing's photos are its WP featured image (primary) plus any image attachments parented to it (the gallery). No legacy ?imgid refs, no image-URL meta strings — everything flows through the media library.
PackageCheckout PackageCheckout.php
Session-package checkout (PrivateLet, _pl - cloned from the Booking Theme's _bo) — buying a "5 sessions for £180" bundle. A sibling of Ppt\Bookings\Checkout and Ppt\Credits\Checkout, not a modification of either: same shared machinery (the ppt_payment_gateways registry, the ppt_gateway_start / ppt_gateway_verify filter contracts, the ppt_orders CPT and the /checkout/ + /callback/ virtual pages), with its own PKG- order prefix so Pages::content() routes the callback here. Flow: the buyer hits buyUrl() → renderCheckout() validates the offer is still on sale and raises (or reuses) a pending order → they pick a gateway → the /callback/ return verifies it, completes the order and calls Packages::grant(), which is idempotent per order so a double verify can't hand out the sessions twice. Sessions are granted on payment only. Nothing is held or reserved beforehand: unlike a booking, a package takes no slot, so an abandoned checkout costs nobody anything and needs no hold-release sweep.
Packages Packages.php
Session packages (PrivateLet, _pl - cloned from the Booking Theme's _bo) — the "5 sessions for £180" bundle a service owner sells so a client pays once and books repeatedly. Two halves, deliberately separate: - What's on sale : booking_packages post meta on the listing, edited by the owner alongside price and duration. Read via offered(). - What's held : rows in {prefix}ppt_booking_packages, one per purchase, carrying how many sessions it bought and how many are spent. Entitlements are ring-fenced to the LISTING they were bought against: five sessions on "Cut & Finish" book "Cut & Finish", not whatever else the site sells. That is why this doesn't ride on Ppt\Credits\Wallet — the wallet is one flat per-user integer, which on a multi-provider site would let a bundle bought from one business pay another's bill. The grant / spend / refund shape mirrors the wallet's on purpose, so the two read the same way even though the store differs. Sessions are consumed FIFO across a buyer's packages for a listing, so the bundle bought first (and likely to expire first) is drawn down first.
Property Property.php
Property — the one holiday property a PrivateLet site is about. Every other product line asks "which listing?". This one has an answer: there is a single property, it belongs to the site owner, and the whole product is arranged around it. So the id is resolved once, here, and every surface asks this class rather than carrying a listing loop that will only ever run once. The record is still an ordinary listing post, which is what lets Stays, Rates and BlockedDates key off a post id and stay reusable by a multi-property line later. What is different is that nothing on the front end reaches it through the listing machinery: search is off for this profile, so the listing single 404s and the property is presented on its own page built from blocks. The post is the RECORD, not the page. This class also owns the descriptive fields a holiday let needs and a directory listing has no concept of — how many it sleeps, what time you may arrive, whether the dog can come, what the house rules are.
ProviderProfile ProviderProfile.php
Provider profile fields (PrivateLet, _pl - cloned from the Booking Theme's _bo). A booking site's supply is PEOPLE — the therapist, the tutor, the trainer — but everything the fork ships today is listing-owned: a Service listing carries the duration, price, capacity and staff roster, and bookings hang off the listing. This class adds the missing member-level half: an "Available for booking" opt-in plus the few fields that turn the shared public member profile (Ppt\Account\AuthorProfile, the author archive) into a bookable provider page. Deliberately NOT the same thing as the per-listing staff roster (see Ppt\Bookings\Bookings::staffRoster()). A roster entry is a name on a salon's shift plan; a provider profile is a member account that can be found and booked. They are unlinked in v1 — linking them is a later phase. Note there is also a Ppt\Bookings\Providers, which is unrelated: that one resolves the booking WIDGET provider (built-in / cal.com / Google). Different namespace, different job. The opt-in is DISCOVERY ONLY. It governs whether the member appears in the people search (Ppt_pl\ProviderSearch, /providers/) and whether their public profile reads as a provider page. It does not gate publishing services and it does not gate taking bookings — an existing site upgrades with nothing switched off and nothing to backfill.
ProviderSearch ProviderSearch.php
Dedicated provider search (PrivateLet, _pl - cloned from the Booking Theme's _bo) — /providers/. The listing search (Ppt_pl\SearchPage) searches POSTS: the services on offer. This searches PEOPLE: the members who have switched on "Available for hire" in Member Hub ▸ Hire me AND have at least one bookable service published. Different object, different query engine (WP_User_Query), so it is its own route and template rather than another layout of the listing search. Self-contained rewrite + render, the same pattern as Ppt_fm\FreelancerSearch and Account\MembersPage: ?ppt_providers=1, prettified to /providers/. Filters, all optional and combinable, all GET, all in the top bar: pcat service category — a listing_category term id or slug pq keyword — name, headline or bio prating minimum member feedback score, 1–5 (Ppt\Account\Feedback) pmin / pmax price band, matched against the provider's cheapest service psort newest | rating | price_low | price_high | name Two things separate this from the freelance people search it is modelled on. There is no category taxonomy on the user: a provider's categories are the categories of the services they publish, so the filter resolves a term to an author allowlist (see authorsInCategory()). And the price is not a rate they typed but the cheapest service they offer, cached per-member by ProviderProfile so this query stays a plain meta comparison.
RelatedListings RelatedListings.php
"Related listings" — a row of up to four extra listings shown as FULL listing cards (the same canonical search-result card) underneath the main single listing. Each card links through to that listing. In demo / preview mode it's populated from the previewed design's own on-brand cards (each carrying a virtual demo single URL), so a showroom single page always reads as rich; on a live site it collects other published listings in the same category, and hides itself when there aren't enough to be worthwhile. The card itself comes from the active fork's ListingCard (Theme::forkClass), so the strip always matches that theme's search cards exactly — one shared class serves every fork template that doesn't override it (_ll has its own).
Reviews Reviews.php
Listing reviews — built on native WordPress comments so they moderate and store like any comment, but stamped with the comment type "review" (the identifier that distinguishes them from ordinary blog comments in the admin) and a 1–5 star rating saved as comment meta. Reviews are always enabled on the listing CPT. Rendered in the single-listing "Reviews" (summary + breakdown + cards) and "Add Review" (star input + comment form) sections.
SearchPage SearchPage.php
Server-rendered search / archive for the listing post type — the clean PPT rebuild of DT10's PPT\Custom\Search\SearchPage. Runs the normal WordPress main query (WP_Query) via official hooks (pre_get_posts + template_include) so it is SEO-friendly and every core filter applies. Contexts it owns: - keyword search (/?s=…) - the listing post-type archive (/listing/) - listing category / tag archives Filters (GET, all optional, combinable): s keyword tax-listing_category category term id price1 / price2 min / max price (numeric, price meta) sort featured | popular | newest | oldest | rating | price_low | price_high | title paged pagination
SingleListing SingleListing.php
Single-listing page for the listing_type post type — the clean PPT rebuild of DT10's PPT\Custom\Single\SingleListing. Owns template_include for singular listings and renders its own token-styled template inside the normal main loop. No legacy $CORE / membership plumbing; standard WordPress access rules apply.
Sold Sold.php
"Sold" status — a shared, cross-theme concept: a listing/item can be marked as sold, which shows a "Sold" ribbon on its card and single page, and (when the admin opts in) drops it from search results. Deliberately thin and fork-agnostic, mirroring the other shared directory helpers: the state is a single post-meta flag (self::META), toggled from the admin and member listing editors. Any fork can adopt it — the visible ribbon is wired per fork (see each fork's ListingCard + the global single template), while the search-hide and the save both run from here so they behave the same everywhere. self::applies() gates which profiles currently expose the toggle.
StayQuote StayQuote.php
StayQuote — the two public, read-only answers the homepage pickers ask of the stay engine once a PrivateLet site has a priced property. ppt_stay_calendar which nights are spoken for over the next two years, and the property's stay rules, so the calendar can grey out what it should. Every reason collapses to "booked" through Availability::publicCalendar() — a visitor is never told the owner is staying in their own cottage. ppt_stay_quote what a given stay costs, as the itemised lines Rates::quote() already produces for the invoice, so the card and the invoice cannot disagree. Also says whether the nights are free. Both are open to logged-out visitors on purpose: a private let takes guest checkout, and neither call writes anything or reveals more than the public calendar does. No nonce, for the same reason a page-cached homepage could not carry a fresh one. The property is always Property::id() — the request never names one, so there is no id to tamper with. In demo mode (no priced property) a homepage picker never calls here; it prices from the block's own fields in the browser. See Ppt\Blocks_pl\StayPicker. The /book/ card always does, and on a DEMO site with no priced property it is answered from the built-in sample cottage (Ppt_pl\DemoBooking). PrivateLet only — registered from inc/_pl/, which Theme::boot() wires under the privatelet profile alone.
StayReviews StayReviews.php
StayReviews — PrivateLet guests rate their stay, from their own booking page. A PrivateLet site has no listing page (search is off), so the review form every other product hangs off a listing never appears, and until this class a PrivateLet site could not collect a single review. The homepage would then either show an invented guest book or none at all. The flow is the Hotel theme's (inc/_ht/StayPage + ReservationsCron), on PrivateLet's own pages: 1. The day after check-out a daily job emails the guest ("How was your stay?", template stay_review_request) with a link to their booking page (#review). 2. That page — /book/?stay=<token>, no account — shows "Rate your stay" once the stay has ended. The token is the guest's authority; one review per stay. 3. The review goes into the ordinary moderation queue, unapproved, as a review on the property, with its star rating and the stay id (the "verified stay" mark). 4. Once the owner approves it, LiveProperty shows it in the homepage guest book and the hero rating — both of which stay hidden until there is a real review.
TermMeta TermMeta.php
Per-term IMAGE + ICON for the listing taxonomies. Adds two custom fields to the native WordPress term add/edit screens (Categories, Tags and any custom listing taxonomy): a media-library image picker and a swatch picker over the theme icon library. The values are stored as term meta and consumed by the live-category blocks (via Blocks\Support\Categories) so an admin can give each category a branded photo and icon instead of the auto-derived listing photo / fallback glyph. Admin-only UI; the read helpers (image()/imageId()/icon()) are safe to call anywhere. Taxonomies are resolved live from the listing post type, so new custom taxonomies get the fields automatically.
Uploads Uploads.php
Listing media uploads — the AJAX backend for the Uppy uploader on the listing editor (replacing the WordPress media frame). Uppy's XHRUpload posts one file per request to ppt_listing_upload; each becomes a normal WordPress attachment (so Media::gallery()/Media::videos() and the single-listing template keep working). Files are left unattached (post_parent = 0) until the listing form is saved, which parents the final set (ListingEditor::save()). A companion ppt_listing_media_remove deletes a freshly uploaded, not-yet- saved attachment when the user removes it in the editor, so abandoned uploads don't pile up in the media library.