# PRD — personal_laundry_website

Evidence date: 2026-10-06.

Source: `git@bitbucket.org:overdrive/personal_laundry_website.git` at `90e6cd3` (`master`, 2021-03-31).

Provenance labels used below: **Documented Requirement**, **Implemented Behaviour**, **Inferred Behaviour**, **Planned Behaviour**, **Unknown**.

There is no product requirements document in the source repository. Requirements below are recovered from the implementation. Assumptions are marked.

Database passwords, WordPress salts, and the Wordfence API key are not copied here.

## Product purpose

**Inferred Behaviour.** The site presents Personal Laundry, a laundry business, to the public at `http://www.personallaundry.co.za`.

Evidence:

- Static sitemap URLs for home, retail laundry, commercial laundry, about, and contact (`sitemap.xml`).
- Wordfence recorded site URL `http://www.personallaundry.co.za` (`wp-content/wflogs/config-synced.php`).
- Image filenames such as `Personal-Logo`, `Personal-Laundry-Factory`, `Retail-Laundry-Services-Cape-Town`, `Commercial-Laundry-Services-Cape-Town`, `Personal-Cleaners-Sea-Point-Store` (`wp-content/uploads/2016/`).

**Unknown / Requires Confirmation:** legal name, whether the business still trades under that name, and whether the live site still uses this codebase.

No goal statement, success metric, or audience definition is written in the repository.

## Users and roles

| User | What the repository shows | Provenance |
|---|---|---|
| Public visitor | Five public URLs in `sitemap.xml`. No login is required to read them. | **Inferred Behaviour** from the sitemap. The sitemap is a static file, not proof those URLs still resolve |
| WordPress administrator | Standard `wp-admin`. `DISALLOW_FILE_EDIT` is defined in `wp-config.php`, so the theme/plugin editor is turned off when that constant is loaded | **Implemented Behaviour** for the constant. Which user accounts exist is **Unknown** (database) |
| Editor / author / other roles | WordPress roles exist in core. No custom roles were found in the child theme or a must-use plugin | **Unknown** who is assigned what |
| Logged-in front-end user | Child theme CSS styles `.login-register` and `#menu-item-30`. Fusion Core ships `shortcodes/class-fusion-login.php` | **Inferred Behaviour** that a login/register menu item was styled. What it authenticates, and whether it is on the live menu, is **Unknown** |

There is no `wp-content/mu-plugins/` directory and no project-authored role or capability code.

## Information architecture

**Implemented Behaviour** in `sitemap.xml` (generated by xml-sitemaps.com; `changefreq` is `yearly` on every URL):

| URL | Slug |
|---|---|
| `http://www.personallaundry.co.za/` | Home |
| `http://www.personallaundry.co.za/retail-laundry/` | Retail laundry |
| `http://www.personallaundry.co.za/commercial-laundry/` | Commercial laundry |
| `http://www.personallaundry.co.za/about-us/` | About |
| `http://www.personallaundry.co.za/contact-us/` | Contact |

Page titles, body copy, and menu labels are **Unknown**. They are WordPress content in MySQL.

`.htaccess` sends non-file requests to `index.php` with `RewriteBase /`, which matches those pretty permalinks. **Implemented Behaviour.**

## Functional requirements

### FR-001 — Public brochure pages

Requirement: The site serves a home page and pages for retail laundry, commercial laundry, about, and contact.

Who: Public visitor.

Trigger: HTTP GET of the URLs in `sitemap.xml`.

Inputs: None beyond the request URL.

Expected behaviour: WordPress resolves the page and Avada renders it. Body content comes from the database.

Outputs: HTML page.

Validation: Permalink rewrite in `.htaccess`.

Error conditions: Unknown page returns the WordPress 404. Custom 404 copy is **Unknown**.

Dependencies: MySQL, active theme.

Implementation: **Implemented** as WordPress plus a static sitemap. The page rows themselves are not in Git.

Confidence: Medium for the URL list. Low for current copy.

Evidence: `sitemap.xml`, `.htaccess`, Avada theme.

### FR-002 — Retail and commercial service presentation

Requirement: The site distinguishes retail laundry from commercial laundry.

Who: Public visitor.

Trigger: Opening `/retail-laundry/` or `/commercial-laundry/`.

Inputs: Page content and images from the media library.

Expected behaviour: **Inferred** from slugs and filenames, not from stored page HTML. Filenames include factory photography, washing machines, ironing, folded shirts and towels, steam cleaning, and separate retail and commercial slide images.

Outputs: Marketing pages. No price list, service catalogue schema, or booking form was found in code.

Validation: None in code.

Error conditions: None specific.

Dependencies: Media in `wp-content/uploads/2016/`, page content in the database.

Implementation: **Inferred Behaviour**. No custom post type for services.

Confidence: Medium that both services were marketed. Low for the exact offer.

Evidence: sitemap slugs; unique upload base names including `Retail-Laundry-Services-Cape-Town`, `Commercial-Laundry-Services-Cape-Town`, `Commercial-Washing-Machines-at-Personal`, `Laundry-Staff-Ironing`, `Personal-Staff-Folding-Towels`, `Steam-Cleaning-Trousers`.

### FR-003 — Brand imagery

Requirement: The site shows a logo, favicon, homepage slides, staff, and premises photography.

Who: Public visitor; editors upload media.

Trigger: Theme and slider templates request attachment URLs.

Inputs: Files under `wp-content/uploads/2016/` (April, May, July, August, September) and `wp-content/uploads/revslider/templates/`.

Expected behaviour: WordPress attachment sizes are generated beside originals (many `-150x150`, `-300x…` derivatives). Logos include `Personal-Logo`, `Personal-Logo-Retina`, `Personal-Logo-Mobile`, `Personal-Logo-Mobile-Retina`, `Personal-Favicon`.

Outputs: Images.

Validation: WordPress media handling. No custom upload validation in the child theme.

Error conditions: **Unknown**.

Implementation: **Implemented** as files in Git. Which attachments are actually inserted into pages is **Unknown**.

Confidence: High that the files exist. Medium that they were used on the public site.

Evidence: `wp-content/uploads/2016/`.

### FR-004 — Homepage and service sliders

Requirement: The homepage and service pages can show image slides.

Who: Public visitor sees them. An administrator configures sliders in Slider Revolution.

Trigger: A slider shortcode or Avada slider element on a page.

Inputs: Slider Revolution 5.2.5 and uploaded slide images (`Home-Slide`, `Hompage-Slide`, `Guest-Laundry-Slide-Image`, `Retail-Laundry-Slide-Photo`, `Commercial-Laundry-Slide-Iamge`, and generic `slide1.jpg` / `slide2.jpg` / `slide3.jpg` under `uploads/revslider/templates/`).

Expected behaviour: Revolution Slider renders the configured slider. Local patch in `wp-content/plugins/revslider/includes/framework/base-admin.class.php` (commit `90e6cd3`) initialises `$arrMetaBoxes` as an array and skips metabox title/location when those keys are missing, to avoid offset notices.

Outputs: HTML/JS slider.

Validation: Plugin's own admin. The patch only guards empty box metadata.

Error conditions: `error_log` records `implode()` warnings in `revslider/includes/slider.class.php` on 8 Apr 2016, on the older cPanel host. Whether that warning still occurs in 5.2.5 as committed was not re-executed.

Implementation: **Implemented** plugin plus a local patch. Which sliders are published is **Unknown** (database).

Confidence: High for presence. Low for which slides are on the homepage.

Evidence: `revslider.php` header Version 5.2.5; commit `90e6cd3`; upload filenames; `error_log`.

### FR-005 — Contact

Requirement: Visitors can open a contact page and, if a form is published, send an enquiry.

Who: Public visitor.

Trigger: GET `/contact-us/`. Form submit is Contact Form 7's standard POST when a form is placed on that page.

Inputs: Contact Form 7 4.8 is installed. Form markup, mail recipient, and required fields are stored as a custom post type in the database and are **not in Git**.

Expected behaviour: **Unknown** which fields are required and where mail is delivered. Core CF7 behaviour is to email the configured recipient and show a success or validation message.

Outputs: Mail and an on-page response, if a form exists.

Validation: CF7 field validation when a form exists. No project-specific validation code.

Error conditions: Mail failure behaviour is CF7's. SMTP settings are **Unknown**.

Implementation: Plugin **present**. A published form is **Unknown / Requires Confirmation**.

Confidence: High that the plugin is vendored. Low that contact currently works.

Evidence: `wp-content/plugins/contact-form-7/wp-contact-form-7.php`; sitemap contact URL.

### FR-006 — SEO metadata and a static sitemap

Requirement: The site exposes a URL list and can emit SEO tags.

Who: Search engines and visitors.

Trigger: Fetch of `/sitemap.xml`. Yoast emits head tags when the plugin is active and a page is rendered.

Inputs: Hand-written `sitemap.xml` (five URLs, yearly change frequency, generator comment `www.xml-sitemaps.com`). Yoast SEO 4.8.

Expected behaviour: The file at the web root is served as a static file because `.htaccess` does not rewrite existing files. Yoast's own sitemap endpoint, if enabled, is a separate URL controlled by plugin options in the database.

Outputs: XML sitemap file. Yoast meta tags when active.

Validation: None.

Error conditions: The static sitemap will not update when pages change unless someone replaces the file.

Implementation: Static sitemap **Implemented**. Yoast activation and titles are **Unknown**.

Confidence: High for the file. Low for live SEO configuration.

Evidence: `sitemap.xml`, `wordpress-seo/wp-seo.php` Version 4.8, `google82a264ecb0e0454e.html` (Google site verification token file).

### FR-007 — Visual design

Requirement: Pages use Avada, with a small set of site-specific CSS rules in the child theme.

Who: Public visitor.

Trigger: Front-end stylesheet enqueue.

Inputs: Child `style.css` rules:

- `.login-register a .menu-text` — white text, background `#0cc0d1`, padding 5px.
- `#menu-item-30 .menu-text:hover` — background `#089eb4`.
- `#site_credit:hover` — white, underlined.
- `.in_profile` — `float: none`.

`functions.php` enqueues the parent `style.css` on `wp_enqueue_scripts` and loads the child text domain `Avada` from a `languages` directory that is not in the child theme.

Expected behaviour: Those selectors change if the matching elements exist. Menu item 30 is a WordPress menu-item ID. What that item is called is **Unknown** (database).

Outputs: CSS.

Validation: None.

Error conditions: If the active theme is the parent rather than the child, these rules do not load. Active theme is **Unknown / Requires Confirmation**.

Implementation: **Implemented** in the child theme. In use: **Unknown**.

Confidence: High for the CSS. Low for menu item 30's label.

Evidence: `wp-content/themes/Avada-Child-Theme/style.css`, `functions.php`.

### FR-008 — Administration and content editing

Requirement: Staff edit pages in WordPress admin. Avada theme options and Fusion shortcodes are available in the vendored code.

Who: Users who can access `wp-admin`.

Trigger: Login at `wp-login.php`.

Inputs: Credentials in the database. `WP_DEBUG` is `false`. File editing from the dashboard is disabled by `define('DISALLOW_FILE_EDIT', true)` at the end of `wp-config.php`, after `wp-settings.php` is required. `wp-settings.php` returns, so the constant is set before the front controller continues. The order is non-standard.

Expected behaviour: Standard WordPress editing. Fusion Core loads its page-builder admin class only when the option `avada_disable_builder` is truthy (`fusion-core.php`). Whether that option is set is **Unknown**.

Outputs: Updated posts and options in MySQL.

Validation: WordPress capabilities. No extra project rules.

Error conditions: Commit `90e6cd3` changes `get_settings_errors()` in `wp-admin/includes/template.php` so an empty `$wp_settings_errors` returns an empty array before `count()`. Comment in source: `20210329 - count error fix - Russel`.

Implementation: **Implemented** core, with one local core patch.

Confidence: High for the patch and the constant. Low for who the editors are.

Evidence: `wp-config.php`, `wp-admin/includes/template.php`, `fusion-core.php`.

### FR-009 — Security filtering

Requirement: Requests are passed through the Wordfence firewall before WordPress when PHP `auto_prepend_file` is honoured.

Who: Every HTTP request on a host that loads `.user.ini`.

Trigger: PHP startup.

Inputs: `.user.ini` sets `auto_prepend_file` to `/usr/www/users/devovvbypb/personal_laundry/wordfence-waf.php`. That file includes Wordfence's bootstrap if it exists at the same QA path, and sets `WFWAF_LOG_PATH` to that tree's `wp-content/wflogs/`.

Expected behaviour: Wordfence WAF runs. The committed synced config says the licence is not paid (`isPaid` false), advanced blocking is on, country blocks are empty, and the recorded site URL is `http://www.personallaundry.co.za`. Block-list contents are in `config-synced.php` and are not reproduced here.

Outputs: Allow, or Wordfence block.

Validation: Wordfence rules.

Error conditions: If the site is not actually deployed at `/usr/www/users/devovvbypb/personal_laundry/`, the `file_exists` check fails and the WAF include does not run. That is the state after the QA path commit, which no longer matches the `persotrccj` path stored in the synced config.

Implementation: **Implemented** as files. Effective on a given host: **Unknown / Requires Confirmation**.

Confidence: High for the committed paths. Low for production effectiveness.

Evidence: `.user.ini`, `wordfence-waf.php`, commits `2c7e12a` and `90e6cd3`, `wp-content/wflogs/config-synced.php`.

### FR-010 — Layout breakpoint arithmetic

Requirement: Avada's dynamic CSS builds a max-width media query from the side-header breakpoint minus 32 pixels.

Who: Front-end visitor when dynamic CSS is generated.

Trigger: `avada_dynamic_css_array()` in `wp-content/themes/Avada/includes/dynamic_css.php`.

Inputs: Theme option `side_header_break_point`.

Expected behaviour: Commit `90e6cd3` parenthesises the subtraction so it happens before string concatenation: `(intval(...) - 32)`. The comment history's commit message calls this a non-numeric error fix. The option value itself is **Unknown** (database).

Outputs: A CSS media query.

Implementation: **Implemented** local patch in the parent theme.

Confidence: High for the code change. Low for the stored breakpoint.

Evidence: `dynamic_css.php` at the side-header breakpoint line; commit `90e6cd3`.

## Major workflows

### Visit the public site

1. Request a URL under `www.personallaundry.co.za`.
2. Apache applies `.htaccess` (directory index off, `wp-config.php` denied, permalinks to `index.php`).
3. If `.user.ini` is active, Wordfence prepends.
4. WordPress boots from `wp-config.php` and loads the active theme.
5. Avada renders the page with Fusion shortcodes and any Revolution slider stored for that page.

Steps 4 and 5 depend on database state that is not in Git. **Unknown** which theme is active and which shortcodes each page uses.

### Edit a page

1. Editor opens `wp-login.php`.
2. WordPress checks the user in `wp1s_*` tables.
3. Editor changes content in admin. Dashboard file editing is disabled.
4. Fusion / Avada builder availability depends on theme options.

Accounts, roles, and builder mode are **Unknown / Requires Confirmation**.

### Enquiry

1. Visitor opens `/contact-us/`.
2. If a Contact Form 7 form is embedded, they submit it.
3. CF7 validates and attempts mail.

Form fields and recipient are **Unknown / Requires Confirmation**.

## Integrations

| System | Purpose | Direction | Auth | Evidence | Failure |
|---|---|---|---|---|---|
| MySQL `personal_db` on `sql47.jnb1.host-h.net` | All content and users | App to database | Username and password in `wp-config.php` (not copied here) | **Implemented** constants `DB_NAME`, `DB_HOST`, `DB_CHARSET` `utf8`, prefix `wp1s_` | WordPress database error. No custom handler |
| Wordfence servers | Firewall rules and scan | Plugin to Wordfence | API key in `wflogs` (not copied here). Snapshot says unpaid | **Implemented** config snapshot | **Unknown** |
| Google Search Console | Site verification file | Google fetches a static file | File `google82a264ecb0e0454e.html` | **Implemented** file | None in app |
| Contact Form 7 mail | Enquiry email | Site to mail server | **Unknown** | Plugin present | **Unknown** |
| xml-sitemaps.com | Produced the static sitemap | None at runtime | None | Comment inside `sitemap.xml` | None at runtime |
| Slider Revolution / ThemeFusion updates | Commercial updates | Not configured in Git | **Unknown** | Updater hooks in Fusion Core are commented (`//add_action('admin_init', array(&$this, 'updater'));`) | **Unknown** |

No payment gateway, booking system, CRM, or analytics snippet was found outside vendored plugin code. Google Analytics or similar may still be pasted into theme options in the database. **Unknown / Requires Confirmation.**

## Constraints

- WordPress schema revision `38590` (WordPress 4.7 family). Reported version string is `4.7.19`. No changelog in the repo explains that patch level. TinyMCE build date in the same file is 2017-04-08. **Implemented** strings. Whether `4.7.19` matches an official release is **Unknown / Requires Confirmation**.
- PHP version required by this tree is not declared. Historical logs show PHP 5.5. A `cgi-bin` wrapper points at PHP 5.2. Current runtime is **Unknown**.
- `WP_DEBUG` is false. Display of PHP notices is off.
- Single site. No multisite constants.
- Table prefix `wp1s_` allows more than one WordPress install in the same database. Other installs are **Unknown**.
- Commercial Avada and Slider Revolution copies are vendored. Local patches in core, Avada, and RevSlider will be overwritten by an upstream update.
- Pretty permalinks assume the site is at the domain root (`RewriteBase /`), while the QA WAF path is a subdirectory `personal_laundry`. Those two deployments are not the same. **Requires Confirmation** which layout is intended.
- Secrets are committed. See `technical-documentation.md`.

## Planned behaviour

No planned features, tickets, or TODOs were found in project-authored code. The child theme has no TODO comments that describe future product work. **Planned Behaviour: none documented.**

## Requirements that could not be confirmed

- Live domain, host, and whether production is `persotrccj`, `personallaundryc`, or the QA path `devovvbypb/personal_laundry`.
- Active theme and which of the five plugins are active.
- Page copy, prices, service area, opening hours, and phone numbers.
- Whether image filenames (`DoubleTree`, `Ellerman-House`, `One-and-Only`, `Taj-Group`, `Bantry-Bay-International-Vacation-Resort`, `Richard-Ward`, `Grant-Gibor`, `Edith`) are clients, stock, or unused media.
- Whether a Sea Point retail store is current.
- Contact form fields, recipient, and spam controls.
- Meaning of menu item 30 and `.in_profile`.
- User accounts and roles.
- Whether Yoast or only the static sitemap is used.
